Implement creator studio and upload updates
This commit is contained in:
122
app/Console/Commands/BuildSitemapsCommand.php
Normal file
122
app/Console/Commands/BuildSitemapsCommand.php
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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')
|
|
||||||
|
if ($order === 'updated-desc') {
|
||||||
|
$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')
|
->orderBy('id')
|
||||||
->limit($take);
|
->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;
|
||||||
|
if ($order === 'updated-desc') {
|
||||||
|
$nextUpdatedAt = $artwork->updated_at !== null
|
||||||
|
? CarbonImmutable::instance($artwork->updated_at)
|
||||||
|
: null;
|
||||||
|
$afterId = $lastId;
|
||||||
|
} else {
|
||||||
$nextId = $lastId + 1;
|
$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
|
||||||
*/
|
*/
|
||||||
|
|||||||
33
app/Console/Commands/ListSitemapReleasesCommand.php
Normal file
33
app/Console/Commands/ListSitemapReleasesCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
120
app/Console/Commands/NormalizeArtworkSlugsCommand.php
Normal file
120
app/Console/Commands/NormalizeArtworkSlugsCommand.php
Normal 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)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
80
app/Console/Commands/PublishScheduledNovaCardsCommand.php
Normal file
80
app/Console/Commands/PublishScheduledNovaCardsCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/Console/Commands/PublishSitemapsCommand.php
Normal file
48
app/Console/Commands/PublishSitemapsCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
168
app/Console/Commands/RescanContentModerationCommand.php
Normal file
168
app/Console/Commands/RescanContentModerationCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Console/Commands/RollbackSitemapReleaseCommand.php
Normal file
30
app/Console/Commands/RollbackSitemapReleaseCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
366
app/Console/Commands/ScanContentModerationCommand.php
Normal file
366
app/Console/Commands/ScanContentModerationCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
116
app/Console/Commands/SyncArtworkCreatedAtCommand.php
Normal file
116
app/Console/Commands/SyncArtworkCreatedAtCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
148
app/Console/Commands/ValidateSitemapsCommand.php
Normal file
148
app/Console/Commands/ValidateSitemapsCommand.php
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
|||||||
15
app/Contracts/Images/SubjectDetectorInterface.php
Normal file
15
app/Contracts/Images/SubjectDetectorInterface.php
Normal 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;
|
||||||
|
}
|
||||||
24
app/Contracts/Moderation/ModerationRuleInterface.php
Normal file
24
app/Contracts/Moderation/ModerationRuleInterface.php
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
50
app/Data/Images/CropBoxData.php
Normal file
50
app/Data/Images/CropBoxData.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Data/Images/SquareThumbnailResultData.php
Normal file
46
app/Data/Images/SquareThumbnailResultData.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Data/Images/SubjectDetectionResultData.php
Normal file
20
app/Data/Images/SubjectDetectionResultData.php
Normal 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 = [],
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
88
app/Data/Moderation/ModerationResultData.php
Normal file
88
app/Data/Moderation/ModerationResultData.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Data/Moderation/ModerationSuggestionData.php
Normal file
29
app/Data/Moderation/ModerationSuggestionData.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Enums/ModerationActionType.php
Normal file
51
app/Enums/ModerationActionType.php
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Enums/ModerationContentType.php
Normal file
35
app/Enums/ModerationContentType.php
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Enums/ModerationDomainStatus.php
Normal file
37
app/Enums/ModerationDomainStatus.php
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Enums/ModerationEscalationStatus.php
Normal file
31
app/Enums/ModerationEscalationStatus.php
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Enums/ModerationRuleType.php
Normal file
19
app/Enums/ModerationRuleType.php
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/Enums/ModerationSeverity.php
Normal file
52
app/Enums/ModerationSeverity.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Enums/ModerationStatus.php
Normal file
34
app/Enums/ModerationStatus.php
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Http/Controllers/RobotsTxtController.php
Normal file
25
app/Http/Controllers/RobotsTxtController.php
Normal 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)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/Http/Controllers/SitemapController.php
Normal file
61
app/Http/Controllers/SitemapController.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()]);
|
||||||
}
|
}
|
||||||
|
|||||||
55
app/Http/Controllers/Studio/StudioCommentsApiController.php
Normal file
55
app/Http/Controllers/Studio/StudioCommentsApiController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
37
app/Http/Controllers/Studio/StudioEventsApiController.php
Normal file
37
app/Http/Controllers/Studio/StudioEventsApiController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
114
app/Http/Controllers/Studio/StudioPreferencesApiController.php
Normal file
114
app/Http/Controllers/Studio/StudioPreferencesApiController.php
Normal 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(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
150
app/Http/Controllers/Studio/StudioScheduleApiController.php
Normal file
150
app/Http/Controllers/Studio/StudioScheduleApiController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
317
app/Http/Controllers/Web/SimilarArtworksPageController.php
Normal file
317
app/Http/Controllers/Web/SimilarArtworksPageController.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
$this->validateUpload($attribute, $value, $fail);
|
||||||
|
},
|
||||||
|
'image',
|
||||||
|
'mimes:jpeg,jpg,png,webp',
|
||||||
|
'max:' . $maxKilobytes,
|
||||||
|
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) {
|
if (! $value instanceof UploadedFile) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$path = $value->getRealPath() ?: $value->getPathname();
|
$path = $value->getRealPath() ?: $value->getPathname();
|
||||||
|
|
||||||
if (! $value->isValid() || ! is_string($path) || trim($path) === '') {
|
if (! $value->isValid() || ! is_string($path) || trim($path) === '' || ! is_readable($path)) {
|
||||||
$fail('The ' . $attribute . ' upload is invalid.');
|
$fail('The ' . $attribute . ' upload is invalid.');
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
'image',
|
|
||||||
'mimes:jpeg,jpg,png,webp',
|
private function validateMinimumDimensions(string $attribute, mixed $value, Closure $fail, int $minWidth, int $minHeight): void
|
||||||
'max:' . $maxKilobytes,
|
{
|
||||||
Rule::dimensions()->minWidth(480)->minHeight(480),
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ?? '');
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
$renderService->render($card);
|
||||||
|
}
|
||||||
|
|
||||||
$evaluation = $moderation->evaluate($card->fresh()->loadMissing(['originalCard.user', 'rootCard.user']));
|
$evaluation = $moderation->evaluate($card->fresh()->loadMissing(['originalCard.user', 'rootCard.user']));
|
||||||
|
|
||||||
|
|||||||
30
app/Jobs/Sitemaps/BuildSitemapReleaseJob.php
Normal file
30
app/Jobs/Sitemaps/BuildSitemapReleaseJob.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Jobs/Sitemaps/CleanupSitemapReleasesJob.php
Normal file
25
app/Jobs/Sitemaps/CleanupSitemapReleasesJob.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Jobs/Sitemaps/PublishSitemapReleaseJob.php
Normal file
30
app/Jobs/Sitemaps/PublishSitemapReleaseJob.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
47
app/Models/ContentModerationActionLog.php
Normal file
47
app/Models/ContentModerationActionLog.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Models/ContentModerationAiSuggestion.php
Normal file
38
app/Models/ContentModerationAiSuggestion.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Models/ContentModerationCluster.php
Normal file
32
app/Models/ContentModerationCluster.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
43
app/Models/ContentModerationDomain.php
Normal file
43
app/Models/ContentModerationDomain.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
39
app/Models/ContentModerationFeedback.php
Normal file
39
app/Models/ContentModerationFeedback.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
199
app/Models/ContentModerationFinding.php
Normal file
199
app/Models/ContentModerationFinding.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Models/ContentModerationRule.php
Normal file
32
app/Models/ContentModerationRule.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
22
app/Models/UserSocialLink.php
Normal file
22
app/Models/UserSocialLink.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, '');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
145
app/Services/Cdn/ArtworkCdnPurgeService.php
Normal file
145
app/Services/Cdn/ArtworkCdnPurgeService.php
Normal 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, '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
347
app/Services/Images/ArtworkSquareThumbnailBackfillService.php
Normal file
347
app/Services/Images/ArtworkSquareThumbnailBackfillService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Services/Images/Detectors/ChainedSubjectDetector.php
Normal file
30
app/Services/Images/Detectors/ChainedSubjectDetector.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
409
app/Services/Images/Detectors/HeuristicSubjectDetector.php
Normal file
409
app/Services/Images/Detectors/HeuristicSubjectDetector.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/Services/Images/Detectors/NullSubjectDetector.php
Normal file
16
app/Services/Images/Detectors/NullSubjectDetector.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
142
app/Services/Images/Detectors/VisionSubjectDetector.php
Normal file
142
app/Services/Images/Detectors/VisionSubjectDetector.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
160
app/Services/Images/SquareThumbnailService.php
Normal file
160
app/Services/Images/SquareThumbnailService.php
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
182
app/Services/Moderation/ContentModerationPersistenceService.php
Normal file
182
app/Services/Moderation/ContentModerationPersistenceService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
292
app/Services/Moderation/ContentModerationReviewService.php
Normal file
292
app/Services/Moderation/ContentModerationReviewService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
203
app/Services/Moderation/ContentModerationService.php
Normal file
203
app/Services/Moderation/ContentModerationService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
352
app/Services/Moderation/ContentModerationSourceService.php
Normal file
352
app/Services/Moderation/ContentModerationSourceService.php
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/Services/Moderation/DomainIntelligenceService.php
Normal file
52
app/Services/Moderation/DomainIntelligenceService.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
200
app/Services/Moderation/DomainReputationService.php
Normal file
200
app/Services/Moderation/DomainReputationService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
106
app/Services/Moderation/DuplicateDetectionService.php
Normal file
106
app/Services/Moderation/DuplicateDetectionService.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/Services/Moderation/ModerationClusterService.php
Normal file
89
app/Services/Moderation/ModerationClusterService.php
Normal 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)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Services/Moderation/ModerationFeedbackService.php
Normal file
25
app/Services/Moderation/ModerationFeedbackService.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Services/Moderation/ModerationPolicyEngineService.php
Normal file
51
app/Services/Moderation/ModerationPolicyEngineService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Services/Moderation/ModerationPriorityService.php
Normal file
47
app/Services/Moderation/ModerationPriorityService.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/Services/Moderation/ModerationRuleRegistryService.php
Normal file
81
app/Services/Moderation/ModerationRuleRegistryService.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Services/Moderation/ModerationSuggestionService.php
Normal file
30
app/Services/Moderation/ModerationSuggestionService.php
Normal 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
Reference in New Issue
Block a user