2026-01-26 23:59:46 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
2026-01-27 16:24:53 +00:00
|
|
|
namespace Core\Mod\Content\Models;
|
2026-01-26 23:59:46 +00:00
|
|
|
|
2026-01-27 17:34:49 +00:00
|
|
|
use Core\Tenant\Models\User;
|
|
|
|
|
use Core\Tenant\Models\Workspace;
|
2026-01-26 23:59:46 +00:00
|
|
|
use Core\Seo\HasSeoMetadata;
|
|
|
|
|
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
|
|
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
|
|
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
|
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
|
|
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
2026-01-27 16:24:53 +00:00
|
|
|
use Core\Mod\Content\Enums\ContentType;
|
|
|
|
|
use Core\Mod\Content\Observers\ContentItemObserver;
|
2026-01-29 12:34:35 +00:00
|
|
|
use Core\Mod\Content\Services\HtmlSanitiser;
|
2026-01-26 23:59:46 +00:00
|
|
|
|
|
|
|
|
#[ObservedBy([ContentItemObserver::class])]
|
|
|
|
|
class ContentItem extends Model
|
|
|
|
|
{
|
|
|
|
|
use HasFactory, HasSeoMetadata, SoftDeletes;
|
|
|
|
|
|
2026-01-27 16:24:53 +00:00
|
|
|
protected static function newFactory(): \Core\Mod\Content\Database\Factories\ContentItemFactory
|
2026-01-26 23:59:46 +00:00
|
|
|
{
|
2026-01-27 16:24:53 +00:00
|
|
|
return \Core\Mod\Content\Database\Factories\ContentItemFactory::new();
|
2026-01-26 23:59:46 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected $fillable = [
|
|
|
|
|
'workspace_id',
|
|
|
|
|
'content_type',
|
|
|
|
|
'author_id',
|
|
|
|
|
'last_edited_by',
|
|
|
|
|
'wp_id',
|
|
|
|
|
'wp_guid',
|
|
|
|
|
'type',
|
|
|
|
|
'status',
|
|
|
|
|
'publish_at',
|
|
|
|
|
'slug',
|
|
|
|
|
'title',
|
|
|
|
|
'excerpt',
|
|
|
|
|
'content_html_original',
|
|
|
|
|
'content_html_clean',
|
|
|
|
|
'content_html',
|
|
|
|
|
'content_markdown',
|
|
|
|
|
'content_json',
|
|
|
|
|
'editor_state',
|
|
|
|
|
'wp_created_at',
|
|
|
|
|
'wp_modified_at',
|
|
|
|
|
'featured_media_id',
|
|
|
|
|
'seo_meta',
|
|
|
|
|
'sync_status',
|
|
|
|
|
'synced_at',
|
|
|
|
|
'sync_error',
|
|
|
|
|
'revision_count',
|
|
|
|
|
'cdn_urls',
|
|
|
|
|
'cdn_purged_at',
|
|
|
|
|
'preview_token',
|
|
|
|
|
'preview_expires_at',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
protected $casts = [
|
|
|
|
|
'content_type' => ContentType::class,
|
|
|
|
|
'content_json' => 'array',
|
|
|
|
|
'editor_state' => 'array',
|
|
|
|
|
'seo_meta' => 'array',
|
|
|
|
|
'cdn_urls' => 'array',
|
|
|
|
|
'wp_created_at' => 'datetime',
|
|
|
|
|
'wp_modified_at' => 'datetime',
|
|
|
|
|
'publish_at' => 'datetime',
|
|
|
|
|
'synced_at' => 'datetime',
|
|
|
|
|
'cdn_purged_at' => 'datetime',
|
|
|
|
|
'preview_expires_at' => 'datetime',
|
|
|
|
|
'revision_count' => 'integer',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the workspace this content belongs to.
|
|
|
|
|
*/
|
|
|
|
|
public function workspace(): BelongsTo
|
|
|
|
|
{
|
|
|
|
|
return $this->belongsTo(Workspace::class);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the author of this content.
|
|
|
|
|
*/
|
|
|
|
|
public function author(): BelongsTo
|
|
|
|
|
{
|
|
|
|
|
return $this->belongsTo(ContentAuthor::class, 'author_id');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the user who last edited this content.
|
|
|
|
|
*/
|
|
|
|
|
public function lastEditedBy(): BelongsTo
|
|
|
|
|
{
|
|
|
|
|
return $this->belongsTo(User::class, 'last_edited_by');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the revision history for this content.
|
|
|
|
|
*/
|
|
|
|
|
public function revisions(): HasMany
|
|
|
|
|
{
|
|
|
|
|
return $this->hasMany(ContentRevision::class)->orderByDesc('revision_number');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the featured media for this content.
|
|
|
|
|
*/
|
|
|
|
|
public function featuredMedia(): BelongsTo
|
|
|
|
|
{
|
|
|
|
|
return $this->belongsTo(ContentMedia::class, 'featured_media_id', 'wp_id')
|
|
|
|
|
->where('workspace_id', $this->workspace_id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the taxonomies (categories and tags) for this content.
|
|
|
|
|
*/
|
|
|
|
|
public function taxonomies(): BelongsToMany
|
|
|
|
|
{
|
|
|
|
|
return $this->belongsToMany(ContentTaxonomy::class, 'content_item_taxonomy')
|
|
|
|
|
->withTimestamps();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get only categories.
|
|
|
|
|
*/
|
|
|
|
|
public function categories(): BelongsToMany
|
|
|
|
|
{
|
|
|
|
|
return $this->taxonomies()->where('type', 'category');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get only tags.
|
|
|
|
|
*/
|
|
|
|
|
public function tags(): BelongsToMany
|
|
|
|
|
{
|
|
|
|
|
return $this->taxonomies()->where('type', 'tag');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Scope to filter by workspace.
|
|
|
|
|
*/
|
|
|
|
|
public function scopeForWorkspace($query, int $workspaceId)
|
|
|
|
|
{
|
|
|
|
|
return $query->where('workspace_id', $workspaceId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Scope to only published content.
|
|
|
|
|
*/
|
|
|
|
|
public function scopePublished($query)
|
|
|
|
|
{
|
|
|
|
|
return $query->where('status', 'publish');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Scope to only posts.
|
|
|
|
|
*/
|
|
|
|
|
public function scopePosts($query)
|
|
|
|
|
{
|
|
|
|
|
return $query->where('type', 'post');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Scope to only pages.
|
|
|
|
|
*/
|
|
|
|
|
public function scopePages($query)
|
|
|
|
|
{
|
|
|
|
|
return $query->where('type', 'page');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Scope to items needing sync.
|
|
|
|
|
*/
|
|
|
|
|
public function scopeNeedsSync($query)
|
|
|
|
|
{
|
|
|
|
|
return $query->whereIn('sync_status', ['pending', 'failed', 'stale']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Scope to find by slug.
|
|
|
|
|
*/
|
|
|
|
|
public function scopeBySlug($query, string $slug)
|
|
|
|
|
{
|
|
|
|
|
return $query->where('slug', $slug);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Scope to filter by slug prefix (e.g., 'help/' for help articles).
|
|
|
|
|
*/
|
|
|
|
|
public function scopeWithSlugPrefix($query, string $prefix)
|
|
|
|
|
{
|
|
|
|
|
return $query->where('slug', 'like', $prefix.'%');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Scope to help articles (pages with 'help' category or 'help/' slug prefix).
|
|
|
|
|
*/
|
|
|
|
|
public function scopeHelpArticles($query)
|
|
|
|
|
{
|
|
|
|
|
return $query->where(function ($q) {
|
|
|
|
|
// Match pages with 'help/' slug prefix
|
|
|
|
|
$q->where('slug', 'like', 'help/%')
|
|
|
|
|
// Or pages in a 'help' category
|
|
|
|
|
->orWhereHas('categories', function ($catQuery) {
|
|
|
|
|
$catQuery->where('slug', 'help')
|
|
|
|
|
->orWhere('slug', 'help-articles')
|
|
|
|
|
->orWhere('name', 'like', '%help%');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Scope to filter by content type.
|
|
|
|
|
*/
|
|
|
|
|
public function scopeOfContentType($query, ContentType|string $contentType)
|
|
|
|
|
{
|
|
|
|
|
$value = $contentType instanceof ContentType ? $contentType->value : $contentType;
|
|
|
|
|
|
|
|
|
|
return $query->where('content_type', $value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Scope to only WordPress content (legacy).
|
|
|
|
|
*/
|
|
|
|
|
public function scopeWordpress($query)
|
|
|
|
|
{
|
|
|
|
|
return $query->where('content_type', ContentType::WORDPRESS->value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Scope to only native Host UK content.
|
|
|
|
|
*/
|
|
|
|
|
public function scopeHostuk($query)
|
|
|
|
|
{
|
|
|
|
|
return $query->where('content_type', ContentType::HOSTUK->value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Scope to only satellite content.
|
|
|
|
|
*/
|
|
|
|
|
public function scopeSatellite($query)
|
|
|
|
|
{
|
|
|
|
|
return $query->where('content_type', ContentType::SATELLITE->value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Scope to only native content (non-WordPress).
|
|
|
|
|
* Includes: native, hostuk, satellite
|
|
|
|
|
*/
|
|
|
|
|
public function scopeNative($query)
|
|
|
|
|
{
|
|
|
|
|
return $query->whereIn('content_type', ContentType::nativeTypeValues());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Scope to only strictly native content (new default type).
|
|
|
|
|
*/
|
|
|
|
|
public function scopeStrictlyNative($query)
|
|
|
|
|
{
|
|
|
|
|
return $query->where('content_type', ContentType::NATIVE->value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if this is WordPress content (legacy).
|
|
|
|
|
*/
|
|
|
|
|
public function isWordpress(): bool
|
|
|
|
|
{
|
|
|
|
|
return $this->content_type === ContentType::WORDPRESS;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if this is native Host UK content.
|
|
|
|
|
*/
|
|
|
|
|
public function isHostuk(): bool
|
|
|
|
|
{
|
|
|
|
|
return $this->content_type === ContentType::HOSTUK;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if this is satellite content.
|
|
|
|
|
*/
|
|
|
|
|
public function isSatellite(): bool
|
|
|
|
|
{
|
|
|
|
|
return $this->content_type === ContentType::SATELLITE;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if this is strictly native content (new default type).
|
|
|
|
|
*/
|
|
|
|
|
public function isNative(): bool
|
|
|
|
|
{
|
|
|
|
|
return $this->content_type === ContentType::NATIVE;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if this is any native content type (non-WordPress).
|
|
|
|
|
*/
|
|
|
|
|
public function isAnyNative(): bool
|
|
|
|
|
{
|
|
|
|
|
return $this->content_type?->isNative() ?? false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if this content uses the Flux editor (non-WordPress).
|
|
|
|
|
*/
|
|
|
|
|
public function usesFluxEditor(): bool
|
|
|
|
|
{
|
|
|
|
|
return $this->content_type?->usesFluxEditor() ?? false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the display content (prefers clean HTML, falls back to markdown).
|
|
|
|
|
*/
|
|
|
|
|
public function getDisplayContentAttribute(): string
|
|
|
|
|
{
|
|
|
|
|
if ($this->usesFluxEditor()) {
|
|
|
|
|
return $this->content_html ?? $this->content_markdown ?? '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->content_html_clean ?? $this->content_html_original ?? '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get sanitised HTML content for safe rendering.
|
|
|
|
|
*
|
|
|
|
|
* Uses HTMLPurifier to remove XSS vectors while preserving
|
|
|
|
|
* safe HTML elements like paragraphs, headings, lists, etc.
|
2026-01-29 12:34:35 +00:00
|
|
|
*
|
|
|
|
|
* SECURITY: This method uses HTMLPurifier which is a required dependency.
|
|
|
|
|
* Never fall back to strip_tags() as it does not sanitise attributes
|
|
|
|
|
* (e.g., onclick, onerror) which can still execute JavaScript.
|
2026-01-26 23:59:46 +00:00
|
|
|
*/
|
|
|
|
|
public function getSanitisedContent(): string
|
|
|
|
|
{
|
|
|
|
|
$content = $this->display_content;
|
|
|
|
|
|
|
|
|
|
if (empty($content)) {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 12:34:35 +00:00
|
|
|
return app(HtmlSanitiser::class)->sanitise($content);
|
2026-01-26 23:59:46 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get URLs that need CDN purge when this content changes.
|
|
|
|
|
*/
|
|
|
|
|
public function getCdnUrlsForPurgeAttribute(): array
|
|
|
|
|
{
|
|
|
|
|
$workspace = $this->workspace;
|
|
|
|
|
if (! $workspace) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$domain = $workspace->domain;
|
|
|
|
|
$urls = [];
|
|
|
|
|
|
|
|
|
|
// Main content URL
|
|
|
|
|
if ($this->type === 'post') {
|
|
|
|
|
$urls[] = "https://{$domain}/blog/{$this->slug}";
|
|
|
|
|
$urls[] = "https://{$domain}/blog"; // Blog listing
|
|
|
|
|
} elseif ($this->type === 'page') {
|
|
|
|
|
$urls[] = "https://{$domain}/{$this->slug}";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Homepage always
|
|
|
|
|
$urls[] = "https://{$domain}/";
|
|
|
|
|
$urls[] = "https://{$domain}";
|
|
|
|
|
|
|
|
|
|
return $urls;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Mark as synced.
|
|
|
|
|
*/
|
|
|
|
|
public function markSynced(): void
|
|
|
|
|
{
|
|
|
|
|
$this->update([
|
|
|
|
|
'sync_status' => 'synced',
|
|
|
|
|
'synced_at' => now(),
|
|
|
|
|
'sync_error' => null,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Mark as failed.
|
|
|
|
|
*/
|
|
|
|
|
public function markFailed(string $error): void
|
|
|
|
|
{
|
|
|
|
|
$this->update([
|
|
|
|
|
'sync_status' => 'failed',
|
|
|
|
|
'sync_error' => $error,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get Flux badge colour for content status.
|
|
|
|
|
*/
|
|
|
|
|
public function getStatusColorAttribute(): string
|
|
|
|
|
{
|
|
|
|
|
return match ($this->status) {
|
|
|
|
|
'publish' => 'green',
|
|
|
|
|
'draft' => 'yellow',
|
|
|
|
|
'pending' => 'orange',
|
|
|
|
|
'future' => 'blue',
|
|
|
|
|
'private' => 'zinc',
|
|
|
|
|
default => 'zinc',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get icon for content status.
|
|
|
|
|
*/
|
|
|
|
|
public function getStatusIconAttribute(): string
|
|
|
|
|
{
|
|
|
|
|
return match ($this->status) {
|
|
|
|
|
'publish' => 'check-circle',
|
|
|
|
|
'draft' => 'pencil',
|
|
|
|
|
'pending' => 'clock',
|
|
|
|
|
'future' => 'calendar',
|
|
|
|
|
'private' => 'lock-closed',
|
|
|
|
|
default => 'document',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get Flux badge colour for sync status.
|
|
|
|
|
*/
|
|
|
|
|
public function getSyncColorAttribute(): string
|
|
|
|
|
{
|
|
|
|
|
return match ($this->sync_status) {
|
|
|
|
|
'synced' => 'green',
|
|
|
|
|
'pending' => 'yellow',
|
|
|
|
|
'stale' => 'orange',
|
|
|
|
|
'failed' => 'red',
|
|
|
|
|
default => 'zinc',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get icon for sync status.
|
|
|
|
|
*/
|
|
|
|
|
public function getSyncIconAttribute(): string
|
|
|
|
|
{
|
|
|
|
|
return match ($this->sync_status) {
|
|
|
|
|
'synced' => 'check',
|
|
|
|
|
'pending' => 'clock',
|
|
|
|
|
'stale' => 'arrow-path',
|
|
|
|
|
'failed' => 'x-mark',
|
|
|
|
|
default => 'question-mark-circle',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get Flux badge colour for content type (post/page).
|
|
|
|
|
*/
|
|
|
|
|
public function getTypeColorAttribute(): string
|
|
|
|
|
{
|
|
|
|
|
return match ($this->type) {
|
|
|
|
|
'post' => 'blue',
|
|
|
|
|
'page' => 'violet',
|
|
|
|
|
default => 'zinc',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get Flux badge colour for content source type.
|
|
|
|
|
*/
|
|
|
|
|
public function getContentTypeColorAttribute(): string
|
|
|
|
|
{
|
|
|
|
|
return $this->content_type?->color() ?? 'zinc';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get icon for content source type.
|
|
|
|
|
*/
|
|
|
|
|
public function getContentTypeIconAttribute(): string
|
|
|
|
|
{
|
|
|
|
|
return $this->content_type?->icon() ?? 'document';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get human-readable content source type.
|
|
|
|
|
*/
|
|
|
|
|
public function getContentTypeLabelAttribute(): string
|
|
|
|
|
{
|
|
|
|
|
return $this->content_type?->label() ?? 'Unknown';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the ContentType enum instance.
|
|
|
|
|
*/
|
|
|
|
|
public function getContentTypeEnum(): ?ContentType
|
|
|
|
|
{
|
|
|
|
|
return $this->content_type;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Scope to scheduled content (status = future and publish_at set).
|
|
|
|
|
*/
|
|
|
|
|
public function scopeScheduled($query)
|
|
|
|
|
{
|
|
|
|
|
return $query->where('status', 'future')
|
|
|
|
|
->whereNotNull('publish_at');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Scope to content ready to be published (scheduled time has passed).
|
|
|
|
|
*/
|
|
|
|
|
public function scopeReadyToPublish($query)
|
|
|
|
|
{
|
|
|
|
|
return $query->where('status', 'future')
|
|
|
|
|
->whereNotNull('publish_at')
|
|
|
|
|
->where('publish_at', '<=', now());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if this content is scheduled for future publication.
|
|
|
|
|
*/
|
|
|
|
|
public function isScheduled(): bool
|
|
|
|
|
{
|
|
|
|
|
return $this->status === 'future' && $this->publish_at !== null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
// Preview Links
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Generate a time-limited preview token for sharing unpublished content.
|
|
|
|
|
*
|
|
|
|
|
* @param int $hours Number of hours until expiry (default 24)
|
|
|
|
|
* @return string The generated preview token
|
|
|
|
|
*/
|
|
|
|
|
public function generatePreviewToken(int $hours = 24): string
|
|
|
|
|
{
|
|
|
|
|
$token = bin2hex(random_bytes(32));
|
|
|
|
|
|
|
|
|
|
$this->update([
|
|
|
|
|
'preview_token' => $token,
|
|
|
|
|
'preview_expires_at' => now()->addHours($hours),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return $token;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the preview URL for this content item.
|
|
|
|
|
*
|
|
|
|
|
* Generates a new token if one doesn't exist or has expired.
|
|
|
|
|
*
|
|
|
|
|
* @param int $hours Number of hours until expiry (default 24)
|
|
|
|
|
* @return string The full preview URL
|
|
|
|
|
*/
|
|
|
|
|
public function getPreviewUrl(int $hours = 24): string
|
|
|
|
|
{
|
|
|
|
|
// Generate new token if needed
|
|
|
|
|
if (! $this->hasValidPreviewToken()) {
|
|
|
|
|
$this->generatePreviewToken($hours);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return route('content.preview', [
|
|
|
|
|
'item' => $this->id,
|
|
|
|
|
'token' => $this->preview_token,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if the current preview token is still valid.
|
|
|
|
|
*/
|
|
|
|
|
public function hasValidPreviewToken(): bool
|
|
|
|
|
{
|
|
|
|
|
return $this->preview_token !== null
|
|
|
|
|
&& $this->preview_expires_at !== null
|
|
|
|
|
&& $this->preview_expires_at->isFuture();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validate a preview token against this content item.
|
|
|
|
|
*/
|
|
|
|
|
public function isValidPreviewToken(?string $token): bool
|
|
|
|
|
{
|
|
|
|
|
if ($token === null || $this->preview_token === null) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return hash_equals($this->preview_token, $token) && $this->hasValidPreviewToken();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Revoke the current preview token.
|
|
|
|
|
*/
|
|
|
|
|
public function revokePreviewToken(): void
|
|
|
|
|
{
|
|
|
|
|
$this->update([
|
|
|
|
|
'preview_token' => null,
|
|
|
|
|
'preview_expires_at' => null,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get remaining time until preview token expires.
|
|
|
|
|
*
|
|
|
|
|
* @return string|null Human-readable time remaining, or null if no valid token
|
|
|
|
|
*/
|
|
|
|
|
public function getPreviewTokenTimeRemaining(): ?string
|
|
|
|
|
{
|
|
|
|
|
if (! $this->hasValidPreviewToken()) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->preview_expires_at->diffForHumans(['parts' => 2]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if this content can be previewed (draft, pending, future, private).
|
|
|
|
|
*/
|
|
|
|
|
public function isPreviewable(): bool
|
|
|
|
|
{
|
|
|
|
|
return in_array($this->status, ['draft', 'pending', 'future', 'private']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a revision snapshot of the current state.
|
|
|
|
|
*/
|
|
|
|
|
public function createRevision(
|
|
|
|
|
?User $user = null,
|
|
|
|
|
string $changeType = ContentRevision::CHANGE_EDIT,
|
|
|
|
|
?string $changeSummary = null
|
|
|
|
|
): ContentRevision {
|
|
|
|
|
$revision = ContentRevision::createFromContentItem($this, $user, $changeType, $changeSummary);
|
|
|
|
|
|
|
|
|
|
// Update revision count
|
|
|
|
|
$this->increment('revision_count');
|
|
|
|
|
|
|
|
|
|
return $revision;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the latest revision for this content.
|
|
|
|
|
*/
|
|
|
|
|
public function latestRevision(): ?ContentRevision
|
|
|
|
|
{
|
|
|
|
|
return $this->revisions()->first();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Convert to array format for frontend rendering.
|
|
|
|
|
*
|
|
|
|
|
* Note: Title and excerpt are plain text and will be escaped by Blade's {{ }}.
|
|
|
|
|
* Content body contains HTML that should be rendered with {!! !!} but is
|
|
|
|
|
* sanitised using HTMLPurifier to prevent XSS.
|
|
|
|
|
*/
|
|
|
|
|
public function toRenderArray(): array
|
|
|
|
|
{
|
|
|
|
|
$author = $this->author;
|
|
|
|
|
$featuredMedia = $this->featuredMedia;
|
|
|
|
|
|
|
|
|
|
$data = [
|
|
|
|
|
'id' => $this->id,
|
|
|
|
|
'date' => $this->created_at?->toIso8601String(),
|
|
|
|
|
'modified' => $this->updated_at?->toIso8601String(),
|
|
|
|
|
'slug' => $this->slug,
|
|
|
|
|
'status' => $this->status,
|
|
|
|
|
'type' => $this->type,
|
|
|
|
|
'title' => ['rendered' => $this->title], // Plain text - escape with {{ }}
|
|
|
|
|
'content' => ['rendered' => $this->getSanitisedContent(), 'protected' => false],
|
|
|
|
|
'excerpt' => ['rendered' => $this->excerpt], // Plain text - escape with {{ }} or use strip_tags()
|
|
|
|
|
'featured_media' => $this->featured_media_id ?? 0,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if ($author) {
|
|
|
|
|
$data['_embedded']['author'] = [[
|
|
|
|
|
'id' => $author->id,
|
|
|
|
|
'name' => $author->name,
|
|
|
|
|
'slug' => $author->slug ?? null,
|
|
|
|
|
'description' => $author->bio ?? null,
|
|
|
|
|
'avatar_urls' => ['96' => $author->avatar_url ?? null],
|
|
|
|
|
]];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($featuredMedia) {
|
|
|
|
|
$data['_embedded']['wp:featuredmedia'] = [[
|
|
|
|
|
'id' => $featuredMedia->id,
|
|
|
|
|
'source_url' => $featuredMedia->url ?? $featuredMedia->cdn_url ?? $featuredMedia->source_url ?? null,
|
|
|
|
|
'alt_text' => $featuredMedia->alt_text ?? null,
|
|
|
|
|
]];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Boot method to set default content type.
|
|
|
|
|
*/
|
|
|
|
|
protected static function booted(): void
|
|
|
|
|
{
|
|
|
|
|
static::creating(function (ContentItem $item) {
|
|
|
|
|
// Default to native content type for new items
|
|
|
|
|
if ($item->content_type === null) {
|
|
|
|
|
$item->content_type = ContentType::default();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|