Implement creator studio and upload updates

This commit is contained in:
2026-04-04 10:12:02 +02:00
parent 1da7d3bf88
commit 0b216b7ecd
15107 changed files with 31206 additions and 626514 deletions

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\Sitemaps\BuildSitemapReleaseJob;
use App\Services\Sitemaps\SitemapBuildService;
use App\Services\Sitemaps\SitemapPublishService;
use Illuminate\Console\Command;
final class BuildSitemapsCommand extends Command
{
protected $signature = 'skinbase:sitemaps:build
{--only=* : Limit the build to one or more sitemap families}
{--release= : Override the generated release id}
{--shards : Show per-shard output in the command report}
{--queue : Dispatch the release build to the queue}
{--force : Accepted for backward compatibility; release builds are always fresh}
{--clear : Accepted for backward compatibility; release builds are isolated}
{--dry-run : Build a release artifact set without activating it}';
protected $description = 'Build a versioned sitemap release artifact set.';
public function handle(SitemapBuildService $build, SitemapPublishService $publish): int
{
$startedAt = microtime(true);
$families = $this->selectedFamilies($build);
$releaseId = ($value = $this->option('release')) !== null && trim((string) $value) !== '' ? trim((string) $value) : null;
if ($families === []) {
$this->error('No valid sitemap families were selected.');
return self::INVALID;
}
$showShards = (bool) $this->option('shards');
if ((bool) $this->option('queue')) {
BuildSitemapReleaseJob::dispatch($families, $releaseId);
$this->info('Queued sitemap release build' . ($releaseId !== null ? ' for [' . $releaseId . '].' : '.'));
return self::SUCCESS;
}
try {
$manifest = $publish->buildRelease($families, $releaseId);
} catch (\Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$totalUrls = 0;
$totalDocuments = 0;
foreach ($families as $family) {
$names = (array) data_get($manifest, 'families.' . $family . '.documents', []);
$familyUrls = 0;
if (! $showShards) {
$this->line('Building family [' . $family . '] with ' . count($names) . ' document(s).');
}
foreach ($names as $name) {
$documentType = str_ends_with((string) $name, '-index') ? 'index' : ((string) $family === (string) config('sitemaps.news.google_variant_name', 'news-google') ? 'google-news' : 'urlset');
$familyUrls += (int) data_get($manifest, 'families.' . $family . '.url_count', 0);
$totalUrls += (int) data_get($manifest, 'families.' . $family . '.url_count', 0);
$totalDocuments++;
if ($showShards || ! str_contains((string) $name, '-000')) {
$this->line(sprintf(
' - %s [%s]',
$name,
$documentType,
));
}
}
$this->info(sprintf('Family [%s] complete: urls=%d documents=%d', $family, (int) data_get($manifest, 'families.' . $family . '.url_count', 0), count($names)));
}
$totalDocuments++;
$this->info(sprintf(
'Sitemap release [%s] complete: families=%d documents=%d urls=%d status=%s duration=%.2fs',
(string) $manifest['release_id'],
(int) data_get($manifest, 'totals.families', 0),
(int) data_get($manifest, 'totals.documents', 0),
(int) data_get($manifest, 'totals.urls', 0),
(string) ($manifest['status'] ?? 'built'),
microtime(true) - $startedAt,
));
$this->line('Sitemap index complete');
return self::SUCCESS;
}
/**
* @return list<string>
*/
private function selectedFamilies(SitemapBuildService $build): array
{
$only = [];
foreach ((array) $this->option('only') as $value) {
foreach (explode(',', (string) $value) as $family) {
$normalized = trim($family);
if ($normalized !== '') {
$only[] = $normalized;
}
}
}
$enabled = $build->enabledFamilies();
if ($only === []) {
return $enabled;
}
return array_values(array_filter($enabled, fn (string $family): bool => in_array($family, $only, true)));
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Services\Images\ArtworkSquareThumbnailBackfillService;
use Illuminate\Console\Command;
use Throwable;
final class GenerateMissingSquareThumbnailsCommand extends Command
{
protected $signature = 'artworks:generate-missing-sq-thumbs
{--id= : Generate only for this artwork ID}
{--limit= : Stop after processing this many artworks}
{--force : Regenerate even if an sq variant row already exists}
{--dry-run : Report what would be generated without writing files}';
protected $description = 'Generate missing smart square artwork thumbnails';
public function handle(ArtworkSquareThumbnailBackfillService $backfill): int
{
$artworkId = $this->option('id') !== null ? max(1, (int) $this->option('id')) : null;
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
$force = (bool) $this->option('force');
$dryRun = (bool) $this->option('dry-run');
$query = Artwork::query()
->whereNotNull('hash')
->where('hash', '!=', '')
->orderBy('id');
if ($artworkId !== null) {
$query->whereKey($artworkId);
}
$processed = 0;
$generated = 0;
$planned = 0;
$skipped = 0;
$failed = 0;
$query->chunkById(100, function ($artworks) use ($backfill, $force, $dryRun, $limit, &$processed, &$generated, &$planned, &$skipped, &$failed) {
foreach ($artworks as $artwork) {
if ($limit !== null && $processed >= $limit) {
return false;
}
try {
$result = $backfill->ensureSquareThumbnail($artwork, $force, $dryRun);
$status = (string) ($result['status'] ?? 'skipped');
if ($status === 'generated') {
$generated++;
} elseif ($status === 'dry_run') {
$planned++;
} else {
$skipped++;
}
} catch (Throwable $e) {
$failed++;
$this->warn(sprintf('Artwork %d failed: %s', (int) $artwork->getKey(), $e->getMessage()));
}
$processed++;
}
return true;
});
$this->info(sprintf(
'Square thumbnail backfill complete. processed=%d generated=%d planned=%d skipped=%d failed=%d',
$processed,
$generated,
$planned,
$skipped,
$failed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
}

View File

@@ -179,15 +179,8 @@ class ImportLegacyArtworks extends Command
$art = null; $art = null;
DB::connection()->transaction(function () use (&$art, $data, $legacyId, $legacyConn, $connectedTable) { DB::connection()->transaction(function () use (&$art, $data, $legacyId, $legacyConn, $connectedTable) {
// create artwork (guard against unique slug collisions) // Preserve the imported slug verbatim. Public artwork URLs include the artwork id.
$baseSlug = $data['slug']; $data['slug'] = Str::limit((string) ($data['slug'] ?: 'artwork'), 160, '');
$attempt = 0;
$slug = $baseSlug;
while (Artwork::where('slug', $slug)->exists()) {
$attempt++;
$slug = $baseSlug . '-' . $attempt;
}
$data['slug'] = $slug;
// Preserve legacy primary ID if available and safe to do so. // Preserve legacy primary ID if available and safe to do so.
if (! empty($legacyId) && is_numeric($legacyId) && (int) $legacyId > 0) { if (! empty($legacyId) && is_numeric($legacyId) && (int) $legacyId > 0) {

View File

@@ -6,13 +6,17 @@ namespace App\Console\Commands;
use App\Models\Artwork; use App\Models\Artwork;
use App\Services\Vision\VectorService; use App\Services\Vision\VectorService;
use Carbon\CarbonImmutable;
use InvalidArgumentException;
use Illuminate\Console\Command; use Illuminate\Console\Command;
final class IndexArtworkVectorsCommand extends Command final class IndexArtworkVectorsCommand extends Command
{ {
protected $signature = 'artworks:vectors-index protected $signature = 'artworks:vectors-index
{--order=updated-desc : Ordering mode: updated-desc or id-asc}
{--start-id=0 : Start from this artwork id (inclusive)} {--start-id=0 : Start from this artwork id (inclusive)}
{--after-id=0 : Resume after this artwork id} {--after-id=0 : Resume after this artwork id}
{--after-updated-at= : Resume updated-desc mode after this ISO-8601 timestamp}
{--batch=100 : Batch size per iteration} {--batch=100 : Batch size per iteration}
{--limit=0 : Maximum artworks to process in this run} {--limit=0 : Maximum artworks to process in this run}
{--embedded-only : Re-upsert only artworks that already have local embeddings} {--embedded-only : Re-upsert only artworks that already have local embeddings}
@@ -29,8 +33,16 @@ final class IndexArtworkVectorsCommand extends Command
return self::FAILURE; return self::FAILURE;
} }
$order = $this->normalizeOrder((string) $this->option('order'));
$startId = max(0, (int) $this->option('start-id')); $startId = max(0, (int) $this->option('start-id'));
$afterId = max(0, (int) $this->option('after-id')); $afterId = max(0, (int) $this->option('after-id'));
try {
$afterUpdatedAt = $this->resolveAfterUpdatedAt($order, (string) $this->option('after-updated-at'));
} catch (InvalidArgumentException $exception) {
$this->error($exception->getMessage());
return self::INVALID;
}
$batch = max(1, min((int) $this->option('batch'), 1000)); $batch = max(1, min((int) $this->option('batch'), 1000));
$limit = max(0, (int) $this->option('limit')); $limit = max(0, (int) $this->option('limit'));
$publicOnly = (bool) $this->option('public-only'); $publicOnly = (bool) $this->option('public-only');
@@ -42,6 +54,7 @@ final class IndexArtworkVectorsCommand extends Command
$skipped = 0; $skipped = 0;
$failed = 0; $failed = 0;
$lastId = $afterId; $lastId = $afterId;
$nextUpdatedAt = $afterUpdatedAt;
if ($startId > 0 && $afterId > 0) { if ($startId > 0 && $afterId > 0) {
$this->warn(sprintf( $this->warn(sprintf(
@@ -51,10 +64,16 @@ final class IndexArtworkVectorsCommand extends Command
)); ));
} }
if ($order === 'updated-desc' && ($startId > 0 || $afterId > 0) && $afterUpdatedAt === null) {
$this->warn('The --start-id/--after-id options are legacy id-asc cursors. They are ignored unless --order=id-asc is used, or unless --after-updated-at is also provided for updated-desc mode.');
}
$this->info(sprintf( $this->info(sprintf(
'Starting vector index: start_id=%d after_id=%d next_id=%d batch=%d limit=%s embedded_only=%s public_only=%s dry_run=%s', 'Starting vector index: order=%s start_id=%d after_id=%d after_updated_at=%s next_id=%d batch=%d limit=%s embedded_only=%s public_only=%s dry_run=%s',
$order,
$startId, $startId,
$afterId, $afterId,
$afterUpdatedAt?->toIso8601String() ?? 'none',
$nextId, $nextId,
$batch, $batch,
$limit > 0 ? (string) $limit : 'all', $limit > 0 ? (string) $limit : 'all',
@@ -73,10 +92,27 @@ final class IndexArtworkVectorsCommand extends Command
$query = Artwork::query() $query = Artwork::query()
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')]) ->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
->where('id', '>=', $nextId) ->whereNotNull('hash');
->whereNotNull('hash')
->orderBy('id') if ($order === 'updated-desc') {
->limit($take); $query->orderByDesc('updated_at')
->orderByDesc('id')
->limit($take);
if ($nextUpdatedAt !== null) {
$query->where(function ($cursorQuery) use ($nextUpdatedAt, $afterId): void {
$cursorQuery->where('updated_at', '<', $nextUpdatedAt)
->orWhere(function ($sameTimestampQuery) use ($nextUpdatedAt, $afterId): void {
$sameTimestampQuery->where('updated_at', '=', $nextUpdatedAt)
->where('id', '<', $afterId);
});
});
}
} else {
$query->where('id', '>=', $nextId)
->orderBy('id')
->limit($take);
}
if ($embeddedOnly) { if ($embeddedOnly) {
$query->whereHas('embeddings'); $query->whereHas('embeddings');
@@ -93,16 +129,25 @@ final class IndexArtworkVectorsCommand extends Command
} }
$this->line(sprintf( $this->line(sprintf(
'Fetched batch: count=%d first_id=%d last_id=%d', 'Fetched batch: count=%d first_id=%d last_id=%d first_updated_at=%s last_updated_at=%s',
$artworks->count(), $artworks->count(),
(int) $artworks->first()->id, (int) $artworks->first()->id,
(int) $artworks->last()->id (int) $artworks->last()->id,
optional($artworks->first()->updated_at)->toIso8601String() ?? 'null',
optional($artworks->last()->updated_at)->toIso8601String() ?? 'null'
)); ));
foreach ($artworks as $artwork) { foreach ($artworks as $artwork) {
$processed++; $processed++;
$lastId = (int) $artwork->id; $lastId = (int) $artwork->id;
$nextId = $lastId + 1; if ($order === 'updated-desc') {
$nextUpdatedAt = $artwork->updated_at !== null
? CarbonImmutable::instance($artwork->updated_at)
: null;
$afterId = $lastId;
} else {
$nextId = $lastId + 1;
}
try { try {
$payload = $vectors->payloadForArtwork($artwork); $payload = $vectors->payloadForArtwork($artwork);
@@ -150,11 +195,49 @@ final class IndexArtworkVectorsCommand extends Command
} }
} }
$this->info("Vector index finished. processed={$processed} indexed={$indexed} skipped={$skipped} failed={$failed} last_id={$lastId} next_id={$nextId}"); $this->info(sprintf(
'Vector index finished. processed=%d indexed=%d skipped=%d failed=%d last_id=%d next_id=%d next_updated_at=%s',
$processed,
$indexed,
$skipped,
$failed,
$lastId,
$nextId,
$nextUpdatedAt?->toIso8601String() ?? 'none'
));
return $failed > 0 ? self::FAILURE : self::SUCCESS; return $failed > 0 ? self::FAILURE : self::SUCCESS;
} }
private function normalizeOrder(string $order): string
{
$normalized = strtolower(trim($order));
return match ($normalized) {
'updated-desc', 'updated', 'latest', 'latest-updated' => 'updated-desc',
'id-asc', 'id', 'legacy' => 'id-asc',
default => 'updated-desc',
};
}
private function resolveAfterUpdatedAt(string $order, string $afterUpdatedAt): ?CarbonImmutable
{
if ($order !== 'updated-desc') {
return null;
}
$value = trim($afterUpdatedAt);
if ($value === '') {
return null;
}
try {
return CarbonImmutable::parse($value);
} catch (\Throwable) {
throw new InvalidArgumentException(sprintf('Invalid --after-updated-at value [%s]. Use an ISO-8601 timestamp.', $afterUpdatedAt));
}
}
/** /**
* @param array<string, string> $payload * @param array<string, string> $payload
*/ */

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Sitemaps\SitemapReleaseManager;
use Illuminate\Console\Command;
final class ListSitemapReleasesCommand extends Command
{
protected $signature = 'skinbase:sitemaps:releases';
protected $description = 'List recent sitemap releases and the active release.';
public function handle(SitemapReleaseManager $releases): int
{
$active = $releases->activeReleaseId();
foreach ($releases->listReleases() as $release) {
$this->line(sprintf(
'%s status=%s families=%d published_at=%s%s',
(string) ($release['release_id'] ?? 'unknown'),
(string) ($release['status'] ?? 'unknown'),
(int) data_get($release, 'totals.families', 0),
(string) ($release['published_at'] ?? 'n/a'),
(string) ($release['release_id'] ?? '') === $active ? ' [active]' : '',
));
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
final class NormalizeArtworkSlugsCommand extends Command
{
protected $signature = 'artworks:normalize-slugs
{--dry-run : Show the slug changes without writing them}
{--chunk=500 : Number of artworks to process per chunk}
{--only-mismatched : Only update rows whose current slug differs from the normalized title slug}';
protected $description = 'Normalize existing artwork slugs from artwork titles without enforcing uniqueness.';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$chunkSize = max(1, (int) $this->option('chunk'));
$onlyMismatched = (bool) $this->option('only-mismatched');
if (! $dryRun) {
$this->ensureSlugIsNotUnique();
}
$processed = 0;
$updated = 0;
DB::table('artworks')
->select(['id', 'title', 'slug'])
->orderBy('id')
->chunkById($chunkSize, function ($artworks) use ($dryRun, $onlyMismatched, &$processed, &$updated): void {
foreach ($artworks as $artwork) {
$processed++;
$normalizedSlug = Str::limit(Str::slug((string) ($artwork->title ?? '')) ?: 'artwork', 160, '');
$currentSlug = (string) ($artwork->slug ?? '');
if ($onlyMismatched && $currentSlug === $normalizedSlug) {
continue;
}
if ($currentSlug === $normalizedSlug) {
continue;
}
if ($dryRun) {
$this->line(sprintf('#%d %s => %s', $artwork->id, $currentSlug !== '' ? $currentSlug : '[empty]', $normalizedSlug));
$updated++;
continue;
}
DB::table('artworks')
->where('id', $artwork->id)
->update(['slug' => $normalizedSlug]);
$updated++;
}
});
if ($dryRun) {
$this->info(sprintf('Dry run complete. Checked %d artworks, %d would be updated.', $processed, $updated));
return self::SUCCESS;
}
$this->info(sprintf('Normalization complete. Checked %d artworks, updated %d.', $processed, $updated));
return self::SUCCESS;
}
private function ensureSlugIsNotUnique(): void
{
$driver = DB::getDriverName();
if ($driver === 'mysql') {
$indexes = collect(DB::select("SHOW INDEX FROM artworks WHERE Column_name = 'slug'"));
$indexes
->filter(fn ($index) => (int) ($index->Non_unique ?? 1) === 0)
->pluck('Key_name')
->filter()
->unique()
->each(function ($indexName): void {
$this->warn(sprintf('Dropping unique slug index %s before normalization.', $indexName));
DB::statement(sprintf('ALTER TABLE artworks DROP INDEX `%s`', str_replace('`', '``', (string) $indexName)));
});
$hasNonUniqueSlugIndex = $indexes->contains(fn ($index) => (string) ($index->Key_name ?? '') === 'artworks_slug_index' || (int) ($index->Non_unique ?? 0) === 1);
if (! $hasNonUniqueSlugIndex) {
DB::statement('CREATE INDEX artworks_slug_index ON artworks (slug)');
}
return;
}
if ($driver === 'sqlite') {
$indexes = collect(DB::select("PRAGMA index_list('artworks')"));
$indexes
->filter(function ($index): bool {
if ((int) ($index->unique ?? 0) !== 1) {
return false;
}
$columns = collect(DB::select(sprintf("PRAGMA index_info('%s')", str_replace("'", "''", (string) $index->name))))
->pluck('name')
->map(fn ($name) => (string) $name);
return $columns->contains('slug');
})
->pluck('name')
->each(fn ($indexName) => DB::statement(sprintf('DROP INDEX IF EXISTS "%s"', str_replace('"', '""', (string) $indexName))));
DB::statement('CREATE INDEX IF NOT EXISTS artworks_slug_index ON artworks (slug)');
}
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\NovaCard;
use App\Services\NovaCards\NovaCardPublishService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class PublishScheduledNovaCardsCommand extends Command
{
protected $signature = 'nova-cards:publish-scheduled {--dry-run : List scheduled cards without publishing} {--limit=100 : Max cards per run}';
protected $description = 'Publish scheduled Nova Cards whose scheduled time has passed.';
public function handle(NovaCardPublishService $publishService): int
{
$dryRun = (bool) $this->option('dry-run');
$limit = (int) $this->option('limit');
$now = now()->utc();
$candidates = NovaCard::query()
->where('status', NovaCard::STATUS_SCHEDULED)
->whereNotNull('scheduled_for')
->where('scheduled_for', '<=', $now)
->orderBy('scheduled_for')
->limit($limit)
->get(['id', 'title', 'scheduled_for']);
if ($candidates->isEmpty()) {
$this->line('No scheduled Nova Cards due for publishing.');
return self::SUCCESS;
}
$published = 0;
$errors = 0;
foreach ($candidates as $candidate) {
if ($dryRun) {
$this->line(sprintf('[dry-run] Would publish Nova Card #%d: "%s"', $candidate->id, $candidate->title));
continue;
}
try {
DB::transaction(function () use ($candidate, $publishService, &$published): void {
$card = NovaCard::query()
->lockForUpdate()
->where('id', $candidate->id)
->where('status', NovaCard::STATUS_SCHEDULED)
->first();
if (! $card) {
return;
}
$publishService->publishNow($card);
$published++;
$this->line(sprintf('Published Nova Card #%d: "%s"', $candidate->id, $candidate->title));
});
} catch (\Throwable $exception) {
$errors++;
Log::error('PublishScheduledNovaCardsCommand failed', [
'card_id' => $candidate->id,
'message' => $exception->getMessage(),
]);
$this->error(sprintf('Failed to publish Nova Card #%d: %s', $candidate->id, $exception->getMessage()));
}
}
if (! $dryRun) {
$this->info(sprintf('Done. Published: %d, Errors: %d.', $published, $errors));
}
return $errors > 0 ? self::FAILURE : self::SUCCESS;
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\Sitemaps\PublishSitemapReleaseJob;
use App\Services\Sitemaps\SitemapPublishService;
use Illuminate\Console\Command;
final class PublishSitemapsCommand extends Command
{
protected $signature = 'skinbase:sitemaps:publish
{--release= : Publish an existing built release}
{--queue : Dispatch publish flow to the queue}
{--sync : Run publish synchronously (default)}';
protected $description = 'Build, validate, and atomically publish a sitemap release.';
public function handle(SitemapPublishService $publish): int
{
$releaseId = $this->option('release');
if ((bool) $this->option('queue')) {
PublishSitemapReleaseJob::dispatch(is_string($releaseId) && $releaseId !== '' ? $releaseId : null);
$this->info('Queued sitemap publish flow' . (is_string($releaseId) && $releaseId !== '' ? ' for release [' . $releaseId . '].' : '.'));
return self::SUCCESS;
}
try {
$manifest = $publish->publish(is_string($releaseId) && $releaseId !== '' ? $releaseId : null);
} catch (\Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->info(sprintf(
'Published sitemap release [%s] with %d families and %d documents.',
(string) $manifest['release_id'],
(int) data_get($manifest, 'totals.families', 0),
(int) data_get($manifest, 'totals.documents', 0),
));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,168 @@
<?php
namespace App\Console\Commands;
use App\Enums\ModerationContentType;
use App\Enums\ModerationStatus;
use App\Models\ContentModerationFinding;
use App\Services\Moderation\ContentModerationActionLogService;
use App\Services\Moderation\ContentModerationProcessingService;
use App\Services\Moderation\ContentModerationSourceService;
use Illuminate\Console\Command;
class RescanContentModerationCommand extends Command
{
protected $signature = 'skinbase:rescan-content-moderation
{--only= : comments, descriptions, titles, bios, profile-links, collections, stories, cards, or a comma-separated list}
{--status=pending : Filter findings by moderation status}
{--limit= : Maximum number of findings to rescan}
{--from-id= : Start rescanning at or after this finding ID}
{--force : Rescan all matching findings, including already resolved findings}';
protected $description = 'Rescan existing moderation findings using the latest rules and scanner version.';
public function __construct(
private readonly ContentModerationProcessingService $processing,
private readonly ContentModerationSourceService $sources,
private readonly ContentModerationActionLogService $actionLogs,
) {
parent::__construct();
}
public function handle(): int
{
$limit = max(0, (int) ($this->option('limit') ?? 0));
$fromId = max(0, (int) ($this->option('from-id') ?? 0));
$status = trim((string) ($this->option('status') ?? 'pending'));
$force = (bool) $this->option('force');
$types = $this->selectedTypes();
$counts = [
'rescanned' => 0,
'updated' => 0,
'auto_hidden' => 0,
'missing_source' => 0,
];
$query = ContentModerationFinding::query()->orderBy('id');
if ($status !== '') {
$query->where('status', $status);
}
if ($fromId > 0) {
$query->where('id', '>=', $fromId);
}
if ($types !== []) {
$query->whereIn('content_type', array_map(static fn (ModerationContentType $type): string => $type->value, $types));
}
if (! $force) {
$query->where('status', ModerationStatus::Pending->value);
}
if ($limit > 0) {
$query->limit($limit);
}
$query->chunkById(100, function ($findings) use (&$counts): bool {
foreach ($findings as $finding) {
$counts['rescanned']++;
$rescanned = $this->processing->rescanFinding($finding, $this->sources);
if ($rescanned === null) {
$counts['missing_source']++;
continue;
}
$counts['updated']++;
if ($rescanned->is_auto_hidden) {
$counts['auto_hidden']++;
}
$this->actionLogs->log(
$rescanned,
'finding',
$rescanned->id,
'rescan',
null,
null,
$rescanned->status->value,
null,
null,
'Finding rescanned with the latest moderation rules.',
['scanner_version' => $rescanned->scanner_version],
);
}
return true;
}, 'id');
$this->table(['Metric', 'Count'], [
['Rescanned', $counts['rescanned']],
['Updated', $counts['updated']],
['Auto-hidden', $counts['auto_hidden']],
['Missing source', $counts['missing_source']],
]);
$this->info('Content moderation rescan complete.');
return self::SUCCESS;
}
/**
* @return array<int, ModerationContentType>
*/
private function selectedTypes(): array
{
$raw = trim((string) ($this->option('only') ?? ''));
if ($raw === '') {
return [];
}
$selected = \collect(explode(',', $raw))
->map(static fn (string $value): string => trim(strtolower($value)))
->filter()
->values();
$types = [];
if ($selected->contains('comments')) {
$types[] = ModerationContentType::ArtworkComment;
}
if ($selected->contains('descriptions')) {
$types[] = ModerationContentType::ArtworkDescription;
}
if ($selected->contains('titles') || $selected->contains('artwork_titles')) {
$types[] = ModerationContentType::ArtworkTitle;
}
if ($selected->contains('bios') || $selected->contains('user_bios')) {
$types[] = ModerationContentType::UserBio;
}
if ($selected->contains('profile-links') || $selected->contains('profile_links')) {
$types[] = ModerationContentType::UserProfileLink;
}
if ($selected->contains('collections') || $selected->contains('collection_titles')) {
$types[] = ModerationContentType::CollectionTitle;
$types[] = ModerationContentType::CollectionDescription;
}
if ($selected->contains('stories') || $selected->contains('story_titles')) {
$types[] = ModerationContentType::StoryTitle;
$types[] = ModerationContentType::StoryContent;
}
if ($selected->contains('cards') || $selected->contains('card_titles')) {
$types[] = ModerationContentType::CardTitle;
$types[] = ModerationContentType::CardText;
}
return $types;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Sitemaps\SitemapPublishService;
use Illuminate\Console\Command;
final class RollbackSitemapReleaseCommand extends Command
{
protected $signature = 'skinbase:sitemaps:rollback {release? : Release id to activate instead of the previous published release}';
protected $description = 'Rollback sitemap delivery to a previous published release.';
public function handle(SitemapPublishService $publish): int
{
try {
$manifest = $publish->rollback(($release = $this->argument('release')) !== null ? (string) $release : null);
} catch (\Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->info('Rolled back to sitemap release [' . (string) $manifest['release_id'] . '].');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,366 @@
<?php
namespace App\Console\Commands;
use App\Enums\ModerationContentType;
use App\Enums\ModerationStatus;
use App\Services\Moderation\ContentModerationPersistenceService;
use App\Services\Moderation\ContentModerationProcessingService;
use App\Services\Moderation\ContentModerationService;
use App\Services\Moderation\ContentModerationSourceService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ScanContentModerationCommand extends Command
{
protected $signature = 'skinbase:scan-content-moderation
{--only= : comments, descriptions, titles, bios, profile-links, collections, stories, cards, or a comma-separated list}
{--limit= : Maximum number of rows to scan}
{--from-id= : Start scanning at or after this source ID}
{--status= : Reserved for compatibility with rescan tooling}
{--force : Re-scan unchanged content}
{--dry-run : Analyze content without persisting findings}';
protected $description = 'Scan artwork comments and descriptions for suspicious or spam-like content.';
public function __construct(
private readonly ContentModerationService $moderation,
private readonly ContentModerationPersistenceService $persistence,
private readonly ContentModerationProcessingService $processing,
private readonly ContentModerationSourceService $sources,
) {
parent::__construct();
}
public function handle(): int
{
$targets = $this->targets();
$limit = max(0, (int) ($this->option('limit') ?? 0));
$remaining = $limit > 0 ? $limit : null;
$counts = [
'scanned' => 0,
'flagged' => 0,
'created' => 0,
'updated' => 0,
'skipped' => 0,
'clean' => 0,
'auto_hidden' => 0,
];
$this->announceScanStart($targets, $limit);
foreach ($targets as $target) {
if ($remaining !== null && $remaining <= 0) {
$this->comment('Scan limit reached. Stopping before the next content target.');
break;
}
$counts = $this->scanTarget($target, $counts, $remaining);
}
$this->table(['Metric', 'Count'], [
['Scanned', $counts['scanned']],
['Flagged', $counts['flagged']],
['Created', $counts['created']],
['Updated', $counts['updated']],
['Auto-hidden', $counts['auto_hidden']],
['Clean', $counts['clean']],
['Skipped', $counts['skipped']],
]);
Log::info('Content moderation scan complete.', [
'targets' => array_map(static fn (ModerationContentType $target): string => $target->value, $targets),
'limit' => $limit > 0 ? $limit : null,
'from_id' => max(0, (int) ($this->option('from-id') ?? 0)) ?: null,
'force' => (bool) $this->option('force'),
'dry_run' => (bool) $this->option('dry-run'),
'counts' => $counts,
]);
$this->info('Content moderation scan complete.');
return self::SUCCESS;
}
/**
* @param array<string, int> $counts
* @return array<string, int>
*/
private function scanTarget(ModerationContentType $target, array $counts, ?int &$remaining): array
{
$before = $counts;
$this->info('Scanning ' . $target->label() . ' entries...');
$query = match ($target) {
ModerationContentType::ArtworkComment,
ModerationContentType::ArtworkDescription,
ModerationContentType::ArtworkTitle,
ModerationContentType::UserBio,
ModerationContentType::UserProfileLink,
ModerationContentType::CollectionTitle,
ModerationContentType::CollectionDescription,
ModerationContentType::StoryTitle,
ModerationContentType::StoryContent,
ModerationContentType::CardTitle,
ModerationContentType::CardText => $this->sources->queryForType($target),
};
$fromId = max(0, (int) ($this->option('from-id') ?? 0));
if ($fromId > 0) {
$query->where('id', '>=', $fromId);
}
$query->chunkById(200, function ($rows) use ($target, &$counts, &$remaining): bool {
foreach ($rows as $row) {
if ($remaining !== null && $remaining <= 0) {
return false;
}
$context = $this->sources->buildContext($target, $row);
$snapshot = (string) ($context['content_snapshot'] ?? '');
$sourceId = (int) ($context['content_id'] ?? 0);
if ($snapshot === '') {
$counts['skipped']++;
$this->verboseLine($target, $sourceId, 'skipped empty snapshot');
continue;
}
$analysis = $this->moderation->analyze($snapshot, $context);
$counts['scanned']++;
if (! $this->option('force') && ! $this->option('dry-run') && $this->persistence->hasCurrentFinding(
(string) $context['content_type'],
(int) $context['content_id'],
$analysis->contentHash,
$analysis->scannerVersion,
)) {
$counts['skipped']++;
$this->verboseLine($target, $sourceId, 'skipped unchanged content');
$remaining = $remaining !== null ? $remaining - 1 : null;
continue;
}
if ($this->option('dry-run')) {
if ($analysis->status === ModerationStatus::Pending) {
$counts['flagged']++;
$this->verboseAnalysis($target, $sourceId, $analysis, 'dry-run flagged');
} else {
$counts['clean']++;
$this->verboseLine($target, $sourceId, 'dry-run clean');
}
$remaining = $remaining !== null ? $remaining - 1 : null;
continue;
}
$result = $this->processing->process($snapshot, $context, true);
if ($analysis->status !== ModerationStatus::Pending) {
$counts['clean']++;
if ($result['updated']) {
$counts['updated']++;
}
$this->verboseLine($target, $sourceId, $result['updated'] ? 'clean, existing finding updated' : 'clean');
$remaining = $remaining !== null ? $remaining - 1 : null;
continue;
}
$counts['flagged']++;
if ($result['created']) {
$counts['created']++;
} elseif ($result['updated']) {
$counts['updated']++;
}
if ($result['auto_hidden']) {
$counts['auto_hidden']++;
}
$outcome = $result['created']
? 'flagged, finding created'
: ($result['updated'] ? 'flagged, finding updated' : 'flagged');
if ($result['auto_hidden']) {
$outcome .= ', auto-hidden';
}
$this->verboseAnalysis($target, $sourceId, $analysis, $outcome);
$remaining = $remaining !== null ? $remaining - 1 : null;
}
return true;
}, 'id');
$targetCounts = [
'scanned' => $counts['scanned'] - $before['scanned'],
'flagged' => $counts['flagged'] - $before['flagged'],
'created' => $counts['created'] - $before['created'],
'updated' => $counts['updated'] - $before['updated'],
'auto_hidden' => $counts['auto_hidden'] - $before['auto_hidden'],
'clean' => $counts['clean'] - $before['clean'],
'skipped' => $counts['skipped'] - $before['skipped'],
];
$this->line(sprintf(
'Finished %s: scanned=%d, flagged=%d, created=%d, updated=%d, auto-hidden=%d, clean=%d, skipped=%d',
$target->label(),
$targetCounts['scanned'],
$targetCounts['flagged'],
$targetCounts['created'],
$targetCounts['updated'],
$targetCounts['auto_hidden'],
$targetCounts['clean'],
$targetCounts['skipped'],
));
return $counts;
}
/**
* @param array<int, ModerationContentType> $targets
*/
private function announceScanStart(array $targets, int $limit): void
{
$this->info('Starting content moderation scan...');
$this->line('Targets: ' . implode(', ', array_map(static fn (ModerationContentType $target): string => $target->label(), $targets)));
$this->line('Mode: ' . ($this->option('dry-run') ? 'dry-run' : 'persist findings'));
$this->line('Force re-scan: ' . ($this->option('force') ? 'yes' : 'no'));
$this->line('From source ID: ' . (max(0, (int) ($this->option('from-id') ?? 0)) ?: 'start'));
$this->line('Limit: ' . ($limit > 0 ? (string) $limit : 'none'));
if ($this->output->isVerbose()) {
$this->comment('Verbose mode enabled. Use -vv for detailed reasons and matched domains.');
}
}
private function verboseLine(ModerationContentType $target, int $sourceId, string $message): void
{
if (! $this->output->isVerbose()) {
return;
}
$this->line(sprintf('[%s #%d] %s', $target->value, $sourceId, $message));
}
private function verboseAnalysis(ModerationContentType $target, int $sourceId, mixed $analysis, string $prefix): void
{
if (! $this->output->isVerbose()) {
return;
}
$message = sprintf(
'[%s #%d] %s; score=%d; severity=%s; policy=%s; queue=%s',
$target->value,
$sourceId,
$prefix,
$analysis->score,
$analysis->severity->value,
$analysis->policyName ?? 'default',
$analysis->status->value,
);
if ($analysis->priorityScore !== null) {
$message .= '; priority=' . $analysis->priorityScore;
}
if ($analysis->reviewBucket !== null) {
$message .= '; bucket=' . $analysis->reviewBucket;
}
if ($analysis->aiLabel !== null) {
$message .= '; ai=' . $analysis->aiLabel;
if ($analysis->aiConfidence !== null) {
$message .= ' (' . $analysis->aiConfidence . '%)';
}
}
$this->line($message);
if ($this->output->isVeryVerbose()) {
if ($analysis->matchedDomains !== []) {
$this->line(' matched domains: ' . implode(', ', $analysis->matchedDomains));
}
if ($analysis->matchedKeywords !== []) {
$this->line(' matched keywords: ' . implode(', ', $analysis->matchedKeywords));
}
if ($analysis->reasons !== []) {
$this->line(' reasons: ' . implode(' | ', $analysis->reasons));
}
}
}
/**
* @return array<int, ModerationContentType>
*/
private function targets(): array
{
$raw = trim((string) ($this->option('only') ?? ''));
if ($raw === '') {
return [
ModerationContentType::ArtworkComment,
ModerationContentType::ArtworkDescription,
];
}
$selected = collect(explode(',', $raw))
->map(static fn (string $value): string => trim(strtolower($value)))
->filter()
->values();
$targets = [];
if ($selected->contains('comments')) {
$targets[] = ModerationContentType::ArtworkComment;
}
if ($selected->contains('descriptions')) {
$targets[] = ModerationContentType::ArtworkDescription;
}
if ($selected->contains('titles') || $selected->contains('artwork_titles')) {
$targets[] = ModerationContentType::ArtworkTitle;
}
if ($selected->contains('bios') || $selected->contains('user_bios')) {
$targets[] = ModerationContentType::UserBio;
}
if ($selected->contains('profile-links') || $selected->contains('profile_links')) {
$targets[] = ModerationContentType::UserProfileLink;
}
if ($selected->contains('collections') || $selected->contains('collection_titles')) {
$targets[] = ModerationContentType::CollectionTitle;
$targets[] = ModerationContentType::CollectionDescription;
}
if ($selected->contains('stories') || $selected->contains('story_titles')) {
$targets[] = ModerationContentType::StoryTitle;
$targets[] = ModerationContentType::StoryContent;
}
if ($selected->contains('cards') || $selected->contains('card_titles')) {
$targets[] = ModerationContentType::CardTitle;
$targets[] = ModerationContentType::CardText;
}
return $targets === [] ? [
ModerationContentType::ArtworkComment,
ModerationContentType::ArtworkDescription,
ModerationContentType::ArtworkTitle,
ModerationContentType::UserBio,
ModerationContentType::UserProfileLink,
ModerationContentType::CollectionTitle,
ModerationContentType::CollectionDescription,
ModerationContentType::StoryTitle,
ModerationContentType::StoryContent,
ModerationContentType::CardTitle,
ModerationContentType::CardText,
] : $targets;
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\UserStatsService;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class SyncArtworkCreatedAtCommand extends Command
{
public function __construct(private readonly UserStatsService $userStats)
{
parent::__construct();
}
protected $signature = 'artworks:sync-created-at
{--chunk=500 : Number of artworks to process per batch}
{--only-null : Update only artworks whose created_at is null}
{--dry-run : Preview changes without writing updates}';
protected $description = 'Copy artworks.published_at into artworks.created_at for published artworks.';
public function handle(): int
{
$chunk = max(1, (int) $this->option('chunk'));
$onlyNull = (bool) $this->option('only-null');
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->warn('[DRY RUN] No changes will be written.');
}
$query = DB::table('artworks')
->select(['id', 'user_id', 'created_at', 'published_at'])
->whereNotNull('published_at')
->orderBy('id');
if ($onlyNull) {
$query->whereNull('created_at');
}
$processed = 0;
$updated = 0;
$unchanged = 0;
$affectedUserIds = [];
$query->chunkById($chunk, function (Collection $rows) use (&$processed, &$updated, &$unchanged, &$affectedUserIds, $dryRun): void {
foreach ($rows as $row) {
$processed++;
$publishedAt = $this->normalizeTimestamp($row->published_at ?? null);
$createdAt = $this->normalizeTimestamp($row->created_at ?? null);
if ($publishedAt === null) {
$unchanged++;
continue;
}
if ($createdAt === $publishedAt) {
$unchanged++;
continue;
}
if ($dryRun) {
$this->line(sprintf(
'[dry] Would update artwork id=%d created_at %s => %s',
(int) $row->id,
$createdAt ?? '<null>',
$publishedAt
));
$updated++;
continue;
}
DB::table('artworks')
->where('id', (int) $row->id)
->update([
'created_at' => $publishedAt,
'updated_at' => now()->toDateTimeString(),
]);
$affectedUserIds[(int) $row->user_id] = true;
$updated++;
$this->line(sprintf('[update] artwork id=%d created_at => %s', (int) $row->id, $publishedAt));
}
}, 'id');
if (! $dryRun) {
foreach (array_keys($affectedUserIds) as $userId) {
$this->userStats->recomputeUser((int) $userId);
}
}
$this->info(sprintf('Finished. processed=%d updated=%d unchanged=%d', $processed, $updated, $unchanged));
return self::SUCCESS;
}
private function normalizeTimestamp(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}
try {
return Carbon::parse((string) $value)->toDateTimeString();
} catch (\Throwable) {
return null;
}
}
}

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Sitemaps\SitemapBuildService;
use App\Services\Sitemaps\SitemapReleaseManager;
use App\Services\Sitemaps\SitemapReleaseValidator;
use App\Services\Sitemaps\SitemapValidationService;
use Illuminate\Console\Command;
final class ValidateSitemapsCommand extends Command
{
protected $signature = 'skinbase:sitemaps:validate
{--only=* : Limit validation to one or more sitemap families}
{--release= : Validate a specific sitemap release}
{--active : Validate the active published sitemap release when available}';
protected $description = 'Validate sitemap XML, shard integrity, and public URL safety.';
public function handle(SitemapValidationService $validation, SitemapBuildService $build, SitemapReleaseManager $releases, SitemapReleaseValidator $releaseValidator): int
{
$startedAt = microtime(true);
$families = $this->selectedFamilies($build);
$releaseId = ($value = $this->option('release')) !== null && trim((string) $value) !== ''
? trim((string) $value)
: ((bool) $this->option('active') ? $releases->activeReleaseId() : $releases->activeReleaseId());
if (is_string($releaseId) && $releaseId !== '') {
$report = $releaseValidator->validate($releaseId);
foreach ((array) ($report['families'] ?? []) as $familyReport) {
$this->line(sprintf(
'Family [%s]: documents=%d urls=%d shards=%d',
(string) $familyReport['family'],
(int) $familyReport['documents'],
(int) $familyReport['url_count'],
(int) $familyReport['shard_count'],
));
foreach ((array) ($familyReport['warnings'] ?? []) as $warning) {
$this->warn(' - ' . $warning);
}
foreach ((array) ($familyReport['errors'] ?? []) as $error) {
$this->error(' - ' . $error);
}
}
$this->info(sprintf(
'Sitemap release validation finished in %.2fs. release=%s families=%d documents=%d urls=%d shards=%d',
microtime(true) - $startedAt,
$releaseId,
(int) data_get($report, 'totals.families', 0),
(int) data_get($report, 'totals.documents', 0),
(int) data_get($report, 'totals.urls', 0),
(int) data_get($report, 'totals.shards', 0),
));
if ((bool) ($report['ok'] ?? false)) {
$this->info('Sitemap validation passed.');
return self::SUCCESS;
}
return self::FAILURE;
}
if ($families === []) {
$this->error('No valid sitemap families were selected for validation.');
return self::INVALID;
}
$report = $validation->validate($families);
foreach ((array) ($report['index']['errors'] ?? []) as $error) {
$this->error('Index: ' . $error);
}
foreach ((array) ($report['families'] ?? []) as $familyReport) {
$this->line(sprintf(
'Family [%s]: documents=%d urls=%d shards=%d',
(string) $familyReport['family'],
(int) $familyReport['documents'],
(int) $familyReport['url_count'],
(int) $familyReport['shard_count'],
));
foreach ((array) ($familyReport['warnings'] ?? []) as $warning) {
$this->warn(' - ' . $warning);
}
foreach ((array) ($familyReport['errors'] ?? []) as $error) {
$this->error(' - ' . $error);
}
}
if ((array) ($report['duplicates'] ?? []) !== []) {
foreach ((array) $report['duplicates'] as $duplicate) {
$this->error('Duplicate URL detected: ' . $duplicate);
}
}
$this->info(sprintf(
'Sitemap validation finished in %.2fs. families=%d documents=%d urls=%d shards=%d',
microtime(true) - $startedAt,
(int) data_get($report, 'totals.families', 0),
(int) data_get($report, 'totals.documents', 0),
(int) data_get($report, 'totals.urls', 0),
(int) data_get($report, 'totals.shards', 0),
));
if ((bool) ($report['ok'] ?? false)) {
$this->info('Sitemap validation passed.');
return self::SUCCESS;
}
return self::FAILURE;
}
/**
* @return list<string>
*/
private function selectedFamilies(SitemapBuildService $build): array
{
$only = [];
foreach ((array) $this->option('only') as $value) {
foreach (explode(',', (string) $value) as $family) {
$normalized = trim($family);
if ($normalized !== '') {
$only[] = $normalized;
}
}
}
$enabled = $build->enabledFamilies();
if ($only === []) {
return $enabled;
}
return array_values(array_filter($enabled, fn (string $family): bool => in_array($family, $only, true)));
}
}

View File

@@ -29,8 +29,16 @@ use App\Jobs\RecalculateRisingNovaCardsJob;
use App\Jobs\RankComputeArtworkScoresJob; use App\Jobs\RankComputeArtworkScoresJob;
use App\Jobs\RankBuildListsJob; use App\Jobs\RankBuildListsJob;
use App\Uploads\Commands\CleanupUploadsCommand; use App\Uploads\Commands\CleanupUploadsCommand;
use App\Console\Commands\NormalizeArtworkSlugsCommand;
use App\Console\Commands\PublishScheduledArtworksCommand; use App\Console\Commands\PublishScheduledArtworksCommand;
use App\Console\Commands\PublishScheduledNovaCardsCommand;
use App\Console\Commands\BuildSitemapsCommand;
use App\Console\Commands\ListSitemapReleasesCommand;
use App\Console\Commands\PublishSitemapsCommand;
use App\Console\Commands\RollbackSitemapReleaseCommand;
use App\Console\Commands\SyncCollectionLifecycleCommand; use App\Console\Commands\SyncCollectionLifecycleCommand;
use App\Console\Commands\ValidateSitemapsCommand;
use App\Jobs\Sitemaps\CleanupSitemapReleasesJob;
class Kernel extends ConsoleKernel class Kernel extends ConsoleKernel
{ {
@@ -48,8 +56,15 @@ class Kernel extends ConsoleKernel
\App\Console\Commands\AvatarsBulkUpdate::class, \App\Console\Commands\AvatarsBulkUpdate::class,
\App\Console\Commands\ResetAllUserPasswords::class, \App\Console\Commands\ResetAllUserPasswords::class,
CleanupUploadsCommand::class, CleanupUploadsCommand::class,
BuildSitemapsCommand::class,
PublishSitemapsCommand::class,
ListSitemapReleasesCommand::class,
RollbackSitemapReleaseCommand::class,
NormalizeArtworkSlugsCommand::class,
PublishScheduledArtworksCommand::class, PublishScheduledArtworksCommand::class,
PublishScheduledNovaCardsCommand::class,
SyncCollectionLifecycleCommand::class, SyncCollectionLifecycleCommand::class,
ValidateSitemapsCommand::class,
DispatchCollectionMaintenanceCommand::class, DispatchCollectionMaintenanceCommand::class,
BackfillArtworkEmbeddingsCommand::class, BackfillArtworkEmbeddingsCommand::class,
BackfillArtworkVectorIndexCommand::class, BackfillArtworkVectorIndexCommand::class,
@@ -77,12 +92,34 @@ class Kernel extends ConsoleKernel
{ {
$schedule->command('uploads:cleanup')->dailyAt('03:00'); $schedule->command('uploads:cleanup')->dailyAt('03:00');
$schedule->command('skinbase:sitemaps:publish --sync')
->everySixHours()
->name('sitemaps-publish')
->withoutOverlapping()
->runInBackground();
$schedule->command('skinbase:sitemaps:validate')
->dailyAt('04:45')
->name('sitemaps-validate')
->withoutOverlapping()
->runInBackground();
$schedule->job(new CleanupSitemapReleasesJob)
->dailyAt('05:00')
->name('sitemaps-cleanup')
->withoutOverlapping()
->runInBackground();
// Publish artworks whose scheduled publish_at has passed // Publish artworks whose scheduled publish_at has passed
$schedule->command('artworks:publish-scheduled') $schedule->command('artworks:publish-scheduled')
->everyMinute() ->everyMinute()
->name('publish-scheduled-artworks') ->name('publish-scheduled-artworks')
->withoutOverlapping(2) // prevent overlap up to 2 minutes ->withoutOverlapping(2) // prevent overlap up to 2 minutes
->runInBackground(); ->runInBackground();
$schedule->command('nova-cards:publish-scheduled')
->everyMinute()
->name('publish-scheduled-nova-cards')
->withoutOverlapping(2)
->runInBackground();
$schedule->command('collections:sync-lifecycle') $schedule->command('collections:sync-lifecycle')
->everyTenMinutes() ->everyTenMinutes()
->name('sync-collection-lifecycle') ->name('sync-collection-lifecycle')

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Contracts\Images;
use App\Data\Images\SubjectDetectionResultData;
interface SubjectDetectorInterface
{
/**
* @param array<string, mixed> $context
*/
public function detect(string $sourcePath, int $sourceWidth, int $sourceHeight, array $context = []): ?SubjectDetectionResultData;
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Contracts\Moderation;
interface ModerationRuleInterface
{
/**
* Analyze the given content and return an array of findings.
*
* Each finding is an associative array with:
* - 'rule' => string (rule identifier)
* - 'score' => int (score contribution)
* - 'reason' => string (human-readable reason)
* - 'links' => array (matched URLs, if any)
* - 'domains' => array (matched domains, if any)
* - 'keywords' => array (matched keywords, if any)
*
* @param string $content The raw text content to analyze
* @param string $normalized Lowercase/trimmed version for matching
* @param array $context Optional metadata (user_id, artwork_id, content_type, etc.)
* @return array<int, array>
*/
public function analyze(string $content, string $normalized, array $context = []): array;
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Contracts\Moderation;
use App\Data\Moderation\ModerationResultData;
use App\Data\Moderation\ModerationSuggestionData;
interface ModerationSuggestionProviderInterface
{
/**
* @param array<string, mixed> $context
*/
public function suggest(string $content, ModerationResultData $result, array $context = []): ModerationSuggestionData;
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Data\Images;
final readonly class CropBoxData
{
public function __construct(
public int $x,
public int $y,
public int $width,
public int $height,
) {
}
public function clampToImage(int $imageWidth, int $imageHeight): self
{
$width = max(1, min($this->width, max(1, $imageWidth)));
$height = max(1, min($this->height, max(1, $imageHeight)));
$x = max(0, min($this->x, max(0, $imageWidth - $width)));
$y = max(0, min($this->y, max(0, $imageHeight - $height)));
return new self($x, $y, $width, $height);
}
public function centerX(): float
{
return $this->x + ($this->width / 2);
}
public function centerY(): float
{
return $this->y + ($this->height / 2);
}
/**
* @return array{x: int, y: int, width: int, height: int}
*/
public function toArray(): array
{
return [
'x' => $this->x,
'y' => $this->y,
'width' => $this->width,
'height' => $this->height,
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Data\Images;
final readonly class SquareThumbnailResultData
{
/**
* @param array<string, mixed> $meta
*/
public function __construct(
public string $destinationPath,
public CropBoxData $cropBox,
public string $cropMode,
public int $sourceWidth,
public int $sourceHeight,
public int $targetWidth,
public int $targetHeight,
public int $outputWidth,
public int $outputHeight,
public ?string $detectionReason = null,
public array $meta = [],
) {
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'destination_path' => $this->destinationPath,
'crop_mode' => $this->cropMode,
'source_width' => $this->sourceWidth,
'source_height' => $this->sourceHeight,
'target_width' => $this->targetWidth,
'target_height' => $this->targetHeight,
'output_width' => $this->outputWidth,
'output_height' => $this->outputHeight,
'detection_reason' => $this->detectionReason,
'crop_box' => $this->cropBox->toArray(),
'meta' => $this->meta,
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Data\Images;
final readonly class SubjectDetectionResultData
{
/**
* @param array<string, mixed> $meta
*/
public function __construct(
public CropBoxData $cropBox,
public string $strategy,
public ?string $reason = null,
public float $confidence = 0.0,
public array $meta = [],
) {
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Data\Moderation;
use App\Enums\ModerationStatus;
use App\Enums\ModerationSeverity;
class ModerationResultData
{
public function __construct(
public readonly int $score,
public readonly ModerationSeverity $severity,
public readonly ModerationStatus $status,
public readonly array $reasons,
public readonly array $matchedLinks,
public readonly array $matchedDomains,
public readonly array $matchedKeywords,
public readonly string $contentHash,
public readonly string $scannerVersion,
public readonly array $ruleHits = [],
public readonly ?string $contentHashNormalized = null,
public readonly ?string $groupKey = null,
public readonly ?int $userRiskScore = null,
public readonly bool $autoHideRecommended = false,
public readonly ?string $contentTargetType = null,
public readonly ?int $contentTargetId = null,
public readonly ?string $campaignKey = null,
public readonly ?int $clusterScore = null,
public readonly ?string $clusterReason = null,
public readonly ?string $policyName = null,
public readonly ?int $priorityScore = null,
public readonly ?string $reviewBucket = null,
public readonly ?string $escalationStatus = null,
public readonly ?string $aiProvider = null,
public readonly ?string $aiLabel = null,
public readonly ?string $aiSuggestedAction = null,
public readonly ?int $aiConfidence = null,
public readonly ?string $aiExplanation = null,
public readonly array $aiRawResponse = [],
public readonly array $scoreBreakdown = [],
) {}
public function isClean(): bool
{
return $this->severity === ModerationSeverity::Low && $this->score === 0;
}
public function isSuspicious(): bool
{
return $this->score >= app('config')->get('content_moderation.severity_thresholds.medium', 30);
}
public function toArray(): array
{
return [
'score' => $this->score,
'severity' => $this->severity->value,
'status' => $this->status->value,
'reasons' => $this->reasons,
'matched_links' => $this->matchedLinks,
'matched_domains' => $this->matchedDomains,
'matched_keywords' => $this->matchedKeywords,
'content_hash' => $this->contentHash,
'scanner_version' => $this->scannerVersion,
'rule_hits' => $this->ruleHits,
'content_hash_normalized' => $this->contentHashNormalized,
'group_key' => $this->groupKey,
'user_risk_score' => $this->userRiskScore,
'auto_hide_recommended' => $this->autoHideRecommended,
'content_target_type' => $this->contentTargetType,
'content_target_id' => $this->contentTargetId,
'campaign_key' => $this->campaignKey,
'cluster_score' => $this->clusterScore,
'cluster_reason' => $this->clusterReason,
'policy_name' => $this->policyName,
'priority_score' => $this->priorityScore,
'review_bucket' => $this->reviewBucket,
'escalation_status' => $this->escalationStatus,
'ai_provider' => $this->aiProvider,
'ai_label' => $this->aiLabel,
'ai_suggested_action' => $this->aiSuggestedAction,
'ai_confidence' => $this->aiConfidence,
'ai_explanation' => $this->aiExplanation,
'ai_raw_response' => $this->aiRawResponse,
'score_breakdown' => $this->scoreBreakdown,
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Data\Moderation;
class ModerationSuggestionData
{
/**
* @param array<string, mixed> $rawResponse
* @param array<int, string> $campaignTags
*/
public function __construct(
public readonly string $provider,
public readonly ?string $suggestedLabel = null,
public readonly ?string $suggestedAction = null,
public readonly ?int $confidence = null,
public readonly ?string $explanation = null,
public readonly array $campaignTags = [],
public readonly array $rawResponse = [],
) {
}
public function isEmpty(): bool
{
return $this->suggestedLabel === null
&& $this->suggestedAction === null
&& $this->confidence === null
&& $this->explanation === null;
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Enums;
enum ModerationActionType: string
{
case MarkSafe = 'mark_safe';
case ConfirmSpam = 'confirm_spam';
case Ignore = 'ignore';
case Resolve = 'resolve';
case HideComment = 'hide_comment';
case HideArtwork = 'hide_artwork';
case AutoHideComment = 'auto_hide_comment';
case AutoHideArtwork = 'auto_hide_artwork';
case RestoreComment = 'restore_comment';
case RestoreArtwork = 'restore_artwork';
case BlockDomain = 'block_domain';
case MarkDomainSuspicious = 'mark_domain_suspicious';
case AllowDomain = 'allow_domain';
case Rescan = 'rescan';
case BulkReview = 'bulk_review';
case MarkFalsePositive = 'mark_false_positive';
case Escalate = 'escalate';
case ResolveCluster = 'resolve_cluster';
case ReviewerFeedback = 'reviewer_feedback';
public function label(): string
{
return match ($this) {
self::MarkSafe => 'Mark Safe',
self::ConfirmSpam => 'Confirm Spam',
self::Ignore => 'Ignore',
self::Resolve => 'Resolve',
self::HideComment => 'Hide Comment',
self::HideArtwork => 'Hide Artwork',
self::AutoHideComment => 'Auto-hide Comment',
self::AutoHideArtwork => 'Auto-hide Artwork',
self::RestoreComment => 'Restore Comment',
self::RestoreArtwork => 'Restore Artwork',
self::BlockDomain => 'Block Domain',
self::MarkDomainSuspicious => 'Mark Domain Suspicious',
self::AllowDomain => 'Allow Domain',
self::Rescan => 'Rescan',
self::BulkReview => 'Bulk Review',
self::MarkFalsePositive => 'Mark False Positive',
self::Escalate => 'Escalate',
self::ResolveCluster => 'Resolve Cluster',
self::ReviewerFeedback => 'Reviewer Feedback',
};
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Enums;
enum ModerationContentType: string
{
case ArtworkComment = 'artwork_comment';
case ArtworkDescription = 'artwork_description';
case ArtworkTitle = 'artwork_title';
case UserBio = 'user_bio';
case UserProfileLink = 'user_profile_link';
case CollectionTitle = 'collection_title';
case CollectionDescription = 'collection_description';
case StoryTitle = 'story_title';
case StoryContent = 'story_content';
case CardTitle = 'card_title';
case CardText = 'card_text';
public function label(): string
{
return match ($this) {
self::ArtworkComment => 'Artwork Comment',
self::ArtworkDescription => 'Artwork Description',
self::ArtworkTitle => 'Artwork Title',
self::UserBio => 'User Bio',
self::UserProfileLink => 'User Profile Link',
self::CollectionTitle => 'Collection Title',
self::CollectionDescription => 'Collection Description',
self::StoryTitle => 'Story Title',
self::StoryContent => 'Story Content',
self::CardTitle => 'Card Title',
self::CardText => 'Card Text',
};
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Enums;
enum ModerationDomainStatus: string
{
case Allowed = 'allowed';
case Neutral = 'neutral';
case Suspicious = 'suspicious';
case Blocked = 'blocked';
case Escalated = 'escalated';
case ReviewRequired = 'review_required';
public function label(): string
{
return match ($this) {
self::Allowed => 'Allowed',
self::Neutral => 'Neutral',
self::Suspicious => 'Suspicious',
self::Blocked => 'Blocked',
self::Escalated => 'Escalated',
self::ReviewRequired => 'Review Required',
};
}
public function badgeClass(): string
{
return match ($this) {
self::Allowed => 'badge-success',
self::Neutral => 'badge-light',
self::Suspicious => 'badge-warning',
self::Blocked => 'badge-danger',
self::Escalated => 'badge-dark',
self::ReviewRequired => 'badge-info',
};
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Enums;
enum ModerationEscalationStatus: string
{
case None = 'none';
case ReviewRequired = 'review_required';
case Escalated = 'escalated';
case Urgent = 'urgent';
public function label(): string
{
return match ($this) {
self::None => 'None',
self::ReviewRequired => 'Review Required',
self::Escalated => 'Escalated',
self::Urgent => 'Urgent',
};
}
public function badgeClass(): string
{
return match ($this) {
self::None => 'badge-light',
self::ReviewRequired => 'badge-info',
self::Escalated => 'badge-warning',
self::Urgent => 'badge-danger',
};
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Enums;
enum ModerationRuleType: string
{
case SuspiciousKeyword = 'suspicious_keyword';
case HighRiskKeyword = 'high_risk_keyword';
case Regex = 'regex';
public function label(): string
{
return match ($this) {
self::SuspiciousKeyword => 'Suspicious Keyword',
self::HighRiskKeyword => 'High-risk Keyword',
self::Regex => 'Regex Rule',
};
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Enums;
enum ModerationSeverity: string
{
case Low = 'low';
case Medium = 'medium';
case High = 'high';
case Critical = 'critical';
public function label(): string
{
return match ($this) {
self::Low => 'Low',
self::Medium => 'Medium',
self::High => 'High',
self::Critical => 'Critical',
};
}
public function badgeClass(): string
{
return match ($this) {
self::Low => 'badge-light',
self::Medium => 'badge-warning',
self::High => 'badge-danger',
self::Critical => 'badge-dark text-white',
};
}
public static function fromScore(int $score): self
{
$thresholds = app('config')->get('content_moderation.severity_thresholds', [
'critical' => 90,
'high' => 60,
'medium' => 30,
]);
if ($score >= $thresholds['critical']) {
return self::Critical;
}
if ($score >= $thresholds['high']) {
return self::High;
}
if ($score >= $thresholds['medium']) {
return self::Medium;
}
return self::Low;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Enums;
enum ModerationStatus: string
{
case Pending = 'pending';
case ReviewedSafe = 'reviewed_safe';
case ConfirmedSpam = 'confirmed_spam';
case Ignored = 'ignored';
case Resolved = 'resolved';
public function label(): string
{
return match ($this) {
self::Pending => 'Pending',
self::ReviewedSafe => 'Safe',
self::ConfirmedSpam => 'Spam',
self::Ignored => 'Ignored',
self::Resolved => 'Resolved',
};
}
public function badgeClass(): string
{
return match ($this) {
self::Pending => 'badge-warning',
self::ReviewedSafe => 'badge-success',
self::ConfirmedSpam => 'badge-danger',
self::Ignored => 'badge-secondary',
self::Resolved => 'badge-info',
};
}
}

View File

@@ -105,6 +105,13 @@ final class ArtworkDownloadController extends Controller
*/ */
private function resolveDownloadUrl(Artwork $artwork): string private function resolveDownloadUrl(Artwork $artwork): string
{ {
$filePath = trim((string) ($artwork->file_path ?? ''), '/');
$cdn = rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/');
if ($filePath !== '') {
return $cdn . '/' . $filePath;
}
$hash = $artwork->hash ?? null; $hash = $artwork->hash ?? null;
$ext = ltrim((string) ($artwork->file_ext ?: $artwork->thumb_ext ?: 'webp'), '.'); $ext = ltrim((string) ($artwork->file_ext ?: $artwork->thumb_ext ?: 'webp'), '.');
@@ -112,9 +119,9 @@ final class ArtworkDownloadController extends Controller
$h = strtolower(preg_replace('/[^a-f0-9]/', '', $hash)); $h = strtolower(preg_replace('/[^a-f0-9]/', '', $hash));
$h1 = substr($h, 0, 2); $h1 = substr($h, 0, 2);
$h2 = substr($h, 2, 2); $h2 = substr($h, 2, 2);
$cdn = rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'); $prefix = trim((string) config('uploads.object_storage.prefix', 'artworks'), '/');
return sprintf('%s/original/%s/%s/%s.%s', $cdn, $h1, $h2, $h, $ext); return sprintf('%s/%s/original/%s/%s/%s.%s', $cdn, $prefix, $h1, $h2, $h, $ext);
} }
// Fallback: best available thumbnail size // Fallback: best available thumbnail size

View File

@@ -16,6 +16,7 @@ use App\Services\NovaCards\NovaCardDraftService;
use App\Services\NovaCards\NovaCardPresenter; use App\Services\NovaCards\NovaCardPresenter;
use App\Services\NovaCards\NovaCardPublishService; use App\Services\NovaCards\NovaCardPublishService;
use App\Services\NovaCards\NovaCardRenderService; use App\Services\NovaCards\NovaCardRenderService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -115,9 +116,10 @@ class NovaCardDraftController extends Controller
public function publish(SaveNovaCardDraftRequest $request, int $id): JsonResponse public function publish(SaveNovaCardDraftRequest $request, int $id): JsonResponse
{ {
$card = $this->editableCard($request, $id); $card = $this->editableCard($request, $id);
$validated = $request->validated();
if ($request->validated() !== []) { if ($validated !== []) {
$card = $this->drafts->autosave($card, $request->validated()); $card = $this->drafts->autosave($card, $validated);
} }
if (trim((string) $card->title) === '' || trim((string) $card->quote_text) === '') { if (trim((string) $card->title) === '' || trim((string) $card->quote_text) === '') {
@@ -126,6 +128,32 @@ class NovaCardDraftController extends Controller
], Response::HTTP_UNPROCESSABLE_ENTITY); ], Response::HTTP_UNPROCESSABLE_ENTITY);
} }
$publishMode = (string) ($validated['publish_mode'] ?? 'now');
if ($publishMode === 'schedule') {
if (empty($validated['scheduled_for'])) {
return response()->json([
'message' => 'Choose a date and time for scheduled publishing.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
try {
$card = $this->publishes->schedule(
$card->loadMissing('backgroundImage'),
Carbon::parse((string) $validated['scheduled_for']),
isset($validated['scheduling_timezone']) ? (string) $validated['scheduling_timezone'] : null,
);
} catch (\InvalidArgumentException $exception) {
return response()->json([
'message' => $exception->getMessage(),
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
]);
}
$card = $this->publishes->publishNow($card->loadMissing('backgroundImage')); $card = $this->publishes->publishNow($card->loadMissing('backgroundImage'));
event(new NovaCardPublished($card)); event(new NovaCardPublished($card));

View File

@@ -83,6 +83,11 @@ final class UploadController extends Controller
$sessionId = (string) $request->validated('session_id'); $sessionId = (string) $request->validated('session_id');
$artworkId = (int) $request->validated('artwork_id'); $artworkId = (int) $request->validated('artwork_id');
$originalFileName = $request->validated('file_name'); $originalFileName = $request->validated('file_name');
$archiveSessionId = $request->validated('archive_session_id');
$archiveOriginalFileName = $request->validated('archive_file_name');
$additionalScreenshotSessions = collect($request->validated('additional_screenshot_sessions', []))
->filter(fn ($payload) => is_array($payload) && is_string($payload['session_id'] ?? null))
->values();
$session = $sessions->getOrFail($sessionId); $session = $sessions->getOrFail($sessionId);
@@ -112,14 +117,81 @@ final class UploadController extends Controller
], Response::HTTP_UNPROCESSABLE_ENTITY); ], Response::HTTP_UNPROCESSABLE_ENTITY);
} }
$validatedArchive = null;
if (is_string($archiveSessionId) && trim($archiveSessionId) !== '') {
$validatedArchive = $pipeline->validateAndHashArchive($archiveSessionId);
if (! $validatedArchive->validation->ok || ! $validatedArchive->hash) {
return response()->json([
'message' => 'Archive validation failed.',
'reason' => $validatedArchive->validation->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$archiveScan = $pipeline->scan($archiveSessionId);
if (! $archiveScan->ok) {
return response()->json([
'message' => 'Archive scan failed.',
'reason' => $archiveScan->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
$validatedAdditionalScreenshots = [];
foreach ($additionalScreenshotSessions as $payload) {
$screenshotSessionId = (string) ($payload['session_id'] ?? '');
if ($screenshotSessionId === '') {
continue;
}
$validatedScreenshot = $pipeline->validateAndHash($screenshotSessionId);
if (! $validatedScreenshot->validation->ok || ! $validatedScreenshot->hash) {
return response()->json([
'message' => 'Screenshot validation failed.',
'reason' => $validatedScreenshot->validation->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$screenshotScan = $pipeline->scan($screenshotSessionId);
if (! $screenshotScan->ok) {
return response()->json([
'message' => 'Screenshot scan failed.',
'reason' => $screenshotScan->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$validatedAdditionalScreenshots[] = [
'session_id' => $screenshotSessionId,
'hash' => $validatedScreenshot->hash,
'file_name' => is_string($payload['file_name'] ?? null) ? $payload['file_name'] : null,
];
}
try { try {
$status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId, $originalFileName) { $status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId, $originalFileName, $archiveSessionId, $validatedArchive, $archiveOriginalFileName, $validatedAdditionalScreenshots) {
if ((bool) config('uploads.queue_derivatives', false)) { if ((bool) config('uploads.queue_derivatives', false)) {
GenerateDerivativesJob::dispatch($sessionId, $validated->hash, $artworkId, is_string($originalFileName) ? $originalFileName : null)->afterCommit(); GenerateDerivativesJob::dispatch(
$sessionId,
$validated->hash,
$artworkId,
is_string($originalFileName) ? $originalFileName : null,
is_string($archiveSessionId) ? $archiveSessionId : null,
$validatedArchive?->hash,
is_string($archiveOriginalFileName) ? $archiveOriginalFileName : null,
$validatedAdditionalScreenshots
)->afterCommit();
return 'queued'; return 'queued';
} }
$pipeline->processAndPublish($sessionId, $validated->hash, $artworkId, is_string($originalFileName) ? $originalFileName : null); $pipeline->processAndPublish(
$sessionId,
$validated->hash,
$artworkId,
is_string($originalFileName) ? $originalFileName : null,
is_string($archiveSessionId) ? $archiveSessionId : null,
$validatedArchive?->hash,
is_string($archiveOriginalFileName) ? $archiveOriginalFileName : null,
$validatedAdditionalScreenshots
);
// Derivatives are available now; dispatch AI auto-tagging. // Derivatives are available now; dispatch AI auto-tagging.
AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit(); AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit();
@@ -132,6 +204,8 @@ final class UploadController extends Controller
'hash' => $validated->hash, 'hash' => $validated->hash,
'artwork_id' => $artworkId, 'artwork_id' => $artworkId,
'status' => $status, 'status' => $status,
'archive_session_id' => is_string($archiveSessionId) ? $archiveSessionId : null,
'additional_screenshot_session_ids' => array_values(array_map(static fn (array $payload): string => (string) $payload['session_id'], $validatedAdditionalScreenshots)),
]); ]);
return response()->json([ return response()->json([
@@ -540,13 +614,6 @@ final class UploadController extends Controller
$slugBase = 'artwork'; $slugBase = 'artwork';
} }
$slug = $slugBase;
$suffix = 2;
while (Artwork::query()->where('slug', $slug)->where('id', '!=', $artwork->id)->exists()) {
$slug = $slugBase . '-' . $suffix;
$suffix++;
}
$artwork->title = $title; $artwork->title = $title;
if (array_key_exists('description', $validated)) { if (array_key_exists('description', $validated)) {
$artwork->description = $validated['description']; $artwork->description = $validated['description'];
@@ -554,7 +621,7 @@ final class UploadController extends Controller
if (array_key_exists('is_mature', $validated) || array_key_exists('nsfw', $validated)) { if (array_key_exists('is_mature', $validated) || array_key_exists('nsfw', $validated)) {
$artwork->is_mature = (bool) ($validated['is_mature'] ?? $validated['nsfw'] ?? false); $artwork->is_mature = (bool) ($validated['is_mature'] ?? $validated['nsfw'] ?? false);
} }
$artwork->slug = $slug; $artwork->slug = Str::limit($slugBase, 160, '');
$artwork->artwork_timezone = $validated['timezone'] ?? null; $artwork->artwork_timezone = $validated['timezone'] ?? null;
// Sync category if provided // Sync category if provided

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\ArtworkIndexRequest; use App\Http\Requests\ArtworkIndexRequest;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\Category; use App\Models\Category;
use App\Models\ContentType;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
@@ -66,7 +67,7 @@ class ArtworkController extends Controller
$artworkSlug = $artwork->slug; $artworkSlug = $artwork->slug;
} elseif ($artwork) { } elseif ($artwork) {
$artworkSlug = (string) $artwork; $artworkSlug = (string) $artwork;
$foundArtwork = Artwork::where('slug', $artworkSlug)->first(); $foundArtwork = $this->findArtworkForCategoryPath($contentTypeSlug, $categoryPath, $artworkSlug);
} }
// When the URL can represent a nested category path (e.g. /skins/audio/winamp), // When the URL can represent a nested category path (e.g. /skins/audio/winamp),
@@ -104,4 +105,24 @@ class ArtworkController extends Controller
$foundArtwork->slug, $foundArtwork->slug,
); );
} }
private function findArtworkForCategoryPath(string $contentTypeSlug, string $categoryPath, string $artworkSlug): ?Artwork
{
$contentType = ContentType::query()->where('slug', strtolower($contentTypeSlug))->first();
$segments = array_values(array_filter(explode('/', trim($categoryPath, '/'))));
$category = $contentType ? Category::findByPath($contentType->slug, $segments) : null;
$query = Artwork::query()->where('slug', $artworkSlug);
if ($category) {
$query->whereHas('categories', function ($categoryQuery) use ($category): void {
$categoryQuery->where('categories.id', $category->id);
});
}
return $query
->orderByDesc('published_at')
->orderByDesc('id')
->first();
}
} }

View File

@@ -27,6 +27,11 @@ final class ArtworkDownloadController extends Controller
'webp', 'webp',
'bmp', 'bmp',
'tiff', 'tiff',
'zip',
'rar',
'7z',
'tar',
'gz',
]; ];
public function __invoke(Request $request, int $id): BinaryFileResponse public function __invoke(Request $request, int $id): BinaryFileResponse
@@ -36,22 +41,19 @@ final class ArtworkDownloadController extends Controller
abort(404); abort(404);
} }
$hash = strtolower((string) $artwork->hash); $filePath = $this->resolveOriginalPath($artwork);
$ext = strtolower(ltrim((string) $artwork->file_ext, '.')); $ext = strtolower(ltrim((string) pathinfo($filePath, PATHINFO_EXTENSION), '.'));
if (! $this->isValidHash($hash) || ! in_array($ext, self::ALLOWED_EXTENSIONS, true)) { if ($filePath === '' || ! in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
abort(404); abort(404);
} }
$filePath = $this->resolveOriginalPath($hash, $ext);
$this->recordDownload($request, $artwork->id); $this->recordDownload($request, $artwork->id);
$this->incrementDownloadCountIfAvailable($artwork->id); $this->incrementDownloadCountIfAvailable($artwork->id);
if (! File::isFile($filePath)) { if (! File::isFile($filePath)) {
Log::warning('Artwork original file missing for download.', [ Log::warning('Artwork original file missing for download.', [
'artwork_id' => $artwork->id, 'artwork_id' => $artwork->id,
'hash' => $hash,
'ext' => $ext, 'ext' => $ext,
'resolved_path' => $filePath, 'resolved_path' => $filePath,
]); ]);
@@ -65,16 +67,29 @@ final class ArtworkDownloadController extends Controller
return response()->download($filePath, $downloadName); return response()->download($filePath, $downloadName);
} }
private function resolveOriginalPath(string $hash, string $ext): string private function resolveOriginalPath(Artwork $artwork): string
{ {
$firstDir = substr($hash, 0, 2); $relative = trim((string) $artwork->file_path, '/');
$secondDir = substr($hash, 2, 2); $prefix = trim((string) config('uploads.object_storage.prefix', 'artworks'), '/') . '/original/';
$root = rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR);
if ($relative !== '' && str_starts_with($relative, $prefix)) {
$suffix = substr($relative, strlen($prefix));
$root = rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
return $root . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, (string) $suffix);
}
$hash = strtolower((string) $artwork->hash);
$ext = strtolower(ltrim((string) $artwork->file_ext, '.'));
if (! $this->isValidHash($hash) || $ext === '') {
return '';
}
$root = rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
return $root return $root
. DIRECTORY_SEPARATOR . 'original' . DIRECTORY_SEPARATOR . substr($hash, 0, 2)
. DIRECTORY_SEPARATOR . $firstDir . DIRECTORY_SEPARATOR . substr($hash, 2, 2)
. DIRECTORY_SEPARATOR . $secondDir
. DIRECTORY_SEPARATOR . $hash . '.' . $ext; . DIRECTORY_SEPARATOR . $hash . '.' . $ext;
} }

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Internal;
use App\Http\Controllers\Controller;
use App\Models\NovaCard;
use App\Services\NovaCards\NovaCardPresenter;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class NovaCardRenderFrameController extends Controller
{
public function __construct(
private readonly NovaCardPresenter $presenter,
) {
}
public function show(Request $request, string $uuid): Response
{
abort_unless($request->hasValidSignature(), 403);
$card = NovaCard::query()
->with(['backgroundImage', 'user'])
->where('uuid', $uuid)
->firstOrFail();
$format = config('nova_cards.formats.' . $card->format) ?? config('nova_cards.formats.square');
$width = (int) ($format['width'] ?? 1080);
$height = (int) ($format['height'] ?? 1080);
$cardData = $this->presenter->card($card, true, $card->user);
$fonts = collect((array) config('nova_cards.font_presets', []))
->map(fn (array $v, string $k): array => array_merge($v, ['key' => $k]))
->values()
->all();
return response()->view('nova-cards.render-frame', compact('cardData', 'fonts', 'width', 'height'));
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\Response;
final class RobotsTxtController extends Controller
{
public function __invoke(): Response
{
$content = implode("\n", [
'User-agent: *',
'Allow: /',
'Sitemap: ' . url('/sitemap.xml'),
'',
]);
return response($content, 200, [
'Content-Type' => 'text/plain; charset=UTF-8',
'Cache-Control' => 'public, max-age=' . max(60, (int) config('sitemaps.cache_ttl_seconds', 900)),
]);
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Services\Sitemaps\SitemapBuildService;
use App\Services\Sitemaps\PublishedSitemapResolver;
use App\Services\Sitemaps\SitemapXmlRenderer;
use Illuminate\Http\Response;
final class SitemapController extends Controller
{
public function __construct(
private readonly SitemapBuildService $build,
private readonly PublishedSitemapResolver $published,
private readonly SitemapXmlRenderer $renderer,
) {
}
public function index(): Response
{
if ((bool) config('sitemaps.delivery.prefer_published_release', true)) {
$published = $this->published->resolveIndex();
if ($published !== null) {
return $this->renderer->xmlResponse($published['content']);
}
}
abort_unless((bool) config('sitemaps.delivery.fallback_to_live_build', true), 404);
$built = $this->build->buildIndex(
force: false,
persist: (bool) config('sitemaps.refresh.build_on_request', true),
);
return $this->renderer->xmlResponse($built['content']);
}
public function show(string $name): Response
{
if ((bool) config('sitemaps.delivery.prefer_published_release', true)) {
$published = $this->published->resolveNamed($name);
if ($published !== null) {
return $this->renderer->xmlResponse($published['content']);
}
}
abort_unless((bool) config('sitemaps.delivery.fallback_to_live_build', true), 404);
$built = $this->build->buildNamed(
$name,
force: false,
persist: (bool) config('sitemaps.refresh.build_on_request', true),
);
abort_if($built === null, 404);
return $this->renderer->xmlResponse($built['content']);
}
}

View File

@@ -9,6 +9,7 @@ use App\Models\Artwork;
use App\Models\Category; use App\Models\Category;
use App\Models\ContentType; use App\Models\ContentType;
use App\Models\ArtworkVersion; use App\Models\ArtworkVersion;
use App\Services\Cdn\ArtworkCdnPurgeService;
use App\Services\ArtworkSearchIndexer; use App\Services\ArtworkSearchIndexer;
use App\Services\TagService; use App\Services\TagService;
use App\Services\ArtworkVersioningService; use App\Services\ArtworkVersioningService;
@@ -36,6 +37,7 @@ final class StudioArtworksApiController extends Controller
private readonly ArtworkSearchIndexer $searchIndexer, private readonly ArtworkSearchIndexer $searchIndexer,
private readonly TagDiscoveryService $tagDiscoveryService, private readonly TagDiscoveryService $tagDiscoveryService,
private readonly TagService $tagService, private readonly TagService $tagService,
private readonly ArtworkCdnPurgeService $cdnPurge,
) {} ) {}
/** /**
@@ -419,17 +421,18 @@ final class StudioArtworksApiController extends Controller
$artworkFiles = app(\App\Repositories\Uploads\ArtworkFileRepository::class); $artworkFiles = app(\App\Repositories\Uploads\ArtworkFileRepository::class);
// 1. Store original on disk (preserve extension when possible) // 1. Store original on disk (preserve extension when possible)
$originalPath = $derivatives->storeOriginal($tempPath, $hash); $originalAsset = $derivatives->storeOriginal($tempPath, $hash);
$originalPath = $originalAsset['local_path'];
$origFilename = basename($originalPath); $origFilename = basename($originalPath);
$originalRelative = $storage->sectionRelativePath('original', $hash, $origFilename); $originalRelative = $storage->sectionRelativePath('original', $hash, $origFilename);
$origMime = File::exists($originalPath) ? File::mimeType($originalPath) : 'application/octet-stream'; $origMime = File::exists($originalPath) ? File::mimeType($originalPath) : 'application/octet-stream';
$artworkFiles->upsert($artwork->id, 'orig', $originalRelative, $origMime, (int) filesize($originalPath)); $artworkFiles->upsert($artwork->id, 'orig', $originalRelative, $origMime, (int) filesize($originalPath));
// 2. Generate thumbnails (xs/sm/md/lg/xl) // 2. Generate thumbnails (xs/sm/md/lg/xl)
$publicAbsolute = $derivatives->generatePublicDerivatives($tempPath, $hash); $publicAssets = $derivatives->generatePublicDerivatives($tempPath, $hash);
foreach ($publicAbsolute as $variant => $absolutePath) { foreach ($publicAssets as $variant => $asset) {
$relativePath = $storage->sectionRelativePath($variant, $hash, $hash . '.webp'); $relativePath = $storage->sectionRelativePath($variant, $hash, $hash . '.webp');
$artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) filesize($absolutePath)); $artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) ($asset['size'] ?? 0));
} }
// 3. Get new dimensions // 3. Get new dimensions
@@ -592,18 +595,10 @@ final class StudioArtworksApiController extends Controller
private function purgeCdnCache(\App\Models\Artwork $artwork, string $oldHash): void private function purgeCdnCache(\App\Models\Artwork $artwork, string $oldHash): void
{ {
try { try {
$purgeUrl = config('cdn.purge_url'); $this->cdnPurge->purgeArtworkHashVariants($oldHash, 'webp', ['xs', 'sm', 'md', 'lg', 'xl', 'sq'], [
if (empty($purgeUrl)) { 'artwork_id' => $artwork->id,
Log::debug('CDN purge skipped — cdn.purge_url not configured', ['artwork_id' => $artwork->id]); 'reason' => 'artwork_file_replaced',
return; ]);
}
$paths = array_map(
fn (string $size) => "/thumbs/{$oldHash}/{$size}.webp",
['sm', 'md', 'lg', 'xl']
);
\Illuminate\Support\Facades\Http::timeout(5)->post($purgeUrl, ['paths' => $paths]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::warning('CDN cache purge failed', ['artwork_id' => $artwork->id, 'error' => $e->getMessage()]); Log::warning('CDN cache purge failed', ['artwork_id' => $artwork->id, 'error' => $e->getMessage()]);
} }

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Services\Studio\CreatorStudioCommentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class StudioCommentsApiController extends Controller
{
public function __construct(
private readonly CreatorStudioCommentService $comments,
) {
}
public function reply(Request $request, string $module, int $commentId): JsonResponse
{
$payload = $request->validate([
'content' => ['required', 'string', 'min:1', 'max:10000'],
]);
$this->comments->reply($request->user(), $module, $commentId, (string) $payload['content']);
return response()->json(['ok' => true]);
}
public function moderate(Request $request, string $module, int $commentId): JsonResponse
{
$this->comments->moderate($request->user(), $module, $commentId);
return response()->json(['ok' => true]);
}
public function report(Request $request, string $module, int $commentId): JsonResponse
{
$payload = $request->validate([
'reason' => ['required', 'string', 'max:120'],
'details' => ['nullable', 'string', 'max:4000'],
]);
return response()->json([
'ok' => true,
'report' => $this->comments->report(
$request->user(),
$module,
$commentId,
(string) $payload['reason'],
isset($payload['details']) ? (string) $payload['details'] : null,
),
], 201);
}
}

View File

@@ -5,10 +5,25 @@ declare(strict_types=1);
namespace App\Http\Controllers\Studio; namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\ContentType; use App\Models\ContentType;
use App\Services\Studio\StudioMetricsService; use App\Services\Studio\CreatorStudioAnalyticsService;
use App\Services\Studio\CreatorStudioAssetService;
use App\Services\Studio\CreatorStudioCalendarService;
use App\Services\Studio\CreatorStudioCommentService;
use App\Services\Studio\CreatorStudioContentService;
use App\Services\Studio\CreatorStudioFollowersService;
use App\Services\Studio\CreatorStudioGrowthService;
use App\Services\Studio\CreatorStudioActivityService;
use App\Services\Studio\CreatorStudioInboxService;
use App\Services\Studio\CreatorStudioOverviewService;
use App\Services\Studio\CreatorStudioPreferenceService;
use App\Services\Studio\CreatorStudioChallengeService;
use App\Services\Studio\CreatorStudioSearchService;
use App\Services\Studio\CreatorStudioScheduledService;
use App\Support\CoverUrl;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -18,20 +33,51 @@ use Inertia\Response;
final class StudioController extends Controller final class StudioController extends Controller
{ {
public function __construct( public function __construct(
private readonly StudioMetricsService $metrics, private readonly CreatorStudioOverviewService $overview,
private readonly CreatorStudioContentService $content,
private readonly CreatorStudioAnalyticsService $analytics,
private readonly CreatorStudioFollowersService $followers,
private readonly CreatorStudioCommentService $comments,
private readonly CreatorStudioAssetService $assets,
private readonly CreatorStudioPreferenceService $preferences,
private readonly CreatorStudioScheduledService $scheduled,
private readonly CreatorStudioActivityService $activity,
private readonly CreatorStudioCalendarService $calendar,
private readonly CreatorStudioInboxService $inbox,
private readonly CreatorStudioSearchService $search,
private readonly CreatorStudioChallengeService $challenges,
private readonly CreatorStudioGrowthService $growth,
) {} ) {}
/** /**
* Studio Overview Dashboard (/studio) * Studio Overview Dashboard (/studio)
*/ */
public function index(Request $request): Response public function index(Request $request): Response|RedirectResponse
{ {
$userId = $request->user()->id; $user = $request->user();
$prefs = $this->preferences->forUser($user);
if (! $request->boolean('overview') && $prefs['default_landing_page'] !== 'overview') {
return redirect()->route($this->landingPageRoute($prefs['default_landing_page']), $request->query(), 302);
}
return Inertia::render('Studio/StudioDashboard', [ return Inertia::render('Studio/StudioDashboard', [
'kpis' => $this->metrics->getDashboardKpis($userId), 'overview' => $this->overview->build($user),
'topPerformers' => $this->metrics->getTopPerformers($userId, 6), 'analytics' => $this->analytics->overview($user, $prefs['analytics_range_days']),
'recentComments' => $this->metrics->getRecentComments($userId, 5), ]);
}
public function content(Request $request): Response
{
$prefs = $this->preferences->forUser($request->user());
$listing = $this->content->list($request->user(), $request->only(['module', 'bucket', 'q', 'sort', 'page', 'per_page', 'category', 'tag', 'visibility', 'activity_state', 'stale']));
$listing['default_view'] = $prefs['default_content_view'];
return Inertia::render('Studio/StudioContentIndex', [
'title' => 'Content',
'description' => 'Manage every artwork, card, collection, and story from one queue.',
'listing' => $listing,
'quickCreate' => $this->content->quickCreate(),
]); ]);
} }
@@ -40,28 +86,329 @@ final class StudioController extends Controller
*/ */
public function artworks(Request $request): Response public function artworks(Request $request): Response
{ {
$provider = $this->content->provider('artworks');
$prefs = $this->preferences->forUser($request->user());
$listing = $this->content->list($request->user(), $request->only(['q', 'sort', 'bucket', 'page', 'per_page', 'category', 'tag']), null, 'artworks');
$listing['default_view'] = $prefs['default_content_view'];
return Inertia::render('Studio/StudioArtworks', [ return Inertia::render('Studio/StudioArtworks', [
'categories' => $this->getCategories(), 'title' => 'Artworks',
'description' => 'Upload, manage, and review long-form visual work from the shared Creator Studio workflow.',
'summary' => $provider?->summary($request->user()),
'listing' => $listing,
'quickCreate' => $this->content->quickCreate(),
]); ]);
} }
/** /**
* Drafts (/studio/artworks/drafts) * Drafts (/studio/drafts)
*/ */
public function drafts(Request $request): Response public function drafts(Request $request): Response
{ {
$prefs = $this->preferences->forUser($request->user());
$listing = $this->content->list($request->user(), $request->only(['module', 'q', 'sort', 'page', 'per_page', 'stale']), 'drafts');
$listing['default_view'] = $prefs['default_content_view'];
return Inertia::render('Studio/StudioDrafts', [ return Inertia::render('Studio/StudioDrafts', [
'categories' => $this->getCategories(), 'title' => 'Drafts',
'description' => 'Resume unfinished work across every creator module.',
'listing' => $listing,
'quickCreate' => $this->content->quickCreate(),
]); ]);
} }
/** /**
* Archived (/studio/artworks/archived) * Archived (/studio/archived)
*/ */
public function archived(Request $request): Response public function archived(Request $request): Response
{ {
$prefs = $this->preferences->forUser($request->user());
$listing = $this->content->list($request->user(), $request->only(['module', 'q', 'sort', 'page', 'per_page']), 'archived');
$listing['default_view'] = $prefs['default_content_view'];
return Inertia::render('Studio/StudioArchived', [ return Inertia::render('Studio/StudioArchived', [
'categories' => $this->getCategories(), 'title' => 'Archived',
'description' => 'Review hidden, rejected, and archived content in one place.',
'listing' => $listing,
'quickCreate' => $this->content->quickCreate(),
]);
}
public function scheduled(Request $request): Response
{
$listing = $this->scheduled->list($request->user(), $request->only(['module', 'q', 'page', 'per_page', 'range', 'start_date', 'end_date']));
return Inertia::render('Studio/StudioScheduled', [
'title' => 'Scheduled',
'description' => 'Keep track of upcoming publishes across artworks, cards, collections, and stories.',
'listing' => $listing,
'endpoints' => [
'publishNowPattern' => route('api.studio.schedule.publishNow', ['module' => '__MODULE__', 'id' => '__ID__']),
'unschedulePattern' => route('api.studio.schedule.unschedule', ['module' => '__MODULE__', 'id' => '__ID__']),
],
]);
}
public function calendar(Request $request): Response
{
return Inertia::render('Studio/StudioCalendar', [
'title' => 'Calendar',
'description' => 'Plan publishing cadence, spot overloaded days, and move quickly between scheduled work and the unscheduled queue.',
'calendar' => $this->calendar->build($request->user(), $request->only(['view', 'module', 'status', 'q', 'focus_date'])),
'endpoints' => [
'publishNowPattern' => route('api.studio.schedule.publishNow', ['module' => '__MODULE__', 'id' => '__ID__']),
'unschedulePattern' => route('api.studio.schedule.unschedule', ['module' => '__MODULE__', 'id' => '__ID__']),
],
]);
}
public function collections(Request $request): Response
{
$provider = $this->content->provider('collections');
$prefs = $this->preferences->forUser($request->user());
$listing = $this->content->list($request->user(), $request->only(['q', 'sort', 'page', 'per_page', 'visibility']), null, 'collections');
$listing['default_view'] = $prefs['default_content_view'];
return Inertia::render('Studio/StudioCollections', [
'title' => 'Collections',
'description' => 'Curate sets, track collection performance, and keep editorial surfaces organised.',
'summary' => $provider?->summary($request->user()),
'listing' => $listing,
'quickCreate' => $this->content->quickCreate(),
'dashboardUrl' => route('settings.collections.dashboard'),
]);
}
public function stories(Request $request): Response
{
$provider = $this->content->provider('stories');
$prefs = $this->preferences->forUser($request->user());
$listing = $this->content->list($request->user(), $request->only(['q', 'sort', 'page', 'per_page', 'activity_state']), null, 'stories');
$listing['default_view'] = $prefs['default_content_view'];
return Inertia::render('Studio/StudioStories', [
'title' => 'Stories',
'description' => 'Track drafts, jump into the editor, and monitor story reach from Studio.',
'summary' => $provider?->summary($request->user()),
'listing' => $listing,
'quickCreate' => $this->content->quickCreate(),
'dashboardUrl' => route('creator.stories.index'),
]);
}
public function assets(Request $request): Response
{
return Inertia::render('Studio/StudioAssets', [
'title' => 'Assets',
'description' => 'A reusable creator asset library for card backgrounds, story covers, collection covers, artwork previews, and profile branding.',
'assets' => $this->assets->library($request->user(), $request->only(['type', 'source', 'sort', 'q', 'page', 'per_page'])),
]);
}
public function comments(Request $request): Response
{
$listing = $this->comments->list($request->user(), $request->only(['module', 'q', 'page', 'per_page']));
return Inertia::render('Studio/StudioComments', [
'title' => 'Comments',
'description' => 'View context, reply in place, remove unsafe comments, and report issues across all of your content.',
'listing' => $listing,
'endpoints' => [
'replyPattern' => route('api.studio.comments.reply', ['module' => '__MODULE__', 'commentId' => '__COMMENT__']),
'moderatePattern' => route('api.studio.comments.moderate', ['module' => '__MODULE__', 'commentId' => '__COMMENT__']),
'reportPattern' => route('api.studio.comments.report', ['module' => '__MODULE__', 'commentId' => '__COMMENT__']),
],
]);
}
public function followers(Request $request): Response
{
return Inertia::render('Studio/StudioFollowers', [
'title' => 'Followers',
'description' => 'See who is following your work, who follows back, and which supporters are most established.',
'listing' => $this->followers->list($request->user(), $request->only(['q', 'sort', 'relationship', 'page'])),
]);
}
public function activity(Request $request): Response
{
return Inertia::render('Studio/StudioActivity', [
'title' => 'Activity',
'description' => 'One creator-facing inbox for notifications, comments, and follower activity.',
'listing' => $this->activity->list($request->user(), $request->only(['type', 'module', 'q', 'page', 'per_page'])),
'endpoints' => [
'markAllRead' => route('api.studio.activity.readAll'),
],
]);
}
public function inbox(Request $request): Response
{
return Inertia::render('Studio/StudioInbox', [
'title' => 'Inbox',
'description' => 'A creator-first response surface for comments, notifications, followers, reminders, and what needs attention now.',
'inbox' => $this->inbox->build($request->user(), $request->only(['type', 'module', 'q', 'page', 'per_page', 'read_state', 'priority'])),
'endpoints' => [
'markAllRead' => route('api.studio.activity.readAll'),
],
]);
}
public function search(Request $request): Response
{
return Inertia::render('Studio/StudioSearch', [
'title' => 'Search',
'description' => 'Search across content, comments, inbox signals, and reusable assets without leaving Creator Studio.',
'search' => $this->search->build($request->user(), $request->only(['q', 'module', 'type'])),
'quickCreate' => $this->content->quickCreate(),
]);
}
public function challenges(Request $request): Response
{
$data = $this->challenges->build($request->user());
return Inertia::render('Studio/StudioChallenges', [
'title' => 'Challenges',
'description' => 'Track active Nova Cards challenge runs, review your submissions, and keep challenge-ready cards close to hand.',
'summary' => $data['summary'],
'spotlight' => $data['spotlight'],
'activeChallenges' => $data['active_challenges'],
'recentEntries' => $data['recent_entries'],
'cardLeaders' => $data['card_leaders'],
'reminders' => $data['reminders'],
]);
}
public function growth(Request $request): Response
{
$prefs = $this->preferences->forUser($request->user());
$rangeDays = in_array((int) $request->query('range_days', 0), [7, 14, 30, 60, 90], true)
? (int) $request->query('range_days')
: $prefs['analytics_range_days'];
$data = $this->growth->build($request->user(), $rangeDays);
return Inertia::render('Studio/StudioGrowth', [
'title' => 'Growth',
'description' => 'A creator-readable view of profile readiness, publishing cadence, engagement momentum, and challenge participation.',
'summary' => $data['summary'],
'moduleFocus' => $data['module_focus'],
'checkpoints' => $data['checkpoints'],
'opportunities' => $data['opportunities'],
'milestones' => $data['milestones'],
'momentum' => $data['momentum'],
'topContent' => $data['top_content'],
'rangeDays' => $data['range_days'],
]);
}
public function profile(Request $request): Response
{
$user = $request->user()->loadMissing(['profile', 'statistics']);
$prefs = $this->preferences->forUser($user);
$socialLinks = DB::table('user_social_links')
->where('user_id', $user->id)
->orderBy('platform')
->get(['platform', 'url'])
->map(fn ($row): array => [
'platform' => (string) $row->platform,
'url' => (string) $row->url,
])
->values()
->all();
return Inertia::render('Studio/StudioProfile', [
'title' => 'Profile',
'description' => 'Keep your public creator presence aligned with the work you are publishing.',
'profile' => [
'name' => $user->name,
'username' => $user->username,
'bio' => $user->profile?->about,
'tagline' => $user->profile?->description,
'location' => $user->profile?->country,
'website' => $user->profile?->website,
'avatar_url' => $user->profile?->avatar_url,
'cover_url' => $user->cover_hash && $user->cover_ext ? CoverUrl::forUser($user->cover_hash, $user->cover_ext, time()) : null,
'cover_position' => (int) ($user->cover_position ?? 50),
'followers' => (int) ($user->statistics?->followers_count ?? 0),
'profile_url' => '/@' . strtolower((string) $user->username),
'social_links' => $socialLinks,
],
'moduleSummaries' => $this->content->moduleSummaries($user),
'featuredModules' => $prefs['featured_modules'],
'featuredContent' => $this->content->selectedItems($user, $prefs['featured_content']),
'endpoints' => [
'profile' => route('api.studio.preferences.profile'),
'avatarUpload' => route('avatar.upload'),
'coverUpload' => route('api.profile.cover.upload'),
'coverPosition' => route('api.profile.cover.position'),
'coverDelete' => route('api.profile.cover.destroy'),
],
]);
}
public function featured(Request $request): Response
{
$prefs = $this->preferences->forUser($request->user());
return Inertia::render('Studio/StudioFeatured', [
'title' => 'Featured',
'description' => 'Choose the artwork, card, collection, and story that should represent each module on your public profile.',
'items' => $this->content->featuredCandidates($request->user(), 12),
'selected' => $prefs['featured_content'],
'featuredModules' => $prefs['featured_modules'],
'endpoints' => [
'save' => route('api.studio.preferences.featured'),
],
]);
}
public function settings(Request $request): Response
{
return Inertia::render('Studio/StudioSettings', [
'title' => 'Settings',
'description' => 'Keep system handoff links, legacy dashboards, and future Studio control surfaces organized in one place.',
'links' => [
['label' => 'Profile settings', 'url' => route('settings.profile'), 'icon' => 'fa-solid fa-user-gear'],
['label' => 'Collection dashboard', 'url' => route('settings.collections.dashboard'), 'icon' => 'fa-solid fa-layer-group'],
['label' => 'Story dashboard', 'url' => route('creator.stories.index'), 'icon' => 'fa-solid fa-feather-pointed'],
['label' => 'Followers', 'url' => route('dashboard.followers'), 'icon' => 'fa-solid fa-user-group'],
['label' => 'Received comments', 'url' => route('dashboard.comments.received'), 'icon' => 'fa-solid fa-comments'],
],
'sections' => [
[
'title' => 'Studio preferences moved into their own surface',
'body' => 'Use the dedicated Preferences page for layout, landing page, analytics window, widget order, and shortcut controls.',
'href' => route('studio.preferences'),
'cta' => 'Open preferences',
],
[
'title' => 'Future-ready control points',
'body' => 'Notification routing, automation defaults, and collaboration hooks can plug into this settings surface without overloading creator workflow pages.',
'href' => route('studio.growth'),
'cta' => 'Review growth',
],
],
]);
}
public function preferences(Request $request): Response
{
$prefs = $this->preferences->forUser($request->user());
return Inertia::render('Studio/StudioPreferences', [
'title' => 'Preferences',
'description' => 'Control how Creator Studio opens, which widgets stay visible, and where your daily workflow starts.',
'preferences' => $prefs,
'links' => [
['label' => 'Profile settings', 'url' => route('settings.profile'), 'icon' => 'fa-solid fa-user-gear'],
['label' => 'Featured content', 'url' => route('studio.featured'), 'icon' => 'fa-solid fa-wand-magic-sparkles'],
['label' => 'Growth overview', 'url' => route('studio.growth'), 'icon' => 'fa-solid fa-chart-line'],
['label' => 'Studio settings', 'url' => route('studio.settings'), 'icon' => 'fa-solid fa-sliders'],
],
'endpoints' => [
'save' => route('api.studio.preferences.settings'),
],
]); ]);
} }
@@ -151,14 +498,24 @@ final class StudioController extends Controller
*/ */
public function analyticsOverview(Request $request): Response public function analyticsOverview(Request $request): Response
{ {
$userId = $request->user()->id; $user = $request->user();
$data = $this->metrics->getAnalyticsOverview($userId); $prefs = $this->preferences->forUser($user);
$rangeDays = in_array((int) $request->query('range_days', 0), [7, 14, 30, 60, 90], true)
? (int) $request->query('range_days')
: $prefs['analytics_range_days'];
$data = $this->analytics->overview($user, $rangeDays);
return Inertia::render('Studio/StudioAnalytics', [ return Inertia::render('Studio/StudioAnalytics', [
'totals' => $data['totals'], 'totals' => $data['totals'],
'topArtworks' => $data['top_artworks'], 'topContent' => $data['top_content'],
'contentBreakdown' => $data['content_breakdown'], 'moduleBreakdown' => $data['module_breakdown'],
'recentComments' => $this->metrics->getRecentComments($userId, 8), 'viewsTrend' => $data['views_trend'],
'engagementTrend' => $data['engagement_trend'],
'publishingTimeline' => $data['publishing_timeline'],
'comparison' => $data['comparison'],
'insightBlocks' => $data['insight_blocks'],
'rangeDays' => $data['range_days'],
'recentComments' => $this->overview->recentComments($user, 8),
]); ]);
} }
@@ -184,4 +541,22 @@ final class StudioController extends Controller
]; ];
})->values()->all(); })->values()->all();
} }
private function landingPageRoute(string $page): string
{
return match ($page) {
'content' => 'studio.content',
'drafts' => 'studio.drafts',
'scheduled' => 'studio.scheduled',
'analytics' => 'studio.analytics',
'activity' => 'studio.activity',
'calendar' => 'studio.calendar',
'inbox' => 'studio.inbox',
'search' => 'studio.search',
'growth' => 'studio.growth',
'challenges' => 'studio.challenges',
'preferences' => 'studio.preferences',
default => 'studio.index',
};
}
} }

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Services\Studio\CreatorStudioEventService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
final class StudioEventsApiController extends Controller
{
public function __construct(
private readonly CreatorStudioEventService $events,
) {
}
public function store(Request $request): JsonResponse
{
$payload = $request->validate([
'event_type' => ['required', 'string', Rule::in($this->events->allowedEvents())],
'module' => ['sometimes', 'nullable', 'string', 'max:40'],
'surface' => ['sometimes', 'nullable', 'string', 'max:120'],
'item_module' => ['sometimes', 'nullable', 'string', 'max:40'],
'item_id' => ['sometimes', 'nullable', 'integer'],
'meta' => ['sometimes', 'array'],
]);
$this->events->record($request->user(), $payload);
return response()->json([
'ok' => true,
], 202);
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\NovaCard; use App\Models\NovaCard;
use App\Services\NovaCards\NovaCardPresenter; use App\Services\NovaCards\NovaCardPresenter;
use App\Services\Studio\CreatorStudioContentService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;
@@ -16,36 +17,22 @@ class StudioNovaCardsController extends Controller
{ {
public function __construct( public function __construct(
private readonly NovaCardPresenter $presenter, private readonly NovaCardPresenter $presenter,
private readonly CreatorStudioContentService $content,
) { ) {
} }
public function index(Request $request): Response public function index(Request $request): Response
{ {
$cards = NovaCard::query() $provider = $this->content->provider('cards');
->with(['category', 'template', 'backgroundImage', 'tags', 'user.profile']) $listing = $this->content->list($request->user(), $request->only(['q', 'sort', 'bucket', 'page', 'per_page']), null, 'cards');
->where('user_id', $request->user()->id)
->latest('updated_at')
->paginate(18)
->withQueryString();
$baseQuery = NovaCard::query()->where('user_id', $request->user()->id);
return Inertia::render('Studio/StudioCardsIndex', [ return Inertia::render('Studio/StudioCardsIndex', [
'cards' => $this->presenter->paginator($cards, false, $request->user()), 'title' => 'Cards',
'stats' => [ 'description' => 'Manage short-form Nova cards with the same shared filters, statuses, and actions used across Creator Studio.',
'all' => (clone $baseQuery)->count(), 'summary' => $provider?->summary($request->user()),
'drafts' => (clone $baseQuery)->where('status', NovaCard::STATUS_DRAFT)->count(), 'listing' => $listing,
'processing' => (clone $baseQuery)->where('status', NovaCard::STATUS_PROCESSING)->count(), 'quickCreate' => $this->content->quickCreate(),
'published' => (clone $baseQuery)->where('status', NovaCard::STATUS_PUBLISHED)->count(), 'publicBrowseUrl' => '/cards',
],
'editorOptions' => $this->presenter->options(),
'endpoints' => [
'create' => route('studio.cards.create'),
'editPattern' => route('studio.cards.edit', ['id' => '__CARD__']),
'previewPattern' => route('studio.cards.preview', ['id' => '__CARD__']),
'analyticsPattern' => route('studio.cards.analytics', ['id' => '__CARD__']),
'draftStore' => route('api.cards.drafts.store'),
],
]); ]);
} }

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Models\UserProfile;
use App\Services\NotificationService;
use App\Services\Studio\CreatorStudioPreferenceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
final class StudioPreferencesApiController extends Controller
{
public function __construct(
private readonly CreatorStudioPreferenceService $preferences,
private readonly NotificationService $notifications,
) {
}
public function updatePreferences(Request $request): JsonResponse
{
$payload = $request->validate([
'default_content_view' => ['required', Rule::in(['grid', 'list'])],
'analytics_range_days' => ['required', Rule::in([7, 14, 30, 60, 90])],
'dashboard_shortcuts' => ['required', 'array', 'max:8'],
'dashboard_shortcuts.*' => ['string'],
'draft_behavior' => ['required', Rule::in(['resume-last', 'open-drafts', 'focus-published'])],
'featured_modules' => ['nullable', 'array'],
'featured_modules.*' => [Rule::in(['artworks', 'cards', 'collections', 'stories'])],
'default_landing_page' => ['nullable', Rule::in(['overview', 'content', 'drafts', 'scheduled', 'analytics', 'activity', 'calendar', 'inbox', 'search', 'growth', 'challenges', 'preferences'])],
'widget_visibility' => ['nullable', 'array'],
'widget_order' => ['nullable', 'array'],
'widget_order.*' => ['string'],
'card_density' => ['nullable', Rule::in(['compact', 'comfortable'])],
'scheduling_timezone' => ['nullable', 'string', 'max:64'],
]);
return response()->json([
'data' => $this->preferences->update($request->user(), $payload),
]);
}
public function updateProfile(Request $request): JsonResponse
{
$payload = $request->validate([
'display_name' => ['required', 'string', 'max:60'],
'tagline' => ['nullable', 'string', 'max:1000'],
'bio' => ['nullable', 'string', 'max:1000'],
'website' => ['nullable', 'url', 'max:255'],
'social_links' => ['nullable', 'array', 'max:8'],
'social_links.*.platform' => ['required_with:social_links', 'string', 'max:32'],
'social_links.*.url' => ['required_with:social_links', 'url', 'max:255'],
]);
$user = $request->user();
$user->forceFill(['name' => (string) $payload['display_name']])->save();
UserProfile::query()->updateOrCreate(
['user_id' => $user->id],
[
'about' => $payload['bio'] ?? null,
'description' => $payload['tagline'] ?? null,
'website' => $payload['website'] ?? null,
]
);
DB::table('user_social_links')->where('user_id', $user->id)->delete();
foreach ($payload['social_links'] ?? [] as $link) {
DB::table('user_social_links')->insert([
'user_id' => $user->id,
'platform' => strtolower(trim((string) $link['platform'])),
'url' => trim((string) $link['url']),
'created_at' => now(),
'updated_at' => now(),
]);
}
return response()->json([
'ok' => true,
]);
}
public function updateFeatured(Request $request): JsonResponse
{
$payload = $request->validate([
'featured_modules' => ['nullable', 'array'],
'featured_modules.*' => [Rule::in(['artworks', 'cards', 'collections', 'stories'])],
'featured_content' => ['nullable', 'array'],
'featured_content.artworks' => ['nullable', 'integer', 'min:1'],
'featured_content.cards' => ['nullable', 'integer', 'min:1'],
'featured_content.collections' => ['nullable', 'integer', 'min:1'],
'featured_content.stories' => ['nullable', 'integer', 'min:1'],
]);
return response()->json([
'data' => $this->preferences->update($request->user(), $payload),
]);
}
public function markActivityRead(Request $request): JsonResponse
{
$this->notifications->markAllRead($request->user());
return response()->json([
'data' => $this->preferences->update($request->user(), [
'activity_last_read_at' => now()->toIso8601String(),
]),
]);
}
}

View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\Collection;
use App\Models\NovaCard;
use App\Models\Story;
use App\Services\CollectionLifecycleService;
use App\Services\NovaCards\NovaCardPublishService;
use App\Services\StoryPublicationService;
use App\Services\Studio\CreatorStudioContentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class StudioScheduleApiController extends Controller
{
public function __construct(
private readonly CreatorStudioContentService $content,
private readonly NovaCardPublishService $cards,
private readonly CollectionLifecycleService $collections,
private readonly StoryPublicationService $stories,
) {
}
public function publishNow(Request $request, string $module, int $id): JsonResponse
{
$user = $request->user();
match ($module) {
'artworks' => $this->publishArtworkNow($user->id, $id),
'cards' => $this->cards->publishNow($this->card($user->id, $id)),
'collections' => $this->publishCollectionNow($user->id, $id),
'stories' => $this->stories->publish($this->story($user->id, $id), 'published', ['published_at' => now()]),
default => abort(404),
};
return response()->json([
'ok' => true,
'item' => $this->serializedItem($request->user(), $module, $id),
]);
}
public function unschedule(Request $request, string $module, int $id): JsonResponse
{
$user = $request->user();
match ($module) {
'artworks' => $this->unscheduleArtwork($user->id, $id),
'cards' => $this->cards->clearSchedule($this->card($user->id, $id)),
'collections' => $this->unscheduleCollection($user->id, $id),
'stories' => $this->unscheduleStory($user->id, $id),
default => abort(404),
};
return response()->json([
'ok' => true,
'item' => $this->serializedItem($request->user(), $module, $id),
]);
}
private function publishArtworkNow(int $userId, int $id): void
{
$artwork = Artwork::query()
->where('user_id', $userId)
->findOrFail($id);
$artwork->forceFill([
'artwork_status' => 'published',
'publish_at' => null,
'artwork_timezone' => null,
'published_at' => now(),
'is_public' => $artwork->visibility !== Artwork::VISIBILITY_PRIVATE,
])->save();
}
private function unscheduleArtwork(int $userId, int $id): void
{
Artwork::query()
->where('user_id', $userId)
->findOrFail($id)
->forceFill([
'artwork_status' => 'draft',
'publish_at' => null,
'artwork_timezone' => null,
'published_at' => null,
])
->save();
}
private function publishCollectionNow(int $userId, int $id): void
{
$collection = Collection::query()
->where('user_id', $userId)
->findOrFail($id);
$this->collections->applyAttributes($collection, [
'published_at' => now(),
'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED,
]);
}
private function unscheduleCollection(int $userId, int $id): void
{
$collection = Collection::query()
->where('user_id', $userId)
->findOrFail($id);
$this->collections->applyAttributes($collection, [
'published_at' => null,
'lifecycle_state' => Collection::LIFECYCLE_DRAFT,
]);
}
private function unscheduleStory(int $userId, int $id): void
{
Story::query()
->where('creator_id', $userId)
->findOrFail($id)
->forceFill([
'status' => 'draft',
'scheduled_for' => null,
'published_at' => null,
])
->save();
}
private function card(int $userId, int $id): NovaCard
{
return NovaCard::query()
->where('user_id', $userId)
->findOrFail($id);
}
private function story(int $userId, int $id): Story
{
return Story::query()
->where('creator_id', $userId)
->findOrFail($id);
}
private function serializedItem($user, string $module, int $id): ?array
{
return $this->content->provider($module)?->items($user, 'all', 400)
->first(fn (array $item): bool => (int) ($item['numeric_id'] ?? 0) === $id);
}
}

View File

@@ -20,6 +20,7 @@ use App\Services\CollectionSaveService;
use App\Services\CollectionSeriesService; use App\Services\CollectionSeriesService;
use App\Services\CollectionSubmissionService; use App\Services\CollectionSubmissionService;
use App\Services\CollectionService; use App\Services\CollectionService;
use App\Support\Seo\SeoFactory;
use App\Support\UsernamePolicy; use App\Support\UsernamePolicy;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -113,6 +114,16 @@ class ProfileCollectionController extends Controller
event(new CollectionViewed($collection, $viewer?->id)); event(new CollectionViewed($collection, $viewer?->id));
$seo = app(SeoFactory::class)->collectionPage(
$collection->is_featured
? sprintf('Featured: %s by %s — Skinbase Nova', $collection->title, $collection->displayOwnerName())
: sprintf('%s by %s — Skinbase Nova', $collection->title, $collection->displayOwnerName()),
$collection->summary ?: $collection->description ?: sprintf('Explore the %s collection by %s on Skinbase Nova.', $collection->title, $collection->displayOwnerName()),
$collectionPayload['public_url'],
$collectionPayload['cover_image'],
$collection->visibility === Collection::VISIBILITY_PUBLIC,
)->toArray();
return Inertia::render('Collection/CollectionShow', [ return Inertia::render('Collection/CollectionShow', [
'collection' => $collectionPayload, 'collection' => $collectionPayload,
'artworks' => $this->collections->mapArtworkPaginator($artworks), 'artworks' => $this->collections->mapArtworkPaginator($artworks),
@@ -168,15 +179,7 @@ class ProfileCollectionController extends Controller
]), ]),
'featuredCollectionsUrl' => route('collections.featured'), 'featuredCollectionsUrl' => route('collections.featured'),
'reportEndpoint' => $viewer ? route('api.reports.store') : null, 'reportEndpoint' => $viewer ? route('api.reports.store') : null,
'seo' => [ 'seo' => $seo,
'title' => $collection->is_featured
? sprintf('Featured: %s by %s — Skinbase Nova', $collection->title, $collection->displayOwnerName())
: sprintf('%s by %s — Skinbase Nova', $collection->title, $collection->displayOwnerName()),
'description' => $collection->summary ?: $collection->description ?: sprintf('Explore the %s collection by %s on Skinbase Nova.', $collection->title, $collection->displayOwnerName()),
'canonical' => $collectionPayload['public_url'],
'og_image' => $collectionPayload['cover_image'],
'robots' => $collection->visibility === Collection::VISIBILITY_PUBLIC ? 'index,follow' : 'noindex,nofollow',
],
])->rootView('collections'); ])->rootView('collections');
} }
@@ -198,6 +201,12 @@ class ProfileCollectionController extends Controller
->first(); ->first();
$seriesDescription = $seriesMeta['description']; $seriesDescription = $seriesMeta['description'];
$seo = app(SeoFactory::class)->collectionListing(
sprintf('Series: %s — Skinbase Nova', $seriesKey),
sprintf('Explore the %s collection series on Skinbase Nova.', $seriesKey),
route('collections.series.show', ['seriesKey' => $seriesKey])
)->toArray();
return Inertia::render('Collection/CollectionSeriesShow', [ return Inertia::render('Collection/CollectionSeriesShow', [
'seriesKey' => $seriesKey, 'seriesKey' => $seriesKey,
'title' => $seriesTitle ?: sprintf('Collection Series: %s', str_replace(['-', '_'], ' ', $seriesKey)), 'title' => $seriesTitle ?: sprintf('Collection Series: %s', str_replace(['-', '_'], ' ', $seriesKey)),
@@ -210,12 +219,7 @@ class ProfileCollectionController extends Controller
'artworks' => $artworksCount, 'artworks' => $artworksCount,
'latest_activity_at' => optional($latestActivityAt)?->toISOString(), 'latest_activity_at' => optional($latestActivityAt)?->toISOString(),
], ],
'seo' => [ 'seo' => $seo,
'title' => sprintf('Series: %s — Skinbase Nova', $seriesKey),
'description' => sprintf('Explore the %s collection series on Skinbase Nova.', $seriesKey),
'canonical' => route('collections.series.show', ['seriesKey' => $seriesKey]),
'robots' => 'index,follow',
],
])->rootView('collections'); ])->rootView('collections');
} }
} }

View File

@@ -36,6 +36,7 @@ use App\Services\UsernameApprovalService;
use App\Services\UserStatsService; use App\Services\UserStatsService;
use App\Support\AvatarUrl; use App\Support\AvatarUrl;
use App\Support\CoverUrl; use App\Support\CoverUrl;
use App\Support\Seo\SeoFactory;
use App\Support\UsernamePolicy; use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -1204,6 +1205,27 @@ class ProfileController extends Controller
? ucfirst($resolvedInitialTab) ? ucfirst($resolvedInitialTab)
: null; : null;
$pageTitle = $galleryOnly
? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase')
: ($isTabLanding
? (($user->username ?? $user->name ?? 'User') . ' ' . $tabMetaLabel . ' on Skinbase')
: (($user->username ?? $user->name ?? 'User') . ' on Skinbase'));
$pageDescription = $galleryOnly
? ('Browse the public gallery of ' . ($user->username ?? $user->name) . ' on Skinbase.')
: ($isTabLanding
? ('Explore the ' . strtolower((string) $tabMetaLabel) . ' section for ' . ($user->username ?? $user->name) . ' on Skinbase.')
: ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase — artworks, favourites and more.'));
$profileSeo = app(SeoFactory::class)->profilePage(
$pageTitle,
$galleryOnly ? $galleryUrl : $activeProfileUrl,
$pageDescription,
$avatarUrl,
collect([
(object) ['name' => 'Home', 'url' => '/'],
(object) ['name' => $user->username ?? $user->name ?? 'Profile', 'url' => $galleryOnly ? $galleryUrl : $activeProfileUrl],
]),
)->toArray();
return Inertia::render($component, [ return Inertia::render($component, [
'user' => [ 'user' => [
'id' => $user->id, 'id' => $user->id,
@@ -1259,18 +1281,12 @@ class ProfileController extends Controller
'collectionFeatureLimit' => (int) config('collections.featured_limit', 3), 'collectionFeatureLimit' => (int) config('collections.featured_limit', 3),
'profileTabUrls' => $profileTabUrls, 'profileTabUrls' => $profileTabUrls,
])->withViewData([ ])->withViewData([
'page_title' => $galleryOnly 'page_title' => $pageTitle,
? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase')
: ($isTabLanding
? (($user->username ?? $user->name ?? 'User') . ' ' . $tabMetaLabel . ' on Skinbase')
: (($user->username ?? $user->name ?? 'User') . ' on Skinbase')),
'page_canonical' => $galleryOnly ? $galleryUrl : $activeProfileUrl, 'page_canonical' => $galleryOnly ? $galleryUrl : $activeProfileUrl,
'page_meta_description' => $galleryOnly 'page_meta_description' => $pageDescription,
? ('Browse the public gallery of ' . ($user->username ?? $user->name) . ' on Skinbase.')
: ($isTabLanding
? ('Explore the ' . strtolower((string) $tabMetaLabel) . ' section for ' . ($user->username ?? $user->name) . ' on Skinbase.')
: ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.')),
'og_image' => $avatarUrl, 'og_image' => $avatarUrl,
'seo' => $profileSeo,
'useUnifiedSeo' => true,
]); ]);
} }

View File

@@ -11,6 +11,7 @@ use App\Models\ArtworkComment;
use App\Services\ContentSanitizer; use App\Services\ContentSanitizer;
use App\Services\ThumbnailPresenter; use App\Services\ThumbnailPresenter;
use App\Services\ErrorSuggestionService; use App\Services\ErrorSuggestionService;
use App\Support\Seo\SeoFactory;
use App\Support\AvatarUrl; use App\Support\AvatarUrl;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -113,7 +114,7 @@ final class ArtworkPageController extends Controller
$description = Str::limit(trim(strip_tags(html_entity_decode((string) ($artwork->description ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'))), 160, '…'); $description = Str::limit(trim(strip_tags(html_entity_decode((string) ($artwork->description ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'))), 160, '…');
$meta = [ $meta = [
'title' => sprintf('%s by %s | Skinbase', html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'), html_entity_decode((string) $authorName, ENT_QUOTES | ENT_HTML5, 'UTF-8')), 'title' => sprintf('%s by %s Skinbase', html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'), html_entity_decode((string) $authorName, ENT_QUOTES | ENT_HTML5, 'UTF-8')),
'description' => $description !== '' ? $description : html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'description' => $description !== '' ? $description : html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'canonical' => $canonical, 'canonical' => $canonical,
'og_image' => $thumbXl['url'] ?? $thumbLg['url'] ?? null, 'og_image' => $thumbXl['url'] ?? $thumbLg['url'] ?? null,
@@ -121,6 +122,12 @@ final class ArtworkPageController extends Controller
'og_height' => $thumbXl['height'] ?? $thumbLg['height'] ?? null, 'og_height' => $thumbXl['height'] ?? $thumbLg['height'] ?? null,
]; ];
$seo = app(SeoFactory::class)->artwork($artwork, [
'md' => $thumbMd,
'lg' => $thumbLg,
'xl' => $thumbXl,
], $canonical)->toArray();
$categoryIds = $artwork->categories->pluck('id')->filter()->values(); $categoryIds = $artwork->categories->pluck('id')->filter()->values();
$tagIds = $artwork->tags->pluck('id')->filter()->values(); $tagIds = $artwork->tags->pluck('id')->filter()->values();
@@ -226,6 +233,8 @@ final class ArtworkPageController extends Controller
'presentXl' => $thumbXl, 'presentXl' => $thumbXl,
'presentSq' => $thumbSq, 'presentSq' => $thumbSq,
'meta' => $meta, 'meta' => $meta,
'seo' => $seo,
'useUnifiedSeo' => true,
'relatedItems' => $related, 'relatedItems' => $related,
'comments' => $comments, 'comments' => $comments,
]); ]);

View File

@@ -82,14 +82,26 @@ class CategoryController extends Controller
$subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get(); $subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get();
$page_title = $category->name; $breadcrumbs = collect(array_merge([
(object) ['name' => 'Home', 'url' => '/'],
(object) ['name' => 'Explore', 'url' => '/browse'],
(object) ['name' => $category->contentType->name, 'url' => '/' . strtolower((string) $category->contentType->slug)],
], collect($category->breadcrumbs)->map(fn ($crumb) => (object) [
'name' => $crumb->name,
'url' => $crumb->url,
])->all()));
$page_title = sprintf('%s — %s — Skinbase', $category->name, $category->contentType->name);
$page_meta_description = $category->description ?? ($category->contentType->name . ' artworks on Skinbase'); $page_meta_description = $category->description ?? ($category->contentType->name . ' artworks on Skinbase');
$page_meta_keywords = strtolower($category->contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography'; $page_meta_keywords = strtolower($category->contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography';
$page_canonical = url()->current();
return view('web.category', compact( return view('web.category', compact(
'page_title', 'page_title',
'page_meta_description', 'page_meta_description',
'page_meta_keywords', 'page_meta_keywords',
'page_canonical',
'breadcrumbs',
'group', 'group',
'category', 'category',
'subcategories', 'subcategories',

View File

@@ -13,6 +13,7 @@ use App\Services\CollectionRecommendationService;
use App\Services\CollectionSearchService; use App\Services\CollectionSearchService;
use App\Services\CollectionService; use App\Services\CollectionService;
use App\Services\CollectionSurfaceService; use App\Services\CollectionSurfaceService;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
@@ -50,18 +51,23 @@ class CollectionDiscoveryController extends Controller
$results = $this->search->publicSearch($filters, (int) config('collections.v5.search.public_per_page', 18)); $results = $this->search->publicSearch($filters, (int) config('collections.v5.search.public_per_page', 18));
$seo = app(SeoFactory::class)->collectionListing(
'Search Collections — Skinbase Nova',
filled($filters['q'] ?? null)
? sprintf('Search results for "%s" across public Skinbase Nova collections.', $filters['q'])
: 'Browse public collections using filters for category, style, theme, color, quality tier, freshness, and programming metadata.',
$request->fullUrl(),
null,
false,
)->toArray();
return Inertia::render('Collection/CollectionFeaturedIndex', [ return Inertia::render('Collection/CollectionFeaturedIndex', [
'eyebrow' => 'Search', 'eyebrow' => 'Search',
'title' => 'Search collections', 'title' => 'Search collections',
'description' => filled($filters['q'] ?? null) 'description' => filled($filters['q'] ?? null)
? sprintf('Search results for "%s" across public Skinbase Nova collections.', $filters['q']) ? sprintf('Search results for "%s" across public Skinbase Nova collections.', $filters['q'])
: 'Browse public collections using filters for category, style, theme, color, quality tier, freshness, and programming metadata.', : 'Browse public collections using filters for category, style, theme, color, quality tier, freshness, and programming metadata.',
'seo' => [ 'seo' => $seo,
'title' => 'Search Collections — Skinbase Nova',
'description' => 'Search public collections by category, theme, quality tier, and curator context.',
'canonical' => route('collections.search'),
'robots' => 'index,follow',
],
'collections' => $this->collections->mapCollectionCardPayloads($results->items(), false, $request->user()), 'collections' => $this->collections->mapCollectionCardPayloads($results->items(), false, $request->user()),
'communityCollections' => [], 'communityCollections' => [],
'editorialCollections' => [], 'editorialCollections' => [],
@@ -197,16 +203,17 @@ class CollectionDiscoveryController extends Controller
abort_if(! $program || collect($landing['collections'])->isEmpty(), 404); abort_if(! $program || collect($landing['collections'])->isEmpty(), 404);
$seo = app(SeoFactory::class)->collectionListing(
sprintf('%s — Skinbase Nova', $program['label']),
$program['description'],
route('collections.program.show', ['programKey' => $program['key']]),
)->toArray();
return Inertia::render('Collection/CollectionFeaturedIndex', [ return Inertia::render('Collection/CollectionFeaturedIndex', [
'eyebrow' => 'Program', 'eyebrow' => 'Program',
'title' => $program['label'], 'title' => $program['label'],
'description' => $program['description'], 'description' => $program['description'],
'seo' => [ 'seo' => $seo,
'title' => sprintf('%s — Skinbase Nova', $program['label']),
'description' => $program['description'],
'canonical' => route('collections.program.show', ['programKey' => $program['key']]),
'robots' => 'index,follow',
],
'collections' => $this->collections->mapCollectionCardPayloads($landing['collections'], false, $request->user()), 'collections' => $this->collections->mapCollectionCardPayloads($landing['collections'], false, $request->user()),
'communityCollections' => $this->collections->mapCollectionCardPayloads($landing['community_collections'] ?? collect(), false, $request->user()), 'communityCollections' => $this->collections->mapCollectionCardPayloads($landing['community_collections'] ?? collect(), false, $request->user()),
'editorialCollections' => $this->collections->mapCollectionCardPayloads($landing['editorial_collections'] ?? collect(), false, $request->user()), 'editorialCollections' => $this->collections->mapCollectionCardPayloads($landing['editorial_collections'] ?? collect(), false, $request->user()),
@@ -231,16 +238,17 @@ class CollectionDiscoveryController extends Controller
$seasonalCollections = null, $seasonalCollections = null,
$campaign = null, $campaign = null,
) { ) {
$seo = app(SeoFactory::class)->collectionListing(
sprintf('%s — Skinbase Nova', $title),
$description,
url()->current(),
)->toArray();
return Inertia::render('Collection/CollectionFeaturedIndex', [ return Inertia::render('Collection/CollectionFeaturedIndex', [
'eyebrow' => $eyebrow, 'eyebrow' => $eyebrow,
'title' => $title, 'title' => $title,
'description' => $description, 'description' => $description,
'seo' => [ 'seo' => $seo,
'title' => sprintf('%s — Skinbase Nova', $title),
'description' => $description,
'canonical' => url()->current(),
'robots' => 'index,follow',
],
'collections' => $this->collections->mapCollectionCardPayloads($collections, false, $viewer), 'collections' => $this->collections->mapCollectionCardPayloads($collections, false, $viewer),
'communityCollections' => $this->collections->mapCollectionCardPayloads($communityCollections ?? collect(), false, $viewer), 'communityCollections' => $this->collections->mapCollectionCardPayloads($communityCollections ?? collect(), false, $viewer),
'editorialCollections' => $this->collections->mapCollectionCardPayloads($editorialCollections ?? collect(), false, $viewer), 'editorialCollections' => $this->collections->mapCollectionCardPayloads($editorialCollections ?? collect(), false, $viewer),

View File

@@ -23,6 +23,36 @@ final class FooterController extends Controller
'page_title' => 'FAQ — Skinbase', 'page_title' => 'FAQ — Skinbase',
'page_meta_description' => 'Frequently Asked Questions about Skinbase — the community for skins, wallpapers, and photography.', 'page_meta_description' => 'Frequently Asked Questions about Skinbase — the community for skins, wallpapers, and photography.',
'page_canonical' => url('/faq'), 'page_canonical' => url('/faq'),
'faq_schema' => [[
'@context' => 'https://schema.org',
'@type' => 'FAQPage',
'mainEntity' => [
[
'@type' => 'Question',
'name' => 'What is Skinbase?',
'acceptedAnswer' => [
'@type' => 'Answer',
'text' => 'Skinbase is a community gallery for desktop customisation including skins, themes, wallpapers, icons, and more.',
],
],
[
'@type' => 'Question',
'name' => 'Is Skinbase free to use?',
'acceptedAnswer' => [
'@type' => 'Answer',
'text' => 'Yes. Browsing and downloading are free, and registering is also free.',
],
],
[
'@type' => 'Question',
'name' => 'Who runs Skinbase?',
'acceptedAnswer' => [
'@type' => 'Answer',
'text' => 'Skinbase is maintained by a small volunteer staff team.',
],
],
],
]],
'hero_title' => 'Frequently Asked Questions', 'hero_title' => 'Frequently Asked Questions',
'hero_description' => 'Answers to the most common questions from our members. Last updated March 1, 2026.', 'hero_description' => 'Answers to the most common questions from our members. Last updated March 1, 2026.',
'breadcrumbs' => collect([ 'breadcrumbs' => collect([

View File

@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Services\HomepageService; use App\Services\HomepageService;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request; use Illuminate\Http\Request;
final class HomeController extends Controller final class HomeController extends Controller
@@ -30,6 +31,8 @@ final class HomeController extends Controller
]; ];
return view('web.home', [ return view('web.home', [
'seo' => app(SeoFactory::class)->homepage($meta)->toArray(),
'useUnifiedSeo' => true,
'meta' => $meta, 'meta' => $meta,
'props' => $sections, 'props' => $sections,
]); ]);

View File

@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Leaderboard; use App\Models\Leaderboard;
use App\Services\LeaderboardService; use App\Services\LeaderboardService;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -26,10 +27,11 @@ class LeaderboardPageController extends Controller
'initialType' => $type, 'initialType' => $type,
'initialPeriod' => $period, 'initialPeriod' => $period,
'initialData' => $leaderboards->getLeaderboard($type, $period), 'initialData' => $leaderboards->getLeaderboard($type, $period),
'meta' => [ 'seo' => app(SeoFactory::class)->leaderboardPage(
'title' => 'Top Creators & Artworks Leaderboard | Skinbase', 'Top Creators & Artworks Leaderboard Skinbase',
'description' => 'Track the leading creators, artworks, and stories across Skinbase by daily, weekly, monthly, and all-time performance.', 'Track the leading creators, artworks, and stories across Skinbase by daily, weekly, monthly, and all-time performance.',
], route('leaderboard')
)->toArray(),
]); ]);
} }
} }

View File

@@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\Recommendations\HybridSimilarArtworksService;
use App\Services\ThumbnailPresenter;
use App\Services\Vision\VectorService;
use App\Support\AvatarUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use RuntimeException;
/**
* GET /art/{id}/similar
*
* Renders a full gallery page of artworks similar to the given source artwork.
*
* Priority:
* 1. Qdrant visual similarity (VectorService / vision gateway)
* 2. HybridSimilarArtworksService (precomputed tag, behavior, hybrid)
* 3. Meilisearch tag-overlap + category fallback
*/
final class SimilarArtworksPageController extends Controller
{
private const PER_PAGE = 24;
/** How many candidates to request from Qdrant (> PER_PAGE to allow pagination) */
private const QDRANT_LIMIT = 120;
public function __construct(
private readonly VectorService $vectors,
private readonly HybridSimilarArtworksService $hybridService,
) {}
public function __invoke(Request $request, int $id)
{
// ── Load source artwork ────────────────────────────────────────────────
$source = Artwork::public()
->published()
->with([
'tags:id,slug',
'categories:id,slug,name',
'categories.contentType:id,name,slug',
'user:id,name,username',
'user.profile:user_id,avatar_hash',
])
->findOrFail($id);
$baseUrl = url("/art/{$id}/similar");
// ── Normalise source artwork for the view ──────────────────────────────
$primaryCat = $source->categories->sortBy('sort_order')->first();
$sourceMd = ThumbnailPresenter::present($source, 'md');
$sourceLg = ThumbnailPresenter::present($source, 'lg');
$sourceTitle = html_entity_decode((string) ($source->title ?? 'Artwork'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$sourceUrl = route('art.show', ['id' => $source->id, 'slug' => $source->slug]);
$sourceCard = (object) [
'id' => $source->id,
'title' => $sourceTitle,
'url' => $sourceUrl,
'thumb_md' => $sourceMd['url'] ?? null,
'thumb_lg' => $sourceLg['url'] ?? null,
'thumb_srcset' => $sourceMd['srcset'] ?? $sourceMd['url'] ?? null,
'author_name' => $source->user?->name ?? 'Artist',
'author_username' => $source->user?->username ?? '',
'author_avatar' => AvatarUrl::forUser(
(int) ($source->user_id ?? 0),
$source->user?->profile?->avatar_hash ?? null,
80
),
'category_name' => $primaryCat?->name ?? '',
'category_slug' => $primaryCat?->slug ?? '',
'content_type_name' => $primaryCat?->contentType?->name ?? '',
'content_type_slug' => $primaryCat?->contentType?->slug ?? '',
'tag_slugs' => $source->tags->pluck('slug')->take(5)->all(),
'width' => $source->width ?? null,
'height' => $source->height ?? null,
];
return view('gallery.similar', [
'sourceArtwork' => $sourceCard,
'gallery_type' => 'similar',
'gallery_nav_section' => 'artworks',
'mainCategories' => collect(),
'subcategories' => collect(),
'contentType' => null,
'category' => null,
'spotlight' => collect(),
'current_sort' => 'trending',
'sort_options' => [],
'page_title' => 'Similar to "' . $sourceTitle . '" — Skinbase',
'page_meta_description' => 'Discover artworks similar to "' . $sourceTitle . '" on Skinbase.',
'page_canonical' => $baseUrl,
'page_robots' => 'noindex,follow',
'breadcrumbs' => collect([
(object) ['name' => 'Explore', 'url' => '/explore'],
(object) ['name' => $sourceTitle, 'url' => $sourceUrl],
(object) ['name' => 'Similar Artworks', 'url' => $baseUrl],
]),
]);
}
/**
* GET /art/{id}/similar-results (JSON)
*
* Returns paginated similar artworks asynchronously so the page shell
* can render instantly while this slower query runs in the background.
*/
public function results(Request $request, int $id): JsonResponse
{
$source = Artwork::public()
->published()
->with([
'tags:id,slug',
'categories:id,slug,name',
'categories.contentType:id,name,slug',
'user:id,name,username',
'user.profile:user_id,avatar_hash',
])
->findOrFail($id);
$page = max(1, (int) $request->query('page', 1));
$baseUrl = url("/art/{$id}/similar");
[$artworks, $similaritySource] = $this->resolveSimilarArtworks($source, $page, $baseUrl);
$galleryItems = $artworks->getCollection()->map(fn ($art) => [
'id' => $art->id ?? null,
'name' => $art->name ?? null,
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
'thumb_srcset' => $art->thumb_srcset ?? null,
'uname' => $art->uname ?? '',
'username' => $art->username ?? $art->uname ?? '',
'avatar_url' => $art->avatar_url ?? null,
'category_name' => $art->category_name ?? '',
'category_slug' => $art->category_slug ?? '',
'slug' => $art->slug ?? '',
'width' => $art->width ?? null,
'height' => $art->height ?? null,
])->values();
return response()->json([
'data' => $galleryItems,
'similarity_source' => $similaritySource,
'total' => $artworks->total(),
'current_page' => $artworks->currentPage(),
'last_page' => $artworks->lastPage(),
'next_page_url' => $artworks->nextPageUrl(),
'prev_page_url' => $artworks->previousPageUrl(),
]);
}
// ── Similarity resolution ──────────────────────────────────────────────────
/**
* @return array{0: LengthAwarePaginator, 1: string}
*/
private function resolveSimilarArtworks(Artwork $source, int $page, string $baseUrl): array
{
// Priority 1 — Qdrant visual (vision) similarity
if ($this->vectors->isConfigured()) {
$qdrantItems = $this->resolveViaQdrant($source);
if ($qdrantItems !== null && $qdrantItems->isNotEmpty()) {
$paginator = $this->paginateCollection(
$qdrantItems->map(fn ($a) => $this->presentArtwork($a)),
$page,
$baseUrl,
);
return [$paginator, 'visual'];
}
}
// Priority 2 — precomputed hybrid list (tag / behavior / AI)
$hybridItems = $this->hybridService->forArtwork($source->id, self::QDRANT_LIMIT);
if ($hybridItems->isNotEmpty()) {
$paginator = $this->paginateCollection(
$hybridItems->map(fn ($a) => $this->presentArtwork($a)),
$page,
$baseUrl,
);
return [$paginator, 'hybrid'];
}
// Priority 3 — Meilisearch tag/category overlap
$paginator = $this->meilisearchFallback($source, $page);
return [$paginator, 'tags'];
}
/**
* Query Qdrant via VectorGateway, then re-hydrate full Artwork models
* (so we have category/dimension data for the masonry grid).
*
* Returns null when the gateway call fails, so the caller can fall through.
*/
private function resolveViaQdrant(Artwork $source): ?Collection
{
try {
$raw = $this->vectors->similarToArtwork($source, self::QDRANT_LIMIT);
} catch (RuntimeException) {
return null;
}
if (empty($raw)) {
return null;
}
// Preserve Qdrant relevance order; IDs are already filtered to public+published
$orderedIds = array_column($raw, 'id');
$artworks = Artwork::query()
->whereIn('id', $orderedIds)
->where('id', '!=', $source->id) // belt-and-braces exclusion
->public()
->published()
->with([
'categories:id,slug,name',
'categories.contentType:id,name,slug',
'user:id,name,username',
'user.profile:user_id,avatar_hash',
])
->get()
->keyBy('id');
return collect($orderedIds)
->map(fn (int $id) => $artworks->get($id))
->filter()
->values();
}
/**
* Meilisearch tag-overlap query with category fallback.
*/
private function meilisearchFallback(Artwork $source, int $page): LengthAwarePaginator
{
$tagSlugs = $source->tags->pluck('slug')->values()->all();
$categorySlugs = $source->categories->pluck('slug')->values()->all();
$filterParts = [
'is_public = true',
'is_approved = true',
'id != ' . $source->id,
];
if ($tagSlugs !== []) {
$quoted = array_map(fn (string $t): string => 'tags = "' . addslashes($t) . '"', $tagSlugs);
$filterParts[] = '(' . implode(' OR ', $quoted) . ')';
} elseif ($categorySlugs !== []) {
$quoted = array_map(fn (string $c): string => 'category = "' . addslashes($c) . '"', $categorySlugs);
$filterParts[] = '(' . implode(' OR ', $quoted) . ')';
}
$results = Artwork::search('')->options([
'filter' => implode(' AND ', $filterParts),
'sort' => ['trending_score_7d:desc', 'created_at:desc'],
])->paginate(self::PER_PAGE, 'page', $page);
$results->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
return $results;
}
/**
* Wrap a Collection into a LengthAwarePaginator for the view.
*/
private function paginateCollection(
Collection $items,
int $page,
string $path,
): LengthAwarePaginator {
$perPage = self::PER_PAGE;
$total = $items->count();
$slice = $items->forPage($page, $perPage)->values();
return new LengthAwarePaginator($slice, $total, $perPage, $page, [
'path' => $path,
'query' => [],
]);
}
// ── Presenter ─────────────────────────────────────────────────────────────
private function presentArtwork(Artwork $artwork): object
{
$primary = $artwork->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($artwork, 'md');
$avatarUrl = AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
);
return (object) [
'id' => $artwork->id,
'name' => $artwork->title,
'content_type_name' => $primary?->contentType?->name ?? '',
'content_type_slug' => $primary?->contentType?->slug ?? '',
'category_name' => $primary?->name ?? '',
'category_slug' => $primary?->slug ?? '',
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $artwork->user?->name ?? 'Skinbase',
'username' => $artwork->user?->username ?? '',
'avatar_url' => $avatarUrl,
'published_at' => $artwork->published_at,
'slug' => $artwork->slug ?? '',
'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null,
];
}
}

View File

@@ -34,6 +34,9 @@ class SaveNovaCardDraftRequest extends FormRequest
'allow_download' => ['sometimes', 'boolean'], 'allow_download' => ['sometimes', 'boolean'],
'allow_remix' => ['sometimes', 'boolean'], 'allow_remix' => ['sometimes', 'boolean'],
'editor_mode_last_used' => ['sometimes', Rule::in(['quick', 'full'])], 'editor_mode_last_used' => ['sometimes', Rule::in(['quick', 'full'])],
'publish_mode' => ['sometimes', Rule::in(['now', 'schedule'])],
'scheduled_for' => ['sometimes', 'nullable', 'date'],
'scheduling_timezone' => ['sometimes', 'nullable', 'string', 'max:64'],
'tags' => ['sometimes', 'array', 'max:' . (int) ($validation['max_tags'] ?? 8)], 'tags' => ['sometimes', 'array', 'max:' . (int) ($validation['max_tags'] ?? 8)],
'tags.*' => ['string', 'min:2', 'max:32'], 'tags.*' => ['string', 'min:2', 'max:32'],
'project_json' => ['sometimes', 'array'], 'project_json' => ['sometimes', 'array'],
@@ -43,6 +46,9 @@ class SaveNovaCardDraftRequest extends FormRequest
'project_json.text_blocks.*.type' => ['sometimes', Rule::in(['title', 'quote', 'author', 'source', 'body', 'caption'])], 'project_json.text_blocks.*.type' => ['sometimes', Rule::in(['title', 'quote', 'author', 'source', 'body', 'caption'])],
'project_json.text_blocks.*.text' => ['sometimes', 'nullable', 'string', 'max:' . (int) ($validation['quote_max'] ?? 420)], 'project_json.text_blocks.*.text' => ['sometimes', 'nullable', 'string', 'max:' . (int) ($validation['quote_max'] ?? 420)],
'project_json.text_blocks.*.enabled' => ['sometimes', 'boolean'], 'project_json.text_blocks.*.enabled' => ['sometimes', 'boolean'],
'project_json.text_blocks.*.pos_x' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:100'],
'project_json.text_blocks.*.pos_y' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:100'],
'project_json.text_blocks.*.pos_width' => ['sometimes', 'nullable', 'numeric', 'min:1', 'max:100'],
'project_json.assets.pack_ids' => ['sometimes', 'array'], 'project_json.assets.pack_ids' => ['sometimes', 'array'],
'project_json.assets.pack_ids.*' => ['integer'], 'project_json.assets.pack_ids.*' => ['integer'],
'project_json.assets.template_pack_ids' => ['sometimes', 'array'], 'project_json.assets.template_pack_ids' => ['sometimes', 'array'],
@@ -57,7 +63,13 @@ class SaveNovaCardDraftRequest extends FormRequest
'project_json.layout.padding' => ['sometimes', Rule::in((array) ($validation['allowed_padding_presets'] ?? []))], 'project_json.layout.padding' => ['sometimes', Rule::in((array) ($validation['allowed_padding_presets'] ?? []))],
'project_json.layout.max_width' => ['sometimes', Rule::in((array) ($validation['allowed_max_widths'] ?? []))], 'project_json.layout.max_width' => ['sometimes', Rule::in((array) ($validation['allowed_max_widths'] ?? []))],
'project_json.typography.font_preset' => ['sometimes', Rule::in(array_keys((array) config('nova_cards.font_presets', [])))], 'project_json.typography.font_preset' => ['sometimes', Rule::in(array_keys((array) config('nova_cards.font_presets', [])))],
'project_json.typography.quote_size' => ['sometimes', 'integer', 'min:24', 'max:160'], 'project_json.typography.quote_size' => ['sometimes', 'integer', 'min:10', 'max:160'],
'project_json.typography.quote_width' => ['sometimes', 'nullable', 'integer', 'min:30', 'max:100'],
'project_json.typography.text_opacity' => ['sometimes', 'nullable', 'integer', 'min:10', 'max:100'],
'project_json.decorations' => ['sometimes', 'array'],
'project_json.decorations.*.pos_x' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:100'],
'project_json.decorations.*.pos_y' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:100'],
'project_json.decorations.*.opacity' => ['sometimes', 'nullable', 'integer', 'min:10', 'max:100'],
'project_json.typography.author_size' => ['sometimes', 'integer', 'min:12', 'max:72'], 'project_json.typography.author_size' => ['sometimes', 'integer', 'min:12', 'max:72'],
'project_json.typography.letter_spacing' => ['sometimes', 'integer', 'min:-2', 'max:12'], 'project_json.typography.letter_spacing' => ['sometimes', 'integer', 'min:-2', 'max:12'],
'project_json.typography.line_height' => ['sometimes', 'numeric', 'min:0.9', 'max:1.8'], 'project_json.typography.line_height' => ['sometimes', 'numeric', 'min:0.9', 'max:1.8'],

View File

@@ -7,7 +7,6 @@ namespace App\Http\Requests\NovaCards;
use Closure; use Closure;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Validation\Rule;
class UploadNovaCardBackgroundRequest extends FormRequest class UploadNovaCardBackgroundRequest extends FormRequest
{ {
@@ -26,22 +25,56 @@ class UploadNovaCardBackgroundRequest extends FormRequest
'bail', 'bail',
'required', 'required',
'file', 'file',
static function (string $attribute, mixed $value, Closure $fail): void { function (string $attribute, mixed $value, Closure $fail): void {
if (! $value instanceof UploadedFile) { $this->validateUpload($attribute, $value, $fail);
return;
}
$path = $value->getRealPath() ?: $value->getPathname();
if (! $value->isValid() || ! is_string($path) || trim($path) === '') {
$fail('The ' . $attribute . ' upload is invalid.');
}
}, },
'image', 'image',
'mimes:jpeg,jpg,png,webp', 'mimes:jpeg,jpg,png,webp',
'max:' . $maxKilobytes, 'max:' . $maxKilobytes,
Rule::dimensions()->minWidth(480)->minHeight(480), function (string $attribute, mixed $value, Closure $fail): void {
$this->validateMinimumDimensions($attribute, $value, $fail, 480, 480);
},
], ],
]; ];
} }
private function validateUpload(string $attribute, mixed $value, Closure $fail): void
{
if (! $value instanceof UploadedFile) {
return;
}
$path = $value->getRealPath() ?: $value->getPathname();
if (! $value->isValid() || ! is_string($path) || trim($path) === '' || ! is_readable($path)) {
$fail('The ' . $attribute . ' upload is invalid.');
}
}
private function validateMinimumDimensions(string $attribute, mixed $value, Closure $fail, int $minWidth, int $minHeight): void
{
if (! $value instanceof UploadedFile) {
return;
}
$path = $value->getRealPath() ?: $value->getPathname();
if (! is_string($path) || trim($path) === '' || ! is_readable($path)) {
$fail('The ' . $attribute . ' upload is invalid.');
return;
}
$binary = @file_get_contents($path);
if ($binary === false || $binary === '') {
$fail('The ' . $attribute . ' upload is invalid.');
return;
}
$dimensions = @getimagesizefromstring($binary);
if (! is_array($dimensions) || ($dimensions[0] ?? 0) < $minWidth || ($dimensions[1] ?? 0) < $minHeight) {
$fail(sprintf('The %s must be at least %dx%d pixels.', $attribute, $minWidth, $minHeight));
}
}
} }

View File

@@ -61,6 +61,36 @@ final class UploadFinishRequest extends FormRequest
$this->denyAsNotFound(); $this->denyAsNotFound();
} }
$archiveSessionId = (string) $this->input('archive_session_id');
if ($archiveSessionId !== '') {
$archiveSession = $sessions->get($archiveSessionId);
if (! $archiveSession || $archiveSession->userId !== $user->id) {
$this->logUnauthorized('archive_session_not_owned_or_missing');
$this->denyAsNotFound();
}
}
$additionalScreenshotSessions = $this->input('additional_screenshot_sessions', []);
if (is_array($additionalScreenshotSessions)) {
foreach ($additionalScreenshotSessions as $index => $payload) {
$screenshotSessionId = (string) data_get($payload, 'session_id', '');
if ($screenshotSessionId === '') {
continue;
}
$screenshotSession = $sessions->get($screenshotSessionId);
if (! $screenshotSession || $screenshotSession->userId !== $user->id) {
$this->logUnauthorized('additional_screenshot_session_not_owned_or_missing');
logger()->warning('Upload finish additional screenshot session rejected', [
'index' => $index,
'session_id' => $screenshotSessionId,
'user_id' => $user->id,
]);
$this->denyAsNotFound();
}
}
}
$artwork = Artwork::query()->find($artworkId); $artwork = Artwork::query()->find($artworkId);
if (! $artwork || (int) $artwork->user_id !== (int) $user->id) { if (! $artwork || (int) $artwork->user_id !== (int) $user->id) {
$this->logUnauthorized('artwork_not_owned_or_missing'); $this->logUnauthorized('artwork_not_owned_or_missing');
@@ -79,6 +109,11 @@ final class UploadFinishRequest extends FormRequest
'artwork_id' => 'required|integer', 'artwork_id' => 'required|integer',
'upload_token' => 'nullable|string|min:40|max:200', 'upload_token' => 'nullable|string|min:40|max:200',
'file_name' => 'nullable|string|max:255', 'file_name' => 'nullable|string|max:255',
'archive_session_id' => 'nullable|uuid|different:session_id',
'archive_file_name' => 'nullable|string|max:255',
'additional_screenshot_sessions' => 'nullable|array|max:4',
'additional_screenshot_sessions.*.session_id' => 'required|uuid|distinct|different:session_id|different:archive_session_id',
'additional_screenshot_sessions.*.file_name' => 'nullable|string|max:255',
]; ];
} }

View File

@@ -18,6 +18,7 @@ class ArtworkResource extends JsonResource
$lg = ThumbnailPresenter::present($this->resource, 'lg'); $lg = ThumbnailPresenter::present($this->resource, 'lg');
$xl = ThumbnailPresenter::present($this->resource, 'xl'); $xl = ThumbnailPresenter::present($this->resource, 'xl');
$sq = ThumbnailPresenter::present($this->resource, 'sq'); $sq = ThumbnailPresenter::present($this->resource, 'sq');
$screenshots = $this->resolveScreenshotAssets();
$canonicalSlug = \Illuminate\Support\Str::slug((string) ($this->slug ?: $this->title)); $canonicalSlug = \Illuminate\Support\Str::slug((string) ($this->slug ?: $this->title));
if ($canonicalSlug === '') { if ($canonicalSlug === '') {
@@ -119,6 +120,7 @@ class ArtworkResource extends JsonResource
'srcset' => ThumbnailPresenter::srcsetForArtwork($this->resource), 'srcset' => ThumbnailPresenter::srcsetForArtwork($this->resource),
'mime_type' => 'image/webp', 'mime_type' => 'image/webp',
], ],
'screenshots' => $screenshots,
'user' => [ 'user' => [
'id' => (int) ($this->user?->id ?? 0), 'id' => (int) ($this->user?->id ?? 0),
'name' => html_entity_decode((string) ($this->user?->name ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'name' => html_entity_decode((string) ($this->user?->name ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
@@ -173,6 +175,48 @@ class ArtworkResource extends JsonResource
]; ];
} }
private function resolveScreenshotAssets(): array
{
if (! Schema::hasTable('artwork_files')) {
return [];
}
return DB::table('artwork_files')
->where('artwork_id', (int) $this->id)
->where('variant', 'like', 'shot%')
->orderBy('variant')
->get(['variant', 'path', 'mime', 'size'])
->map(function ($row, int $index): array {
$path = (string) ($row->path ?? '');
$url = $this->objectUrl($path);
return [
'id' => (string) ($row->variant ?? ('shot' . ($index + 1))),
'variant' => (string) ($row->variant ?? ''),
'label' => 'Screenshot ' . ($index + 1),
'url' => $url,
'thumb_url' => $url,
'mime_type' => (string) ($row->mime ?? 'image/jpeg'),
'size' => (int) ($row->size ?? 0),
];
})
->filter(fn (array $item): bool => $item['url'] !== null)
->values()
->all();
}
private function objectUrl(string $path): ?string
{
$trimmedPath = trim($path, '/');
if ($trimmedPath === '') {
return null;
}
$base = rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/');
return $base . '/' . $trimmedPath;
}
private function renderDescriptionHtml(): string private function renderDescriptionHtml(): string
{ {
$rawDescription = (string) ($this->description ?? ''); $rawDescription = (string) ($this->description ?? '');

View File

@@ -25,13 +25,26 @@ final class GenerateDerivativesJob implements ShouldQueue
private readonly string $sessionId, private readonly string $sessionId,
private readonly string $hash, private readonly string $hash,
private readonly int $artworkId, private readonly int $artworkId,
private readonly ?string $originalFileName = null private readonly ?string $originalFileName = null,
private readonly ?string $archiveSessionId = null,
private readonly ?string $archiveHash = null,
private readonly ?string $archiveOriginalFileName = null,
private readonly array $additionalScreenshotSessions = []
) { ) {
} }
public function handle(UploadPipelineService $pipeline): void public function handle(UploadPipelineService $pipeline): void
{ {
$pipeline->processAndPublish($this->sessionId, $this->hash, $this->artworkId, $this->originalFileName); $pipeline->processAndPublish(
$this->sessionId,
$this->hash,
$this->artworkId,
$this->originalFileName,
$this->archiveSessionId,
$this->archiveHash,
$this->archiveOriginalFileName,
$this->additionalScreenshotSessions
);
// Auto-tagging is async and must never block publish. // Auto-tagging is async and must never block publish.
AutoTagArtworkJob::dispatch($this->artworkId, $this->hash)->afterCommit(); AutoTagArtworkJob::dispatch($this->artworkId, $this->hash)->afterCommit();

View File

@@ -6,6 +6,7 @@ namespace App\Jobs\NovaCards;
use App\Models\NovaCard; use App\Models\NovaCard;
use App\Services\NovaCards\NovaCardPublishModerationService; use App\Services\NovaCards\NovaCardPublishModerationService;
use App\Services\NovaCards\NovaCardPlaywrightRenderService;
use App\Services\NovaCards\NovaCardRenderService; use App\Services\NovaCards\NovaCardRenderService;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@@ -22,14 +23,25 @@ class RenderNovaCardPreviewJob implements ShouldQueue
) { ) {
} }
public function handle(NovaCardRenderService $renderService, NovaCardPublishModerationService $moderation): void public function handle(NovaCardRenderService $renderService, NovaCardPlaywrightRenderService $playwrightService, NovaCardPublishModerationService $moderation): void
{ {
$card = NovaCard::query()->with(['backgroundImage'])->find($this->cardId); $card = NovaCard::query()->with(['backgroundImage', 'user'])->find($this->cardId);
if (! $card) { if (! $card) {
return; return;
} }
$renderService->render($card); // Try the CSS/Playwright renderer first (pixel-perfect match with the editor).
// Falls back to the GD renderer if Playwright is disabled or encounters an error.
if ($playwrightService->isAvailable()) {
try {
$playwrightService->render($card);
} catch (\Throwable $e) {
report($e);
$renderService->render($card->fresh()->load(['backgroundImage']));
}
} else {
$renderService->render($card);
}
$evaluation = $moderation->evaluate($card->fresh()->loadMissing(['originalCard.user', 'rootCard.user'])); $evaluation = $moderation->evaluate($card->fresh()->loadMissing(['originalCard.user', 'rootCard.user']));

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Sitemaps;
use App\Services\Sitemaps\SitemapPublishService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
final class BuildSitemapReleaseJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(private readonly ?array $families = null, private readonly ?string $releaseId = null)
{
$this->onQueue('default');
}
public function handle(SitemapPublishService $publish): void
{
$publish->buildRelease($this->families, $this->releaseId);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Sitemaps;
use App\Services\Sitemaps\SitemapReleaseCleanupService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
final class CleanupSitemapReleasesJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function handle(SitemapReleaseCleanupService $cleanup): void
{
$cleanup->cleanup();
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Sitemaps;
use App\Services\Sitemaps\SitemapPublishService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
final class PublishSitemapReleaseJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(private readonly ?string $releaseId = null)
{
$this->onQueue('default');
}
public function handle(SitemapPublishService $publish): void
{
$publish->publish($this->releaseId);
}
}

View File

@@ -379,6 +379,18 @@ class Artwork extends Model
protected static function booted(): void protected static function booted(): void
{ {
static::saving(function (Artwork $artwork): void {
if ($artwork->published_at === null) {
return;
}
$publishedAt = $artwork->published_at->copy();
if ($artwork->created_at === null || ! $artwork->created_at->equalTo($publishedAt)) {
$artwork->created_at = $publishedAt;
}
});
static::deleting(function (Artwork $artwork): void { static::deleting(function (Artwork $artwork): void {
if (! method_exists($artwork, 'isForceDeleting') || ! $artwork->isForceDeleting()) { if (! method_exists($artwork, 'isForceDeleting') || ! $artwork->isForceDeleting()) {
return; return;

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ContentModerationActionLog extends Model
{
protected $table = 'content_moderation_action_logs';
public $timestamps = false;
protected $fillable = [
'finding_id',
'target_type',
'target_id',
'action_type',
'actor_type',
'actor_id',
'old_status',
'new_status',
'old_visibility',
'new_visibility',
'notes',
'meta_json',
'created_at',
];
protected $casts = [
'finding_id' => 'integer',
'target_id' => 'integer',
'actor_id' => 'integer',
'meta_json' => 'array',
'created_at' => 'datetime',
];
public function finding(): BelongsTo
{
return $this->belongsTo(ContentModerationFinding::class, 'finding_id');
}
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'actor_id');
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ContentModerationAiSuggestion extends Model
{
protected $table = 'content_moderation_ai_suggestions';
public $timestamps = false;
protected $fillable = [
'finding_id',
'provider',
'suggested_label',
'suggested_action',
'confidence',
'explanation',
'campaign_tags_json',
'raw_response_json',
'created_at',
];
protected $casts = [
'finding_id' => 'integer',
'confidence' => 'integer',
'campaign_tags_json' => 'array',
'raw_response_json' => 'array',
'created_at' => 'datetime',
];
public function finding(): BelongsTo
{
return $this->belongsTo(ContentModerationFinding::class, 'finding_id');
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ContentModerationCluster extends Model
{
protected $table = 'content_moderation_clusters';
protected $fillable = [
'campaign_key',
'cluster_reason',
'review_bucket',
'escalation_status',
'cluster_score',
'findings_count',
'unique_users_count',
'unique_domains_count',
'latest_finding_at',
'summary_json',
];
protected $casts = [
'cluster_score' => 'integer',
'findings_count' => 'integer',
'unique_users_count' => 'integer',
'unique_domains_count' => 'integer',
'latest_finding_at' => 'datetime',
'summary_json' => 'array',
];
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use App\Enums\ModerationDomainStatus;
use Illuminate\Database\Eloquent\Model;
class ContentModerationDomain extends Model
{
protected $table = 'content_moderation_domains';
protected $fillable = [
'domain',
'status',
'times_seen',
'times_flagged',
'times_confirmed_spam',
'linked_users_count',
'linked_findings_count',
'linked_clusters_count',
'first_seen_at',
'last_seen_at',
'top_keywords_json',
'top_content_types_json',
'false_positive_count',
'notes',
];
protected $casts = [
'status' => ModerationDomainStatus::class,
'times_seen' => 'integer',
'times_flagged' => 'integer',
'times_confirmed_spam' => 'integer',
'linked_users_count' => 'integer',
'linked_findings_count' => 'integer',
'linked_clusters_count' => 'integer',
'first_seen_at' => 'datetime',
'last_seen_at' => 'datetime',
'top_keywords_json' => 'array',
'top_content_types_json' => 'array',
'false_positive_count' => 'integer',
];
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ContentModerationFeedback extends Model
{
protected $table = 'content_moderation_feedback';
public $timestamps = false;
protected $fillable = [
'finding_id',
'feedback_type',
'actor_id',
'notes',
'meta_json',
'created_at',
];
protected $casts = [
'finding_id' => 'integer',
'actor_id' => 'integer',
'meta_json' => 'array',
'created_at' => 'datetime',
];
public function finding(): BelongsTo
{
return $this->belongsTo(ContentModerationFinding::class, 'finding_id');
}
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'actor_id');
}
}

View File

@@ -0,0 +1,199 @@
<?php
namespace App\Models;
use App\Enums\ModerationEscalationStatus;
use App\Enums\ModerationContentType;
use App\Enums\ModerationSeverity;
use App\Enums\ModerationStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property ModerationContentType $content_type
* @property int $content_id
* @property int|null $artwork_id
* @property int|null $user_id
* @property ModerationStatus $status
* @property ModerationSeverity $severity
* @property int $score
* @property string $content_hash
* @property string $scanner_version
* @property array|null $reasons_json
* @property array|null $matched_links_json
* @property array|null $matched_domains_json
* @property array|null $matched_keywords_json
* @property string|null $content_snapshot
* @property int|null $reviewed_by
* @property \Illuminate\Support\Carbon|null $reviewed_at
* @property string|null $action_taken
* @property string|null $admin_notes
* @property string|null $content_hash_normalized
* @property string|null $group_key
* @property array|null $rule_hits_json
* @property array|null $domain_ids_json
* @property int|null $user_risk_score
* @property bool $is_auto_hidden
* @property string|null $auto_action_taken
* @property \Illuminate\Support\Carbon|null $auto_hidden_at
* @property int|null $resolved_by
* @property \Illuminate\Support\Carbon|null $resolved_at
* @property int|null $restored_by
* @property \Illuminate\Support\Carbon|null $restored_at
* @property string|null $content_target_type
* @property int|null $content_target_id
* @property string|null $campaign_key
* @property int|null $cluster_score
* @property string|null $cluster_reason
* @property int|null $priority_score
* @property string|null $ai_provider
* @property string|null $ai_label
* @property int|null $ai_confidence
* @property string|null $ai_explanation
* @property bool $is_false_positive
* @property int $false_positive_count
* @property string|null $policy_name
* @property string|null $review_bucket
* @property ModerationEscalationStatus $escalation_status
* @property array|null $score_breakdown_json
*/
class ContentModerationFinding extends Model
{
protected $table = 'content_moderation_findings';
protected $fillable = [
'content_type',
'content_id',
'artwork_id',
'user_id',
'status',
'severity',
'score',
'content_hash',
'scanner_version',
'reasons_json',
'matched_links_json',
'matched_domains_json',
'matched_keywords_json',
'content_snapshot',
'reviewed_by',
'reviewed_at',
'action_taken',
'admin_notes',
'content_hash_normalized',
'group_key',
'rule_hits_json',
'domain_ids_json',
'user_risk_score',
'is_auto_hidden',
'auto_action_taken',
'auto_hidden_at',
'resolved_by',
'resolved_at',
'restored_by',
'restored_at',
'content_target_type',
'content_target_id',
'campaign_key',
'cluster_score',
'cluster_reason',
'priority_score',
'ai_provider',
'ai_label',
'ai_confidence',
'ai_explanation',
'is_false_positive',
'false_positive_count',
'policy_name',
'review_bucket',
'escalation_status',
'score_breakdown_json',
];
protected $casts = [
'content_type' => ModerationContentType::class,
'status' => ModerationStatus::class,
'severity' => ModerationSeverity::class,
'score' => 'integer',
'reasons_json' => 'array',
'matched_links_json' => 'array',
'matched_domains_json' => 'array',
'matched_keywords_json' => 'array',
'rule_hits_json' => 'array',
'domain_ids_json' => 'array',
'user_risk_score' => 'integer',
'is_auto_hidden' => 'boolean',
'reviewed_at' => 'datetime',
'auto_hidden_at' => 'datetime',
'resolved_at' => 'datetime',
'restored_at' => 'datetime',
'content_target_id' => 'integer',
'cluster_score' => 'integer',
'priority_score' => 'integer',
'ai_confidence' => 'integer',
'is_false_positive' => 'boolean',
'false_positive_count' => 'integer',
'escalation_status' => ModerationEscalationStatus::class,
'score_breakdown_json' => 'array',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function reviewer(): BelongsTo
{
return $this->belongsTo(User::class, 'reviewed_by');
}
public function resolver(): BelongsTo
{
return $this->belongsTo(User::class, 'resolved_by');
}
public function restorer(): BelongsTo
{
return $this->belongsTo(User::class, 'restored_by');
}
public function actionLogs(): HasMany
{
return $this->hasMany(ContentModerationActionLog::class, 'finding_id')->orderByDesc('created_at');
}
public function aiSuggestions(): HasMany
{
return $this->hasMany(ContentModerationAiSuggestion::class, 'finding_id')->orderByDesc('created_at');
}
public function feedback(): HasMany
{
return $this->hasMany(ContentModerationFeedback::class, 'finding_id')->orderByDesc('created_at');
}
public function isPending(): bool
{
return $this->status === ModerationStatus::Pending;
}
public function isReviewed(): bool
{
return in_array($this->status, [
ModerationStatus::ReviewedSafe,
ModerationStatus::ConfirmedSpam,
ModerationStatus::Resolved,
], true);
}
public function hasMatchedDomains(): bool
{
return ! empty($this->matched_domains_json);
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use App\Enums\ModerationRuleType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ContentModerationRule extends Model
{
protected $table = 'content_moderation_rules';
protected $fillable = [
'type',
'value',
'enabled',
'weight',
'notes',
'created_by',
];
protected $casts = [
'type' => ModerationRuleType::class,
'enabled' => 'boolean',
'weight' => 'integer',
];
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
}

View File

@@ -36,12 +36,14 @@ class DashboardPreference extends Model
protected $fillable = [ protected $fillable = [
'user_id', 'user_id',
'pinned_spaces', 'pinned_spaces',
'studio_preferences',
]; ];
protected function casts(): array protected function casts(): array
{ {
return [ return [
'pinned_spaces' => 'array', 'pinned_spaces' => 'array',
'studio_preferences' => 'array',
]; ];
} }

View File

@@ -28,6 +28,7 @@ class NovaCard extends Model
public const VISIBILITY_PRIVATE = 'private'; public const VISIBILITY_PRIVATE = 'private';
public const STATUS_DRAFT = 'draft'; public const STATUS_DRAFT = 'draft';
public const STATUS_SCHEDULED = 'scheduled';
public const STATUS_PROCESSING = 'processing'; public const STATUS_PROCESSING = 'processing';
public const STATUS_PUBLISHED = 'published'; public const STATUS_PUBLISHED = 'published';
public const STATUS_HIDDEN = 'hidden'; public const STATUS_HIDDEN = 'hidden';
@@ -85,6 +86,8 @@ class NovaCard extends Model
'allow_export', 'allow_export',
'original_creator_id', 'original_creator_id',
'published_at', 'published_at',
'scheduled_for',
'scheduling_timezone',
'last_engaged_at', 'last_engaged_at',
'last_ranked_at', 'last_ranked_at',
'last_rendered_at', 'last_rendered_at',
@@ -114,6 +117,7 @@ class NovaCard extends Model
'allow_background_reuse' => 'boolean', 'allow_background_reuse' => 'boolean',
'allow_export' => 'boolean', 'allow_export' => 'boolean',
'published_at' => 'datetime', 'published_at' => 'datetime',
'scheduled_for' => 'datetime',
'last_engaged_at' => 'datetime', 'last_engaged_at' => 'datetime',
'last_ranked_at' => 'datetime', 'last_ranked_at' => 'datetime',
'last_rendered_at' => 'datetime', 'last_rendered_at' => 'datetime',
@@ -245,6 +249,12 @@ class NovaCard extends Model
return null; return null;
} }
// Prefer an explicit CDN URL so images are served through the CDN edge layer.
$cdnBase = (string) env('FILES_CDN_URL', '');
if ($cdnBase !== '') {
return rtrim($cdnBase, '/') . '/' . ltrim($this->preview_path, '/');
}
return Storage::disk((string) config('nova_cards.storage.public_disk', 'public'))->url($this->preview_path); return Storage::disk((string) config('nova_cards.storage.public_disk', 'public'))->url($this->preview_path);
} }
@@ -259,6 +269,11 @@ class NovaCard extends Model
return $this->previewUrl(); return $this->previewUrl();
} }
$cdnBase = (string) env('FILES_CDN_URL', '');
if ($cdnBase !== '') {
return rtrim($cdnBase, '/') . '/' . ltrim($ogPath, '/');
}
return Storage::disk((string) config('nova_cards.storage.public_disk', 'public'))->url($ogPath); return Storage::disk((string) config('nova_cards.storage.public_disk', 'public'))->url($ogPath);
} }

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserSocialLink extends Model
{
protected $table = 'user_social_links';
protected $fillable = [
'user_id',
'platform',
'url',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -55,6 +55,10 @@ class ArtworkObserver
// The pivot sync happens outside this observer, so we dispatch on every // The pivot sync happens outside this observer, so we dispatch on every
// meaningful update and let the job be idempotent (cheap if nothing changed). // meaningful update and let the job be idempotent (cheap if nothing changed).
if ($artwork->is_public && $artwork->published_at) { if ($artwork->is_public && $artwork->published_at) {
if ($artwork->wasChanged('published_at') || $artwork->wasChanged('created_at')) {
$this->userStats->setLastUploadAt($artwork->user_id, $artwork->created_at ?? $artwork->published_at);
}
RecComputeSimilarByTagsJob::dispatch($artwork->id)->delay(now()->addSeconds(30)); RecComputeSimilarByTagsJob::dispatch($artwork->id)->delay(now()->addSeconds(30));
RecComputeSimilarHybridJob::dispatch($artwork->id)->delay(now()->addMinutes(1)); RecComputeSimilarHybridJob::dispatch($artwork->id)->delay(now()->addMinutes(1));

View File

@@ -2,6 +2,7 @@
namespace App\Providers; namespace App\Providers;
use App\Contracts\Images\SubjectDetectorInterface;
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
@@ -25,6 +26,10 @@ use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Queue\Events\JobFailed; use Illuminate\Queue\Events\JobFailed;
use App\Services\ReceivedCommentsInboxService; use App\Services\ReceivedCommentsInboxService;
use App\Services\Images\Detectors\ChainedSubjectDetector;
use App\Services\Images\Detectors\HeuristicSubjectDetector;
use App\Services\Images\Detectors\NullSubjectDetector;
use App\Services\Images\Detectors\VisionSubjectDetector;
use Klevze\ControlPanel\Framework\Core\Menu; use Klevze\ControlPanel\Framework\Core\Menu;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -55,6 +60,14 @@ class AppServiceProvider extends ServiceProvider
\App\Services\EarlyGrowth\SpotlightEngineInterface::class, \App\Services\EarlyGrowth\SpotlightEngineInterface::class,
\App\Services\EarlyGrowth\SpotlightEngine::class, \App\Services\EarlyGrowth\SpotlightEngine::class,
); );
$this->app->singleton(SubjectDetectorInterface::class, function ($app) {
return new ChainedSubjectDetector([
$app->make(VisionSubjectDetector::class),
$app->make(HeuristicSubjectDetector::class),
$app->make(NullSubjectDetector::class),
]);
});
} }
/** /**

View File

@@ -8,6 +8,8 @@ use Illuminate\Support\Facades\DB;
final class ArtworkFileRepository final class ArtworkFileRepository
{ {
private const SCREENSHOT_VARIANT_PREFIX = 'shot';
public function upsert(int $artworkId, string $variant, string $path, string $mime, int $size): void public function upsert(int $artworkId, string $variant, string $path, string $mime, int $size): void
{ {
DB::table('artwork_files')->updateOrInsert( DB::table('artwork_files')->updateOrInsert(
@@ -15,4 +17,20 @@ final class ArtworkFileRepository
['path' => $path, 'mime' => $mime, 'size' => $size] ['path' => $path, 'mime' => $mime, 'size' => $size]
); );
} }
public function deleteVariant(int $artworkId, string $variant): void
{
DB::table('artwork_files')
->where('artwork_id', $artworkId)
->where('variant', $variant)
->delete();
}
public function deleteScreenshotVariants(int $artworkId): void
{
DB::table('artwork_files')
->where('artwork_id', $artworkId)
->where('variant', 'like', self::SCREENSHOT_VARIANT_PREFIX . '%')
->delete();
}
} }

View File

@@ -14,7 +14,7 @@ final class ArtworkDraftService
public function createDraft(int $userId, string $title, ?string $description, ?int $categoryId = null, bool $isMature = false): ArtworkDraftResult public function createDraft(int $userId, string $title, ?string $description, ?int $categoryId = null, bool $isMature = false): ArtworkDraftResult
{ {
return DB::transaction(function () use ($userId, $title, $description, $categoryId, $isMature) { return DB::transaction(function () use ($userId, $title, $description, $categoryId, $isMature) {
$slug = $this->uniqueSlug($title); $slug = $this->makeSlug($title);
$artwork = Artwork::create([ $artwork = Artwork::create([
'user_id' => $userId, 'user_id' => $userId,
@@ -44,20 +44,10 @@ final class ArtworkDraftService
}); });
} }
private function uniqueSlug(string $title): string private function makeSlug(string $title): string
{ {
$base = Str::slug($title); $base = Str::slug($title);
$base = $base !== '' ? $base : 'artwork';
for ($i = 0; $i < 5; $i++) { return Str::limit($base !== '' ? $base : 'artwork', 160, '');
$suffix = Str::lower(Str::random(6));
$slug = Str::limit($base . '-' . $suffix, 160, '');
if (! Artwork::where('slug', $slug)->exists()) {
return $slug;
}
}
return Str::limit($base . '-' . Str::uuid()->toString(), 160, '');
} }
} }

View File

@@ -8,6 +8,7 @@ use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver as GdDriver; use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver; use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\PngEncoder;
use Intervention\Image\Encoders\WebpEncoder; use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager; use Intervention\Image\ImageManager;
use RuntimeException; use RuntimeException;
@@ -91,7 +92,16 @@ class AvatarService
public function removeAvatar(int $userId): void public function removeAvatar(int $userId): void
{ {
$diskName = (string) config('avatars.disk', 's3'); $diskName = (string) config('avatars.disk', 's3');
Storage::disk($diskName)->deleteDirectory("avatars/{$userId}"); $disk = Storage::disk($diskName);
$existingHash = UserProfile::query()
->where('user_id', $userId)
->value('avatar_hash');
if (is_string($existingHash) && trim($existingHash) !== '') {
$disk->deleteDirectory($this->avatarDirectory(trim($existingHash)));
}
$disk->deleteDirectory("avatars/{$userId}");
UserProfile::query()->updateOrCreate( UserProfile::query()->updateOrCreate(
['user_id' => $userId], ['user_id' => $userId],
@@ -108,20 +118,25 @@ class AvatarService
$image = $this->readImageFromBinary($binary); $image = $this->readImageFromBinary($binary);
$image = $this->normalizeImage($image); $image = $this->normalizeImage($image);
$cropPosition = $this->normalizePosition($position); $cropPosition = $this->normalizePosition($position);
$normalizedSource = (string) $image->encode(new PngEncoder());
if ($normalizedSource === '') {
throw new RuntimeException('Avatar processing failed to prepare the source image.');
}
$diskName = (string) config('avatars.disk', 's3'); $diskName = (string) config('avatars.disk', 's3');
$disk = Storage::disk($diskName); $disk = Storage::disk($diskName);
$basePath = "avatars/{$userId}";
$existingHash = UserProfile::query()
->where('user_id', $userId)
->value('avatar_hash');
$hashSeed = ''; $hashSeed = '';
$encodedVariants = [];
foreach ($this->sizes as $size) { foreach ($this->sizes as $size) {
$variant = $image->cover($size, $size, $cropPosition); $variant = $this->manager->read($normalizedSource)->cover($size, $size, $cropPosition);
$encoded = (string) $variant->encode(new WebpEncoder($this->quality)); $encoded = (string) $variant->encode(new WebpEncoder($this->quality));
$disk->put("{$basePath}/{$size}.webp", $encoded, [ $encodedVariants[(int) $size] = $encoded;
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => 'image/webp',
]);
if ($size === 128) { if ($size === 128) {
$hashSeed = $encoded; $hashSeed = $encoded;
@@ -133,11 +148,34 @@ class AvatarService
} }
$hash = hash('sha256', $hashSeed); $hash = hash('sha256', $hashSeed);
$basePath = $this->avatarDirectory($hash);
foreach ($encodedVariants as $size => $encoded) {
$disk->put("{$basePath}/{$size}.webp", $encoded, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => 'image/webp',
]);
}
if (is_string($existingHash) && trim($existingHash) !== '' && trim($existingHash) !== $hash) {
$disk->deleteDirectory($this->avatarDirectory(trim($existingHash)));
}
$disk->deleteDirectory("avatars/{$userId}");
$this->updateProfileMetadata($userId, $hash); $this->updateProfileMetadata($userId, $hash);
return $hash; return $hash;
} }
private function avatarDirectory(string $hash): string
{
$p1 = substr($hash, 0, 2);
$p2 = substr($hash, 2, 2);
return sprintf('avatars/%s/%s/%s', $p1, $p2, $hash);
}
private function normalizePosition(string $position): string private function normalizePosition(string $position): string
{ {
$normalized = strtolower(trim($position)); $normalized = strtolower(trim($position));

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Services\Cdn;
use App\Services\ThumbnailService;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
final class ArtworkCdnPurgeService
{
/**
* @param array<int, string> $objectPaths
* @param array<string, mixed> $context
*/
public function purgeArtworkObjectPaths(array $objectPaths, array $context = []): bool
{
$urls = array_values(array_unique(array_filter(array_map(
fn (mixed $path): ?string => is_string($path) && trim($path) !== ''
? $this->cdnUrlForObjectPath($path)
: null,
$objectPaths,
))));
return $this->purgeUrls($urls, $context);
}
/**
* @param array<int, string> $variants
* @param array<string, mixed> $context
*/
public function purgeArtworkHashVariants(string $hash, string $extension = 'webp', array $variants = ['xs', 'sm', 'md', 'lg', 'xl', 'sq'], array $context = []): bool
{
$urls = array_values(array_unique(array_filter(array_map(
fn (string $variant): ?string => ThumbnailService::fromHash($hash, $extension, $variant),
$variants,
))));
return $this->purgeUrls($urls, $context + ['hash' => $hash]);
}
/**
* @param array<int, string> $urls
* @param array<string, mixed> $context
*/
private function purgeUrls(array $urls, array $context = []): bool
{
if ($urls === []) {
return false;
}
if ($this->hasCloudflareCredentials()) {
return $this->purgeViaCloudflare($urls, $context);
}
$legacyPurgeUrl = trim((string) config('cdn.purge_url', ''));
if ($legacyPurgeUrl !== '') {
return $this->purgeViaLegacyWebhook($legacyPurgeUrl, $urls, $context);
}
Log::debug('CDN purge skipped - no Cloudflare or legacy purge configuration is available', $context + [
'url_count' => count($urls),
]);
return false;
}
private function purgeViaCloudflare(array $urls, array $context): bool
{
$purgeUrl = sprintf(
'https://api.cloudflare.com/client/v4/zones/%s/purge_cache',
trim((string) config('cdn.cloudflare.zone_id')),
);
try {
$response = Http::timeout(10)
->acceptJson()
->withToken(trim((string) config('cdn.cloudflare.api_token')))
->post($purgeUrl, ['files' => $urls]);
if ($response->successful()) {
return true;
}
Log::warning('Cloudflare artwork CDN purge failed', $context + [
'status' => $response->status(),
'body' => $response->body(),
'url_count' => count($urls),
]);
} catch (\Throwable $e) {
Log::warning('Cloudflare artwork CDN purge threw an exception', $context + [
'error' => $e->getMessage(),
'url_count' => count($urls),
]);
}
return false;
}
private function purgeViaLegacyWebhook(string $purgeUrl, array $urls, array $context): bool
{
$paths = array_values(array_unique(array_filter(array_map(function (string $url): ?string {
$path = parse_url($url, PHP_URL_PATH);
return is_string($path) && $path !== '' ? $path : null;
}, $urls))));
if ($paths === []) {
return false;
}
try {
$response = Http::timeout(10)->acceptJson()->post($purgeUrl, ['paths' => $paths]);
if ($response->successful()) {
return true;
}
Log::warning('Legacy artwork CDN purge failed', $context + [
'status' => $response->status(),
'body' => $response->body(),
'path_count' => count($paths),
]);
} catch (\Throwable $e) {
Log::warning('Legacy artwork CDN purge threw an exception', $context + [
'error' => $e->getMessage(),
'path_count' => count($paths),
]);
}
return false;
}
private function hasCloudflareCredentials(): bool
{
return trim((string) config('cdn.cloudflare.zone_id', '')) !== ''
&& trim((string) config('cdn.cloudflare.api_token', '')) !== '';
}
private function cdnUrlForObjectPath(string $objectPath): string
{
return rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/') . '/' . ltrim($objectPath, '/');
}
}

View File

@@ -0,0 +1,347 @@
<?php
declare(strict_types=1);
namespace App\Services\Images;
use App\Models\Artwork;
use App\Repositories\Uploads\ArtworkFileRepository;
use App\Services\Cdn\ArtworkCdnPurgeService;
use App\Services\ThumbnailService;
use App\Services\Uploads\UploadDerivativesService;
use App\Services\Uploads\UploadStorageService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use RuntimeException;
final class ArtworkSquareThumbnailBackfillService
{
public function __construct(
private readonly UploadDerivativesService $derivatives,
private readonly UploadStorageService $storage,
private readonly ArtworkFileRepository $artworkFiles,
private readonly ArtworkCdnPurgeService $cdnPurge,
) {
}
/**
* @return array<string, mixed>
*/
public function ensureSquareThumbnail(Artwork $artwork, bool $force = false, bool $dryRun = false): array
{
$hash = strtolower((string) ($artwork->hash ?? ''));
if ($hash === '') {
throw new RuntimeException('Artwork hash is required to generate a square thumbnail.');
}
$existing = DB::table('artwork_files')
->where('artwork_id', $artwork->id)
->where('variant', 'sq')
->first(['path']);
if ($existing !== null && ! $force) {
return [
'status' => 'skipped',
'reason' => 'already_exists',
'artwork_id' => $artwork->id,
'path' => (string) ($existing->path ?? ''),
];
}
$resolved = $this->resolveBestSource($artwork);
if ($dryRun) {
return [
'status' => 'dry_run',
'artwork_id' => $artwork->id,
'source_variant' => $resolved['variant'],
'source_path' => $resolved['source_path'],
'object_path' => $this->storage->objectPathForVariant('sq', $hash, $hash . '.webp'),
];
}
try {
$asset = $this->derivatives->generateSquareDerivative($resolved['source_path'], $hash, [
'context' => ['artwork' => $artwork],
]);
$this->artworkFiles->upsert($artwork->id, 'sq', $asset['path'], $asset['mime'], $asset['size']);
$this->cdnPurge->purgeArtworkObjectPaths([$asset['path']], [
'artwork_id' => $artwork->id,
'reason' => 'square_thumbnail_regenerated',
]);
if (! is_string($artwork->thumb_ext) || trim($artwork->thumb_ext) === '') {
$artwork->forceFill(['thumb_ext' => 'webp'])->saveQuietly();
}
return [
'status' => 'generated',
'artwork_id' => $artwork->id,
'path' => $asset['path'],
'source_variant' => $resolved['variant'],
'crop_mode' => $asset['result']?->cropMode,
];
} finally {
if (($resolved['cleanup'] ?? false) === true) {
File::delete($resolved['source_path']);
}
}
}
/**
* @return array{variant: string, source_path: string, cleanup: bool}
*/
private function resolveBestSource(Artwork $artwork): array
{
$hash = strtolower((string) ($artwork->hash ?? ''));
$files = DB::table('artwork_files')
->where('artwork_id', $artwork->id)
->pluck('path', 'variant')
->all();
$variants = ['orig_image', 'orig', 'xl', 'lg', 'md', 'sm', 'xs'];
foreach ($variants as $variant) {
$path = $files[$variant] ?? null;
if (! is_string($path) || trim($path) === '') {
continue;
}
if ($variant === 'orig_image' || $variant === 'orig') {
$filename = basename($path);
$localPath = $this->storage->localOriginalPath($hash, $filename);
if (is_file($localPath)) {
return [
'variant' => $variant,
'source_path' => $localPath,
'cleanup' => false,
];
}
}
$temporary = $this->downloadToTempFile($path, pathinfo($path, PATHINFO_EXTENSION) ?: 'webp');
if ($temporary !== null) {
return [
'variant' => $variant,
'source_path' => $temporary,
'cleanup' => true,
];
}
}
$directSource = $this->resolveArtworkFilePathSource($artwork);
if ($directSource !== null) {
return $directSource;
}
$canonicalDerivativeSource = $this->resolveCanonicalDerivativeSource($artwork);
if ($canonicalDerivativeSource !== null) {
return $canonicalDerivativeSource;
}
throw new RuntimeException(sprintf('No usable source image was found for artwork %d.', (int) $artwork->id));
}
/**
* @return array{variant: string, source_path: string, cleanup: bool}|null
*/
private function resolveArtworkFilePathSource(Artwork $artwork): ?array
{
$relativePath = trim((string) ($artwork->file_path ?? ''), '/');
if ($relativePath === '') {
return null;
}
foreach ($this->localFilePathCandidates($relativePath) as $candidate) {
if (is_file($candidate)) {
return [
'variant' => 'file_path',
'source_path' => $candidate,
'cleanup' => false,
];
}
}
$downloaded = $this->downloadUrlToTempFile($this->cdnUrlForPath($relativePath), pathinfo($relativePath, PATHINFO_EXTENSION));
if ($downloaded === null) {
return null;
}
return [
'variant' => 'file_path',
'source_path' => $downloaded,
'cleanup' => true,
];
}
/**
* @return array{variant: string, source_path: string, cleanup: bool}|null
*/
private function resolveCanonicalDerivativeSource(Artwork $artwork): ?array
{
$hash = strtolower((string) ($artwork->hash ?? ''));
$thumbExt = strtolower(ltrim((string) ($artwork->thumb_ext ?? ''), '.'));
if ($hash === '' || $thumbExt === '') {
return null;
}
foreach (['xl', 'lg', 'md', 'sm', 'xs'] as $variant) {
$url = ThumbnailService::fromHash($hash, $thumbExt, $variant);
if (! is_string($url) || $url === '') {
continue;
}
$downloaded = $this->downloadUrlToTempFile($url, $thumbExt);
if ($downloaded === null) {
continue;
}
return [
'variant' => $variant,
'source_path' => $downloaded,
'cleanup' => true,
];
}
return null;
}
/**
* @return array<int, string>
*/
private function localFilePathCandidates(string $relativePath): array
{
$normalizedPath = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relativePath);
return array_values(array_unique([
$normalizedPath,
base_path($normalizedPath),
public_path($normalizedPath),
storage_path('app/public' . DIRECTORY_SEPARATOR . $normalizedPath),
storage_path('app/private' . DIRECTORY_SEPARATOR . $normalizedPath),
]));
}
private function cdnUrlForPath(string $relativePath): string
{
return rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/') . '/' . ltrim($relativePath, '/');
}
private function downloadUrlToTempFile(string $url, string $extension = ''): ?string
{
$context = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 30,
'ignore_errors' => true,
'header' => implode("\r\n", [
'User-Agent: Skinbase Nova square-thumb backfill',
'Accept: image/*,*/*;q=0.8',
'Accept-Encoding: identity',
'Connection: close',
]) . "\r\n",
],
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$contents = @file_get_contents($url, false, $context);
$headers = $http_response_header ?? [];
if (! is_string($contents) || $contents === '' || ! $this->isSuccessfulHttpResponse($url, $headers)) {
return null;
}
if (! is_string($contents) || $contents === '') {
return null;
}
$resolvedExtension = trim($extension) !== ''
? trim($extension)
: $this->extensionFromContentType($this->contentTypeFromHeaders($headers));
return $this->writeTemporaryFile($contents, $resolvedExtension);
}
/**
* @param array<int, string> $headers
*/
private function isSuccessfulHttpResponse(string $url, array $headers): bool
{
if ($headers === [] && parse_url($url, PHP_URL_SCHEME) === 'file') {
return true;
}
$statusLine = $headers[0] ?? '';
if (! is_string($statusLine) || ! preg_match('/\s(\d{3})\s/', $statusLine, $matches)) {
return false;
}
$statusCode = (int) ($matches[1] ?? 0);
return $statusCode >= 200 && $statusCode < 300;
}
/**
* @param array<int, string> $headers
*/
private function contentTypeFromHeaders(array $headers): string
{
foreach ($headers as $header) {
if (! is_string($header) || stripos($header, 'Content-Type:') !== 0) {
continue;
}
return trim(substr($header, strlen('Content-Type:')));
}
return '';
}
private function writeTemporaryFile(string $contents, string $extension = ''): string
{
$temp = tempnam(sys_get_temp_dir(), 'sq-thumb-');
if ($temp === false) {
throw new RuntimeException('Unable to allocate a temporary file for square thumbnail generation.');
}
$normalizedExtension = trim((string) $extension);
$path = $normalizedExtension !== '' ? $temp . '.' . $normalizedExtension : $temp;
if ($normalizedExtension !== '') {
rename($temp, $path);
}
File::put($path, $contents);
return $path;
}
private function extensionFromContentType(string $contentType): string
{
$normalized = strtolower(trim(strtok($contentType, ';') ?: ''));
return match ($normalized) {
'image/jpeg', 'image/jpg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
'image/gif' => 'gif',
default => '',
};
}
private function downloadToTempFile(string $objectPath, string $extension): ?string
{
$contents = $this->storage->readObject($objectPath);
if (! is_string($contents) || $contents === '') {
return null;
}
return $this->writeTemporaryFile($contents, $extension);
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Services\Images\Detectors;
use App\Contracts\Images\SubjectDetectorInterface;
use App\Data\Images\SubjectDetectionResultData;
final class ChainedSubjectDetector implements SubjectDetectorInterface
{
/**
* @param iterable<int, SubjectDetectorInterface> $detectors
*/
public function __construct(private readonly iterable $detectors)
{
}
public function detect(string $sourcePath, int $sourceWidth, int $sourceHeight, array $context = []): ?SubjectDetectionResultData
{
foreach ($this->detectors as $detector) {
$result = $detector->detect($sourcePath, $sourceWidth, $sourceHeight, $context);
if ($result !== null) {
return $result;
}
}
return null;
}
}

View File

@@ -0,0 +1,409 @@
<?php
declare(strict_types=1);
namespace App\Services\Images\Detectors;
use App\Contracts\Images\SubjectDetectorInterface;
use App\Data\Images\CropBoxData;
use App\Data\Images\SubjectDetectionResultData;
final class HeuristicSubjectDetector implements SubjectDetectorInterface
{
public function detect(string $sourcePath, int $sourceWidth, int $sourceHeight, array $context = []): ?SubjectDetectionResultData
{
if (! function_exists('imagecreatefromstring')) {
return null;
}
$binary = @file_get_contents($sourcePath);
if (! is_string($binary) || $binary === '') {
return null;
}
$source = @imagecreatefromstring($binary);
if ($source === false) {
return null;
}
try {
$sampleMax = max(24, (int) config('uploads.square_thumbnails.saliency.sample_max_dimension', 96));
$longest = max(1, max($sourceWidth, $sourceHeight));
$scale = min(1.0, $sampleMax / $longest);
$sampleWidth = max(8, (int) round($sourceWidth * $scale));
$sampleHeight = max(8, (int) round($sourceHeight * $scale));
$sample = imagecreatetruecolor($sampleWidth, $sampleHeight);
if ($sample === false) {
return null;
}
try {
imagecopyresampled($sample, $source, 0, 0, 0, 0, $sampleWidth, $sampleHeight, $sourceWidth, $sourceHeight);
$gray = $this->grayscaleMatrix($sample, $sampleWidth, $sampleHeight);
$rarity = $this->colorRarityMatrix($sample, $sampleWidth, $sampleHeight);
$vegetation = $this->vegetationMaskMatrix($sample, $sampleWidth, $sampleHeight);
} finally {
imagedestroy($sample);
}
$energy = $this->energyMatrix($gray, $sampleWidth, $sampleHeight);
$saliency = $this->combineSaliency($energy, $rarity, $sampleWidth, $sampleHeight);
$prefix = $this->prefixMatrix($saliency, $sampleWidth, $sampleHeight);
$vegetationPrefix = $this->prefixMatrix($vegetation, $sampleWidth, $sampleHeight);
$totalEnergy = $prefix[$sampleHeight][$sampleWidth] ?? 0.0;
if ($totalEnergy < (float) config('uploads.square_thumbnails.saliency.min_total_energy', 2400.0)) {
return null;
}
$candidate = $this->bestCandidate($prefix, $vegetationPrefix, $sampleWidth, $sampleHeight, $totalEnergy);
$rareSubjectCandidate = $this->rareSubjectCandidate($rarity, $vegetation, $sampleWidth, $sampleHeight);
if ($rareSubjectCandidate !== null && ($candidate === null || $rareSubjectCandidate['score'] > ($candidate['score'] * 0.72))) {
$candidate = $rareSubjectCandidate;
}
if ($candidate === null) {
return null;
}
$scaleX = $sourceWidth / max(1, $sampleWidth);
$scaleY = $sourceHeight / max(1, $sampleHeight);
$sideScale = max($scaleX, $scaleY);
$cropBox = new CropBoxData(
x: (int) floor($candidate['x'] * $scaleX),
y: (int) floor($candidate['y'] * $scaleY),
width: max(1, (int) round($candidate['side'] * $sideScale)),
height: max(1, (int) round($candidate['side'] * $sideScale)),
);
$averageDensity = $totalEnergy / max(1, $sampleWidth * $sampleHeight);
$confidence = min(1.0, max(0.15, ($candidate['density'] / max(1.0, $averageDensity)) / 4.0));
return new SubjectDetectionResultData(
cropBox: $cropBox->clampToImage($sourceWidth, $sourceHeight),
strategy: 'saliency',
reason: 'heuristic_saliency',
confidence: $confidence,
meta: [
'sample_width' => $sampleWidth,
'sample_height' => $sampleHeight,
'score' => $candidate['score'],
],
);
} finally {
imagedestroy($source);
}
}
/**
* @return array<int, array<int, int>>
*/
private function grayscaleMatrix($sample, int $width, int $height): array
{
$gray = [];
for ($y = 0; $y < $height; $y++) {
$gray[$y] = [];
for ($x = 0; $x < $width; $x++) {
$rgb = imagecolorat($sample, $x, $y);
$r = ($rgb >> 16) & 0xFF;
$g = ($rgb >> 8) & 0xFF;
$b = $rgb & 0xFF;
$gray[$y][$x] = (int) round($r * 0.299 + $g * 0.587 + $b * 0.114);
}
}
return $gray;
}
/**
* @param array<int, array<int, int>> $gray
* @return array<int, array<int, float>>
*/
private function energyMatrix(array $gray, int $width, int $height): array
{
$energy = [];
for ($y = 0; $y < $height; $y++) {
$energy[$y] = [];
for ($x = 0; $x < $width; $x++) {
$center = $gray[$y][$x] ?? 0;
$right = $gray[$y][$x + 1] ?? $center;
$down = $gray[$y + 1][$x] ?? $center;
$diag = $gray[$y + 1][$x + 1] ?? $center;
$energy[$y][$x] = abs($center - $right)
+ abs($center - $down)
+ (abs($center - $diag) * 0.5);
}
}
return $energy;
}
/**
* Build a map that highlights globally uncommon colors, which helps distinguish
* a main subject from repetitive foliage or sky textures.
*
* @return array<int, array<int, float>>
*/
private function colorRarityMatrix($sample, int $width, int $height): array
{
$counts = [];
$pixels = [];
$totalPixels = max(1, $width * $height);
for ($y = 0; $y < $height; $y++) {
$pixels[$y] = [];
for ($x = 0; $x < $width; $x++) {
$rgb = imagecolorat($sample, $x, $y);
$r = ($rgb >> 16) & 0xFF;
$g = ($rgb >> 8) & 0xFF;
$b = $rgb & 0xFF;
$bucket = (($r >> 5) << 6) | (($g >> 5) << 3) | ($b >> 5);
$counts[$bucket] = ($counts[$bucket] ?? 0) + 1;
$pixels[$y][$x] = [$r, $g, $b, $bucket];
}
}
$rarity = [];
for ($y = 0; $y < $height; $y++) {
$rarity[$y] = [];
for ($x = 0; $x < $width; $x++) {
[$r, $g, $b, $bucket] = $pixels[$y][$x];
$bucketCount = max(1, (int) ($counts[$bucket] ?? 1));
$baseRarity = log(($totalPixels + 1) / $bucketCount);
$maxChannel = max($r, $g, $b);
$minChannel = min($r, $g, $b);
$saturation = $maxChannel - $minChannel;
$luma = ($r * 0.299) + ($g * 0.587) + ($b * 0.114);
$neutralLightBoost = ($luma >= 135 && $saturation <= 95) ? 1.0 : 0.0;
$warmBoost = ($r >= 96 && $r >= $b + 10) ? 1.0 : 0.0;
$vegetationPenalty = ($g >= 72 && $g >= $r * 1.12 && $g >= $b * 1.08) ? 1.0 : 0.0;
$rarity[$y][$x] = max(0.0,
($baseRarity * 32.0)
+ ($saturation * 0.10)
+ ($neutralLightBoost * 28.0)
+ ($warmBoost * 18.0)
- ($vegetationPenalty * 18.0)
);
}
}
return $rarity;
}
/**
* @return array<int, array<int, float>>
*/
private function vegetationMaskMatrix($sample, int $width, int $height): array
{
$mask = [];
for ($y = 0; $y < $height; $y++) {
$mask[$y] = [];
for ($x = 0; $x < $width; $x++) {
$rgb = imagecolorat($sample, $x, $y);
$r = ($rgb >> 16) & 0xFF;
$g = ($rgb >> 8) & 0xFF;
$b = $rgb & 0xFF;
$mask[$y][$x] = ($g >= 72 && $g >= $r * 1.12 && $g >= $b * 1.08) ? 1.0 : 0.0;
}
}
return $mask;
}
/**
* @param array<int, array<int, float>> $energy
* @param array<int, array<int, float>> $rarity
* @return array<int, array<int, float>>
*/
private function combineSaliency(array $energy, array $rarity, int $width, int $height): array
{
$combined = [];
for ($y = 0; $y < $height; $y++) {
$combined[$y] = [];
for ($x = 0; $x < $width; $x++) {
$combined[$y][$x] = ($energy[$y][$x] ?? 0.0) + (($rarity[$y][$x] ?? 0.0) * 1.45);
}
}
return $combined;
}
/**
* @param array<int, array<int, float>> $matrix
* @return array<int, array<int, float>>
*/
private function prefixMatrix(array $matrix, int $width, int $height): array
{
$prefix = array_fill(0, $height + 1, array_fill(0, $width + 1, 0.0));
for ($y = 1; $y <= $height; $y++) {
for ($x = 1; $x <= $width; $x++) {
$prefix[$y][$x] = $matrix[$y - 1][$x - 1]
+ $prefix[$y - 1][$x]
+ $prefix[$y][$x - 1]
- $prefix[$y - 1][$x - 1];
}
}
return $prefix;
}
/**
* @param array<int, array<int, float>> $prefix
* @return array{x: int, y: int, side: int, density: float, score: float}|null
*/
private function bestCandidate(array $prefix, array $vegetationPrefix, int $sampleWidth, int $sampleHeight, float $totalEnergy): ?array
{
$minDimension = min($sampleWidth, $sampleHeight);
$ratios = (array) config('uploads.square_thumbnails.saliency.window_ratios', [0.55, 0.7, 0.82, 1.0]);
$best = null;
foreach ($ratios as $ratio) {
$side = max(8, min($minDimension, (int) round($minDimension * (float) $ratio)));
$step = max(1, (int) floor($side / 5));
for ($y = 0; $y <= max(0, $sampleHeight - $side); $y += $step) {
for ($x = 0; $x <= max(0, $sampleWidth - $side); $x += $step) {
$sum = $this->sumRegion($prefix, $x, $y, $side, $side);
$density = $sum / max(1, $side * $side);
$centerX = ($x + ($side / 2)) / max(1, $sampleWidth);
$centerY = ($y + ($side / 2)) / max(1, $sampleHeight);
$centerBias = 1.0 - min(1.0, abs($centerX - 0.5) * 1.2 + abs($centerY - 0.42) * 0.9);
$coverage = $side / max(1, $minDimension);
$coverageFit = 1.0 - min(1.0, abs($coverage - 0.72) / 0.45);
$vegetationRatio = $this->sumRegion($vegetationPrefix, $x, $y, $side, $side) / max(1, $side * $side);
$score = $density * (1.0 + max(0.0, $centerBias) * 0.18)
+ (($sum / max(1.0, $totalEnergy)) * 4.0)
+ (max(0.0, $coverageFit) * 2.5)
- ($vegetationRatio * 68.0);
if ($best === null || $score > $best['score']) {
$best = [
'x' => $x,
'y' => $y,
'side' => $side,
'density' => $density,
'score' => $score,
];
}
}
}
}
return $best;
}
/**
* Build a second candidate from rare, non-foliage pixels so a smooth subject can
* still win even when repetitive textured leaves dominate edge energy.
*
* @param array<int, array<int, float>> $rarity
* @param array<int, array<int, float>> $vegetation
* @return array{x: int, y: int, side: int, density: float, score: float}|null
*/
private function rareSubjectCandidate(array $rarity, array $vegetation, int $sampleWidth, int $sampleHeight): ?array
{
$values = [];
for ($y = 0; $y < $sampleHeight; $y++) {
for ($x = 0; $x < $sampleWidth; $x++) {
if (($vegetation[$y][$x] ?? 0.0) >= 0.5) {
continue;
}
$values[] = (float) ($rarity[$y][$x] ?? 0.0);
}
}
if (count($values) < 24) {
return null;
}
sort($values);
$thresholdIndex = max(0, (int) floor((count($values) - 1) * 0.88));
$threshold = max(48.0, (float) ($values[$thresholdIndex] ?? 0.0));
$weightSum = 0.0;
$weightedX = 0.0;
$weightedY = 0.0;
$minX = $sampleWidth;
$minY = $sampleHeight;
$maxX = 0;
$maxY = 0;
$count = 0;
for ($y = 0; $y < $sampleHeight; $y++) {
for ($x = 0; $x < $sampleWidth; $x++) {
if (($vegetation[$y][$x] ?? 0.0) >= 0.5) {
continue;
}
$weight = (float) ($rarity[$y][$x] ?? 0.0);
if ($weight < $threshold) {
continue;
}
$weightSum += $weight;
$weightedX += ($x + 0.5) * $weight;
$weightedY += ($y + 0.5) * $weight;
$minX = min($minX, $x);
$minY = min($minY, $y);
$maxX = max($maxX, $x);
$maxY = max($maxY, $y);
$count++;
}
}
if ($count < 12 || $weightSum <= 0.0) {
return null;
}
$meanX = $weightedX / $weightSum;
$meanY = $weightedY / $weightSum;
$boxWidth = max(8, ($maxX - $minX) + 1);
$boxHeight = max(8, ($maxY - $minY) + 1);
$minDimension = min($sampleWidth, $sampleHeight);
$side = max($boxWidth, $boxHeight);
$side = max($side, (int) round($minDimension * 0.42));
$side = min($minDimension, (int) round($side * 1.18));
return [
'x' => (int) round($meanX - ($side / 2)),
'y' => (int) round($meanY - ($side / 2)),
'side' => max(8, $side),
'density' => $weightSum / max(1, $count),
'score' => ($weightSum / max(1, $count)) + ($count * 0.35),
];
}
/**
* @param array<int, array<int, float>> $prefix
*/
private function sumRegion(array $prefix, int $x, int $y, int $width, int $height): float
{
$x2 = $x + $width;
$y2 = $y + $height;
return ($prefix[$y2][$x2] ?? 0.0)
- ($prefix[$y][$x2] ?? 0.0)
- ($prefix[$y2][$x] ?? 0.0)
+ ($prefix[$y][$x] ?? 0.0);
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Services\Images\Detectors;
use App\Contracts\Images\SubjectDetectorInterface;
use App\Data\Images\SubjectDetectionResultData;
final class NullSubjectDetector implements SubjectDetectorInterface
{
public function detect(string $sourcePath, int $sourceWidth, int $sourceHeight, array $context = []): ?SubjectDetectionResultData
{
return null;
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Services\Images\Detectors;
use App\Contracts\Images\SubjectDetectorInterface;
use App\Data\Images\CropBoxData;
use App\Data\Images\SubjectDetectionResultData;
use App\Models\Artwork;
final class VisionSubjectDetector implements SubjectDetectorInterface
{
public function detect(string $sourcePath, int $sourceWidth, int $sourceHeight, array $context = []): ?SubjectDetectionResultData
{
$boxes = $this->extractCandidateBoxes($context, $sourceWidth, $sourceHeight);
if ($boxes === []) {
return null;
}
usort($boxes, static function (array $left, array $right): int {
return $right['score'] <=> $left['score'];
});
$best = $boxes[0];
return new SubjectDetectionResultData(
cropBox: $best['box'],
strategy: 'subject',
reason: 'vision_subject_box',
confidence: (float) $best['confidence'],
meta: [
'label' => $best['label'],
'score' => $best['score'],
],
);
}
/**
* @return array<int, array{box: CropBoxData, label: string, confidence: float, score: float}>
*/
private function extractCandidateBoxes(array $context, int $sourceWidth, int $sourceHeight): array
{
$boxes = [];
$preferredLabels = collect((array) config('uploads.square_thumbnails.subject_detector.preferred_labels', []))
->map(static fn ($label): string => mb_strtolower((string) $label))
->filter()
->values()
->all();
$candidates = $context['subject_boxes'] ?? $context['vision_boxes'] ?? null;
if ($candidates === null && ($context['artwork'] ?? null) instanceof Artwork) {
$candidates = $this->boxesFromArtwork($context['artwork']);
}
foreach ((array) $candidates as $row) {
if (! is_array($row)) {
continue;
}
$box = $this->normalizeBox($row, $sourceWidth, $sourceHeight);
if ($box === null) {
continue;
}
$label = mb_strtolower((string) ($row['label'] ?? $row['tag'] ?? $row['name'] ?? 'subject'));
$confidence = max(0.0, min(1.0, (float) ($row['confidence'] ?? $row['score'] ?? 0.75)));
$areaWeight = ($box->width * $box->height) / max(1, $sourceWidth * $sourceHeight);
$preferredBoost = in_array($label, $preferredLabels, true) ? 1.25 : 1.0;
$boxes[] = [
'box' => $box,
'label' => $label,
'confidence' => $confidence,
'score' => ($confidence * 0.8 + $areaWeight * 0.2) * $preferredBoost,
];
}
return $boxes;
}
/**
* @return array<int, array<string, mixed>>
*/
private function boxesFromArtwork(Artwork $artwork): array
{
return collect((array) ($artwork->yolo_objects_json ?? []))
->filter(static fn ($row): bool => is_array($row))
->values()
->all();
}
/**
* @param array<string, mixed> $row
*/
private function normalizeBox(array $row, int $sourceWidth, int $sourceHeight): ?CropBoxData
{
$payload = is_array($row['box'] ?? null) ? $row['box'] : $row;
$left = $payload['x'] ?? $payload['left'] ?? $payload['x1'] ?? null;
$top = $payload['y'] ?? $payload['top'] ?? $payload['y1'] ?? null;
$width = $payload['width'] ?? null;
$height = $payload['height'] ?? null;
if ($width === null && isset($payload['x2'], $payload['x1'])) {
$width = (float) $payload['x2'] - (float) $payload['x1'];
}
if ($height === null && isset($payload['y2'], $payload['y1'])) {
$height = (float) $payload['y2'] - (float) $payload['y1'];
}
if (! is_numeric($left) || ! is_numeric($top) || ! is_numeric($width) || ! is_numeric($height)) {
return null;
}
$left = (float) $left;
$top = (float) $top;
$width = (float) $width;
$height = (float) $height;
$normalized = max(abs($left), abs($top), abs($width), abs($height)) <= 1.0;
if ($normalized) {
$left *= $sourceWidth;
$top *= $sourceHeight;
$width *= $sourceWidth;
$height *= $sourceHeight;
}
if ($width <= 1 || $height <= 1) {
return null;
}
return (new CropBoxData(
x: (int) floor($left),
y: (int) floor($top),
width: (int) round($width),
height: (int) round($height),
))->clampToImage($sourceWidth, $sourceHeight);
}
}

View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace App\Services\Images;
use App\Contracts\Images\SubjectDetectorInterface;
use App\Data\Images\CropBoxData;
use App\Data\Images\SquareThumbnailResultData;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
use RuntimeException;
final class SquareThumbnailService
{
private ?ImageManager $manager = null;
public function __construct(private readonly SubjectDetectorInterface $subjectDetector)
{
try {
$this->manager = extension_loaded('gd') ? ImageManager::gd() : ImageManager::imagick();
} catch (\Throwable $e) {
Log::warning('Square thumbnail image manager configuration failed', [
'error' => $e->getMessage(),
]);
$this->manager = null;
}
}
/**
* @param array<string, mixed> $options
*/
public function generateFromPath(string $sourcePath, string $destinationPath, array $options = []): SquareThumbnailResultData
{
$this->assertImageManagerAvailable();
if (! is_file($sourcePath) || ! is_readable($sourcePath)) {
throw new RuntimeException('Square thumbnail source image is not readable.');
}
$size = @getimagesize($sourcePath);
if (! is_array($size) || ($size[0] ?? 0) < 1 || ($size[1] ?? 0) < 1) {
throw new RuntimeException('Square thumbnail source image dimensions are invalid.');
}
$sourceWidth = (int) $size[0];
$sourceHeight = (int) $size[1];
$config = $this->resolveOptions($options);
$context = is_array($options['context'] ?? null) ? $options['context'] : $options;
$detection = null;
if ($config['smart_crop']) {
$detection = $this->subjectDetector->detect($sourcePath, $sourceWidth, $sourceHeight, $context);
}
$cropBox = $this->calculateCropBox($sourceWidth, $sourceHeight, $detection?->cropBox, $config);
$cropMode = $detection?->strategy ?? $config['fallback_strategy'];
$image = $this->manager->read($sourcePath)->crop($cropBox->width, $cropBox->height, $cropBox->x, $cropBox->y);
$outputWidth = $config['target_width'];
$outputHeight = $config['target_height'];
if ($config['allow_upscale']) {
$image = $image->resize($config['target_width'], $config['target_height']);
} else {
$image = $image->resizeDown($config['target_width'], $config['target_height']);
$outputWidth = min($config['target_width'], $cropBox->width);
$outputHeight = min($config['target_height'], $cropBox->height);
}
$encoded = (string) $image->encode(new WebpEncoder($config['quality']));
File::ensureDirectoryExists(dirname($destinationPath));
File::put($destinationPath, $encoded);
$result = new SquareThumbnailResultData(
destinationPath: $destinationPath,
cropBox: $cropBox,
cropMode: $cropMode,
sourceWidth: $sourceWidth,
sourceHeight: $sourceHeight,
targetWidth: $config['target_width'],
targetHeight: $config['target_height'],
outputWidth: $outputWidth,
outputHeight: $outputHeight,
detectionReason: $detection?->reason,
meta: [
'smart_crop' => $config['smart_crop'],
'padding_ratio' => $config['padding_ratio'],
'confidence' => $detection?->confidence,
],
);
if ($config['log']) {
Log::debug('square-thumbnail-generated', $result->toArray());
}
return $result;
}
/**
* @param array<string, mixed> $options
*/
public function calculateCropBox(int $sourceWidth, int $sourceHeight, ?CropBoxData $focusBox = null, array $options = []): CropBoxData
{
$config = $this->resolveOptions($options);
if ($focusBox === null) {
return $this->safeCenterCrop($sourceWidth, $sourceHeight);
}
$baseSide = max($focusBox->width, $focusBox->height);
$side = max(1, (int) ceil($baseSide * (1 + ($config['padding_ratio'] * 2))));
$side = min($side, min($sourceWidth, $sourceHeight));
$x = (int) round($focusBox->centerX() - ($side / 2));
$y = (int) round($focusBox->centerY() - ($side / 2));
return (new CropBoxData($x, $y, $side, $side))->clampToImage($sourceWidth, $sourceHeight);
}
private function safeCenterCrop(int $sourceWidth, int $sourceHeight): CropBoxData
{
$side = min($sourceWidth, $sourceHeight);
$x = (int) floor(($sourceWidth - $side) / 2);
$y = (int) floor(($sourceHeight - $side) / 2);
return new CropBoxData($x, $y, $side, $side);
}
/**
* @param array<string, mixed> $options
* @return array{target_width: int, target_height: int, quality: int, smart_crop: bool, padding_ratio: float, allow_upscale: bool, fallback_strategy: string, log: bool}
*/
private function resolveOptions(array $options): array
{
$config = (array) config('uploads.square_thumbnails', []);
$targetWidth = (int) ($options['target_width'] ?? $options['target_size'] ?? $config['width'] ?? config('uploads.derivatives.sq.size', 512));
$targetHeight = (int) ($options['target_height'] ?? $options['target_size'] ?? $config['height'] ?? $targetWidth);
return [
'target_width' => max(1, $targetWidth),
'target_height' => max(1, $targetHeight),
'quality' => max(1, min(100, (int) ($options['quality'] ?? $config['quality'] ?? 82))),
'smart_crop' => (bool) ($options['smart_crop'] ?? $config['smart_crop'] ?? true),
'padding_ratio' => max(0.0, min(0.5, (float) ($options['padding_ratio'] ?? $config['padding_ratio'] ?? 0.18))),
'allow_upscale' => (bool) ($options['allow_upscale'] ?? $config['allow_upscale'] ?? false),
'fallback_strategy' => (string) ($options['fallback_strategy'] ?? $config['fallback_strategy'] ?? 'center'),
'log' => (bool) ($options['log'] ?? $config['log'] ?? false),
];
}
private function assertImageManagerAvailable(): void
{
if ($this->manager === null) {
throw new RuntimeException('Square thumbnail generation requires Intervention Image.');
}
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Services\Moderation;
use App\Models\ContentModerationActionLog;
use App\Models\ContentModerationFinding;
use App\Models\User;
class ContentModerationActionLogService
{
/**
* @param array<string, mixed>|null $meta
*/
public function log(
?ContentModerationFinding $finding,
string $targetType,
?int $targetId,
string $actionType,
?User $actor = null,
?string $oldStatus = null,
?string $newStatus = null,
?string $oldVisibility = null,
?string $newVisibility = null,
?string $notes = null,
?array $meta = null,
): ContentModerationActionLog {
return ContentModerationActionLog::query()->create([
'finding_id' => $finding?->id,
'target_type' => $targetType,
'target_id' => $targetId,
'action_type' => $actionType,
'actor_type' => $actor ? 'admin' : 'system',
'actor_id' => $actor?->id,
'old_status' => $oldStatus,
'new_status' => $newStatus,
'old_visibility' => $oldVisibility,
'new_visibility' => $newVisibility,
'notes' => $notes,
'meta_json' => $meta,
'created_at' => \now(),
]);
}
}

View File

@@ -0,0 +1,182 @@
<?php
namespace App\Services\Moderation;
use App\Data\Moderation\ModerationResultData;
use App\Enums\ModerationEscalationStatus;
use App\Enums\ModerationContentType;
use App\Enums\ModerationStatus;
use App\Models\ContentModerationAiSuggestion;
use App\Models\ContentModerationFinding;
class ContentModerationPersistenceService
{
public function __construct(
private readonly ContentModerationReviewService $review,
private readonly ContentModerationActionLogService $actionLogs,
) {
}
public function shouldQueue(ModerationResultData $result): bool
{
return $result->score >= (int) \app('config')->get('content_moderation.queue_threshold', 30);
}
public function hasCurrentFinding(string $contentType, int $contentId, string $contentHash, string $scannerVersion): bool
{
return ContentModerationFinding::query()
->where('content_type', $contentType)
->where('content_id', $contentId)
->where('content_hash', $contentHash)
->where('scanner_version', $scannerVersion)
->exists();
}
/**
* @param array<string, mixed> $context
* @return array{finding:?ContentModerationFinding, created:bool, updated:bool}
*/
public function persist(ModerationResultData $result, array $context): array
{
$contentType = (string) ($context['content_type'] ?? '');
$contentId = (int) ($context['content_id'] ?? 0);
if ($contentType === '' || $contentId <= 0) {
return ['finding' => null, 'created' => false, 'updated' => false];
}
$existing = ContentModerationFinding::query()
->where('content_type', $contentType)
->where('content_id', $contentId)
->where('content_hash', $result->contentHash)
->where('scanner_version', $result->scannerVersion)
->first();
if (! $this->shouldQueue($result) && $existing === null) {
return ['finding' => null, 'created' => false, 'updated' => false];
}
$finding = $existing ?? new ContentModerationFinding();
$isNew = ! $finding->exists;
$finding->fill([
'content_type' => $contentType,
'content_id' => $contentId,
'content_target_type' => $result->contentTargetType,
'content_target_id' => $result->contentTargetId,
'artwork_id' => $context['artwork_id'] ?? null,
'user_id' => $context['user_id'] ?? null,
'severity' => $result->severity->value,
'score' => $result->score,
'content_hash' => $result->contentHash,
'scanner_version' => $result->scannerVersion,
'reasons_json' => $result->reasons,
'matched_links_json' => $result->matchedLinks,
'matched_domains_json' => $result->matchedDomains,
'matched_keywords_json' => $result->matchedKeywords,
'rule_hits_json' => $result->ruleHits,
'score_breakdown_json' => $result->scoreBreakdown,
'content_hash_normalized' => $result->contentHashNormalized,
'group_key' => $result->groupKey,
'campaign_key' => $result->campaignKey,
'cluster_score' => $result->clusterScore,
'cluster_reason' => $result->clusterReason,
'priority_score' => $result->priorityScore,
'policy_name' => $result->policyName,
'review_bucket' => $result->reviewBucket,
'escalation_status' => $result->escalationStatus ?? ModerationEscalationStatus::None->value,
'ai_provider' => $result->aiProvider,
'ai_label' => $result->aiLabel,
'ai_suggested_action' => $result->aiSuggestedAction,
'ai_confidence' => $result->aiConfidence,
'ai_explanation' => $result->aiExplanation,
'user_risk_score' => $result->userRiskScore,
'content_snapshot' => (string) ($context['content_snapshot'] ?? ''),
'auto_action_taken' => $result->autoHideRecommended ? 'recommended' : null,
]);
if ($isNew) {
$finding->status = $result->status;
} elseif (! $this->shouldQueue($result) && $finding->isPending()) {
$finding->status = ModerationStatus::Resolved;
$finding->action_taken = 'rescanned_clean';
$finding->resolved_at = \now();
$finding->resolved_by = null;
}
$finding->save();
if ($result->aiProvider !== null && ($result->aiLabel !== null || $result->aiExplanation !== null || $result->aiConfidence !== null)) {
ContentModerationAiSuggestion::query()->create([
'finding_id' => $finding->id,
'provider' => $result->aiProvider,
'suggested_label' => $result->aiLabel,
'suggested_action' => $result->aiSuggestedAction,
'confidence' => $result->aiConfidence,
'explanation' => $result->aiExplanation,
'campaign_tags_json' => $result->campaignKey ? [$result->campaignKey] : [],
'raw_response_json' => $result->aiRawResponse,
'created_at' => now(),
]);
}
if (! $isNew && ! $this->shouldQueue($result) && $finding->action_taken === 'rescanned_clean') {
$this->actionLogs->log(
$finding,
'finding',
$finding->id,
'rescan',
null,
ModerationStatus::Pending->value,
ModerationStatus::Resolved->value,
null,
null,
'Finding resolved automatically after a clean rescan.',
['scanner_version' => $result->scannerVersion],
);
}
return [
'finding' => $finding,
'created' => $isNew,
'updated' => ! $isNew,
];
}
/**
* @param array<string, mixed> $context
*/
public function applyAutomatedActionIfNeeded(ContentModerationFinding $finding, ModerationResultData $result, array $context): bool
{
if (! $result->autoHideRecommended) {
return false;
}
if ($finding->is_auto_hidden) {
return false;
}
$supportedTypes = (array) \app('config')->get('content_moderation.auto_hide.supported_types', []);
if (! in_array($finding->content_type->value, $supportedTypes, true)) {
$finding->forceFill([
'auto_action_taken' => 'recommended_review',
])->save();
return false;
}
if (! in_array($finding->content_type, [ModerationContentType::ArtworkComment, ModerationContentType::ArtworkDescription], true)) {
if ($finding->content_type !== ModerationContentType::ArtworkTitle) {
return false;
}
}
if (! in_array($finding->content_type, [ModerationContentType::ArtworkComment, ModerationContentType::ArtworkDescription, ModerationContentType::ArtworkTitle], true)) {
return false;
}
$this->review->autoHideContent($finding, 'Triggered by automated moderation threshold.');
return true;
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Services\Moderation;
use App\Models\ContentModerationFinding;
class ContentModerationProcessingService
{
public function __construct(
private readonly ContentModerationService $moderation,
private readonly ContentModerationPersistenceService $persistence,
private readonly DomainReputationService $domains,
private readonly ModerationClusterService $clusters,
private readonly DomainIntelligenceService $domainIntelligence,
) {
}
/**
* @param array<string, mixed> $context
* @return array{result:\App\Data\Moderation\ModerationResultData,finding:?ContentModerationFinding,created:bool,updated:bool,auto_hidden:bool}
*/
public function process(string $content, array $context, bool $persist = true): array
{
$result = $this->moderation->analyze($content, $context);
$this->domains->trackDomains(
$result->matchedDomains,
$this->persistence->shouldQueue($result),
false,
);
if (! $persist) {
return [
'result' => $result,
'finding' => null,
'created' => false,
'updated' => false,
'auto_hidden' => false,
];
}
$persisted = $this->persistence->persist($result, $context);
$finding = $persisted['finding'];
if ($finding !== null) {
$this->domains->attachDomainIds($finding);
$autoHidden = $this->persistence->applyAutomatedActionIfNeeded($finding, $result, $context);
$this->clusters->syncFinding($finding->fresh());
foreach ($result->matchedDomains as $domain) {
$this->domainIntelligence->refreshDomain($domain);
}
return [
'result' => $result,
'finding' => $finding->fresh(),
'created' => $persisted['created'],
'updated' => $persisted['updated'],
'auto_hidden' => $autoHidden,
];
}
return [
'result' => $result,
'finding' => null,
'created' => false,
'updated' => false,
'auto_hidden' => false,
];
}
public function rescanFinding(ContentModerationFinding $finding, ContentModerationSourceService $sources): ?ContentModerationFinding
{
$resolved = $sources->contextForFinding($finding);
if ($resolved['context'] === null) {
return null;
}
$result = $this->process((string) $resolved['context']['content_snapshot'], $resolved['context'], true);
return $result['finding'];
}
}

View File

@@ -0,0 +1,292 @@
<?php
namespace App\Services\Moderation;
use App\Enums\ModerationActionType;
use App\Enums\ModerationContentType;
use App\Enums\ModerationStatus;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\ContentModerationFinding;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class ContentModerationReviewService
{
public function __construct(
private readonly ContentModerationActionLogService $actionLogs,
private readonly DomainReputationService $domains,
private readonly ModerationFeedbackService $feedback,
) {
}
public function markSafe(ContentModerationFinding $finding, User $reviewer, ?string $notes = null): void
{
$this->updateFinding($finding, ModerationStatus::ReviewedSafe, $reviewer, $notes, ModerationActionType::MarkSafe);
$this->feedback->record($finding->fresh(), 'marked_safe', $reviewer, $notes);
}
public function confirmSpam(ContentModerationFinding $finding, User $reviewer, ?string $notes = null): void
{
$this->updateFinding($finding, ModerationStatus::ConfirmedSpam, $reviewer, $notes, ModerationActionType::ConfirmSpam);
$this->domains->trackDomains((array) $finding->matched_domains_json, true, true);
$this->feedback->record($finding->fresh(), 'confirmed_spam', $reviewer, $notes);
}
public function ignore(ContentModerationFinding $finding, User $reviewer, ?string $notes = null): void
{
$this->updateFinding($finding, ModerationStatus::Ignored, $reviewer, $notes, ModerationActionType::Ignore);
}
public function resolve(ContentModerationFinding $finding, User $reviewer, ?string $notes = null): void
{
$this->updateFinding($finding, ModerationStatus::Resolved, $reviewer, $notes, ModerationActionType::Resolve);
$this->feedback->record($finding->fresh(), 'resolved', $reviewer, $notes);
}
public function markFalsePositive(ContentModerationFinding $finding, User $reviewer, ?string $notes = null): void
{
DB::transaction(function () use ($finding, $reviewer, $notes): void {
$oldVisibility = null;
$newVisibility = null;
if (in_array($finding->content_type, [ModerationContentType::ArtworkComment, ModerationContentType::ArtworkDescription, ModerationContentType::ArtworkTitle], true)) {
[$action, $oldVisibility, $newVisibility] = match ($finding->content_type) {
ModerationContentType::ArtworkComment => $this->restoreComment($finding),
default => $this->restoreArtwork($finding),
};
$actionType = $action;
} else {
$actionType = ModerationActionType::MarkFalsePositive;
}
$oldStatus = $finding->status->value;
$finding->forceFill([
'status' => ModerationStatus::ReviewedSafe,
'reviewed_by' => $reviewer->id,
'reviewed_at' => now(),
'resolved_by' => $reviewer->id,
'resolved_at' => now(),
'restored_by' => $oldVisibility !== null ? $reviewer->id : $finding->restored_by,
'restored_at' => $oldVisibility !== null ? now() : $finding->restored_at,
'is_auto_hidden' => false,
'is_false_positive' => true,
'false_positive_count' => ((int) $finding->false_positive_count) + 1,
'action_taken' => ModerationActionType::MarkFalsePositive->value,
'admin_notes' => $this->normalizeNotes($notes, $finding->admin_notes),
])->save();
$this->actionLogs->log(
$finding,
$finding->content_type->value,
$finding->content_id,
ModerationActionType::MarkFalsePositive->value,
$reviewer,
$oldStatus,
ModerationStatus::ReviewedSafe->value,
$oldVisibility,
$newVisibility,
$notes,
['restored_action' => $actionType->value],
);
$this->feedback->record($finding->fresh(), 'false_positive', $reviewer, $notes, ['restored' => $oldVisibility !== null]);
});
}
public function hideContent(ContentModerationFinding $finding, User $reviewer, ?string $notes = null): ModerationActionType
{
return DB::transaction(function () use ($finding, $reviewer, $notes): ModerationActionType {
[$action, $oldVisibility, $newVisibility] = match ($finding->content_type) {
ModerationContentType::ArtworkComment => $this->hideComment($finding, false),
ModerationContentType::ArtworkDescription, ModerationContentType::ArtworkTitle => $this->hideArtwork($finding, false),
};
$this->updateFinding($finding, ModerationStatus::ConfirmedSpam, $reviewer, $notes, $action, $oldVisibility, $newVisibility);
$this->domains->trackDomains((array) $finding->matched_domains_json, true, true);
return $action;
});
}
public function autoHideContent(ContentModerationFinding $finding, ?string $notes = null): ModerationActionType
{
return DB::transaction(function () use ($finding, $notes): ModerationActionType {
[$action, $oldVisibility, $newVisibility] = match ($finding->content_type) {
ModerationContentType::ArtworkComment => $this->hideComment($finding, true),
ModerationContentType::ArtworkDescription, ModerationContentType::ArtworkTitle => $this->hideArtwork($finding, true),
};
$finding->forceFill([
'is_auto_hidden' => true,
'auto_action_taken' => $action->value,
'auto_hidden_at' => \now(),
'action_taken' => $action->value,
'admin_notes' => $this->normalizeNotes($notes, $finding->admin_notes),
])->save();
$this->actionLogs->log(
$finding,
$finding->content_type->value,
$finding->content_id,
$action->value,
null,
$finding->status->value,
$finding->status->value,
$oldVisibility,
$newVisibility,
$notes,
['automated' => true],
);
$this->domains->trackDomains((array) $finding->matched_domains_json, true, false);
return $action;
});
}
public function restoreContent(ContentModerationFinding $finding, User $reviewer, ?string $notes = null): ModerationActionType
{
return DB::transaction(function () use ($finding, $reviewer, $notes): ModerationActionType {
[$action, $oldVisibility, $newVisibility] = match ($finding->content_type) {
ModerationContentType::ArtworkComment => $this->restoreComment($finding),
ModerationContentType::ArtworkDescription, ModerationContentType::ArtworkTitle => $this->restoreArtwork($finding),
};
$oldStatus = $finding->status->value;
$finding->forceFill([
'status' => ModerationStatus::ReviewedSafe,
'reviewed_by' => $reviewer->id,
'reviewed_at' => \now(),
'resolved_by' => $reviewer->id,
'resolved_at' => \now(),
'restored_by' => $reviewer->id,
'restored_at' => \now(),
'is_auto_hidden' => false,
'action_taken' => $action->value,
'admin_notes' => $this->normalizeNotes($notes, $finding->admin_notes),
])->save();
$this->actionLogs->log(
$finding,
$finding->content_type->value,
$finding->content_id,
$action->value,
$reviewer,
$oldStatus,
ModerationStatus::ReviewedSafe->value,
$oldVisibility,
$newVisibility,
$notes,
['restored' => true],
);
return $action;
});
}
/**
* @return array{0:ModerationActionType,1:string,2:string}
*/
private function hideComment(ContentModerationFinding $finding, bool $automated): array
{
$comment = ArtworkComment::query()->find($finding->content_id);
$oldVisibility = $comment && $comment->is_approved ? 'visible' : 'hidden';
if ($comment) {
$comment->forceFill(['is_approved' => false])->save();
}
return [$automated ? ModerationActionType::AutoHideComment : ModerationActionType::HideComment, $oldVisibility, 'hidden'];
}
/**
* @return array{0:ModerationActionType,1:string,2:string}
*/
private function hideArtwork(ContentModerationFinding $finding, bool $automated): array
{
$artworkId = (int) ($finding->artwork_id ?? $finding->content_id);
$artwork = Artwork::query()->find($artworkId);
$oldVisibility = $artwork && $artwork->is_public ? 'visible' : 'hidden';
if ($artwork) {
$artwork->forceFill(['is_public' => false])->save();
}
return [$automated ? ModerationActionType::AutoHideArtwork : ModerationActionType::HideArtwork, $oldVisibility, 'hidden'];
}
/**
* @return array{0:ModerationActionType,1:string,2:string}
*/
private function restoreComment(ContentModerationFinding $finding): array
{
$comment = ArtworkComment::query()->find($finding->content_id);
$oldVisibility = $comment && $comment->is_approved ? 'visible' : 'hidden';
if ($comment) {
$comment->forceFill(['is_approved' => true])->save();
}
return [ModerationActionType::RestoreComment, $oldVisibility, 'visible'];
}
/**
* @return array{0:ModerationActionType,1:string,2:string}
*/
private function restoreArtwork(ContentModerationFinding $finding): array
{
$artworkId = (int) ($finding->artwork_id ?? $finding->content_id);
$artwork = Artwork::query()->find($artworkId);
$oldVisibility = $artwork && $artwork->is_public ? 'visible' : 'hidden';
if ($artwork) {
$artwork->forceFill(['is_public' => true])->save();
}
return [ModerationActionType::RestoreArtwork, $oldVisibility, 'visible'];
}
private function updateFinding(
ContentModerationFinding $finding,
ModerationStatus $status,
User $reviewer,
?string $notes,
ModerationActionType $action,
?string $oldVisibility = null,
?string $newVisibility = null,
): void {
$oldStatus = $finding->status->value;
$finding->forceFill([
'status' => $status,
'reviewed_by' => $reviewer->id,
'reviewed_at' => \now(),
'resolved_by' => $reviewer->id,
'resolved_at' => \now(),
'action_taken' => $action->value,
'admin_notes' => $this->normalizeNotes($notes, $finding->admin_notes),
])->save();
$this->actionLogs->log(
$finding,
$finding->content_type->value,
$finding->content_id,
$action->value,
$reviewer,
$oldStatus,
$status->value,
$oldVisibility,
$newVisibility,
$notes,
);
}
private function normalizeNotes(?string $incoming, ?string $existing): ?string
{
$normalized = is_string($incoming) ? trim($incoming) : '';
return $normalized !== '' ? $normalized : $existing;
}
}

View File

@@ -0,0 +1,203 @@
<?php
namespace App\Services\Moderation;
use App\Contracts\Moderation\ModerationRuleInterface;
use App\Data\Moderation\ModerationResultData;
use App\Enums\ModerationSeverity;
use App\Enums\ModerationStatus;
use App\Services\Moderation\DuplicateDetectionService;
use App\Services\Moderation\Rules\LinkPresenceRule;
class ContentModerationService
{
public function __construct(
private readonly ModerationPolicyEngineService $policies,
private readonly ModerationSuggestionService $suggestions,
private readonly ModerationClusterService $clusters,
private readonly ModerationPriorityService $priorities,
) {
}
public function analyze(string $content, array $context = []): ModerationResultData
{
$normalized = $this->normalize($content);
$campaignNormalized = app(DuplicateDetectionService::class)->campaignText($content);
$linkRule = app(LinkPresenceRule::class);
$extractedUrls = $linkRule->extractUrls($content);
$extractedDomains = array_values(array_unique(array_filter(array_map(
static fn (string $url): ?string => $linkRule->extractHost($url),
$extractedUrls
))));
$riskAssessment = app(UserRiskScoreService::class)->assess(
isset($context['user_id']) ? (int) $context['user_id'] : null,
$extractedDomains,
);
$context['extracted_urls'] = $extractedUrls;
$context['extracted_domains'] = $extractedDomains;
$context['user_risk_assessment'] = $riskAssessment;
$score = 0;
$reasons = [];
$matchedLinks = [];
$matchedDomains = [];
$matchedKeywords = [];
$ruleHits = [];
$scoreBreakdown = [];
foreach ($this->rules() as $rule) {
foreach ($rule->analyze($content, $normalized, $context) as $finding) {
$ruleScore = (int) ($finding['score'] ?? 0);
$score += $ruleScore;
$reason = (string) ($finding['reason'] ?? 'Flagged by moderation rule');
$reasons[] = $reason;
$matchedLinks = array_merge($matchedLinks, (array) ($finding['links'] ?? []));
$matchedDomains = array_merge($matchedDomains, array_filter((array) ($finding['domains'] ?? [])));
$matchedKeywords = array_merge($matchedKeywords, array_filter((array) ($finding['keywords'] ?? [])));
$ruleKey = (string) ($finding['rule'] ?? 'unknown');
$ruleHits[$ruleKey] = ($ruleHits[$ruleKey] ?? 0) + 1;
$scoreBreakdown[] = [
'rule' => $ruleKey,
'score' => $ruleScore,
'reason' => $reason,
];
}
}
$modifier = (int) ($riskAssessment['score_modifier'] ?? 0);
if ($modifier !== 0) {
$score += $modifier;
$reasons[] = $modifier > 0
? 'User risk profile increased moderation score by ' . $modifier
: 'User trust profile reduced moderation score by ' . abs($modifier);
$ruleHits['user_risk_modifier'] = 1;
$scoreBreakdown[] = [
'rule' => 'user_risk_modifier',
'score' => $modifier,
'reason' => $modifier > 0
? 'User risk profile increased moderation score by ' . $modifier
: 'User trust profile reduced moderation score by ' . abs($modifier),
];
}
$score = max(0, $score);
$severity = ModerationSeverity::fromScore($score);
$policy = $this->policies->resolve($context, $riskAssessment);
$autoHideRecommended = $this->shouldAutoHide($score, $ruleHits, $matchedDomains ?: $extractedDomains, $policy);
$groupKey = app(DuplicateDetectionService::class)->buildGroupKey($content, $matchedDomains ?: $extractedDomains);
$draft = new ModerationResultData(
score: $score,
severity: $severity,
status: $score >= (int) ($policy['queue_threshold'] ?? app('config')->get('content_moderation.queue_threshold', 30))
? ModerationStatus::Pending
: ModerationStatus::ReviewedSafe,
reasons: array_values(array_unique(array_filter($reasons))),
matchedLinks: array_values(array_unique(array_filter($matchedLinks))),
matchedDomains: array_values(array_unique(array_filter($matchedDomains))),
matchedKeywords: array_values(array_unique(array_filter($matchedKeywords))),
contentHash: hash('sha256', $normalized),
scannerVersion: (string) app('config')->get('content_moderation.scanner_version', '1.0'),
ruleHits: $ruleHits,
contentHashNormalized: hash('sha256', $campaignNormalized),
groupKey: $groupKey,
userRiskScore: (int) ($riskAssessment['risk_score'] ?? 0),
autoHideRecommended: $autoHideRecommended,
contentTargetType: isset($context['content_target_type']) ? (string) $context['content_target_type'] : null,
contentTargetId: isset($context['content_target_id']) ? (int) $context['content_target_id'] : null,
policyName: (string) ($policy['name'] ?? 'default'),
scoreBreakdown: $scoreBreakdown,
);
$suggestion = $this->suggestions->suggest($content, $draft, $context);
$cluster = $this->clusters->classify($content, $draft, $context, [
'campaign_tags' => $suggestion->campaignTags,
'confidence' => $suggestion->confidence,
]);
$priority = $this->priorities->score($draft, $context, $policy, [
'confidence' => $suggestion->confidence,
'campaign_tags' => $suggestion->campaignTags,
]);
return new ModerationResultData(
score: $score,
severity: $severity,
status: $score >= (int) ($policy['queue_threshold'] ?? app('config')->get('content_moderation.queue_threshold', 30))
? ModerationStatus::Pending
: ModerationStatus::ReviewedSafe,
reasons: array_values(array_unique(array_filter($reasons))),
matchedLinks: array_values(array_unique(array_filter($matchedLinks))),
matchedDomains: array_values(array_unique(array_filter($matchedDomains))),
matchedKeywords: array_values(array_unique(array_filter($matchedKeywords))),
contentHash: hash('sha256', $normalized),
scannerVersion: (string) app('config')->get('content_moderation.scanner_version', '1.0'),
ruleHits: $ruleHits,
contentHashNormalized: hash('sha256', $campaignNormalized),
groupKey: $groupKey,
userRiskScore: (int) ($riskAssessment['risk_score'] ?? 0),
autoHideRecommended: $autoHideRecommended,
contentTargetType: isset($context['content_target_type']) ? (string) $context['content_target_type'] : null,
contentTargetId: isset($context['content_target_id']) ? (int) $context['content_target_id'] : null,
campaignKey: $cluster['campaign_key'],
clusterScore: $cluster['cluster_score'],
clusterReason: $cluster['cluster_reason'],
policyName: (string) ($policy['name'] ?? 'default'),
priorityScore: (int) ($priority['priority_score'] ?? $score),
reviewBucket: (string) ($priority['review_bucket'] ?? ($policy['review_bucket'] ?? 'standard')),
escalationStatus: (string) ($priority['escalation_status'] ?? 'none'),
aiProvider: $suggestion->provider,
aiLabel: $suggestion->suggestedLabel,
aiSuggestedAction: $suggestion->suggestedAction,
aiConfidence: $suggestion->confidence,
aiExplanation: $suggestion->explanation,
aiRawResponse: $suggestion->rawResponse,
scoreBreakdown: $scoreBreakdown,
);
}
public function normalize(string $content): string
{
$normalized = preg_replace('/\s+/u', ' ', trim($content));
return mb_strtolower((string) $normalized);
}
/**
* @return array<int, ModerationRuleInterface>
*/
private function rules(): array
{
$classes = app('config')->get('content_moderation.rules.enabled', []);
return array_values(array_filter(array_map(function (string $class): ?ModerationRuleInterface {
$rule = app($class);
return $rule instanceof ModerationRuleInterface ? $rule : null;
}, $classes)));
}
/**
* @param array<string, int> $ruleHits
* @param array<int, string> $matchedDomains
*/
private function shouldAutoHide(int $score, array $ruleHits, array $matchedDomains, array $policy = []): bool
{
if (! app('config')->get('content_moderation.auto_hide.enabled', true)) {
return false;
}
$threshold = (int) ($policy['auto_hide_threshold'] ?? app('config')->get('content_moderation.auto_hide.threshold', 95));
if ($score >= $threshold) {
return true;
}
$blockedHit = isset($ruleHits['blacklisted_domain']) || isset($ruleHits['blocked_domain']);
$severeHitCount = collect($ruleHits)
->only(['blacklisted_domain', 'blocked_domain', 'high_risk_keyword', 'near_duplicate_campaign', 'duplicate_comment'])
->sum();
return $blockedHit && $severeHitCount >= 2 && count($matchedDomains) >= 1;
}
}

View File

@@ -0,0 +1,352 @@
<?php
namespace App\Services\Moderation;
use App\Enums\ModerationContentType;
use App\Models\Collection;
use App\Models\NovaCard;
use App\Models\Story;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\ContentModerationFinding;
use App\Models\UserProfile;
use App\Models\UserSocialLink;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
class ContentModerationSourceService
{
public function queryForType(ModerationContentType $type): EloquentBuilder
{
return match ($type) {
ModerationContentType::ArtworkComment => ArtworkComment::query()
->with('artwork:id,title,slug,user_id')
->whereNull('deleted_at')
->where(function (EloquentBuilder $query): void {
$query->whereNotNull('raw_content')->where('raw_content', '!=', '')
->orWhere(function (EloquentBuilder $fallback): void {
$fallback->whereNotNull('content')->where('content', '!=', '');
});
})
->orderBy('id'),
ModerationContentType::ArtworkDescription => Artwork::query()
->whereNotNull('description')
->where('description', '!=', '')
->orderBy('id'),
ModerationContentType::ArtworkTitle => Artwork::query()
->whereNotNull('title')
->where('title', '!=', '')
->orderBy('id'),
ModerationContentType::UserBio => UserProfile::query()
->with('user:id,username,name')
->where(function (EloquentBuilder $query): void {
$query->whereNotNull('about')->where('about', '!=', '')
->orWhere(function (EloquentBuilder $fallback): void {
$fallback->whereNotNull('description')->where('description', '!=', '');
});
})
->orderBy('user_id'),
ModerationContentType::UserProfileLink => UserSocialLink::query()
->with('user:id,username,name')
->whereNotNull('url')
->where('url', '!=', '')
->orderBy('id'),
ModerationContentType::CollectionTitle => Collection::query()
->with('user:id,username,name')
->whereNotNull('title')
->where('title', '!=', '')
->orderBy('id'),
ModerationContentType::CollectionDescription => Collection::query()
->with('user:id,username,name')
->whereNotNull('description')
->where('description', '!=', '')
->orderBy('id'),
ModerationContentType::StoryTitle => Story::query()
->with('creator:id,username,name')
->whereNotNull('title')
->where('title', '!=', '')
->orderBy('id'),
ModerationContentType::StoryContent => Story::query()
->with('creator:id,username,name')
->whereNotNull('content')
->where('content', '!=', '')
->orderBy('id'),
ModerationContentType::CardTitle => NovaCard::query()
->with('user:id,username,name')
->whereNotNull('title')
->where('title', '!=', '')
->orderBy('id'),
ModerationContentType::CardText => NovaCard::query()
->with('user:id,username,name')
->where(function (EloquentBuilder $query): void {
$query->whereNotNull('quote_text')->where('quote_text', '!=', '')
->orWhere(function (EloquentBuilder $description): void {
$description->whereNotNull('description')->where('description', '!=', '');
});
})
->orderBy('id'),
default => throw new \InvalidArgumentException('Unsupported moderation content type: ' . $type->value),
};
}
/**
* @param Artwork|ArtworkComment|Collection|Story|NovaCard|UserProfile|UserSocialLink $row
* @return array<string, mixed>
*/
public function buildContext(ModerationContentType $type, object $row): array
{
return match ($type) {
ModerationContentType::ArtworkComment => [
'content_type' => $type->value,
'content_id' => (int) $row->id,
'content_target_type' => 'artwork_comment',
'content_target_id' => (int) $row->id,
'artwork_id' => (int) $row->artwork_id,
'user_id' => $row->user_id ? (int) $row->user_id : null,
'content_snapshot' => (string) ($row->raw_content ?: $row->content),
'is_publicly_exposed' => true,
],
ModerationContentType::ArtworkDescription => [
'content_type' => $type->value,
'content_id' => (int) $row->id,
'content_target_type' => 'artwork',
'content_target_id' => (int) $row->id,
'artwork_id' => (int) $row->id,
'user_id' => $row->user_id ? (int) $row->user_id : null,
'content_snapshot' => (string) ($row->description ?? ''),
'is_publicly_exposed' => (bool) ($row->is_public ?? false),
],
ModerationContentType::ArtworkTitle => [
'content_type' => $type->value,
'content_id' => (int) $row->id,
'content_target_type' => 'artwork',
'content_target_id' => (int) $row->id,
'artwork_id' => (int) $row->id,
'user_id' => $row->user_id ? (int) $row->user_id : null,
'content_snapshot' => (string) ($row->title ?? ''),
'is_publicly_exposed' => (bool) ($row->is_public ?? false),
],
ModerationContentType::UserBio => [
'content_type' => $type->value,
'content_id' => (int) $row->user_id,
'content_target_type' => 'user_profile',
'content_target_id' => (int) $row->user_id,
'user_id' => (int) $row->user_id,
'content_snapshot' => trim((string) ($row->about ?: $row->description ?: '')),
'is_publicly_exposed' => true,
],
ModerationContentType::UserProfileLink => [
'content_type' => $type->value,
'content_id' => (int) $row->id,
'content_target_type' => 'user_social_link',
'content_target_id' => (int) $row->id,
'user_id' => (int) $row->user_id,
'content_snapshot' => trim((string) ($row->url ?? '')),
'is_publicly_exposed' => true,
],
ModerationContentType::CollectionTitle => [
'content_type' => $type->value,
'content_id' => (int) $row->id,
'content_target_type' => 'collection',
'content_target_id' => (int) $row->id,
'user_id' => $row->user_id ? (int) $row->user_id : null,
'content_snapshot' => (string) ($row->title ?? ''),
'is_publicly_exposed' => in_array((string) ($row->visibility ?? ''), ['public', 'unlisted'], true),
],
ModerationContentType::CollectionDescription => [
'content_type' => $type->value,
'content_id' => (int) $row->id,
'content_target_type' => 'collection',
'content_target_id' => (int) $row->id,
'user_id' => $row->user_id ? (int) $row->user_id : null,
'content_snapshot' => (string) ($row->description ?? ''),
'is_publicly_exposed' => in_array((string) ($row->visibility ?? ''), ['public', 'unlisted'], true),
],
ModerationContentType::StoryTitle => [
'content_type' => $type->value,
'content_id' => (int) $row->id,
'content_target_type' => 'story',
'content_target_id' => (int) $row->id,
'user_id' => $row->creator_id ? (int) $row->creator_id : null,
'content_snapshot' => (string) ($row->title ?? ''),
'is_publicly_exposed' => in_array((string) ($row->status ?? ''), ['published', 'scheduled'], true),
],
ModerationContentType::StoryContent => [
'content_type' => $type->value,
'content_id' => (int) $row->id,
'content_target_type' => 'story',
'content_target_id' => (int) $row->id,
'user_id' => $row->creator_id ? (int) $row->creator_id : null,
'content_snapshot' => (string) ($row->content ?? ''),
'is_publicly_exposed' => in_array((string) ($row->status ?? ''), ['published', 'scheduled'], true),
],
ModerationContentType::CardTitle => [
'content_type' => $type->value,
'content_id' => (int) $row->id,
'content_target_type' => 'nova_card',
'content_target_id' => (int) $row->id,
'user_id' => $row->user_id ? (int) $row->user_id : null,
'content_snapshot' => (string) ($row->title ?? ''),
'is_publicly_exposed' => in_array((string) ($row->visibility ?? ''), ['public', 'unlisted'], true),
],
ModerationContentType::CardText => [
'content_type' => $type->value,
'content_id' => (int) $row->id,
'content_target_type' => 'nova_card',
'content_target_id' => (int) $row->id,
'user_id' => $row->user_id ? (int) $row->user_id : null,
'content_snapshot' => trim(implode("\n", array_filter([
(string) ($row->quote_text ?? ''),
(string) ($row->description ?? ''),
(string) ($row->quote_author ?? ''),
(string) ($row->quote_source ?? ''),
]))),
'is_publicly_exposed' => in_array((string) ($row->visibility ?? ''), ['public', 'unlisted'], true),
],
default => throw new \InvalidArgumentException('Unsupported moderation content type: ' . $type->value),
};
}
/**
* @return array{context:array<string, mixed>|null, type:ModerationContentType|null}
*/
public function contextForFinding(ContentModerationFinding $finding): array
{
return match ($finding->content_type) {
ModerationContentType::ArtworkComment => $this->commentContextForFinding($finding),
ModerationContentType::ArtworkDescription => $this->descriptionContextForFinding($finding),
ModerationContentType::ArtworkTitle => $this->artworkTitleContextForFinding($finding),
ModerationContentType::UserBio => $this->userBioContextForFinding($finding),
ModerationContentType::UserProfileLink => $this->userProfileLinkContextForFinding($finding),
ModerationContentType::CollectionTitle => $this->collectionTitleContextForFinding($finding),
ModerationContentType::CollectionDescription => $this->collectionDescriptionContextForFinding($finding),
ModerationContentType::StoryTitle => $this->storyTitleContextForFinding($finding),
ModerationContentType::StoryContent => $this->storyContentContextForFinding($finding),
ModerationContentType::CardTitle => $this->cardTitleContextForFinding($finding),
ModerationContentType::CardText => $this->cardTextContextForFinding($finding),
default => ['context' => null, 'type' => null],
};
}
/**
* @return array{context:array<string, mixed>|null, type:ModerationContentType|null}
*/
private function commentContextForFinding(ContentModerationFinding $finding): array
{
$comment = ArtworkComment::query()->find($finding->content_id);
if (! $comment) {
return ['context' => null, 'type' => null];
}
return [
'context' => $this->buildContext(ModerationContentType::ArtworkComment, $comment),
'type' => ModerationContentType::ArtworkComment,
];
}
/**
* @return array{context:array<string, mixed>|null, type:ModerationContentType|null}
*/
private function descriptionContextForFinding(ContentModerationFinding $finding): array
{
$artworkId = (int) ($finding->artwork_id ?? $finding->content_id);
$artwork = Artwork::query()->find($artworkId);
if (! $artwork) {
return ['context' => null, 'type' => null];
}
return [
'context' => $this->buildContext(ModerationContentType::ArtworkDescription, $artwork),
'type' => ModerationContentType::ArtworkDescription,
];
}
private function artworkTitleContextForFinding(ContentModerationFinding $finding): array
{
$artwork = Artwork::query()->find((int) ($finding->artwork_id ?? $finding->content_id));
if (! $artwork) {
return ['context' => null, 'type' => null];
}
return ['context' => $this->buildContext(ModerationContentType::ArtworkTitle, $artwork), 'type' => ModerationContentType::ArtworkTitle];
}
private function userBioContextForFinding(ContentModerationFinding $finding): array
{
$profile = UserProfile::query()->find($finding->content_id);
if (! $profile) {
return ['context' => null, 'type' => null];
}
return ['context' => $this->buildContext(ModerationContentType::UserBio, $profile), 'type' => ModerationContentType::UserBio];
}
private function userProfileLinkContextForFinding(ContentModerationFinding $finding): array
{
$link = UserSocialLink::query()->find($finding->content_id);
if (! $link) {
return ['context' => null, 'type' => null];
}
return ['context' => $this->buildContext(ModerationContentType::UserProfileLink, $link), 'type' => ModerationContentType::UserProfileLink];
}
private function collectionTitleContextForFinding(ContentModerationFinding $finding): array
{
$collection = Collection::query()->find($finding->content_id);
if (! $collection) {
return ['context' => null, 'type' => null];
}
return ['context' => $this->buildContext(ModerationContentType::CollectionTitle, $collection), 'type' => ModerationContentType::CollectionTitle];
}
private function collectionDescriptionContextForFinding(ContentModerationFinding $finding): array
{
$collection = Collection::query()->find($finding->content_id);
if (! $collection) {
return ['context' => null, 'type' => null];
}
return ['context' => $this->buildContext(ModerationContentType::CollectionDescription, $collection), 'type' => ModerationContentType::CollectionDescription];
}
private function storyTitleContextForFinding(ContentModerationFinding $finding): array
{
$story = Story::query()->find($finding->content_id);
if (! $story) {
return ['context' => null, 'type' => null];
}
return ['context' => $this->buildContext(ModerationContentType::StoryTitle, $story), 'type' => ModerationContentType::StoryTitle];
}
private function storyContentContextForFinding(ContentModerationFinding $finding): array
{
$story = Story::query()->find($finding->content_id);
if (! $story) {
return ['context' => null, 'type' => null];
}
return ['context' => $this->buildContext(ModerationContentType::StoryContent, $story), 'type' => ModerationContentType::StoryContent];
}
private function cardTitleContextForFinding(ContentModerationFinding $finding): array
{
$card = NovaCard::query()->find($finding->content_id);
if (! $card) {
return ['context' => null, 'type' => null];
}
return ['context' => $this->buildContext(ModerationContentType::CardTitle, $card), 'type' => ModerationContentType::CardTitle];
}
private function cardTextContextForFinding(ContentModerationFinding $finding): array
{
$card = NovaCard::query()->find($finding->content_id);
if (! $card) {
return ['context' => null, 'type' => null];
}
return ['context' => $this->buildContext(ModerationContentType::CardText, $card), 'type' => ModerationContentType::CardText];
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Services\Moderation;
use App\Models\ContentModerationDomain;
use App\Models\ContentModerationFinding;
class DomainIntelligenceService
{
public function refreshDomain(string $domain): ?ContentModerationDomain
{
$record = ContentModerationDomain::query()->where('domain', $domain)->first();
if (! $record) {
return null;
}
$findings = ContentModerationFinding::query()
->whereJsonContains('matched_domains_json', $domain)
->get(['id', 'user_id', 'campaign_key', 'matched_keywords_json', 'content_type', 'is_false_positive']);
$topKeywords = $findings
->flatMap(static fn (ContentModerationFinding $finding): array => (array) $finding->matched_keywords_json)
->filter()
->countBy()
->sortDesc()
->take(8)
->keys()
->values()
->all();
$topContentTypes = $findings
->pluck('content_type')
->filter()
->countBy()
->sortDesc()
->take(8)
->map(static fn (int $count, string $type): array => ['type' => $type, 'count' => $count])
->values()
->all();
$record->forceFill([
'linked_users_count' => $findings->pluck('user_id')->filter()->unique()->count(),
'linked_findings_count' => $findings->count(),
'linked_clusters_count' => $findings->pluck('campaign_key')->filter()->unique()->count(),
'top_keywords_json' => $topKeywords,
'top_content_types_json' => $topContentTypes,
'false_positive_count' => $findings->where('is_false_positive', true)->count(),
])->save();
return $record->fresh();
}
}

View File

@@ -0,0 +1,200 @@
<?php
namespace App\Services\Moderation;
use App\Enums\ModerationActionType;
use App\Enums\ModerationDomainStatus;
use App\Models\ContentModerationActionLog;
use App\Models\ContentModerationDomain;
use App\Models\ContentModerationFinding;
use App\Models\User;
class DomainReputationService
{
public function __construct(
private readonly DomainIntelligenceService $intelligence,
) {
}
public function normalizeDomain(?string $domain): ?string
{
if (! is_string($domain)) {
return null;
}
$normalized = trim(mb_strtolower($domain));
$normalized = preg_replace('/^www\./', '', $normalized);
return $normalized !== '' ? $normalized : null;
}
public function statusForDomain(string $domain): ModerationDomainStatus
{
$normalized = $this->normalizeDomain($domain);
if ($normalized === null) {
return ModerationDomainStatus::Neutral;
}
$record = ContentModerationDomain::query()->where('domain', $normalized)->first();
if ($record !== null) {
return $record->status;
}
if ($this->matchesAnyPattern($normalized, (array) \app('config')->get('content_moderation.allowed_domains', []))) {
return ModerationDomainStatus::Allowed;
}
if ($this->matchesAnyPattern($normalized, (array) \app('config')->get('content_moderation.blacklisted_domains', []))) {
return ModerationDomainStatus::Blocked;
}
if ($this->matchesAnyPattern($normalized, (array) \app('config')->get('content_moderation.suspicious_domains', []))) {
return ModerationDomainStatus::Suspicious;
}
return ModerationDomainStatus::Neutral;
}
/**
* @param array<int, string> $domains
* @return array<int, ContentModerationDomain>
*/
public function trackDomains(array $domains, bool $flagged = false, bool $confirmedSpam = false): array
{
$normalized = \collect($domains)
->map(fn (?string $domain): ?string => $this->normalizeDomain($domain))
->filter()
->unique()
->values();
if ($normalized->isEmpty()) {
return [];
}
$existing = ContentModerationDomain::query()
->whereIn('domain', $normalized->all())
->get()
->keyBy('domain');
$records = [];
$now = \now();
foreach ($normalized as $domain) {
$defaultStatus = $this->statusForDomain($domain);
$record = $existing[$domain] ?? new ContentModerationDomain([
'domain' => $domain,
'status' => $defaultStatus,
'first_seen_at' => $now,
]);
$record->forceFill([
'status' => $record->status ?? $defaultStatus,
'times_seen' => ((int) $record->times_seen) + 1,
'times_flagged' => ((int) $record->times_flagged) + ($flagged ? 1 : 0),
'times_confirmed_spam' => ((int) $record->times_confirmed_spam) + ($confirmedSpam ? 1 : 0),
'first_seen_at' => $record->first_seen_at ?? $now,
'last_seen_at' => $now,
])->save();
$records[] = $record->fresh();
}
return $records;
}
public function updateStatus(string $domain, ModerationDomainStatus $status, ?User $actor = null, ?string $notes = null): ContentModerationDomain
{
$normalized = $this->normalizeDomain($domain);
\abort_unless($normalized !== null, 422, 'Invalid domain.');
$record = ContentModerationDomain::query()->firstOrNew(['domain' => $normalized]);
$previous = $record->status?->value;
$record->forceFill([
'status' => $status,
'first_seen_at' => $record->first_seen_at ?? \now(),
'last_seen_at' => \now(),
'notes' => $notes !== null && trim($notes) !== '' ? trim($notes) : $record->notes,
])->save();
ContentModerationActionLog::query()->create([
'target_type' => 'domain',
'target_id' => $record->id,
'action_type' => match ($status) {
ModerationDomainStatus::Blocked => ModerationActionType::BlockDomain->value,
ModerationDomainStatus::Suspicious => ModerationActionType::MarkDomainSuspicious->value,
ModerationDomainStatus::Escalated => ModerationActionType::Escalate->value,
ModerationDomainStatus::ReviewRequired => ModerationActionType::MarkDomainSuspicious->value,
ModerationDomainStatus::Allowed, ModerationDomainStatus::Neutral => ModerationActionType::AllowDomain->value,
},
'actor_type' => $actor ? 'admin' : 'system',
'actor_id' => $actor?->id,
'notes' => $notes,
'old_status' => $previous,
'new_status' => $status->value,
'meta_json' => ['domain' => $normalized],
'created_at' => \now(),
]);
$this->intelligence->refreshDomain($normalized);
return $record->fresh();
}
/**
* @return array<int, string>
*/
public function shortenerDomains(): array
{
return \collect((array) \app('config')->get('content_moderation.shortener_domains', []))
->map(fn (string $domain): ?string => $this->normalizeDomain($domain))
->filter()
->values()
->all();
}
public function attachDomainIds(ContentModerationFinding $finding): void
{
$domains = \collect((array) $finding->matched_domains_json)
->map(fn (?string $domain): ?string => $this->normalizeDomain($domain))
->filter()
->unique()
->values();
if ($domains->isEmpty()) {
$finding->forceFill(['domain_ids_json' => []])->save();
return;
}
$ids = ContentModerationDomain::query()
->whereIn('domain', $domains->all())
->pluck('id')
->map(static fn (int $id): int => $id)
->values()
->all();
$finding->forceFill(['domain_ids_json' => $ids])->save();
foreach ($domains as $domain) {
$this->intelligence->refreshDomain((string) $domain);
}
}
private function matchesAnyPattern(string $domain, array $patterns): bool
{
foreach ($patterns as $pattern) {
$pattern = trim(mb_strtolower((string) $pattern));
if ($pattern === '') {
continue;
}
$quoted = preg_quote($pattern, '/');
$regex = '/^' . str_replace('\\*', '.*', $quoted) . '$/i';
if (preg_match($regex, $domain) === 1) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Services\Moderation;
use App\Enums\ModerationContentType;
use App\Models\Artwork;
use App\Models\ArtworkComment;
class DuplicateDetectionService
{
public function campaignText(string $content): string
{
$text = mb_strtolower($content);
$text = preg_replace('/https?:\/\/\S+/iu', ' [link] ', $text);
$text = preg_replace('/www\.\S+/iu', ' [link] ', (string) $text);
$text = preg_replace('/[^\p{L}\p{N}\s\[\]]+/u', ' ', (string) $text);
$text = preg_replace('/\s+/u', ' ', trim((string) $text));
return (string) $text;
}
/**
* @param array<int, string> $domains
*/
public function buildGroupKey(string $content, array $domains = []): string
{
$template = $this->campaignText($content);
$tokens = preg_split('/\s+/u', $template, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$signature = implode(' ', array_slice($tokens, 0, 12));
$domainPart = implode('|', array_slice(array_values(array_unique($domains)), 0, 2));
return hash('sha256', $domainPart . '::' . $signature);
}
/**
* @param array<string, mixed> $context
* @param array<int, string> $domains
*/
public function nearDuplicateCount(string $content, array $context = [], array $domains = []): int
{
$type = (string) ($context['content_type'] ?? '');
$contentId = (int) ($context['content_id'] ?? 0);
$artworkId = (int) ($context['artwork_id'] ?? 0);
$signature = $this->campaignText($content);
if ($signature === '') {
return 0;
}
$candidates = match ($type) {
ModerationContentType::ArtworkComment->value => ArtworkComment::query()
->where('id', '!=', $contentId)
->whereNull('deleted_at')
->latest('id')
->limit(80)
->get(['id', 'artwork_id', 'raw_content', 'content']),
ModerationContentType::ArtworkDescription->value => Artwork::query()
->where('id', '!=', $contentId)
->whereNotNull('description')
->latest('id')
->limit(80)
->get(['id', 'description']),
default => \collect(),
};
$matches = 0;
foreach ($candidates as $candidate) {
$candidateText = match ($type) {
ModerationContentType::ArtworkComment->value => (string) ($candidate->raw_content ?: $candidate->content),
ModerationContentType::ArtworkDescription->value => (string) ($candidate->description ?? ''),
default => '',
};
if ($candidateText === '') {
continue;
}
$candidateSignature = $this->campaignText($candidateText);
similar_text($signature, $candidateSignature, $similarity);
$sameArtworkPenalty = $artworkId > 0 && (int) ($candidate->artwork_id ?? $candidate->id ?? 0) === $artworkId ? 4 : 0;
if ($similarity >= (float) \app('config')->get('content_moderation.duplicate_detection.near_duplicate_similarity', 84) - $sameArtworkPenalty) {
$matches++;
continue;
}
if ($domains !== []) {
$topDomain = $domains[0] ?? null;
if ($topDomain !== null && str_contains(mb_strtolower($candidateText), mb_strtolower($topDomain))) {
similar_text($this->stripLinks($signature), $this->stripLinks($candidateSignature), $linklessSimilarity);
if ($linklessSimilarity >= 72) {
$matches++;
}
}
}
}
return $matches;
}
private function stripLinks(string $text): string
{
return trim(str_replace('[link]', '', $text));
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Services\Moderation;
use App\Data\Moderation\ModerationResultData;
use App\Models\ContentModerationCluster;
use App\Models\ContentModerationFinding;
class ModerationClusterService
{
/**
* @param array<string, mixed> $context
* @param array<string, mixed> $suggestion
* @return array{campaign_key:string,cluster_score:int,cluster_reason:string}
*/
public function classify(string $content, ModerationResultData $result, array $context = [], array $suggestion = []): array
{
$domains = array_values(array_filter($result->matchedDomains));
$keywords = array_values(array_filter($result->matchedKeywords));
$reason = 'normalized_content';
if ($domains !== [] && $keywords !== []) {
$reason = 'domain_keyword_cta';
$key = 'campaign:' . sha1(implode('|', [implode(',', array_slice($domains, 0, 3)), implode(',', array_slice($keywords, 0, 3))]));
} elseif ($domains !== []) {
$reason = 'domain_fingerprint';
$key = 'campaign:' . sha1(implode(',', array_slice($domains, 0, 3)) . '|' . ($result->contentHashNormalized ?? $result->contentHash));
} elseif (! empty($suggestion['campaign_tags'])) {
$reason = 'suggested_cluster';
$key = 'campaign:' . sha1(implode('|', (array) $suggestion['campaign_tags']));
} else {
$key = 'campaign:' . sha1((string) ($result->groupKey ?? $result->contentHashNormalized ?? $result->contentHash));
}
$clusterScore = min(100, $result->score + (count($domains) * 8) + (count($keywords) * 4));
return [
'campaign_key' => $key,
'cluster_score' => $clusterScore,
'cluster_reason' => $reason,
];
}
public function syncFinding(ContentModerationFinding $finding): void
{
if (! $finding->campaign_key) {
return;
}
$query = ContentModerationFinding::query()->where('campaign_key', $finding->campaign_key);
$findings = $query->get(['id', 'user_id', 'matched_domains_json', 'matched_keywords_json', 'review_bucket', 'cluster_score', 'created_at']);
$domains = $findings
->flatMap(static fn (ContentModerationFinding $item): array => (array) $item->matched_domains_json)
->filter()
->unique()
->values();
$keywords = $findings
->flatMap(static fn (ContentModerationFinding $item): array => (array) $item->matched_keywords_json)
->filter()
->unique()
->take(8)
->values();
ContentModerationCluster::query()->updateOrCreate(
['campaign_key' => $finding->campaign_key],
[
'cluster_reason' => $finding->cluster_reason,
'review_bucket' => $finding->review_bucket,
'escalation_status' => $finding->escalation_status?->value ?? (string) $finding->escalation_status,
'cluster_score' => (int) ($findings->max('cluster_score') ?? $finding->cluster_score ?? 0),
'findings_count' => $findings->count(),
'unique_users_count' => $findings->pluck('user_id')->filter()->unique()->count(),
'unique_domains_count' => $domains->count(),
'latest_finding_at' => $findings->max('created_at') ?: now(),
'summary_json' => [
'domains' => $domains->take(8)->all(),
'keywords' => $keywords->all(),
],
],
);
$clusterSize = $findings->count();
if ($clusterSize > 1) {
$query->update(['priority_score' => $finding->priority_score + min(25, ($clusterSize - 1) * 3)]);
}
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Services\Moderation;
use App\Models\ContentModerationFeedback;
use App\Models\ContentModerationFinding;
use App\Models\User;
class ModerationFeedbackService
{
/**
* @param array<string, mixed> $meta
*/
public function record(ContentModerationFinding $finding, string $feedbackType, ?User $actor = null, ?string $notes = null, array $meta = []): ContentModerationFeedback
{
return ContentModerationFeedback::query()->create([
'finding_id' => $finding->id,
'feedback_type' => $feedbackType,
'actor_id' => $actor?->id,
'notes' => $notes,
'meta_json' => $meta,
'created_at' => now(),
]);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Services\Moderation;
use App\Enums\ModerationContentType;
class ModerationPolicyEngineService
{
/**
* @param array<string, mixed> $context
* @param array<string, mixed> $riskAssessment
* @return array<string, mixed>
*/
public function resolve(array $context, array $riskAssessment = []): array
{
$policies = (array) app('config')->get('content_moderation.policies', []);
$contentType = ModerationContentType::tryFrom((string) ($context['content_type'] ?? ''));
$accountAgeDays = (int) data_get($riskAssessment, 'signals.account_age_days', 0);
$riskScore = (int) ($riskAssessment['risk_score'] ?? 0);
$hasLinks = ! empty($context['extracted_urls'] ?? []) || ! empty($context['extracted_domains'] ?? []);
$name = 'default';
if ($riskScore >= 70 || ($accountAgeDays > 0 && $accountAgeDays < 14)) {
$name = 'new_user_strict_mode';
} elseif ($riskScore <= 8 && $accountAgeDays >= 180) {
$name = 'trusted_user_relaxed_mode';
}
if ($contentType === ModerationContentType::ArtworkComment && $riskScore >= 45) {
$name = 'comments_high_volume_antispam';
}
if ($hasLinks && in_array($contentType, [
ModerationContentType::UserProfileLink,
ModerationContentType::CollectionDescription,
ModerationContentType::CollectionTitle,
ModerationContentType::StoryContent,
ModerationContentType::StoryTitle,
ModerationContentType::CardText,
ModerationContentType::CardTitle,
], true)) {
$name = 'strict_seo_protection';
}
$policy = $policies[$name] ?? ($policies['default'] ?? []);
$policy['name'] = $name;
return $policy;
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Services\Moderation;
use App\Data\Moderation\ModerationResultData;
class ModerationPriorityService
{
/**
* @param array<string, mixed> $context
* @param array<string, mixed> $policy
* @param array<string, mixed> $suggestion
*/
public function score(ModerationResultData $result, array $context = [], array $policy = [], array $suggestion = []): array
{
$score = $result->score;
$score += $result->isSuspicious() ? 10 : 0;
$score += $result->autoHideRecommended ? 25 : 0;
$score += max(0, (int) ($result->userRiskScore ?? 0) / 2);
$score += (int) ($policy['priority_bonus'] ?? 0);
$score += max(0, (int) (($suggestion['confidence'] ?? 0) / 5));
$score += ! empty($context['is_publicly_exposed']) ? 12 : 0;
$score += ! empty($result->matchedDomains) ? 10 : 0;
$score += isset($result->ruleHits['blocked_domain']) ? 18 : 0;
$score += isset($result->ruleHits['near_duplicate_campaign']) ? 14 : 0;
$bucket = match (true) {
$score >= 140 => 'urgent',
$score >= 95 => 'high',
$score >= 50 => 'priority',
default => 'standard',
};
$escalation = match ($bucket) {
'urgent' => 'urgent',
'high' => 'escalated',
'priority' => 'review_required',
default => 'none',
};
return [
'priority_score' => $score,
'review_bucket' => $bucket,
'escalation_status' => $escalation,
];
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Services\Moderation;
use App\Enums\ModerationRuleType;
use App\Models\ContentModerationRule;
class ModerationRuleRegistryService
{
/**
* @return array<int, string>
*/
public function suspiciousKeywords(): array
{
return $this->mergeValues(
(array) \app('config')->get('content_moderation.keywords.suspicious', []),
$this->rulesByType(ModerationRuleType::SuspiciousKeyword)
);
}
/**
* @return array<int, string>
*/
public function highRiskKeywords(): array
{
return $this->mergeValues(
(array) \app('config')->get('content_moderation.keywords.high_risk', []),
$this->rulesByType(ModerationRuleType::HighRiskKeyword)
);
}
/**
* @return array<int, array{pattern:string,weight:?int,id:int|null}>
*/
public function regexRules(): array
{
return ContentModerationRule::query()
->where('enabled', true)
->where('type', ModerationRuleType::Regex->value)
->orderByDesc('id')
->get(['id', 'value', 'weight'])
->map(static fn (ContentModerationRule $rule): array => [
'pattern' => (string) $rule->value,
'weight' => $rule->weight,
'id' => $rule->id,
])
->values()
->all();
}
/**
* @return array<int, string>
*/
private function rulesByType(ModerationRuleType $type): array
{
return ContentModerationRule::query()
->where('enabled', true)
->where('type', $type->value)
->orderByDesc('id')
->pluck('value')
->map(static fn (string $value): string => trim($value))
->filter()
->values()
->all();
}
/**
* @param array<int, string> $configValues
* @param array<int, string> $dbValues
* @return array<int, string>
*/
private function mergeValues(array $configValues, array $dbValues): array
{
return \collect(array_merge($configValues, $dbValues))
->map(static fn (string $value): string => trim($value))
->filter()
->unique(static fn (string $value): string => mb_strtolower($value))
->values()
->all();
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Services\Moderation;
use App\Contracts\Moderation\ModerationSuggestionProviderInterface;
use App\Data\Moderation\ModerationResultData;
use App\Data\Moderation\ModerationSuggestionData;
use App\Services\Moderation\Providers\HeuristicModerationSuggestionProvider;
use App\Services\Moderation\Providers\NullModerationSuggestionProvider;
class ModerationSuggestionService
{
public function provider(): ModerationSuggestionProviderInterface
{
$provider = (string) app('config')->get('content_moderation.suggestions.provider', 'heuristic');
return match ($provider) {
'null' => app(NullModerationSuggestionProvider::class),
default => app(HeuristicModerationSuggestionProvider::class),
};
}
/**
* @param array<string, mixed> $context
*/
public function suggest(string $content, ModerationResultData $result, array $context = []): ModerationSuggestionData
{
return $this->provider()->suggest($content, $result, $context);
}
}

Some files were not shown because too many files have changed in this diff Show More