Allow heading tags (h1-h6) in ContentSanitizer so news editor headings render

This commit is contained in:
2026-06-04 07:52:57 +02:00
parent 0b33a1b074
commit 15870ddb1f
191 changed files with 15453 additions and 1786 deletions

View File

@@ -0,0 +1,290 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Enhance;
use App\Models\EnhanceJob;
use App\Services\Enhance\EnhanceStorageService;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;
final class CleanupEnhanceJobsCommand extends Command
{
protected $signature = 'enhance:cleanup
{--dry-run : Preview cleanup actions only}
{--force : Delete files and update records}
{--only= : Restrict cleanup to expired, deleted, failed, or orphaned}
{--days= : Override retention days for failed or deleted cleanup}';
protected $description = 'Safely clean expired, deleted, failed, and orphaned Enhance files.';
public function __construct(
private readonly EnhanceStorageService $storage,
) {
parent::__construct();
}
public function handle(): int
{
$target = strtolower(trim((string) $this->option('only')));
$validTargets = ['', 'expired', 'deleted', 'failed', 'orphaned'];
if (! in_array($target, $validTargets, true)) {
$this->error('The --only option must be one of: expired, deleted, failed, orphaned.');
return self::FAILURE;
}
if ((bool) $this->option('dry-run') && (bool) $this->option('force')) {
$this->error('Use either --dry-run or --force, not both.');
return self::FAILURE;
}
$dryRun = (bool) $this->option('dry-run') || ! (bool) $this->option('force');
$daysOverride = $this->option('days');
$selectedTarget = $target !== '' ? $target : 'all';
Log::info('enhance.cleanup.started', [
'dry_run' => $dryRun,
'target' => $selectedTarget,
'days_override' => $daysOverride,
]);
if ($dryRun) {
$this->warn('Running in dry-run mode. No files will be deleted.');
}
$rows = [];
if ($target === '' || $target === 'expired') {
$expired = $this->cleanupExpiredCompletedJobs($dryRun);
$rows[] = ['expired', $expired['jobs'], $expired['files'], $dryRun ? 'dry-run' : 'cleaned'];
}
if ($target === '' || $target === 'failed') {
$failed = $this->cleanupFailedJobs($dryRun, $daysOverride);
$rows[] = ['failed', $failed['jobs'], $failed['files'], $dryRun ? 'dry-run' : 'cleaned'];
}
if ($target === '' || $target === 'deleted') {
$deleted = $this->cleanupSoftDeletedJobs($dryRun, $daysOverride);
$rows[] = ['deleted', $deleted['jobs'], $deleted['files'], $dryRun ? 'dry-run' : 'cleaned'];
}
if ($target === 'orphaned') {
$orphaned = $this->scanOrphanedFiles($dryRun);
$rows[] = ['orphaned', $orphaned['files'], $orphaned['deleted'], $dryRun ? 'dry-run' : 'deleted'];
if ($orphaned['unsupported']) {
$this->warn('Orphaned file scan was skipped because the configured disk does not support safe listing.');
}
foreach ($orphaned['sample'] as $path) {
$this->line(' - ' . $path);
}
}
if ($rows !== []) {
$this->table(['Target', 'Jobs/Files', 'Files deleted', 'Mode'], $rows);
}
Log::info('enhance.cleanup.completed', [
'dry_run' => $dryRun,
'target' => $selectedTarget,
'rows' => $rows,
]);
$this->info('Enhance cleanup finished.');
return self::SUCCESS;
}
private function cleanupExpiredCompletedJobs(bool $dryRun): array
{
$query = EnhanceJob::query()
->where('status', EnhanceJob::STATUS_COMPLETED)
->whereNotNull('expires_at')
->where('expires_at', '<=', now());
return $this->cleanupJobs($query, $dryRun, 'expired', fn (): array => [
'status' => EnhanceJob::STATUS_EXPIRED,
]);
}
private function cleanupFailedJobs(bool $dryRun, mixed $daysOverride): array
{
$days = $this->resolveDays($daysOverride, (int) config('enhance.lifecycle.failed_expires_after_days', 7));
$cutoff = now()->subDays($days);
$query = EnhanceJob::query()
->where('status', EnhanceJob::STATUS_FAILED)
->where(function (Builder $builder) use ($cutoff): void {
$builder
->where('finished_at', '<=', $cutoff)
->orWhere(function (Builder $fallback) use ($cutoff): void {
$fallback->whereNull('finished_at')->where('created_at', '<=', $cutoff);
});
});
return $this->cleanupJobs($query, $dryRun, 'failed-expired');
}
private function cleanupSoftDeletedJobs(bool $dryRun, mixed $daysOverride): array
{
$days = $this->resolveDays($daysOverride, (int) config('enhance.lifecycle.deleted_file_grace_days', 1));
$cutoff = now()->subDays($days);
$query = EnhanceJob::withTrashed()
->whereNotNull('deleted_at')
->where('deleted_at', '<=', $cutoff);
return $this->cleanupJobs($query, $dryRun, 'deleted-grace');
}
private function cleanupJobs(Builder $query, bool $dryRun, string $reason, ?callable $attributes = null): array
{
$result = ['jobs' => 0, 'files' => 0];
$chunkSize = max(1, (int) config('enhance.lifecycle.cleanup_chunk_size', 100));
$query->chunkById($chunkSize, function ($jobs) use (&$result, $dryRun, $reason, $attributes): void {
foreach ($jobs as $job) {
$result['jobs']++;
$result['files'] += count($this->enhanceJobPaths($job));
if ($dryRun) {
continue;
}
$deleteResult = $this->storage->deleteFilesForJob($job);
$metadata = is_array($job->metadata) ? $job->metadata : [];
$job->forceFill(array_merge(
$this->cleanupAttributesForJob($job),
$attributes ? $attributes($job) : [],
[
'metadata' => array_merge($metadata, [
'cleanup' => [
'files_removed_at' => now()->toIso8601String(),
'reason' => $reason,
'deleted' => $deleteResult['deleted'],
],
]),
],
))->save();
}
});
return $result;
}
private function scanOrphanedFiles(bool $dryRun): array
{
$disk = $this->storage->diskName();
$knownPaths = array_fill_keys(array_map(
static fn (string $path): string => ltrim($path, '/'),
$this->storage->listKnownJobPaths(),
), true);
$sample = [];
$result = [
'files' => 0,
'deleted' => 0,
'unsupported' => false,
'sample' => [],
];
foreach ($this->enhancePrefixes() as $prefix) {
try {
$files = Storage::disk($disk)->allFiles($prefix);
} catch (Throwable) {
$result['unsupported'] = true;
return $result;
}
foreach ($files as $file) {
$normalized = ltrim($file, '/');
if (isset($knownPaths[$normalized])) {
continue;
}
$result['files']++;
if (count($sample) < 20) {
$sample[] = $normalized;
}
if (! $dryRun && $this->storage->safeDelete($disk, $normalized)) {
$result['deleted']++;
}
}
}
$result['sample'] = $sample;
return $result;
}
private function cleanupAttributesForJob(EnhanceJob $job): array
{
$attributes = [];
if ($this->storage->isEnhancePath($job->source_path)) {
$attributes['source_disk'] = null;
$attributes['source_path'] = null;
$attributes['source_hash'] = null;
}
if ($this->storage->isEnhancePath($job->output_path)) {
$attributes['output_disk'] = null;
$attributes['output_path'] = null;
$attributes['output_hash'] = null;
$attributes['output_width'] = null;
$attributes['output_height'] = null;
$attributes['output_filesize'] = null;
$attributes['output_mime'] = null;
}
if ($this->storage->isEnhancePath($job->preview_path)) {
$attributes['preview_disk'] = null;
$attributes['preview_path'] = null;
}
return $attributes;
}
private function enhanceJobPaths(EnhanceJob $job): array
{
return array_values(array_filter([
$this->storage->isEnhancePath($job->source_path) ? trim((string) $job->source_path) : null,
$this->storage->isEnhancePath($job->output_path) ? trim((string) $job->output_path) : null,
$this->storage->isEnhancePath($job->preview_path) ? trim((string) $job->preview_path) : null,
]));
}
private function enhancePrefixes(): array
{
return array_values(array_filter(array_unique(array_map(
static fn (string $prefix): string => trim($prefix, '/'),
[
(string) config('enhance.source_prefix', 'enhance/sources'),
(string) config('enhance.output_prefix', 'enhance/outputs'),
(string) config('enhance.preview_prefix', 'enhance/previews'),
],
))));
}
private function resolveDays(mixed $daysOverride, int $default): int
{
if ($daysOverride === null || $daysOverride === '') {
return max(0, $default);
}
return max(0, (int) $daysOverride);
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Enhance;
use App\Models\EnhanceJob;
use Illuminate\Console\Command;
final class EnhanceHealthCommand extends Command
{
protected $signature = 'enhance:health {--json : Output machine-readable JSON}';
protected $description = 'Report operational health and lifecycle metrics for Enhance jobs.';
public function handle(): int
{
$payload = $this->payload();
if ((bool) $this->option('json')) {
$this->line(json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$this->info('Enhance health');
$this->newLine();
$this->table(['Metric', 'Value'], [
['Configured engine', $payload['engine']],
['Configured queue', $payload['queue']],
['Worker URL configured', $payload['worker_configured'] ? 'yes' : 'no'],
['Storage disk', $payload['storage_disk']],
['Total jobs', $payload['counts']['total']],
['Pending jobs', $payload['counts']['pending']],
['Queued jobs', $payload['counts']['queued']],
['Processing jobs', $payload['counts']['processing']],
['Completed jobs', $payload['counts']['completed']],
['Failed jobs', $payload['counts']['failed']],
['Cancelled jobs', $payload['counts']['cancelled']],
['Expired jobs', $payload['counts']['expired']],
['Stuck queued jobs', $payload['health']['stuck_queued']],
['Stuck processing jobs', $payload['health']['stuck_processing']],
['Jobs created today', $payload['today']['created']],
['Jobs completed today', $payload['today']['completed']],
['Jobs failed today', $payload['today']['failed']],
['Average processing time today', $payload['today']['average_processing_seconds'] ?? '—'],
]);
return self::SUCCESS;
}
private function payload(): array
{
$todayStart = now()->startOfDay();
$todayEnd = now()->endOfDay();
$stuckQueuedCutoff = now()->subMinutes((int) config('enhance.health.stuck_queued_after_minutes', 60));
$stuckProcessingCutoff = now()->subMinutes((int) config('enhance.health.stuck_processing_after_minutes', 30));
$counts = [
'total' => EnhanceJob::query()->count(),
'pending' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_PENDING)->count(),
'queued' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_QUEUED)->count(),
'processing' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_PROCESSING)->count(),
'completed' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_COMPLETED)->count(),
'failed' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_FAILED)->count(),
'cancelled' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_CANCELLED)->count(),
'expired' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_EXPIRED)->count(),
];
return [
'engine' => (string) config('enhance.default_engine', EnhanceJob::ENGINE_STUB),
'queue' => (string) config('enhance.queue', 'default'),
'worker_configured' => trim((string) config('enhance.external_worker.url', '')) !== '',
'storage_disk' => (string) config('enhance.disk', 'public'),
'counts' => $counts,
'health' => [
'stuck_queued' => EnhanceJob::query()
->where('status', EnhanceJob::STATUS_QUEUED)
->whereNotNull('queued_at')
->where('queued_at', '<=', $stuckQueuedCutoff)
->count(),
'stuck_processing' => EnhanceJob::query()
->where('status', EnhanceJob::STATUS_PROCESSING)
->whereNotNull('started_at')
->where('started_at', '<=', $stuckProcessingCutoff)
->count(),
],
'today' => [
'created' => EnhanceJob::query()->whereBetween('created_at', [$todayStart, $todayEnd])->count(),
'completed' => EnhanceJob::query()
->where('status', EnhanceJob::STATUS_COMPLETED)
->whereBetween('finished_at', [$todayStart, $todayEnd])
->count(),
'failed' => EnhanceJob::query()
->where('status', EnhanceJob::STATUS_FAILED)
->whereBetween('finished_at', [$todayStart, $todayEnd])
->count(),
'average_processing_seconds' => ($average = EnhanceJob::query()
->whereNotNull('processing_seconds')
->whereBetween('finished_at', [$todayStart, $todayEnd])
->avg('processing_seconds')) !== null
? round((float) $average, 2)
: null,
],
];
}
}

View File

@@ -0,0 +1,290 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Enhance;
use App\Models\EnhanceJob;
use App\Services\Enhance\EnhanceProcessorFactory;
use App\Services\Enhance\EnhanceStorageService;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;
final class EnhanceRunCommand extends Command
{
protected $signature = 'enhance:run
{--id= : Process specific job ID(s), comma-separated}
{--limit=1 : Max pending/queued jobs to pick up from the queue (0 = all)}
{--engine= : Override the processing engine for this run (stub, external_worker)}
{--failed : Also include failed jobs when scanning the queue}
{--dry-run : Show what would be processed without executing}';
protected $description = 'Synchronously process pending enhance jobs inline — useful for debugging with -v / -vv / -vvv.';
private const PROCESSABLE_STATUSES = [
EnhanceJob::STATUS_PENDING,
EnhanceJob::STATUS_QUEUED,
EnhanceJob::STATUS_PROCESSING,
EnhanceJob::STATUS_FAILED,
];
public function __construct(
private readonly EnhanceProcessorFactory $processorFactory,
private readonly EnhanceStorageService $storage,
) {
parent::__construct();
}
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$engineOverride = trim((string) $this->option('engine'));
$idOption = trim((string) $this->option('id'));
$limit = max(0, (int) $this->option('limit'));
$includeFailed = (bool) $this->option('failed');
if ($engineOverride !== '' && ! in_array($engineOverride, [EnhanceJob::ENGINE_STUB, EnhanceJob::ENGINE_EXTERNAL_WORKER], true)) {
$this->error("Unknown engine override: {$engineOverride}. Use 'stub' or 'external_worker'.");
return self::FAILURE;
}
$jobs = $this->resolveJobs($idOption, $limit, $includeFailed);
if ($jobs->isEmpty()) {
$this->info('No eligible enhance jobs found.');
return self::SUCCESS;
}
$this->info(sprintf('Found %d job(s) to process.', $jobs->count()));
$this->newLine();
if ($dryRun) {
$this->warn('Dry-run mode — no jobs will be processed.');
foreach ($jobs as $job) {
$engine = $engineOverride !== '' ? "{$engineOverride} (overridden)" : $job->engine;
$this->line(sprintf(
' [dry-run] Job #%d status=%-12s engine=%-18s scale=%dx mode=%-14s user_id=%d',
$job->id,
$job->status,
$engine,
$job->scale,
$job->mode,
$job->user_id,
));
}
return self::SUCCESS;
}
$processed = 0;
$failed = 0;
foreach ($jobs as $job) {
if ($this->processJob($job, $engineOverride)) {
$processed++;
} else {
$failed++;
}
$this->newLine();
}
$this->info(sprintf('Done: %d completed, %d failed.', $processed, $failed));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
private function resolveJobs(string $idOption, int $limit, bool $includeFailed): Collection
{
if ($idOption !== '') {
$ids = array_filter(array_map('intval', explode(',', $idOption)));
return EnhanceJob::query()
->whereIn('id', $ids)
->whereIn('status', self::PROCESSABLE_STATUSES)
->get();
}
$statuses = [EnhanceJob::STATUS_PENDING, EnhanceJob::STATUS_QUEUED, EnhanceJob::STATUS_PROCESSING];
if ($includeFailed) {
$statuses[] = EnhanceJob::STATUS_FAILED;
}
$query = EnhanceJob::query()->whereIn('status', $statuses)->oldest();
if ($limit > 0) {
$query->limit($limit);
}
return $query->get();
}
private function processJob(EnhanceJob $job, string $engineOverride): bool
{
$engine = $engineOverride !== '' ? $engineOverride : (string) $job->engine;
$this->line(sprintf('<comment>--- Job #%d ---</comment>', $job->id));
$this->line(sprintf(' Status : %s', $job->status));
$this->line(sprintf(' Engine : %s%s', $engine, $engineOverride !== '' ? ' (overridden)' : ''));
$this->line(sprintf(' Scale : %dx', $job->scale));
$this->line(sprintf(' Mode : %s', $job->mode));
$this->line(sprintf(' User : #%d', $job->user_id));
if ($this->output->isVerbose()) {
$this->line(sprintf(
' Source : disk=%-10s path=%s',
$job->source_disk ?: '(default)',
$job->source_path ?: '—',
));
$this->line(sprintf(
' Input : %dx%d size=%s mime=%s',
(int) $job->input_width,
(int) $job->input_height,
$this->formatBytes((int) $job->input_filesize),
$job->input_mime ?: '—',
));
if ($job->error_message !== null) {
$this->warn(sprintf(' Previous error: %s', $job->error_message));
}
}
if (! in_array($job->status, self::PROCESSABLE_STATUSES, true)) {
$this->warn(sprintf(' Skipping: status "%s" is not processable.', $job->status));
return false;
}
$job->forceFill([
'status' => EnhanceJob::STATUS_PROCESSING,
'started_at' => now(),
'finished_at' => null,
'error_message' => null,
])->save();
$started = microtime(true);
$completedExpiryDays = (int) config('enhance.lifecycle.completed_expires_after_days', 30);
try {
$this->line(' Processing...');
$processor = $this->processorFactory->make($engine);
$result = $processor->process($job);
if ($this->output->isVerbose()) {
$this->line(sprintf(
' Output : %dx%d size=%s mime=%s',
$result->width,
$result->height,
$this->formatBytes($result->filesize),
$result->mime,
));
$this->line(sprintf(
' Stored : disk=%-10s path=%s',
$result->disk,
$result->path,
));
}
$this->line(' Generating preview...');
$preview = $this->storage->createPreviewFromStoredOutput($job, $result->disk, $result->path) ?? [];
$outputHash = null;
$outputContents = Storage::disk($result->disk)->get($result->path);
if (is_string($outputContents) && $outputContents !== '') {
$outputHash = hash('sha256', $outputContents);
}
$job->forceFill([
'status' => EnhanceJob::STATUS_COMPLETED,
'output_disk' => $result->disk,
'output_path' => $result->path,
'output_hash' => $outputHash,
'output_width' => $result->width,
'output_height' => $result->height,
'output_filesize' => $result->filesize,
'output_mime' => $result->mime,
'metadata' => array_merge($job->metadata ?? [], $result->metadata ?? []),
'processing_seconds' => (int) round(microtime(true) - $started),
'finished_at' => now(),
'expires_at' => $completedExpiryDays > 0 ? now()->addDays($completedExpiryDays) : null,
] + $preview)->save();
$elapsed = round(microtime(true) - $started, 2);
$this->info(sprintf(' Completed in %.2fs', $elapsed));
if ($this->output->isVerbose() && ! empty($result->metadata)) {
$this->line(' Metadata:');
foreach ($result->metadata as $key => $value) {
$display = is_scalar($value) ? (string) $value : json_encode($value);
$this->line(sprintf(' %-30s %s', $key . ':', $display));
}
}
return true;
} catch (Throwable $exception) {
$elapsed = round(microtime(true) - $started, 2);
$job->forceFill([
'status' => EnhanceJob::STATUS_FAILED,
'error_message' => Str::limit($exception->getMessage(), 1000),
'processing_seconds' => (int) round(microtime(true) - $started),
'finished_at' => now(),
])->save();
$this->error(sprintf(' Failed in %.2fs: %s', $elapsed, $exception->getMessage()));
if ($this->output->isVerbose()) {
$this->line(sprintf(' Exception : %s', get_class($exception)));
$this->line(sprintf(' At : %s:%d', $exception->getFile(), $exception->getLine()));
$previous = $exception->getPrevious();
if ($previous !== null) {
$this->line(sprintf(' Caused by : %s: %s', get_class($previous), $previous->getMessage()));
}
}
if ($this->output->isVeryVerbose()) {
$this->line(' Stack trace:');
$frames = array_slice(explode("\n", $exception->getTraceAsString()), 0, 25);
foreach ($frames as $frame) {
$this->line(' ' . $frame);
}
}
Log::warning('enhance.run.command.failed', [
'enhance_job_id' => $job->id,
'engine' => $engine,
'message' => $exception->getMessage(),
'exception' => get_class($exception),
]);
return false;
}
}
private function formatBytes(int $bytes): string
{
if ($bytes < 1024) {
return $bytes . 'B';
}
if ($bytes < 1_048_576) {
return round($bytes / 1024, 1) . 'KB';
}
return round($bytes / 1_048_576, 1) . 'MB';
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use App\Services\News\NewsService;
use cPad\Plugins\News\Models\NewsArticle;
final class PublishScheduledNewsCommand extends Command
@@ -17,6 +18,11 @@ final class PublishScheduledNewsCommand extends Command
protected $description = 'Publish scheduled News articles whose publish time has passed.';
public function __construct(private readonly NewsService $news)
{
parent::__construct();
}
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
@@ -60,11 +66,7 @@ final class PublishScheduledNewsCommand extends Command
return;
}
$article->forceFill([
'editorial_status' => NewsArticle::EDITORIAL_STATUS_PUBLISHED,
'status' => 'published',
'published_at' => $article->published_at ?? $now,
])->save();
$this->news->publish($article);
$published++;
$this->line(sprintf('Published News article #%d: "%s"', $article->id, $article->title));

View File

@@ -85,6 +85,13 @@ final class AcademyCourseController extends Controller
'featuredCourses' => $featuredCourses->all(),
'filters' => $filters,
'pricingUrl' => route('academy.pricing'),
'lessonsUrl' => route('academy.lessons.index'),
'promptLibraryUrl' => route('academy.prompts.index'),
'academyAccess' => array_merge($this->access->accessSummary($request->user()), [
'billingUrl' => $request->user() && (bool) config('academy_billing.enabled', false)
? route('academy.billing.account')
: route('academy.pricing'),
]),
'analytics' => [
'enabled' => true,
'contentType' => null,

View File

@@ -60,10 +60,16 @@ final class AcademyHomeController extends Controller
return Inertia::render('Academy/Index', [
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'academyAccess' => array_merge($this->access->accessSummary($request->user()), [
'billingUrl' => $request->user() && (bool) config('academy_billing.enabled', false)
? route('academy.billing.account')
: route('academy.pricing'),
]),
'links' => [
'lessons' => route('academy.lessons.index'),
'courses' => route('academy.courses.index'),
'prompts' => route('academy.prompts.index'),
'promptPopular' => route('academy.prompts.popular'),
'packs' => route('academy.packs.index'),
'challenges' => route('academy.challenges.index'),
],

View File

@@ -13,6 +13,7 @@ use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyInteractionService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
@@ -27,7 +28,7 @@ final class AcademyLessonController extends Controller
private readonly AcademyInteractionService $interactions,
) {}
public function index(Request $request): Response
public function index(Request $request): Response|JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
@@ -65,6 +66,10 @@ final class AcademyLessonController extends Controller
$this->analytics->trackSearch((string) $filters['q'], (int) $lessons->total(), array_filter($filters), $request);
}
if ($request->expectsJson()) {
return response()->json($lessons);
}
$seo = app(SeoFactory::class)
->collectionListing(
'Academy Lessons — Skinbase',
@@ -78,10 +83,21 @@ final class AcademyLessonController extends Controller
'title' => 'Academy lessons',
'description' => 'Step-by-step tutorials and workflow guides for AI-assisted creative work on Skinbase.',
'seo' => $seo,
'breadcrumbs' => [
['label' => 'Academy', 'href' => route('academy.index')],
['label' => 'Lessons', 'href' => route('academy.lessons.index')],
],
'items' => $lessons,
'filters' => $filters,
'categories' => $this->cache->categoriesByType('lesson'),
'pricingUrl' => route('academy.pricing'),
'coursesUrl' => route('academy.courses.index'),
'promptLibraryUrl' => route('academy.prompts.index'),
'academyAccess' => array_merge($this->access->accessSummary($request->user()), [
'billingUrl' => $request->user() && (bool) config('academy_billing.enabled', false)
? route('academy.billing.account')
: route('academy.pricing'),
]),
'analytics' => [
'enabled' => true,
'contentType' => filled($filters['q'] ?? null) ? AcademyAnalyticsContentType::SEARCH : null,

View File

@@ -10,11 +10,13 @@ use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyAnalyticsService;
use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyInteractionService;
use App\Services\Academy\AcademyPopularityService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
@@ -25,6 +27,7 @@ final class AcademyPromptController extends Controller
private readonly AcademyCacheService $cache,
private readonly AcademyAnalyticsService $analytics,
private readonly AcademyInteractionService $interactions,
private readonly AcademyPopularityService $popularity,
) {
}
@@ -86,16 +89,32 @@ final class AcademyPromptController extends Controller
return Inertia::render('Academy/List', [
'pageType' => 'prompts',
'promptView' => 'library',
'title' => 'Prompt library',
'description' => 'Reusable prompt templates for wallpapers, worlds, mascots, covers, and digital art workflows.',
'seo' => $seo,
'breadcrumbs' => [
['label' => 'Academy', 'href' => route('academy.index')],
['label' => 'Prompt Library', 'href' => route('academy.prompts.index')],
],
'items' => $prompts,
'filters' => $filters,
'categories' => $this->cache->categoriesByType('prompt'),
'pricingUrl' => route('academy.pricing'),
'coursesUrl' => route('academy.courses.index'),
'packsUrl' => route('academy.packs.index'),
'promptPopularUrl' => route('academy.prompts.popular'),
'promptLibraryUrl' => route('academy.prompts.index'),
'academyAccess' => array_merge($this->access->accessSummary($request->user()), [
'billingUrl' => $request->user() && (bool) config('academy_billing.enabled', false)
? route('academy.billing.account')
: route('academy.pricing'),
]),
'featuredPrompts' => $this->featuredPromptPayloads($request->user()),
'popularPrompts' => $this->popularPromptPayloads($request->user()),
'analytics' => [
'enabled' => true,
'contentType' => filled($filters['q'] ?? null) ? AcademyAnalyticsContentType::SEARCH : null,
'contentType' => AcademyAnalyticsContentType::PROMPT_LIBRARY,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_prompts_index',
@@ -110,6 +129,186 @@ final class AcademyPromptController extends Controller
])->rootView('collections');
}
public function popular(Request $request): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $request->validate([
'period' => ['nullable', 'string', 'in:7d,30d,90d'],
]);
$selectedPeriod = $this->selectedPopularPromptPeriod($validated['period'] ?? null);
$from = now()->subDays($selectedPeriod['days'] - 1)->startOfDay();
$to = now()->endOfDay();
$rows = DB::query()
->fromSub(
$this->popularity->queryBetween($from, $to)
->where('content_type', AcademyAnalyticsContentType::PROMPT)
->whereNotNull('content_id')
->selectRaw('content_id, sum(views) as views, sum(prompt_copies) as prompt_copies, sum(popularity_score) as popularity_score')
->groupBy('content_id'),
'prompt_rankings'
)
->orderByDesc('popularity_score')
->orderByDesc('prompt_copies')
->orderByDesc('views')
->paginate(12)
->withQueryString();
$prompts = AcademyPromptTemplate::query()
->with('category')
->active()
->published()
->whereIn('id', $rows->pluck('content_id')->map(static fn ($value): int => (int) $value)->all())
->get()
->keyBy('id');
$baseRank = (($rows->currentPage() - 1) * $rows->perPage());
$rows->setCollection(
$rows->getCollection()
->values()
->map(function (object $row, int $index) use ($prompts, $request, $baseRank, $selectedPeriod): ?array {
$prompt = $prompts->get((int) $row->content_id);
if (! $prompt instanceof AcademyPromptTemplate) {
return null;
}
$payload = $this->access->promptPayload($prompt, $request->user());
$payload['ranking'] = [
'rank' => $baseRank + $index + 1,
'views' => max(0, (int) ($row->views ?? 0)),
'prompt_copies' => max(0, (int) ($row->prompt_copies ?? 0)),
'popularity_score' => round((float) ($row->popularity_score ?? 0), 2),
];
$payload['spotlight'] = [
'eyebrow' => max(0, (int) ($row->prompt_copies ?? 0)) > 0
? sprintf('%d copies %s', (int) $row->prompt_copies, $selectedPeriod['eyebrow_suffix'])
: sprintf('%d views %s', (int) $row->views, $selectedPeriod['eyebrow_suffix']),
];
return $payload;
})
->filter()
->values()
);
$seo = app(SeoFactory::class)
->collectionListing(
sprintf('%s Prompts — Skinbase Academy', $selectedPeriod['title_prefix']),
sprintf('See which Skinbase Academy prompt templates are driving the most views and copies %s.', $selectedPeriod['description_suffix']),
route('academy.prompts.popular', $request->query()),
)
->toArray();
return Inertia::render('Academy/List', [
'pageType' => 'prompts',
'promptView' => 'popular',
'title' => sprintf('%s prompts', $selectedPeriod['title_prefix']),
'description' => sprintf('The prompt templates getting the most momentum from views and copies across the Academy %s.', $selectedPeriod['description_suffix']),
'seo' => $seo,
'breadcrumbs' => [
['label' => 'Academy', 'href' => route('academy.index')],
['label' => 'Prompt Library', 'href' => route('academy.prompts.index')],
['label' => 'Popular Prompts', 'href' => route('academy.prompts.popular')],
],
'items' => $rows,
'filters' => [],
'categories' => [],
'pricingUrl' => route('academy.pricing'),
'coursesUrl' => route('academy.courses.index'),
'packsUrl' => route('academy.packs.index'),
'promptPopularUrl' => route('academy.prompts.popular'),
'promptLibraryUrl' => route('academy.prompts.index'),
'academyAccess' => array_merge($this->access->accessSummary($request->user()), [
'billingUrl' => $request->user() && (bool) config('academy_billing.enabled', false)
? route('academy.billing.account')
: route('academy.pricing'),
]),
'popularPeriod' => [
'value' => $selectedPeriod['value'],
'label' => $selectedPeriod['label'],
'description' => $selectedPeriod['description'],
],
'popularPeriods' => collect($this->popularPromptPeriods())
->map(fn (array $period): array => [
'value' => $period['value'],
'label' => $period['label'],
'description' => $period['description'],
'href' => route('academy.prompts.popular', ['period' => $period['value']]),
'active' => $period['value'] === $selectedPeriod['value'],
])
->values()
->all(),
'featuredPrompts' => $this->featuredPromptPayloads($request->user()),
'popularPrompts' => [],
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::PROMPT_POPULAR,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_prompts_popular',
'trackingKey' => sprintf('period:%s', $selectedPeriod['value']),
'metadata' => [
'period' => $selectedPeriod['value'],
'period_days' => $selectedPeriod['days'],
],
'search' => null,
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
}
/**
* @return array<int, array<string, mixed>>
*/
private function popularPromptPeriods(): array
{
return [
[
'value' => '7d',
'days' => 7,
'label' => '7 days',
'description' => 'Fresh momentum from the last 7 days.',
'title_prefix' => 'Top 7-day',
'description_suffix' => 'in the last 7 days',
'eyebrow_suffix' => 'in the last 7 days',
],
[
'value' => '30d',
'days' => 30,
'label' => '30 days',
'description' => 'The default monthly view of prompt momentum.',
'title_prefix' => 'Popular',
'description_suffix' => 'this month',
'eyebrow_suffix' => 'this month',
],
[
'value' => '90d',
'days' => 90,
'label' => '90 days',
'description' => 'Longer-running prompt momentum across the quarter.',
'title_prefix' => 'Top 90-day',
'description_suffix' => 'in the last 90 days',
'eyebrow_suffix' => 'in the last 90 days',
],
];
}
/**
* @return array<string, mixed>
*/
private function selectedPopularPromptPeriod(?string $value): array
{
return collect($this->popularPromptPeriods())
->first(fn (array $period): bool => $period['value'] === $value)
?? $this->popularPromptPeriods()[1];
}
public function show(Request $request, string $slug): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
@@ -201,4 +400,70 @@ final class AcademyPromptController extends Controller
],
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []);
}
/**
* @return array<int, array<string, mixed>>
*/
private function featuredPromptPayloads(mixed $viewer, int $limit = 4): array
{
return collect($this->cache->featuredPrompts())
->take($limit)
->map(function (AcademyPromptTemplate $prompt) use ($viewer): array {
$payload = $this->access->promptPayload($prompt, $viewer);
$payload['spotlight'] = [
'eyebrow' => $prompt->prompt_of_week ? 'Prompt of the week' : 'Featured pick',
];
return $payload;
})
->values()
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function popularPromptPayloads(mixed $viewer, int $limit = 4): array
{
$rows = $this->popularity->queryBetween(now()->subDays(29)->startOfDay(), now()->endOfDay())
->where('content_type', AcademyAnalyticsContentType::PROMPT)
->whereNotNull('content_id')
->selectRaw('content_id, sum(views) as views, sum(prompt_copies) as prompt_copies, sum(popularity_score) as popularity_score')
->groupBy('content_id')
->orderByDesc('popularity_score')
->limit($limit)
->get();
if ($rows->isEmpty()) {
return [];
}
$prompts = AcademyPromptTemplate::query()
->with('category')
->active()
->published()
->whereIn('id', $rows->pluck('content_id')->all())
->get()
->keyBy('id');
return $rows->map(function ($row) use ($prompts, $viewer): ?array {
$prompt = $prompts->get((int) $row->content_id);
if (! $prompt instanceof AcademyPromptTemplate) {
return null;
}
$payload = $this->access->promptPayload($prompt, $viewer);
$copies = max(0, (int) ($row->prompt_copies ?? 0));
$views = max(0, (int) ($row->views ?? 0));
$payload['spotlight'] = [
'eyebrow' => $copies > 0 ? sprintf('%d copies this month', $copies) : sprintf('%d views this month', $views),
];
return $payload;
})
->filter()
->values()
->all();
}
}

View File

@@ -29,7 +29,6 @@ final class AcademyPromptPackController extends Controller
abort_unless((bool) config('academy.enabled', true), 404);
$packs = AcademyPromptPack::query()
->with('prompts')
->active()
->published()
->latest('published_at')
@@ -57,7 +56,7 @@ final class AcademyPromptPackController extends Controller
'pricingUrl' => route('academy.pricing'),
'analytics' => [
'enabled' => true,
'contentType' => null,
'contentType' => AcademyAnalyticsContentType::PROMPT_PACK_LIBRARY,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_packs_index',

View File

@@ -53,7 +53,12 @@ class LinkPreviewController extends Controller
return response()->json(['error' => 'Invalid URL.'], 422);
}
// Resolve hostname and block private/loopback IPs (SSRF protection)
// Resolve hostname and block private/loopback IPs (SSRF protection).
// NOTE: This check is not atomic with Guzzle's own DNS resolution — a
// DNS rebinding attack could theoretically pass this check and then
// resolve to an internal IP when Guzzle makes the actual request.
// Risk is low (requires attacker-controlled DNS with very short TTL),
// but this is a known limitation of the current approach.
$resolved = gethostbyname($host);
if ($this->isBlockedIp($resolved)) {
return response()->json(['error' => 'URL not allowed.'], 422);

View File

@@ -47,7 +47,9 @@ use App\Uploads\Exceptions\DraftQuotaException;
use App\Models\Artwork;
use App\Models\Group;
use App\Services\GroupArtworkReviewService;
use App\Support\ArtworkDescriptionContentValidator;
use App\Services\Worlds\WorldSubmissionService;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Str;
final class UploadController extends Controller
@@ -534,6 +536,8 @@ final class UploadController extends Controller
'nsfw' => ['nullable', 'boolean'],
]);
$this->ensureValidArtworkDescription($validated);
$updates = [];
foreach (['title', 'category_id', 'description', 'tags', 'license', 'nsfw'] as $field) {
if (array_key_exists($field, $validated)) {
@@ -635,6 +639,8 @@ final class UploadController extends Controller
'world_submissions.*.source_surface' => ['nullable', 'string', 'max:80'],
]);
$this->ensureValidArtworkDescription($validated);
$mode = $validated['mode'] ?? 'now';
$visibility = $validated['visibility'] ?? 'public';
@@ -814,6 +820,8 @@ final class UploadController extends Controller
'world_submissions.*.source_surface' => ['nullable', 'string', 'max:80'],
]);
$this->ensureValidArtworkDescription($validated);
if (! ctype_digit($id)) {
return response()->json(['message' => 'Artwork review submission requires an artwork draft id.'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
@@ -842,4 +850,13 @@ final class UploadController extends Controller
'group_review_status' => (string) $artwork->group_review_status,
], Response::HTTP_OK);
}
private function ensureValidArtworkDescription(array $validated): void
{
foreach (ArtworkDescriptionContentValidator::errors($validated['description'] ?? null) as $message) {
throw ValidationException::withMessages([
'description' => [$message],
]);
}
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Artwork;
use App\Services\Enhance\EnhanceService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use RuntimeException;
final class ArtworkEnhanceController extends Controller
{
public function __construct(
private readonly EnhanceService $enhanceService,
) {
}
public function store(Request $request, int $artwork): RedirectResponse
{
$artwork = Artwork::query()->findOrFail($artwork);
$actor = $request->user();
abort_unless($actor !== null, 403);
$isOwner = (int) $artwork->user_id === (int) $actor->id;
$isStaff = $actor->isAdmin() || $actor->isModerator();
abort_unless($isOwner || $isStaff, 403);
$validated = $request->validate([
'scale' => ['required', 'integer', Rule::in((array) config('enhance.allowed_scales', [2, 4]))],
'mode' => ['required', 'string', Rule::in((array) config('enhance.allowed_modes', ['standard', 'artwork', 'photo', 'illustration']))],
]);
try {
$job = $this->enhanceService->createFromArtwork($actor, $artwork, $validated);
} catch (RuntimeException $exception) {
return redirect()
->route('enhance.create', ['artwork' => $artwork->id])
->withErrors([
'source' => $exception->getMessage(),
]);
}
return redirect()
->route('enhance.show', ['enhanceJob' => $job])
->with('success', 'Artwork enhance job created.');
}
}

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Artwork;
use App\Models\EnhanceJob;
use App\Services\Enhance\EnhanceService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
final class EnhanceController extends Controller
{
public function __construct(
private readonly EnhanceService $enhanceService,
) {
}
public function index(Request $request): Response
{
$this->authorize('viewAny', EnhanceJob::class);
$jobs = EnhanceJob::query()
->where('user_id', (int) $request->user()->id)
->with('artwork:id,title,slug')
->latest('id')
->paginate(12)
->withQueryString()
->through(fn (EnhanceJob $job): array => $this->serializeJobListItem($job));
$latestCompleted = EnhanceJob::query()
->where('user_id', (int) $request->user()->id)
->where('status', EnhanceJob::STATUS_COMPLETED)
->latest('finished_at')
->limit(4)
->get()
->map(fn (EnhanceJob $job): array => $this->serializeJobListItem($job))
->all();
return Inertia::render('Enhance/Index', [
'title' => 'Skinbase Enhance',
'jobs' => $jobs,
'latestCompleted' => $latestCompleted,
'createUrl' => route('enhance.create'),
'indexUrl' => route('enhance.index'),
'dailyLimit' => (int) config('enhance.daily_limit', 10),
'enhanceConfig' => $this->enhanceService->frontendConfig(),
]);
}
public function create(Request $request): Response
{
$this->authorize('create', EnhanceJob::class);
$selectedArtwork = null;
if (($artworkId = (int) $request->integer('artwork')) > 0) {
$artwork = Artwork::query()
->select(['id', 'user_id', 'title', 'slug'])
->findOrFail($artworkId);
$actor = $request->user();
abort_unless($actor !== null, 403);
$isOwner = (int) $artwork->user_id === (int) $actor->id;
$isStaff = $actor->isAdmin() || $actor->isModerator();
abort_unless($isOwner || $isStaff, 403);
$selectedArtwork = [
'id' => $artwork->id,
'title' => $artwork->title,
'show_url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]),
'store_url' => route('artworks.enhance.store', ['artwork' => $artwork->id]),
];
}
return Inertia::render('Enhance/Create', [
'title' => 'Skinbase Enhance',
'options' => $this->optionsPayload(),
'storeUrl' => route('enhance.store'),
'indexUrl' => route('enhance.index'),
'maxUploadMb' => (int) config('enhance.max_upload_mb', 20),
'selectedArtwork' => $selectedArtwork,
'enhanceConfig' => $this->enhanceService->frontendConfig(),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorize('create', EnhanceJob::class);
$validated = $request->validate([
'image' => ['required', 'file', 'mimetypes:image/jpeg,image/png,image/webp', 'max:' . ((int) config('enhance.max_upload_mb', 20) * 1024)],
'scale' => ['required', 'integer', Rule::in((array) config('enhance.allowed_scales', [2, 4]))],
'mode' => ['required', 'string', Rule::in((array) config('enhance.allowed_modes', ['standard', 'artwork', 'photo', 'illustration']))],
]);
$job = $this->enhanceService->createFromUpload($request->user(), $request->file('image'), $validated);
return redirect()
->route('enhance.show', ['enhanceJob' => $job])
->with('success', 'Enhance job created.');
}
public function show(EnhanceJob $enhanceJob): Response
{
$this->authorize('view', $enhanceJob);
$enhanceJob->loadMissing('artwork:id,title,slug');
return Inertia::render('Enhance/Show', [
'title' => 'Enhance Job',
'job' => $this->serializeJobDetail($enhanceJob),
'indexUrl' => route('enhance.index'),
'createUrl' => route('enhance.create'),
'enhanceConfig' => $this->enhanceService->frontendConfig(),
]);
}
public function retry(EnhanceJob $enhanceJob): RedirectResponse
{
$this->authorize('retry', $enhanceJob);
$job = $this->enhanceService->retry($enhanceJob);
return redirect()
->route('enhance.show', ['enhanceJob' => $job])
->with('success', 'Enhance job queued again.');
}
public function destroy(EnhanceJob $enhanceJob): RedirectResponse
{
$this->authorize('delete', $enhanceJob);
$this->enhanceService->delete($enhanceJob);
return redirect()
->route('enhance.index')
->with('success', 'Enhance job deleted.');
}
private function optionsPayload(): array
{
return [
'modes' => array_map(fn (string $mode): array => [
'value' => $mode,
'label' => ucfirst($mode),
], (array) config('enhance.allowed_modes', [])),
'scales' => array_map(fn (int $scale): array => [
'value' => $scale,
'label' => $scale . 'x',
], array_map('intval', (array) config('enhance.allowed_scales', []))),
];
}
private function serializeJobListItem(EnhanceJob $job): array
{
return [
'id' => $job->id,
'status' => (string) $job->status,
'engine' => (string) $job->engine,
'mode' => (string) $job->mode,
'scale' => (int) $job->scale,
'source_url' => $job->sourceUrl(),
'output_url' => $job->outputUrl(),
'preview_url' => $job->previewUrl(),
'input_width' => (int) ($job->input_width ?? 0),
'input_height' => (int) ($job->input_height ?? 0),
'output_width' => (int) ($job->output_width ?? 0),
'output_height' => (int) ($job->output_height ?? 0),
'error_message' => $job->error_message,
'processing_seconds' => $job->processing_seconds,
'created_at' => optional($job->created_at)?->toIso8601String(),
'finished_at' => optional($job->finished_at)?->toIso8601String(),
'show_url' => route('enhance.show', ['enhanceJob' => $job]),
'artwork' => $job->artwork ? [
'id' => $job->artwork->id,
'title' => $job->artwork->title,
'slug' => $job->artwork->slug,
'url' => route('art.show', ['id' => $job->artwork->id, 'slug' => $job->artwork->slug]),
] : null,
];
}
private function serializeJobDetail(EnhanceJob $job): array
{
return $this->serializeJobListItem($job) + [
'input_filesize' => (int) ($job->input_filesize ?? 0),
'input_mime' => $job->input_mime,
'output_filesize' => (int) ($job->output_filesize ?? 0),
'output_mime' => $job->output_mime,
'metadata' => $job->metadata ?? [],
'queued_at' => optional($job->queued_at)?->toIso8601String(),
'started_at' => optional($job->started_at)?->toIso8601String(),
'deleted_at' => optional($job->deleted_at)?->toIso8601String(),
'expires_at' => optional($job->expires_at)?->toIso8601String(),
'retry_url' => route('enhance.retry', ['enhanceJob' => $job]),
'delete_url' => route('enhance.destroy', ['enhanceJob' => $job]),
'download_url' => $job->outputUrl(),
'can_retry' => auth()->user()?->can('retry', $job) ?? false,
'can_delete' => auth()->user()?->can('delete', $job) ?? false,
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Internal;
use App\Http\Controllers\Controller;
use App\Models\EnhanceJob;
use App\Services\Enhance\EnhanceStorageService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Throwable;
final class EnhanceSourceController extends Controller
{
public function __construct(
private readonly EnhanceStorageService $storage,
) {
}
public function show(Request $request, EnhanceJob $enhanceJob): Response
{
abort_unless($request->hasValidSignature(), 403);
abort_unless($this->storage->isEnhancePath($enhanceJob->source_path), 404);
try {
$binary = $this->storage->fetchSourceBinary($enhanceJob);
} catch (Throwable) {
abort(404);
}
return response($binary, 200, [
'Content-Type' => trim((string) ($enhanceJob->input_mime ?: 'application/octet-stream')),
'Content-Length' => (string) strlen($binary),
'Cache-Control' => 'private, max-age=60',
'Content-Disposition' => 'inline; filename="enhance-source-' . $enhanceJob->id . '"',
]);
}
}

View File

@@ -79,19 +79,27 @@ class UserController extends Controller
}
}
$allowedLegacyMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if ($request->hasFile('personal_picture')) {
$f = $request->file('personal_picture');
$name = $user->id . '.' . $f->getClientOriginalExtension();
$f->move(public_path('user-picture'), $name);
$profileUpdates['cover_image'] = $name;
$user->picture = $name;
if (in_array($f->getMimeType(), $allowedLegacyMimes, true)) {
$ext = $f->guessExtension() ?: 'jpg';
$name = $user->id . '.' . $ext;
$f->move(public_path('user-picture'), $name);
$profileUpdates['cover_image'] = $name;
$user->picture = $name;
}
}
if ($request->hasFile('emotion_icon')) {
$f = $request->file('emotion_icon');
$name = $user->id . '.' . $f->getClientOriginalExtension();
$f->move(public_path('emotion'), $name);
$user->eicon = $name;
if (in_array($f->getMimeType(), $allowedLegacyMimes, true)) {
$ext = $f->guessExtension() ?: 'jpg';
$name = $user->id . '.' . $ext;
$f->move(public_path('emotion'), $name);
$user->eicon = $name;
}
}
// Save core user fields

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Moderation;
use App\Http\Controllers\Controller;
use App\Models\EnhanceJob;
use App\Services\Enhance\EnhanceService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class ModerationEnhanceController extends Controller
{
public function __construct(
private readonly EnhanceService $enhanceService,
) {
}
public function index(Request $request): Response
{
$filters = [
'status' => trim((string) $request->query('status', 'all')),
'engine' => trim((string) $request->query('engine', 'all')),
'mode' => trim((string) $request->query('mode', 'all')),
'scale' => trim((string) $request->query('scale', 'all')),
'user' => trim((string) $request->query('user', '')),
'date_from' => trim((string) $request->query('date_from', '')),
'date_to' => trim((string) $request->query('date_to', '')),
];
$jobs = EnhanceJob::query()
->with(['user:id,name,username', 'artwork:id,title,slug'])
->when($filters['status'] !== '' && $filters['status'] !== 'all', fn ($query) => $query->where('status', $filters['status']))
->when($filters['engine'] !== '' && $filters['engine'] !== 'all', fn ($query) => $query->where('engine', $filters['engine']))
->when($filters['mode'] !== '' && $filters['mode'] !== 'all', fn ($query) => $query->where('mode', $filters['mode']))
->when($filters['scale'] !== '' && $filters['scale'] !== 'all', fn ($query) => $query->where('scale', (int) $filters['scale']))
->when($filters['user'] !== '', function ($query) use ($filters): void {
$query->whereHas('user', function ($userQuery) use ($filters): void {
$userQuery
->where('name', 'like', '%' . $filters['user'] . '%')
->orWhere('username', 'like', '%' . $filters['user'] . '%');
});
})
->when($filters['date_from'] !== '', fn ($query) => $query->whereDate('created_at', '>=', $filters['date_from']))
->when($filters['date_to'] !== '', fn ($query) => $query->whereDate('created_at', '<=', $filters['date_to']))
->latest('id')
->paginate(20)
->withQueryString()
->through(fn (EnhanceJob $job): array => $this->serializeJob($job));
return Inertia::render('Moderation/Enhance/Index', [
'title' => 'Enhance Jobs',
'jobs' => $jobs,
'filters' => $filters,
'options' => [
'statuses' => ['all', 'pending', 'queued', 'processing', 'completed', 'failed', 'cancelled', 'expired'],
'engines' => ['all', 'stub', 'external_worker'],
'modes' => array_merge(['all'], (array) config('enhance.allowed_modes', [])),
'scales' => array_merge(['all'], array_map('intval', (array) config('enhance.allowed_scales', []))),
],
'indexUrl' => route('admin.enhance.index'),
'enhanceConfig' => $this->enhanceService->frontendConfig(),
])->rootView('moderation');
}
public function show(EnhanceJob $enhanceJob): Response
{
$enhanceJob->loadMissing(['user:id,name,username', 'artwork:id,title,slug']);
return Inertia::render('Moderation/Enhance/Show', [
'title' => 'Enhance Job #' . $enhanceJob->id,
'job' => $this->serializeJob($enhanceJob, true),
'indexUrl' => route('admin.enhance.index'),
'enhanceConfig' => $this->enhanceService->frontendConfig(),
])->rootView('moderation');
}
public function retry(EnhanceJob $enhanceJob): RedirectResponse
{
$this->authorize('retry', $enhanceJob);
$job = $this->enhanceService->retry($enhanceJob);
return redirect()
->route('admin.enhance.show', ['enhanceJob' => $job])
->with('success', 'Enhance job queued again.');
}
public function markFailed(Request $request, EnhanceJob $enhanceJob): RedirectResponse
{
$this->authorize('markFailed', $enhanceJob);
$job = $this->enhanceService->markFailedByModerator($enhanceJob, $request->user());
return redirect()
->route('admin.enhance.show', ['enhanceJob' => $job])
->with('success', 'Enhance job marked as failed.');
}
public function destroy(EnhanceJob $enhanceJob): RedirectResponse
{
$this->authorize('delete', $enhanceJob);
$this->enhanceService->delete($enhanceJob);
return redirect()
->route('admin.enhance.index')
->with('success', 'Enhance job deleted.');
}
private function serializeJob(EnhanceJob $job, bool $detailed = false): array
{
return [
'id' => $job->id,
'status' => (string) $job->status,
'engine' => (string) $job->engine,
'mode' => (string) $job->mode,
'scale' => (int) $job->scale,
'source_url' => $job->sourceUrl(),
'output_url' => $job->outputUrl(),
'preview_url' => $job->previewUrl(),
'input_width' => (int) ($job->input_width ?? 0),
'input_height' => (int) ($job->input_height ?? 0),
'input_filesize' => (int) ($job->input_filesize ?? 0),
'input_mime' => $job->input_mime,
'output_width' => (int) ($job->output_width ?? 0),
'output_height' => (int) ($job->output_height ?? 0),
'output_filesize' => (int) ($job->output_filesize ?? 0),
'output_mime' => $job->output_mime,
'processing_seconds' => $job->processing_seconds,
'error_message' => $job->error_message,
'metadata' => $job->metadata ?? [],
'created_at' => optional($job->created_at)?->toIso8601String(),
'queued_at' => optional($job->queued_at)?->toIso8601String(),
'started_at' => optional($job->started_at)?->toIso8601String(),
'finished_at' => optional($job->finished_at)?->toIso8601String(),
'expires_at' => optional($job->expires_at)?->toIso8601String(),
'user' => $job->user ? [
'id' => $job->user->id,
'name' => $job->user->name,
'username' => $job->user->username,
] : null,
'artwork' => $job->artwork ? [
'id' => $job->artwork->id,
'title' => $job->artwork->title,
'slug' => $job->artwork->slug,
'url' => route('art.show', ['id' => $job->artwork->id, 'slug' => $job->artwork->slug]),
] : null,
'show_url' => route('admin.enhance.show', ['enhanceJob' => $job]),
'download_url' => $job->outputUrl(),
'retry_url' => route('admin.enhance.retry', ['enhanceJob' => $job]),
'mark_failed_url' => route('admin.enhance.mark-failed', ['enhanceJob' => $job]),
'delete_url' => route('admin.enhance.destroy', ['enhanceJob' => $job]),
'can_retry' => $job->status === EnhanceJob::STATUS_FAILED,
'can_mark_failed' => in_array($job->status, [EnhanceJob::STATUS_PENDING, EnhanceJob::STATUS_QUEUED, EnhanceJob::STATUS_PROCESSING], true),
'detailed' => $detailed,
];
}
}

View File

@@ -132,6 +132,32 @@ class NewsController extends Controller
] + $this->sidebarData());
}
// -----------------------------------------------------------------------
// Type page — /news/type/{type}
// -----------------------------------------------------------------------
public function type(Request $request, string $type): View
{
$typeLabels = \cPad\Plugins\News\Models\NewsArticle::TYPE_LABELS;
abort_unless(array_key_exists($type, $typeLabels), 404);
$label = $typeLabels[$type];
$perPage = config('news.articles_per_page', 12);
$articles = NewsArticle::with('author', 'category')
->published()
->where('type', $type)
->editorialOrder()
->paginate($perPage);
return view('news.type', [
'type' => $type,
'typeLabel' => $label,
'articles' => $articles,
] + $this->sidebarData());
}
// -----------------------------------------------------------------------
// Article page — /news/{slug}
// -----------------------------------------------------------------------
@@ -173,14 +199,21 @@ class NewsController extends Controller
return;
}
NewsView::create([
'article_id' => $article->id,
'user_id' => $userId,
'ip' => $ip,
'created_at' => now(),
]);
try {
NewsView::create([
'article_id' => $article->id,
'user_id' => $userId,
'ip' => $ip,
'created_at' => now(),
]);
$article->incrementViews();
$article->incrementViews();
} catch (\Illuminate\Database\QueryException $e) {
// Unique constraint violation — duplicate view, skip silently.
if (($e->errorInfo[1] ?? 0) !== 1062) {
throw $e;
}
}
if ($canReadSession) {
$request->session()->put($session, true);

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\News;
use App\Http\Controllers\Controller;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use cPad\Plugins\News\Models\NewsArticle;
class NewsRssController extends Controller
@@ -14,13 +15,17 @@ class NewsRssController extends Controller
*/
public function feed(): Response
{
$articles = NewsArticle::with('author', 'category')
->published()
->orderByDesc('published_at')
->limit(config('news.rss_limit', 25))
->get();
$ttl = max(60, (int) config('news.rss_cache_ttl', 300));
$xml = $this->buildRss($articles);
$xml = Cache::remember('news.rss.feed', $ttl, function (): string {
$articles = NewsArticle::with('author', 'category')
->published()
->orderByDesc('published_at')
->limit(config('news.rss_limit', 25))
->get();
return $this->buildRss($articles);
});
return response($xml, 200, [
'Content-Type' => 'application/rss+xml; charset=UTF-8',

View File

@@ -6,11 +6,13 @@ namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\AcademyContentMetricDaily;
use App\Models\AcademyEvent;
use App\Models\AcademySearchLog;
use App\Services\Academy\AcademyAnalyticsContentResolver;
use App\Services\Academy\AcademyContentIntelligenceService;
use App\Services\Academy\AcademyPopularityService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\AcademyAnalytics\AcademyAnalyticsEventType;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
@@ -29,6 +31,9 @@ final class AcademyAdminAnalyticsController extends Controller
public function overview(Request $request): Response
{
[$from, $to, $range] = $this->resolveDateRange($request);
$promptLibraryCurrent = $this->contentSummary(AcademyAnalyticsContentType::PROMPT_LIBRARY, $from, $to);
[$previousFrom, $previousTo] = $this->previousRange($from, $to);
$promptLibraryPrevious = $this->contentSummary(AcademyAnalyticsContentType::PROMPT_LIBRARY, $previousFrom, $previousTo);
$summary = $this->metricsQuery($from, $to)
->selectRaw('sum(views) as views, sum(unique_visitors) as unique_visitors, sum(user_views) as user_views, sum(guest_views) as guest_views, sum(subscriber_views) as subscriber_views, sum(prompt_copies) as prompt_copies, sum(likes) as likes, sum(saves) as saves, sum(completions) as completions, sum(starts) as starts, sum(upgrade_clicks) as upgrade_clicks')
@@ -50,6 +55,21 @@ final class AcademyAdminAnalyticsController extends Controller
'courseStarts' => (int) ($summary?->starts ?? 0),
'upgradeClicks' => (int) ($summary?->upgrade_clicks ?? 0),
],
'promptLibraryTrend' => [
'current' => $promptLibraryCurrent,
'previous' => $promptLibraryPrevious,
'deltas' => [
'views' => $this->percentDelta((int) $promptLibraryCurrent['views'], (int) $promptLibraryPrevious['views']),
'uniqueVisitors' => $this->percentDelta((int) $promptLibraryCurrent['uniqueVisitors'], (int) $promptLibraryPrevious['uniqueVisitors']),
'engagedViews' => $this->percentDelta((int) $promptLibraryCurrent['engagedViews'], (int) $promptLibraryPrevious['engagedViews']),
'engagementRate' => $this->percentDelta((float) $promptLibraryCurrent['engagementRate'], (float) $promptLibraryPrevious['engagementRate']),
],
'range' => [
'current' => ['from' => $from->toDateString(), 'to' => $to->toDateString()],
'previous' => ['from' => $previousFrom->toDateString(), 'to' => $previousTo->toDateString()],
],
],
'popularPromptPeriodUsage' => $this->popularPromptPeriodUsage($from, $to),
'topContent' => $this->serializeContentRows($this->popularity->topContent($from, $to, 8)),
'topWeek' => $this->serializeContentRows($this->popularity->topContent(now()->subDays(6)->startOfDay(), now()->endOfDay(), 8)),
]);
@@ -65,6 +85,11 @@ final class AcademyAdminAnalyticsController extends Controller
return $this->renderContentPage($request, AcademyAnalyticsContentType::PROMPT, 'Prompt analytics', 'Copy-heavy prompt performance, save rates, and upgrade interest.');
}
public function promptLibrary(Request $request): Response
{
return $this->renderContentPage($request, AcademyAnalyticsContentType::PROMPT_LIBRARY, 'Prompt library analytics', 'Discovery and engagement on the public /academy/prompts library page.');
}
public function lessons(Request $request): Response
{
return $this->renderContentPage($request, AcademyAnalyticsContentType::LESSON, 'Lesson analytics', 'Lesson engagement, starts, completions, and drop-off signals.');
@@ -333,9 +358,14 @@ final class AcademyAdminAnalyticsController extends Controller
'access' => $access,
'content_type' => $contentType,
],
'summary' => $contentType === AcademyAnalyticsContentType::PROMPT_LIBRARY
? $this->contentSummary(AcademyAnalyticsContentType::PROMPT_LIBRARY, $from, $to)
: null,
'rows' => $serializedRows,
'contentTypeOptions' => [
['value' => '', 'label' => 'All content'],
['value' => AcademyAnalyticsContentType::PROMPT_LIBRARY, 'label' => 'Prompt library'],
['value' => AcademyAnalyticsContentType::PROMPT_PACK_LIBRARY, 'label' => 'Prompt pack library'],
['value' => AcademyAnalyticsContentType::PROMPT, 'label' => 'Prompts'],
['value' => AcademyAnalyticsContentType::LESSON, 'label' => 'Lessons'],
['value' => AcademyAnalyticsContentType::COURSE, 'label' => 'Courses'],
@@ -359,7 +389,134 @@ final class AcademyAdminAnalyticsController extends Controller
private function metricsQuery(Carbon $from, Carbon $to)
{
return AcademyContentMetricDaily::query()
->whereBetween('date', [$from->toDateString(), $to->toDateString()]);
->whereBetween('date', [$from->copy()->startOfDay(), $to->copy()->endOfDay()]);
}
/**
* @return array<string, int|float>
*/
private function contentSummary(string $contentType, Carbon $from, Carbon $to): array
{
$query = $this->metricsQuery($from, $to)
->where('content_type', $contentType);
if (! AcademyAnalyticsContentType::requiresContentId($contentType)) {
$query->whereNull('content_id');
}
$summary = $query
->selectRaw('sum(views) as views, sum(unique_visitors) as unique_visitors, sum(engaged_views) as engaged_views, sum(scroll_50) as scroll_50, sum(scroll_75) as scroll_75, sum(scroll_100) as scroll_100, avg(avg_engaged_seconds) as avg_engaged_seconds, sum(popularity_score) as popularity_score')
->first();
$uniqueVisitors = max(0, (int) ($summary?->unique_visitors ?? 0));
$engagedViews = max(0, (int) ($summary?->engaged_views ?? 0));
$scroll100 = max(0, (int) ($summary?->scroll_100 ?? 0));
return [
'views' => max(0, (int) ($summary?->views ?? 0)),
'uniqueVisitors' => $uniqueVisitors,
'engagedViews' => $engagedViews,
'scroll50' => max(0, (int) ($summary?->scroll_50 ?? 0)),
'scroll75' => max(0, (int) ($summary?->scroll_75 ?? 0)),
'scroll100' => $scroll100,
'avgEngagedSeconds' => round((float) ($summary?->avg_engaged_seconds ?? 0), 1),
'popularityScore' => round((float) ($summary?->popularity_score ?? 0), 2),
'engagementRate' => $uniqueVisitors > 0 ? round(($engagedViews / $uniqueVisitors) * 100, 1) : 0.0,
'deepScrollRate' => $uniqueVisitors > 0 ? round(($scroll100 / $uniqueVisitors) * 100, 1) : 0.0,
];
}
/**
* @return array{0: Carbon, 1: Carbon}
*/
private function previousRange(Carbon $from, Carbon $to): array
{
$days = $from->copy()->startOfDay()->diffInDays($to->copy()->startOfDay()) + 1;
return [
$from->copy()->subDays($days)->startOfDay(),
$from->copy()->subDay()->endOfDay(),
];
}
private function percentDelta(int|float $current, int|float $previous): ?float
{
if ((float) $previous === 0.0) {
return (float) $current === 0.0 ? 0.0 : null;
}
return round((((float) $current - (float) $previous) / (float) $previous) * 100, 1);
}
/**
* @return array{totalViews:int,totalVisitors:int,periods:list<array<string,int|float|string>>}
*/
private function popularPromptPeriodUsage(Carbon $from, Carbon $to): array
{
$events = AcademyEvent::query()
->whereBetween('occurred_at', [$from, $to])
->where('event_type', AcademyAnalyticsEventType::PAGE_VIEW)
->where('content_type', AcademyAnalyticsContentType::PROMPT_POPULAR)
->get(['visitor_id', 'metadata']);
$summary = [];
$totalViews = 0;
$visitorBuckets = [];
foreach ($events as $event) {
$metadata = is_array($event->metadata) ? $event->metadata : [];
$period = trim((string) ($metadata['period'] ?? ''));
if ($period === '') {
continue;
}
$days = max(0, (int) ($metadata['period_days'] ?? 0));
if (! isset($summary[$period])) {
$summary[$period] = [
'period' => $period,
'label' => sprintf('%s days', $days > 0 ? $days : (int) preg_replace('/\D+/', '', $period)),
'views' => 0,
'uniqueVisitors' => 0,
'share' => 0.0,
'days' => $days,
];
$visitorBuckets[$period] = [];
}
$summary[$period]['views']++;
$totalViews++;
$visitorId = trim((string) ($event->visitor_id ?? ''));
if ($visitorId !== '') {
$visitorBuckets[$period][$visitorId] = true;
}
}
$totalVisitors = 0;
foreach ($summary as $period => &$row) {
$uniqueVisitors = count($visitorBuckets[$period] ?? []);
$row['uniqueVisitors'] = $uniqueVisitors;
$row['share'] = $totalViews > 0 ? round((((int) $row['views']) / $totalViews) * 100, 1) : 0.0;
$totalVisitors += $uniqueVisitors;
}
unset($row);
usort($summary, static function (array $left, array $right): int {
if ((int) $right['views'] === (int) $left['views']) {
return ((int) $left['days']) <=> ((int) $right['days']);
}
return ((int) $right['views']) <=> ((int) $left['views']);
});
return [
'totalViews' => $totalViews,
'totalVisitors' => $totalVisitors,
'periods' => array_values($summary),
];
}
/**
@@ -440,6 +597,7 @@ final class AcademyAdminAnalyticsController extends Controller
['label' => 'Overview', 'href' => route('admin.academy.analytics.overview')],
['label' => 'Intelligence', 'href' => route('admin.academy.analytics.intelligence')],
['label' => 'Content', 'href' => route('admin.academy.analytics.content')],
['label' => 'Prompt Library', 'href' => route('admin.academy.analytics.prompt-library')],
['label' => 'Prompts', 'href' => route('admin.academy.analytics.prompts')],
['label' => 'Lessons', 'href' => route('admin.academy.analytics.lessons')],
['label' => 'Courses', 'href' => route('admin.academy.analytics.courses')],

View File

@@ -18,6 +18,7 @@ use App\Services\TagService;
use App\Services\ArtworkVersioningService;
use App\Services\Studio\StudioArtworkQueryService;
use App\Services\Studio\StudioBulkActionService;
use App\Support\ArtworkDescriptionContentValidator;
use App\Services\Tags\TagDiscoveryService;
use App\Services\Worlds\WorldSubmissionService;
use Carbon\Carbon;
@@ -164,6 +165,8 @@ final class StudioArtworksApiController extends Controller
'evolution_note' => 'sometimes|nullable|string|max:1200',
]);
$this->ensureValidArtworkDescription($validated);
$hasAttributionUpdates = array_key_exists('group', $validated)
|| array_key_exists('primary_author_user_id', $validated)
|| array_key_exists('contributor_user_ids', $validated)
@@ -326,6 +329,15 @@ final class StudioArtworksApiController extends Controller
]);
}
private function ensureValidArtworkDescription(array $validated): void
{
foreach (ArtworkDescriptionContentValidator::errors($validated['description'] ?? null) as $message) {
throw ValidationException::withMessages([
'description' => [$message],
]);
}
}
public function evolutionOptions(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->findOrFail($id);

View File

@@ -95,7 +95,13 @@ final class StudioController extends Controller
{
$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', 'content_type', 'category', 'tag']), null, 'artworks');
$filters = $request->only(['q', 'sort', 'bucket', 'page', 'per_page', 'content_type', 'category', 'tag']);
if (! $request->filled('sort')) {
$filters['sort'] = 'published_desc';
}
$listing = $this->content->list($request->user(), $filters, null, 'artworks');
$listing['default_view'] = $prefs['default_content_view'];
return Inertia::render('Studio/StudioArtworks', [

View File

@@ -377,10 +377,41 @@ final class StudioNewsController extends Controller
'og_image' => ['nullable', 'string', 'max:2048'],
'relations' => ['nullable', 'array', 'max:12'],
'relations.*.entity_type' => ['required_with:relations', Rule::in(array_column($this->news->relationTypeOptions(), 'value'))],
'relations.*.entity_id' => ['required_with:relations', 'integer', 'min:1'],
'relations.*.entity_id' => ['nullable', 'integer', 'min:1'],
'relations.*.external_url' => ['nullable', 'string', 'max:2048'],
'relations.*.context_label' => ['nullable', 'string', 'max:120'],
]);
$relationErrors = [];
foreach ((array) ($validated['relations'] ?? []) as $index => $relation) {
$entityType = trim(Str::lower((string) ($relation['entity_type'] ?? '')));
if ($entityType === NewsService::RELATION_SOURCE) {
$externalUrl = $this->normalizeExternalRelationUrl($relation['external_url'] ?? null);
if ($externalUrl === null) {
$relationErrors["relations.{$index}.external_url"] = 'Source relations need a valid URL.';
continue;
}
$validated['relations'][$index]['entity_id'] = null;
$validated['relations'][$index]['external_url'] = $externalUrl;
continue;
}
if ((int) ($relation['entity_id'] ?? 0) < 1) {
$relationErrors["relations.{$index}.entity_id"] = 'Select a related entity.';
}
$validated['relations'][$index]['external_url'] = null;
}
if ($relationErrors !== []) {
throw ValidationException::withMessages($relationErrors);
}
if (($validated['editorial_status'] ?? null) === NewsArticle::EDITORIAL_STATUS_SCHEDULED && empty($validated['published_at'])) {
throw ValidationException::withMessages([
'published_at' => 'Scheduled articles need a publish date and time.',
@@ -390,6 +421,25 @@ final class StudioNewsController extends Controller
return $validated;
}
private function normalizeExternalRelationUrl(mixed $value): ?string
{
$url = trim((string) ($value ?? ''));
if ($url === '') {
return null;
}
if (preg_match('/^\[[^\]]+\]\((https?:\/\/[^)]+)\)$/i', $url, $matches) === 1) {
$url = trim((string) ($matches[1] ?? ''));
}
if ($url === '' || filter_var($url, FILTER_VALIDATE_URL) === false) {
return null;
}
return Str::limit($url, 2048, '');
}
private function tagPayload(): array
{
return NewsTag::query()

View File

@@ -46,6 +46,7 @@ final class StudioNewsMediaApiController extends Controller
'size_bytes' => $stored['size_bytes'],
'mobile_url' => $stored['mobile_url'],
'desktop_url' => $stored['desktop_url'],
'large_url' => $stored['large_url'],
'srcset' => $stored['srcset'],
]);
} catch (RuntimeException $e) {

View File

@@ -855,25 +855,33 @@ class ProfileController extends Controller
}
}
$allowedImageMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if ($request->hasFile('emoticon')) {
$file = $request->file('emoticon');
$fname = $file->getClientOriginalName();
$path = \Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-emoticons/'.$user->id, $file, $fname);
try {
\Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['eicon' => $fname]);
} catch (\Exception $e) {}
if (in_array($file->getMimeType(), $allowedImageMimes, true)) {
$ext = $file->guessExtension() ?: 'jpg';
$fname = $user->id . '_emoticon_' . time() . '.' . $ext;
\Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-emoticons/'.$user->id, $file, $fname);
try {
\Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['eicon' => $fname]);
} catch (\Exception $e) {}
}
}
if ($request->hasFile('photo')) {
$file = $request->file('photo');
$fname = $file->getClientOriginalName();
$path = \Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-picture/'.$user->id, $file, $fname);
if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) {
$profileUpdates['cover_image'] = $fname;
} else {
try {
\Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['picture' => $fname]);
} catch (\Exception $e) {}
if (in_array($file->getMimeType(), $allowedImageMimes, true)) {
$ext = $file->guessExtension() ?: 'jpg';
$fname = $user->id . '_photo_' . time() . '.' . $ext;
\Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-picture/'.$user->id, $file, $fname);
if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) {
$profileUpdates['cover_image'] = $fname;
} else {
try {
\Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['picture' => $fname]);
} catch (\Exception $e) {}
}
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
return $response;
}
}

View File

@@ -111,7 +111,7 @@ class UpsertAcademyLessonRequest extends FormRequest
'cover_image' => ['nullable', 'string', 'max:2048'],
'article_cover_image' => ['nullable', 'string', 'max:2048'],
'tags' => ['nullable', 'array'],
'tags.*' => ['string', 'max:100'],
'tags.*' => ['string', 'max:200'],
'video_url' => ['nullable', 'string', 'max:2048'],
'reading_minutes' => ['required', 'integer', 'min:1', 'max:999'],
'featured' => ['required', 'boolean'],

View File

@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Http\Requests\Artworks;
use App\Support\ArtworkDescriptionContentValidator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class ArtworkCreateRequest extends FormRequest
@@ -32,6 +34,15 @@ final class ArtworkCreateRequest extends FormRequest
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
foreach (ArtworkDescriptionContentValidator::errors($this->input('description')) as $message) {
$validator->errors()->add('description', $message);
}
});
}
private function denyAsNotFound(): void
{
throw new NotFoundHttpException();

View File

@@ -3,7 +3,9 @@
namespace App\Http\Requests\Dashboard;
use App\Models\Artwork;
use App\Support\ArtworkDescriptionContentValidator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class UpdateArtworkRequest extends FormRequest
@@ -45,6 +47,15 @@ class UpdateArtworkRequest extends FormRequest
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
foreach (ArtworkDescriptionContentValidator::errors($this->input('description')) as $message) {
$validator->errors()->add('description', $message);
}
});
}
public function artwork(): Artwork
{
if (! $this->artwork) {

View File

@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\Http\Requests\Manage;
use App\Support\ArtworkDescriptionContentValidator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Validator;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class ManageArtworkUpdateRequest extends FormRequest
@@ -48,6 +50,15 @@ final class ManageArtworkUpdateRequest extends FormRequest
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
foreach (ArtworkDescriptionContentValidator::errors($this->input('description')) as $message) {
$validator->errors()->add('description', $message);
}
});
}
public function artwork(): object
{
if (! $this->artwork) {

View File

@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\Http\Requests\Studio;
use App\Support\ArtworkDescriptionContentValidator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
final class ApplyArtworkAiAssistRequest extends FormRequest
{
@@ -31,4 +33,13 @@ final class ApplyArtworkAiAssistRequest extends FormRequest
'similar_actions.*.state' => ['required_with:similar_actions', Rule::in(['ignored', 'reviewed'])],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
foreach (ArtworkDescriptionContentValidator::errors($this->input('description')) as $message) {
$validator->errors()->add('description', $message);
}
});
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Enhance;
use App\Models\EnhanceJob;
use App\Services\Enhance\EnhanceProcessorFactory;
use App\Services\Enhance\EnhanceStorageService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;
final class ProcessEnhanceJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 2;
public int $timeout = 300;
public function __construct(
private readonly int $enhanceJobId,
) {
$queue = (string) config('enhance.queue', 'default');
if ($queue !== '') {
$this->onQueue($queue);
}
}
public function handle(EnhanceProcessorFactory $factory, EnhanceStorageService $storage): void
{
$enhanceJob = EnhanceJob::query()->find($this->enhanceJobId);
if (! $enhanceJob instanceof EnhanceJob) {
return;
}
if (! in_array($enhanceJob->status, [EnhanceJob::STATUS_PENDING, EnhanceJob::STATUS_QUEUED, EnhanceJob::STATUS_PROCESSING, EnhanceJob::STATUS_FAILED], true)) {
return;
}
$enhanceJob->forceFill([
'status' => EnhanceJob::STATUS_PROCESSING,
'started_at' => now(),
'finished_at' => null,
'error_message' => null,
])->save();
Log::info('enhance.job.processing', [
'enhance_job_id' => $enhanceJob->id,
'user_id' => $enhanceJob->user_id,
'engine' => $enhanceJob->engine,
]);
$started = microtime(true);
$completedExpiryDays = (int) config('enhance.lifecycle.completed_expires_after_days', 30);
try {
$processor = $factory->make((string) $enhanceJob->engine);
$result = $processor->process($enhanceJob);
$preview = $storage->createPreviewFromStoredOutput($enhanceJob, $result->disk, $result->path) ?? [];
$outputHash = null;
$outputContents = Storage::disk($result->disk)->get($result->path);
if (is_string($outputContents) && $outputContents !== '') {
$outputHash = hash('sha256', $outputContents);
}
$enhanceJob->forceFill([
'status' => EnhanceJob::STATUS_COMPLETED,
'output_disk' => $result->disk,
'output_path' => $result->path,
'output_hash' => $outputHash,
'output_width' => $result->width,
'output_height' => $result->height,
'output_filesize' => $result->filesize,
'output_mime' => $result->mime,
'metadata' => array_merge($enhanceJob->metadata ?? [], $result->metadata ?? []),
'processing_seconds' => (int) round(microtime(true) - $started),
'finished_at' => now(),
'expires_at' => $completedExpiryDays > 0 ? now()->addDays($completedExpiryDays) : null,
] + $preview)->save();
Log::info('enhance.job.completed', [
'enhance_job_id' => $enhanceJob->id,
'user_id' => $enhanceJob->user_id,
'processing_seconds' => $enhanceJob->processing_seconds,
]);
} catch (Throwable $exception) {
report($exception);
$enhanceJob->forceFill([
'status' => EnhanceJob::STATUS_FAILED,
'error_message' => Str::limit($exception->getMessage(), 1000),
'processing_seconds' => (int) round(microtime(true) - $started),
'finished_at' => now(),
])->save();
Log::warning('enhance.job.failed', [
'enhance_job_id' => $enhanceJob->id,
'user_id' => $enhanceJob->user_id,
'message' => $exception->getMessage(),
]);
throw $exception;
}
}
}

157
app/Models/EnhanceJob.php Normal file
View File

@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
final class EnhanceJob extends Model
{
use HasFactory;
use SoftDeletes;
public const STATUS_PENDING = 'pending';
public const STATUS_QUEUED = 'queued';
public const STATUS_PROCESSING = 'processing';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_EXPIRED = 'expired';
public const ENGINE_STUB = 'stub';
public const ENGINE_EXTERNAL_WORKER = 'external_worker';
protected $fillable = [
'user_id',
'artwork_id',
'status',
'engine',
'mode',
'scale',
'source_disk',
'source_path',
'source_hash',
'input_width',
'input_height',
'input_filesize',
'input_mime',
'output_disk',
'output_path',
'output_hash',
'output_width',
'output_height',
'output_filesize',
'output_mime',
'preview_disk',
'preview_path',
'processing_seconds',
'error_message',
'metadata',
'queued_at',
'started_at',
'finished_at',
'expires_at',
];
protected $casts = [
'metadata' => 'array',
'queued_at' => 'datetime',
'started_at' => 'datetime',
'finished_at' => 'datetime',
'expires_at' => 'datetime',
'deleted_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function isPending(): bool
{
return $this->status === self::STATUS_PENDING;
}
public function isQueued(): bool
{
return $this->status === self::STATUS_QUEUED;
}
public function isProcessing(): bool
{
return $this->status === self::STATUS_PROCESSING;
}
public function isCompleted(): bool
{
return $this->status === self::STATUS_COMPLETED;
}
public function isFailed(): bool
{
return $this->status === self::STATUS_FAILED;
}
public function canBeDeletedBy(User $user): bool
{
if ($user->isAdmin() || $user->isModerator()) {
return true;
}
return (int) $this->user_id === (int) $user->id
&& in_array($this->status, [self::STATUS_PENDING, self::STATUS_FAILED, self::STATUS_COMPLETED, self::STATUS_CANCELLED, self::STATUS_EXPIRED], true);
}
public function sourceUrl(): ?string
{
return $this->resolveDiskUrl($this->source_disk, $this->source_path);
}
public function outputUrl(): ?string
{
return $this->resolveDiskUrl($this->output_disk, $this->output_path);
}
public function previewUrl(): ?string
{
return $this->resolveDiskUrl($this->preview_disk, $this->preview_path);
}
private function resolveDiskUrl(?string $disk, ?string $path): ?string
{
$trimmedPath = ltrim(trim((string) $path), '/');
if ($trimmedPath === '') {
return null;
}
$configuredDisk = trim((string) config('enhance.disk', 'public'));
$targetDisk = trim((string) $disk) ?: $configuredDisk ?: 'public';
// For non-local disks (e.g. S3-backed), construct the CDN URL directly.
// For local disks ('public', 'local') fall through to Storage::disk()->url()
// so that the correct APP_URL-based path is returned in non-CDN environments.
$base = rtrim((string) config('cdn.files_url', ''), '/');
if ($base !== '' && $targetDisk === $configuredDisk && ! in_array($targetDisk, ['public', 'local'], true)) {
return $base . '/' . $trimmedPath;
}
$url = Storage::disk($targetDisk)->url($trimmedPath);
if (str_starts_with($url, 'http://') || str_starts_with($url, 'https://')) {
return $url;
}
return url($url);
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\EnhanceJob;
use App\Models\User;
final class EnhanceJobPolicy
{
public function before(?User $user, string $ability): ?bool
{
if ($user && ($user->isAdmin() || $user->isModerator())) {
return true;
}
return null;
}
public function viewAny(?User $user): bool
{
return $user !== null;
}
public function view(User $user, EnhanceJob $enhanceJob): bool
{
return (int) $enhanceJob->user_id === (int) $user->id;
}
public function create(?User $user): bool
{
if ($user === null) {
return false;
}
return ! method_exists($user, 'hasVerifiedEmail') || $user->hasVerifiedEmail();
}
public function delete(User $user, EnhanceJob $enhanceJob): bool
{
return $enhanceJob->canBeDeletedBy($user);
}
public function retry(User $user, EnhanceJob $enhanceJob): bool
{
return (int) $enhanceJob->user_id === (int) $user->id
&& $enhanceJob->isFailed();
}
public function markFailed(User $user, EnhanceJob $enhanceJob): bool
{
return false;
}
}

View File

@@ -11,6 +11,7 @@ use App\Models\AcademyChallengeSubmission;
use App\Models\AcademyLesson;
use App\Models\AcademyPromptPack;
use App\Models\AcademyPromptTemplate;
use App\Models\EnhanceJob;
use App\Models\Collection;
use App\Models\Group;
use App\Models\NovaCard;
@@ -25,6 +26,7 @@ use App\Policies\AcademyChallengeSubmissionPolicy;
use App\Policies\AcademyLessonPolicy;
use App\Policies\AcademyPromptPackPolicy;
use App\Policies\AcademyPromptTemplatePolicy;
use App\Policies\EnhanceJobPolicy;
use App\Policies\CollectionPolicy;
use App\Policies\GroupPolicy;
use App\Policies\NovaCardPolicy;
@@ -43,6 +45,7 @@ class AuthServiceProvider extends ServiceProvider
AcademyLesson::class => AcademyLessonPolicy::class,
AcademyPromptPack::class => AcademyPromptPackPolicy::class,
AcademyPromptTemplate::class => AcademyPromptTemplatePolicy::class,
EnhanceJob::class => EnhanceJobPolicy::class,
Collection::class => CollectionPolicy::class,
Group::class => GroupPolicy::class,
NovaCard::class => NovaCardPolicy::class,

View File

@@ -92,6 +92,121 @@ final class AcademyAccessService
return $this->activeAcademySubscription($user) instanceof Subscription;
}
/**
* @return array<string, mixed>
*/
public function accessSummary(?User $user): array
{
if (! $user instanceof User) {
return [
'signedIn' => false,
'tier' => 'free',
'tierLabel' => 'Guest',
'hasPaidAccess' => false,
'status' => 'guest',
'statusLabel' => 'Preview access only',
'expiresAt' => null,
'dateLabel' => null,
'renewsAutomatically' => false,
'source' => 'none',
];
}
if ($this->isAcademyAdmin($user)) {
return [
'signedIn' => true,
'tier' => 'admin',
'tierLabel' => 'Admin',
'hasPaidAccess' => true,
'status' => 'staff_access',
'statusLabel' => 'Full staff access',
'expiresAt' => null,
'dateLabel' => null,
'renewsAutomatically' => false,
'source' => 'admin',
];
}
$tier = $this->currentTier($user);
$subscription = $this->activeAcademySubscription($user);
if ($subscription instanceof Subscription) {
$trialEndsAt = $subscription->trial_ends_at?->toISOString();
$endsAt = $subscription->ends_at?->toISOString();
if ($subscription->onGracePeriod()) {
return [
'signedIn' => true,
'tier' => $tier,
'tierLabel' => $this->tierLabel($tier),
'hasPaidAccess' => $tier !== 'free',
'status' => 'grace_period',
'statusLabel' => 'Cancels soon',
'expiresAt' => $endsAt,
'dateLabel' => 'Access ends',
'renewsAutomatically' => false,
'source' => 'subscription',
];
}
if ($subscription->onTrial()) {
return [
'signedIn' => true,
'tier' => $tier,
'tierLabel' => $this->tierLabel($tier),
'hasPaidAccess' => $tier !== 'free',
'status' => 'trialing',
'statusLabel' => 'Trial active',
'expiresAt' => $trialEndsAt,
'dateLabel' => 'Trial ends',
'renewsAutomatically' => ! $subscription->cancelled(),
'source' => 'subscription',
];
}
return [
'signedIn' => true,
'tier' => $tier,
'tierLabel' => $this->tierLabel($tier),
'hasPaidAccess' => $tier !== 'free',
'status' => 'active',
'statusLabel' => 'Renews automatically',
'expiresAt' => null,
'dateLabel' => null,
'renewsAutomatically' => true,
'source' => 'subscription',
];
}
if ($tier !== 'free') {
return [
'signedIn' => true,
'tier' => $tier,
'tierLabel' => $this->tierLabel($tier),
'hasPaidAccess' => true,
'status' => 'active',
'statusLabel' => 'Full access active',
'expiresAt' => null,
'dateLabel' => null,
'renewsAutomatically' => false,
'source' => 'legacy_role',
];
}
return [
'signedIn' => true,
'tier' => 'free',
'tierLabel' => 'Free',
'hasPaidAccess' => false,
'status' => 'free',
'statusLabel' => 'Free access',
'expiresAt' => null,
'dateLabel' => null,
'renewsAutomatically' => false,
'source' => 'none',
];
}
public function canAccessLesson(?User $user, AcademyLesson $lesson): bool
{
return $this->canAccessContent($user, (string) $lesson->access_level);
@@ -633,6 +748,16 @@ final class AcademyAccessService
};
}
private function tierLabel(string $tier): string
{
return match ($this->normalizeAccessLevel($tier)) {
'admin' => 'Admin',
'pro' => 'Pro',
'creator' => 'Creator',
default => 'Free',
};
}
private function isAcademyAdmin(User $user): bool
{
return $user->hasStaffAccess() || $user->isModerator();

View File

@@ -42,6 +42,9 @@ final class AcademyAnalyticsContentResolver
if (! $contentId) {
return match ($contentType) {
AcademyAnalyticsContentType::HOME => 'Academy Home',
AcademyAnalyticsContentType::PROMPT_LIBRARY => 'Prompt Library',
AcademyAnalyticsContentType::PROMPT_POPULAR => 'Popular Prompts',
AcademyAnalyticsContentType::PROMPT_PACK_LIBRARY => 'Prompt Pack Library',
AcademyAnalyticsContentType::SEARCH => 'Academy Search',
AcademyAnalyticsContentType::UPGRADE => 'Academy Upgrade',
default => 'Unknown Academy Content',

View File

@@ -45,7 +45,7 @@ final class AcademyPopularityService
public function queryBetween(Carbon $from, Carbon $to): Builder
{
return AcademyContentMetricDaily::query()
->whereBetween('date', [$from->toDateString(), $to->toDateString()]);
->whereBetween('date', [$from->copy()->startOfDay(), $to->copy()->endOfDay()]);
}
public function topContent(Carbon $from, Carbon $to, int $limit = 10): Collection

View File

@@ -32,9 +32,11 @@ class ContentSanitizer
public const EMOJI_DENSITY_MAX = 0.40;
// HTML tags we allow in the final rendered output
// Include heading tags so editor-produced headings (h1-h6) are preserved.
private const ALLOWED_TAGS = [
'p', 'br', 'strong', 'em', 'code', 'pre',
'a', 'ul', 'ol', 'li', 'blockquote', 'del',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
];
// Allowed attributes per tag

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Services\Enhance;
use App\Models\EnhanceJob;
interface EnhanceProcessor
{
public function process(EnhanceJob $job): EnhanceProcessorResult;
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Services\Enhance;
use App\Models\EnhanceJob;
use App\Services\Enhance\Processors\ExternalWorkerEnhanceProcessor;
use App\Services\Enhance\Processors\StubEnhanceProcessor;
use RuntimeException;
final class EnhanceProcessorFactory
{
public function __construct(
private readonly StubEnhanceProcessor $stubProcessor,
private readonly ExternalWorkerEnhanceProcessor $externalWorkerProcessor,
) {
}
public function make(string $engine): EnhanceProcessor
{
return match ($engine) {
EnhanceJob::ENGINE_STUB => $this->stubProcessor,
EnhanceJob::ENGINE_EXTERNAL_WORKER => $this->externalWorkerProcessor,
default => throw new RuntimeException('Unknown enhance processor engine.'),
};
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Services\Enhance;
final class EnhanceProcessorResult
{
public function __construct(
public readonly string $disk,
public readonly string $path,
public readonly int $width,
public readonly int $height,
public readonly int $filesize,
public readonly string $mime,
public readonly ?array $metadata = null,
) {
}
}

View File

@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
namespace App\Services\Enhance;
use App\Jobs\Enhance\ProcessEnhanceJob;
use App\Models\Artwork;
use App\Models\EnhanceJob;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
final class EnhanceService
{
public function __construct(
private readonly EnhanceValidator $validator,
private readonly EnhanceStorageService $storage,
) {
}
public function createFromUpload(User $user, UploadedFile $file, array $options): EnhanceJob
{
$this->assertCreationAllowed($user);
$this->assertDailyLimit($user);
$validated = $this->validator->validateUpload($file, $options);
$source = $this->storage->storeUploadedSource($user, $file);
$job = DB::transaction(function () use ($user, $validated, $source): EnhanceJob {
$enhanceJob = EnhanceJob::query()->create($validated + $source + [
'user_id' => (int) $user->id,
'status' => EnhanceJob::STATUS_PENDING,
]);
$this->queue($enhanceJob);
return $enhanceJob->fresh();
});
Log::info('enhance.job.created', [
'enhance_job_id' => $job->id,
'user_id' => $user->id,
'type' => 'upload',
'engine' => $job->engine,
]);
return $job;
}
public function createFromArtwork(User $user, Artwork $artwork, array $options): EnhanceJob
{
$this->assertCreationAllowed($user);
$this->assertDailyLimit($user);
$artworkSource = $this->storage->fetchArtworkSource($artwork);
$validated = $this->validator->validateBinary(
$artworkSource['binary'],
$options,
(int) ($artwork->file_size ?? strlen((string) $artworkSource['binary'])),
);
$source = $this->storage->storeSourceBinary($user, (string) $artworkSource['binary'], (string) $artworkSource['extension']);
$job = DB::transaction(function () use ($user, $artwork, $validated, $source): EnhanceJob {
$enhanceJob = EnhanceJob::query()->create($validated + $source + [
'user_id' => (int) $user->id,
'artwork_id' => (int) $artwork->id,
'status' => EnhanceJob::STATUS_PENDING,
]);
$this->queue($enhanceJob);
return $enhanceJob->fresh();
});
Log::info('enhance.job.created', [
'enhance_job_id' => $job->id,
'user_id' => $user->id,
'artwork_id' => $artwork->id,
'type' => 'artwork',
'engine' => $job->engine,
]);
return $job;
}
public function retry(EnhanceJob $job): EnhanceJob
{
Log::info('enhance.retry.started', [
'enhance_job_id' => $job->id,
'user_id' => $job->user_id,
'status' => $job->status,
]);
if (! $job->isFailed()) {
throw ValidationException::withMessages([
'job' => 'Only failed enhance jobs can be retried.',
]);
}
if (! $this->sourceExists($job)) {
Log::warning('enhance.retry.failed_missing_source', [
'enhance_job_id' => $job->id,
'user_id' => $job->user_id,
]);
throw ValidationException::withMessages([
'job' => 'This enhance job can no longer be retried because the original source file was cleaned up.',
]);
}
DB::transaction(function () use ($job): void {
$this->storage->deleteGeneratedFiles($job);
$metadata = is_array($job->metadata) ? $job->metadata : [];
$retryCount = max(0, (int) ($metadata['retry_count'] ?? 0)) + 1;
$job->forceFill([
'status' => EnhanceJob::STATUS_QUEUED,
'output_disk' => null,
'output_path' => null,
'output_hash' => null,
'output_width' => null,
'output_height' => null,
'output_filesize' => null,
'output_mime' => null,
'preview_disk' => null,
'preview_path' => null,
'processing_seconds' => null,
'error_message' => null,
'started_at' => null,
'finished_at' => null,
'queued_at' => now(),
'metadata' => array_merge($metadata, [
'retry_count' => $retryCount,
'last_retried_at' => now()->toIso8601String(),
]),
])->save();
ProcessEnhanceJob::dispatch((int) $job->id)->afterCommit();
});
Log::info('enhance.retry.dispatched', [
'enhance_job_id' => $job->id,
'user_id' => $job->user_id,
'retry_count' => (int) (($job->fresh()?->metadata['retry_count'] ?? 0)),
]);
return $job->fresh();
}
public function markFailedByModerator(EnhanceJob $job, User $actor): EnhanceJob
{
if (! in_array($job->status, [EnhanceJob::STATUS_PENDING, EnhanceJob::STATUS_QUEUED, EnhanceJob::STATUS_PROCESSING], true)) {
throw ValidationException::withMessages([
'job' => 'Only pending, queued, or processing jobs can be marked as failed.',
]);
}
$metadata = is_array($job->metadata) ? $job->metadata : [];
DB::transaction(function () use ($job, $actor, $metadata): void {
$job->forceFill([
'status' => EnhanceJob::STATUS_FAILED,
'error_message' => 'Marked as failed by moderator.',
'finished_at' => now(),
'processing_seconds' => $job->started_at ? max(0, now()->diffInSeconds($job->started_at)) : $job->processing_seconds,
'metadata' => array_merge($metadata, [
'moderation' => [
'marked_failed_at' => now()->toIso8601String(),
'marked_failed_by' => (int) $actor->id,
],
]),
])->save();
});
Log::info('enhance.moderation.mark_failed', [
'enhance_job_id' => $job->id,
'moderator_id' => $actor->id,
]);
return $job->fresh();
}
public function delete(EnhanceJob $job): void
{
DB::transaction(function () use ($job): void {
$this->storage->deleteFiles($job);
$job->delete();
});
Log::info('enhance.job.deleted', [
'enhance_job_id' => $job->id,
'user_id' => $job->user_id,
]);
}
private function assertCreationAllowed(User $user): void
{
if (method_exists($user, 'hasVerifiedEmail') && ! $user->hasVerifiedEmail()) {
throw ValidationException::withMessages([
'image' => 'Please verify your email address before using Skinbase Enhance.',
]);
}
}
private function assertDailyLimit(User $user): void
{
$limit = max(0, (int) config('enhance.daily_limit', 10));
if ($limit === 0) {
return;
}
$count = EnhanceJob::query()
->where('user_id', (int) $user->id)
->whereBetween('created_at', [now()->startOfDay(), now()->endOfDay()])
->count();
if ($count >= $limit) {
throw ValidationException::withMessages([
'image' => 'You have reached your daily enhance limit. Please try again tomorrow.',
]);
}
}
private function queue(EnhanceJob $job): void
{
$job->forceFill([
'status' => EnhanceJob::STATUS_QUEUED,
'queued_at' => now(),
])->save();
ProcessEnhanceJob::dispatch((int) $job->id)->afterCommit();
}
public function frontendConfig(): array
{
$engine = (string) config('enhance.default_engine', EnhanceJob::ENGINE_STUB);
$showStubWarning = (bool) config('enhance.stub.show_warning', true) && $engine === EnhanceJob::ENGINE_STUB;
return [
'engine' => $engine,
'isStub' => $engine === EnhanceJob::ENGINE_STUB,
'showStubWarning' => $showStubWarning,
'maxUploadMb' => (int) config('enhance.max_upload_mb', 20),
'allowedModes' => array_values((array) config('enhance.allowed_modes', [])),
'allowedScales' => array_map('intval', (array) config('enhance.allowed_scales', [])),
];
}
private function sourceExists(EnhanceJob $job): bool
{
$path = ltrim(trim((string) $job->source_path), '/');
if ($path === '') {
return false;
}
return Storage::disk($job->source_disk ?: $this->storage->diskName())->exists($path);
}
}

View File

@@ -0,0 +1,366 @@
<?php
declare(strict_types=1);
namespace App\Services\Enhance;
use App\Models\Artwork;
use App\Models\EnhanceJob;
use App\Models\User;
use App\Services\ArtworkOriginalFileLocator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
use RuntimeException;
final class EnhanceStorageService
{
private ?ImageManager $manager = null;
public function __construct(
private readonly ArtworkOriginalFileLocator $artworkOriginalFileLocator,
) {
try {
$this->manager = extension_loaded('gd')
? new ImageManager(new GdDriver())
: new ImageManager(new ImagickDriver());
} catch (\Throwable) {
$this->manager = null;
}
}
public function diskName(): string
{
return (string) config('enhance.disk', 'public');
}
public function fetchSourceBinary(EnhanceJob $job): string
{
$path = trim((string) $job->source_path);
if ($path === '') {
throw new RuntimeException('Enhance source image is missing.');
}
$contents = Storage::disk($job->source_disk ?: $this->diskName())->get($path);
if (! is_string($contents) || $contents === '') {
throw new RuntimeException('Unable to read enhance source image.');
}
return $contents;
}
public function fetchArtworkSource(Artwork $artwork): array
{
$objectPath = $this->artworkOriginalFileLocator->resolveObjectPath($artwork);
if ($objectPath === '') {
throw new RuntimeException('Artwork source file is unavailable for enhance.');
}
$disk = (string) config('uploads.object_storage.disk', 's3');
$contents = Storage::disk($disk)->get($objectPath);
if (! is_string($contents) || $contents === '') {
throw new RuntimeException('Unable to read the original artwork source.');
}
$extension = strtolower(ltrim((string) ($artwork->file_ext ?? pathinfo($objectPath, PATHINFO_EXTENSION)), '.'));
$mime = trim(strtolower((string) ($artwork->mime_type ?? '')));
if ($mime === '') {
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = strtolower((string) $finfo->buffer($contents));
}
return [
'disk' => $disk,
'path' => $objectPath,
'binary' => $contents,
'mime' => $mime,
'extension' => $extension !== '' ? $extension : $this->extensionFromMime($mime),
];
}
public function storeUploadedSource(User $user, UploadedFile $file): array
{
$path = (string) ($file->getRealPath() ?: $file->getPathname());
if ($path === '' || ! is_readable($path)) {
throw new RuntimeException('Unable to resolve uploaded source path.');
}
$binary = file_get_contents($path);
if (! is_string($binary) || $binary === '') {
throw new RuntimeException('Unable to read uploaded source image.');
}
$extension = strtolower(ltrim((string) ($file->getClientOriginalExtension() ?: $file->extension()), '.'));
return $this->storeSourceBinary($user, $binary, $extension !== '' ? $extension : 'bin');
}
public function storeSourceBinary(User $user, string $binary, string $extension): array
{
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = strtolower((string) $finfo->buffer($binary));
$normalizedExtension = $extension !== '' ? $extension : $this->extensionFromMime($mime);
$relativePath = $this->buildPath((string) config('enhance.source_prefix', 'enhance/sources'), (int) $user->id, sprintf('%s.%s', Str::uuid()->toString(), $normalizedExtension));
$this->writeBinary($this->diskName(), $relativePath, $binary, $mime);
return [
'source_disk' => $this->diskName(),
'source_path' => $relativePath,
'source_hash' => hash('sha256', $binary),
];
}
public function putOutputBinary(EnhanceJob $job, string $binary, string $mime, ?string $extension = null): array
{
$normalizedMime = strtolower(trim($mime));
$ext = $extension !== null && $extension !== '' ? strtolower(ltrim($extension, '.')) : $this->extensionFromMime($normalizedMime);
$filename = sprintf('%s_x%d.%s', Str::uuid()->toString(), (int) $job->scale, $ext);
$relativePath = $this->buildPath((string) config('enhance.output_prefix', 'enhance/outputs'), (int) $job->user_id, $filename);
$this->writeBinary($this->diskName(), $relativePath, $binary, $normalizedMime);
$dimensions = @getimagesizefromstring($binary) ?: [0, 0];
return [
'disk' => $this->diskName(),
'path' => $relativePath,
'hash' => hash('sha256', $binary),
'width' => (int) ($dimensions[0] ?? 0),
'height' => (int) ($dimensions[1] ?? 0),
'filesize' => strlen($binary),
'mime' => $normalizedMime,
];
}
public function storePreviewFromBinary(EnhanceJob $job, string $binary): ?array
{
$previewBinary = $binary;
$previewMime = 'image/webp';
if ($this->manager !== null) {
try {
$previewBinary = (string) $this->manager
->read($binary)
->scaleDown(width: 1600, height: 1600)
->encode(new WebpEncoder(82));
} catch (\Throwable) {
$previewMime = strtolower((string) ((new \finfo(FILEINFO_MIME_TYPE))->buffer($binary) ?: 'image/jpeg'));
$previewBinary = $binary;
}
} else {
$previewMime = strtolower((string) ((new \finfo(FILEINFO_MIME_TYPE))->buffer($binary) ?: 'image/jpeg'));
}
$extension = $this->extensionFromMime($previewMime);
$relativePath = $this->buildPath(
(string) config('enhance.preview_prefix', 'enhance/previews'),
(int) $job->user_id,
sprintf('%s_preview.%s', Str::uuid()->toString(), $extension),
);
$this->writeBinary($this->diskName(), $relativePath, $previewBinary, $previewMime);
return [
'preview_disk' => $this->diskName(),
'preview_path' => $relativePath,
];
}
public function createPreviewFromStoredOutput(EnhanceJob $job, string $disk, string $path): ?array
{
$contents = Storage::disk($disk)->get($path);
if (! is_string($contents) || $contents === '') {
return null;
}
return $this->storePreviewFromBinary($job, $contents);
}
public function deleteFiles(EnhanceJob $job): void
{
$this->deleteFilesForJob($job);
}
public function deleteGeneratedFiles(EnhanceJob $job): void
{
foreach ([
[$job->output_disk, $job->output_path],
[$job->preview_disk, $job->preview_path],
] as [$disk, $path]) {
$this->safeDelete($disk, $path);
}
}
public function deleteFilesForJob(EnhanceJob $job): array
{
$result = [
'deleted' => [
'source' => false,
'output' => false,
'preview' => false,
],
'skipped' => [],
'errors' => [],
];
foreach ([
'source' => [$job->source_disk, $job->source_path],
'output' => [$job->output_disk, $job->output_path],
'preview' => [$job->preview_disk, $job->preview_path],
] as $key => [$disk, $path]) {
try {
$deleted = $this->safeDelete($disk, $path);
$result['deleted'][$key] = $deleted;
if (! $deleted && trim((string) $path) !== '') {
$result['skipped'][] = $key;
}
} catch (\Throwable $exception) {
$result['errors'][$key] = $exception->getMessage();
Log::warning('enhance.cleanup.file_delete_failed', [
'path' => trim((string) $path),
'disk' => $disk ?: $this->diskName(),
'message' => $exception->getMessage(),
]);
}
}
return $result;
}
public function isEnhancePath(?string $path): bool
{
$trimmedPath = ltrim(trim((string) $path), '/');
if ($trimmedPath === '') {
return false;
}
foreach ($this->enhancePrefixes() as $prefix) {
if ($trimmedPath === $prefix || str_starts_with($trimmedPath, $prefix . '/')) {
return true;
}
}
return false;
}
public function safeDelete(?string $disk, ?string $path): bool
{
$trimmedPath = ltrim(trim((string) $path), '/');
if ($trimmedPath === '') {
return false;
}
if (! $this->isEnhancePath($trimmedPath)) {
Log::warning('enhance.cleanup.file_skipped', [
'path' => $trimmedPath,
'disk' => $disk ?: $this->diskName(),
'reason' => 'outside-enhance-prefixes',
]);
return false;
}
$targetDisk = $disk ?: $this->diskName();
if (! Storage::disk($targetDisk)->exists($trimmedPath)) {
return false;
}
$deleted = Storage::disk($targetDisk)->delete($trimmedPath);
if ($deleted) {
Log::info('enhance.cleanup.file_deleted', [
'path' => $trimmedPath,
'disk' => $targetDisk,
]);
return true;
}
Log::warning('enhance.cleanup.file_delete_failed', [
'path' => $trimmedPath,
'disk' => $targetDisk,
'message' => 'Storage delete returned false.',
]);
return false;
}
public function listKnownJobPaths(): array
{
return EnhanceJob::withTrashed()
->get(['source_path', 'output_path', 'preview_path'])
->flatMap(fn (EnhanceJob $job): array => array_values(array_filter([
ltrim(trim((string) $job->source_path), '/'),
ltrim(trim((string) $job->output_path), '/'),
ltrim(trim((string) $job->preview_path), '/'),
])))
->unique()
->values()
->all();
}
private function buildPath(string $prefix, int $userId, string $filename): string
{
return sprintf(
'%s/%d/%s/%s/%s',
trim($prefix, '/'),
$userId,
now()->format('Y'),
now()->format('m'),
ltrim($filename, '/'),
);
}
private function enhancePrefixes(): array
{
return array_values(array_filter(array_unique(array_map(
static fn (string $prefix): string => trim($prefix, '/'),
[
(string) config('enhance.source_prefix', 'enhance/sources'),
(string) config('enhance.output_prefix', 'enhance/outputs'),
(string) config('enhance.preview_prefix', 'enhance/previews'),
],
))));
}
private function extensionFromMime(string $mime): string
{
return match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
default => 'bin',
};
}
private function writeBinary(string $disk, string $path, string $binary, string $mime): void
{
$written = Storage::disk($disk)->put($path, $binary, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => $mime,
]);
if ($written !== true) {
throw new RuntimeException('Unable to store enhance image in storage.');
}
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Services\Enhance;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\ValidationException;
final class EnhanceValidator
{
public function validateUpload(UploadedFile $file, array $options): array
{
$path = (string) ($file->getRealPath() ?: $file->getPathname());
if ($path === '' || ! is_readable($path)) {
throw ValidationException::withMessages([
'image' => 'Unable to read the uploaded image.',
]);
}
$binary = file_get_contents($path);
if (! is_string($binary) || $binary === '') {
throw ValidationException::withMessages([
'image' => 'Unable to read the uploaded image.',
]);
}
return $this->validateBinary($binary, $options, (int) ($file->getSize() ?? strlen($binary)));
}
public function validateBinary(string $binary, array $options, ?int $filesize = null): array
{
$normalized = $this->normalizeOptions($options);
$size = $filesize ?? strlen($binary);
if ($size <= 0) {
throw ValidationException::withMessages([
'image' => 'Uploaded image is empty.',
]);
}
$maxBytes = (int) config('enhance.max_upload_mb', 20) * 1024 * 1024;
if ($maxBytes > 0 && $size > $maxBytes) {
throw ValidationException::withMessages([
'image' => sprintf('The image may not be greater than %d MB.', (int) config('enhance.max_upload_mb', 20)),
]);
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = strtolower((string) $finfo->buffer($binary));
if (! in_array($mime, (array) config('enhance.allowed_mimes', []), true)) {
throw ValidationException::withMessages([
'image' => 'Unsupported image format. Upload a JPEG, PNG, or WebP image.',
]);
}
$dimensions = @getimagesizefromstring($binary);
if (! is_array($dimensions) || (int) ($dimensions[0] ?? 0) < 1 || (int) ($dimensions[1] ?? 0) < 1) {
throw ValidationException::withMessages([
'image' => 'Uploaded file is not a valid image.',
]);
}
$width = (int) ($dimensions[0] ?? 0);
$height = (int) ($dimensions[1] ?? 0);
if ($width > (int) config('enhance.max_input_width', 4096)) {
throw ValidationException::withMessages([
'image' => sprintf('Image width may not exceed %d pixels.', (int) config('enhance.max_input_width', 4096)),
]);
}
if ($height > (int) config('enhance.max_input_height', 4096)) {
throw ValidationException::withMessages([
'image' => sprintf('Image height may not exceed %d pixels.', (int) config('enhance.max_input_height', 4096)),
]);
}
return $normalized + [
'input_width' => $width,
'input_height' => $height,
'input_filesize' => $size,
'input_mime' => $mime,
];
}
public function normalizeOptions(array $options): array
{
$allowedScales = array_map('intval', (array) config('enhance.allowed_scales', [2, 4]));
$allowedModes = array_map('strval', (array) config('enhance.allowed_modes', ['standard', 'artwork', 'photo', 'illustration']));
$allowedEngines = [
\App\Models\EnhanceJob::ENGINE_STUB,
\App\Models\EnhanceJob::ENGINE_EXTERNAL_WORKER,
];
$scale = (int) ($options['scale'] ?? config('enhance.allowed_scales.0', 2));
$mode = trim((string) ($options['mode'] ?? 'standard'));
$engine = trim((string) ($options['engine'] ?? config('enhance.default_engine', \App\Models\EnhanceJob::ENGINE_STUB)));
if (! in_array($scale, $allowedScales, true)) {
throw ValidationException::withMessages([
'scale' => 'Please select a supported scale.',
]);
}
if (! in_array($mode, $allowedModes, true)) {
throw ValidationException::withMessages([
'mode' => 'Please select a supported enhance mode.',
]);
}
if (! in_array($engine, $allowedEngines, true)) {
throw ValidationException::withMessages([
'engine' => 'Please select a supported enhance engine.',
]);
}
return [
'scale' => $scale,
'mode' => $mode,
'engine' => $engine,
];
}
}

View File

@@ -0,0 +1,304 @@
<?php
declare(strict_types=1);
namespace App\Services\Enhance\Processors;
use App\Models\EnhanceJob;
use App\Services\Enhance\EnhanceProcessor;
use App\Services\Enhance\EnhanceProcessorResult;
use App\Services\Enhance\EnhanceStorageService;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use RuntimeException;
use Throwable;
final class ExternalWorkerEnhanceProcessor implements EnhanceProcessor
{
private const SAFE_WORKER_ERRORS = [
'Worker is unavailable.',
'Worker token is missing.',
'Worker rejected the image.',
'Worker returned an invalid response.',
'The upscaled output exceeded the maximum allowed size.',
'The source file could not be downloaded by the worker.',
'Upscale engine is not available. Check model files and worker installation.',
'The enhance worker timed out while processing this image.',
];
public function __construct(
private readonly EnhanceStorageService $storage,
) {
}
public function process(EnhanceJob $job): EnhanceProcessorResult
{
$workerUrl = trim((string) config('enhance.external_worker.url', ''));
if ($workerUrl === '') {
throw new RuntimeException('Worker URL is missing.');
}
$token = trim((string) config('enhance.external_worker.token', ''));
if ($token === '') {
throw new RuntimeException('Worker token is missing.');
}
$timeout = max(1, (int) config('enhance.external_worker.timeout', 300));
$sourceUrl = $this->sourceUrlForWorker($job);
try {
$response = $this->http($timeout)
->post($this->workerEndpoint($workerUrl, '/v1/upscale'), [
'job_id' => (int) $job->id,
'source_url' => $sourceUrl,
'scale' => (int) $job->scale,
'mode' => (string) $job->mode,
'output_format' => 'webp',
]);
} catch (ConnectionException $exception) {
throw $this->wrapHttpException($exception, $job, 'upscale');
}
$payload = $this->decodeWorkerPayload($response);
[$binary, $cleanupFilename] = $this->resolveWorkerOutputBinary($payload, $workerUrl, $token, $timeout, $job);
$validated = $this->validateOutputBinary($binary);
$stored = $this->storage->putOutputBinary($job, $binary, $validated['mime']);
if ($cleanupFilename !== null) {
$this->deleteWorkerResult($workerUrl, $cleanupFilename, $token, $timeout, $job);
}
$metadata = is_array($payload['metadata'] ?? null) ? $payload['metadata'] : [];
$metadata['source_transport'] = str_contains($sourceUrl, '/internal/enhance/source/') ? 'signed-route' : 'temporary-url';
return new EnhanceProcessorResult(
disk: $stored['disk'],
path: $stored['path'],
width: (int) $validated['width'],
height: (int) $validated['height'],
filesize: (int) $validated['filesize'],
mime: (string) $validated['mime'],
metadata: $metadata,
);
}
private function http(int $timeout): PendingRequest
{
return Http::timeout($timeout)
->acceptJson()
->asJson()
->withToken((string) config('enhance.external_worker.token'));
}
private function decodeWorkerPayload(Response $response): array
{
if (! $response->successful()) {
$payload = $response->json();
throw new RuntimeException(
$response->status() >= 500
? 'Worker is unavailable.'
: $this->normalizeWorkerError(is_array($payload) ? ($payload['error'] ?? null) : null, 'Worker rejected the image.'),
);
}
$payload = $response->json();
if (! is_array($payload) || ! ($payload['success'] ?? false)) {
throw new RuntimeException(
$this->normalizeWorkerError(is_array($payload) ? ($payload['error'] ?? null) : null, 'Worker returned an invalid response.'),
);
}
return $payload;
}
private function resolveWorkerOutputBinary(array $payload, string $workerUrl, string $token, int $timeout, EnhanceJob $job): array
{
$base64 = trim((string) ($payload['output_base64'] ?? ''));
if ($base64 !== '') {
$binary = base64_decode($base64, true);
if (! is_string($binary) || $binary === '') {
throw new RuntimeException('Worker returned an invalid response.');
}
return [$binary, null];
}
$outputUrl = trim((string) ($payload['output_url'] ?? ''));
if ($outputUrl === '') {
throw new RuntimeException('Worker returned an invalid response.');
}
$safeOutputUrl = $this->normalizeWorkerOutputUrl($workerUrl, $outputUrl);
try {
$outputResponse = Http::timeout($timeout)
->withToken($token)
->get($safeOutputUrl);
} catch (ConnectionException $exception) {
throw $this->wrapHttpException($exception, $job, 'download');
}
if (! $outputResponse->successful()) {
throw new RuntimeException('Worker returned an invalid response.');
}
$binary = $outputResponse->body();
if ($binary === '') {
throw new RuntimeException('Worker returned an invalid response.');
}
$path = trim((string) parse_url($safeOutputUrl, PHP_URL_PATH));
$filename = basename($path);
return [$binary, $filename !== '' ? $filename : null];
}
private function validateOutputBinary(string $binary): array
{
$maxBytes = max(1, (int) config('enhance.external_worker.max_download_mb', 60)) * 1024 * 1024;
if (strlen($binary) > $maxBytes) {
throw new RuntimeException('The upscaled output exceeded the maximum allowed size.');
}
$dimensions = @getimagesizefromstring($binary);
if (! is_array($dimensions)) {
throw new RuntimeException('Worker returned an invalid response.');
}
$width = (int) ($dimensions[0] ?? 0);
$height = (int) ($dimensions[1] ?? 0);
$maxWidth = max(1, (int) config('enhance.max_output_width', 8192));
$maxHeight = max(1, (int) config('enhance.max_output_height', 8192));
if ($width < 1 || $height < 1 || $width > $maxWidth || $height > $maxHeight) {
throw new RuntimeException('Worker returned an invalid response.');
}
$mime = strtolower((string) ((new \finfo(FILEINFO_MIME_TYPE))->buffer($binary) ?: ''));
if (! in_array($mime, (array) config('enhance.allowed_mimes', []), true)) {
throw new RuntimeException('Worker returned an invalid response.');
}
return [
'width' => $width,
'height' => $height,
'filesize' => strlen($binary),
'mime' => $mime,
];
}
private function sourceUrlForWorker(EnhanceJob $job): string
{
$disk = Storage::disk($job->source_disk ?: $this->storage->diskName());
$path = ltrim(trim((string) $job->source_path), '/');
if ($path === '') {
throw new RuntimeException('The source file could not be downloaded by the worker.');
}
try {
if (method_exists($disk, 'providesTemporaryUrls') && $disk->providesTemporaryUrls()) {
return $disk->temporaryUrl($path, now()->addMinutes(15));
}
} catch (Throwable) {
}
return URL::temporarySignedRoute(
'enhance.source.download',
now()->addMinutes(15),
['enhanceJob' => $job->id],
);
}
private function normalizeWorkerOutputUrl(string $workerUrl, string $outputUrl): string
{
if (str_starts_with($outputUrl, '/')) {
return rtrim($workerUrl, '/') . $outputUrl;
}
$workerParts = parse_url($workerUrl);
$outputParts = parse_url($outputUrl);
if (! is_array($workerParts) || ! is_array($outputParts)) {
throw new RuntimeException('Worker returned an invalid response.');
}
$sameHost = ($workerParts['scheme'] ?? null) === ($outputParts['scheme'] ?? null)
&& ($workerParts['host'] ?? null) === ($outputParts['host'] ?? null)
&& (($workerParts['port'] ?? null) === ($outputParts['port'] ?? null));
if (! $sameHost) {
throw new RuntimeException('Worker returned an invalid response.');
}
return $outputUrl;
}
private function deleteWorkerResult(string $workerUrl, string $filename, string $token, int $timeout, EnhanceJob $job): void
{
$safeFilename = basename($filename);
if ($safeFilename === '') {
return;
}
try {
Http::timeout(min($timeout, 30))
->acceptJson()
->withToken($token)
->delete($this->workerEndpoint($workerUrl, '/v1/results/' . rawurlencode($safeFilename)));
} catch (ConnectionException $exception) {
Log::warning('enhance.external_worker.cleanup_failed', [
'enhance_job_id' => $job->id,
'message' => $exception->getMessage(),
]);
}
}
private function workerEndpoint(string $workerUrl, string $path): string
{
return rtrim($workerUrl, '/') . $path;
}
private function normalizeWorkerError(mixed $error, string $fallback): string
{
$message = trim((string) $error);
if (in_array($message, self::SAFE_WORKER_ERRORS, true)) {
return $message;
}
return $fallback;
}
private function wrapHttpException(ConnectionException $exception, EnhanceJob $job, string $stage): RuntimeException
{
$message = str_contains(strtolower($exception->getMessage()), 'timed out')
? 'The enhance worker timed out while processing this image.'
: 'Worker is unavailable.';
Log::warning('enhance.external_worker.http_failed', [
'enhance_job_id' => $job->id,
'stage' => $stage,
'message' => $exception->getMessage(),
]);
return new RuntimeException($message, 0, $exception);
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Services\Enhance\Processors;
use App\Models\EnhanceJob;
use App\Services\Enhance\EnhanceProcessor;
use App\Services\Enhance\EnhanceProcessorResult;
use App\Services\Enhance\EnhanceStorageService;
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
final class StubEnhanceProcessor implements EnhanceProcessor
{
private ?ImageManager $manager = null;
public function __construct(
private readonly EnhanceStorageService $storage,
) {
try {
$this->manager = extension_loaded('gd')
? new ImageManager(new GdDriver())
: new ImageManager(new ImagickDriver());
} catch (\Throwable) {
$this->manager = null;
}
}
public function process(EnhanceJob $job): EnhanceProcessorResult
{
$sourceBinary = $this->storage->fetchSourceBinary($job);
$outputBinary = $sourceBinary;
$outputMime = (string) ($job->input_mime ?: 'image/jpeg');
$scale = max(1, (int) $job->scale);
$metadata = [
'stub' => true,
'engine' => EnhanceJob::ENGINE_STUB,
'requested_scale' => $scale,
];
if ($this->manager !== null) {
try {
$image = $this->manager->read($sourceBinary);
$targetWidth = max((int) $image->width(), (int) $image->width() * $scale);
$targetHeight = max((int) $image->height(), (int) $image->height() * $scale);
$outputBinary = (string) $image
->resize($targetWidth, $targetHeight)
->encode(new WebpEncoder(88));
$outputMime = 'image/webp';
$metadata['actual_scale'] = $scale;
} catch (\Throwable) {
$metadata['actual_scale'] = 1;
$metadata['fallback'] = 'source-copy';
}
} else {
$metadata['actual_scale'] = 1;
$metadata['fallback'] = 'source-copy';
}
$stored = $this->storage->putOutputBinary($job, $outputBinary, $outputMime);
return new EnhanceProcessorResult(
disk: $stored['disk'],
path: $stored['path'],
width: (int) $stored['width'],
height: (int) $stored['height'],
filesize: (int) $stored['filesize'],
mime: (string) $stored['mime'],
metadata: $metadata,
);
}
}

View File

@@ -75,6 +75,7 @@ final class NewsCoverImageService
'size_bytes' => strlen($masterEncoded),
'mobile_url' => NewsCoverImage::variantUrl($path, 'mobile'),
'desktop_url' => NewsCoverImage::variantUrl($path, 'desktop'),
'large_url' => NewsCoverImage::variantUrl($path, 'large'),
'srcset' => NewsCoverImage::srcset($path),
];
}

View File

@@ -26,6 +26,7 @@ use cPad\Plugins\News\Models\NewsArticle;
use cPad\Plugins\News\Models\NewsArticleRelation;
use cPad\Plugins\News\Models\NewsCategory;
use cPad\Plugins\News\Models\NewsTag;
use cPad\Plugins\News\Services\NewsArticleService;
final class NewsService
{
@@ -39,6 +40,7 @@ final class NewsService
public const RELATION_CHALLENGE = 'challenge';
public const RELATION_EVENT = 'event';
public const RELATION_USER = 'user';
public const RELATION_SOURCE = 'source';
public const RELATION_LABELS = [
self::RELATION_GROUP => 'Group',
@@ -49,10 +51,15 @@ final class NewsService
self::RELATION_CHALLENGE => 'Challenge',
self::RELATION_EVENT => 'Event',
self::RELATION_USER => 'Profile',
self::RELATION_SOURCE => 'Source',
];
private ?bool $artworkStatsViewsColumnExists = null;
public function __construct(private readonly NewsArticleService $articleService)
{
}
public function articleTypeOptions(): array
{
return \collect(NewsArticle::TYPE_LABELS)
@@ -224,12 +231,22 @@ final class NewsService
'og_description' => (string) ($article->og_description ?? ''),
'og_image' => (string) ($article->og_image ?? ''),
'relations' => $article->relatedEntities
->map(fn (NewsArticleRelation $relation): array => [
'entity_type' => (string) $relation->entity_type,
'entity_id' => (int) $relation->entity_id,
'context_label' => (string) ($relation->context_label ?? ''),
'preview' => $this->resolveEntityPreview((string) $relation->entity_type, (int) $relation->entity_id, $viewer),
])
->map(function (NewsArticleRelation $relation) use ($viewer): array {
$entityType = (string) $relation->entity_type;
$externalUrl = $entityType === self::RELATION_SOURCE
? (string) ($relation->external_url ?? '')
: '';
return [
'entity_type' => $entityType,
'entity_id' => $entityType === self::RELATION_SOURCE ? '' : (int) $relation->entity_id,
'external_url' => $externalUrl,
'context_label' => (string) ($relation->context_label ?? ''),
'preview' => $entityType === self::RELATION_SOURCE
? $this->resolveSourcePreview($externalUrl, (string) ($relation->context_label ?? ''))
: $this->resolveEntityPreview($entityType, (int) $relation->entity_id, $viewer),
];
})
->values()
->all(),
];
@@ -263,6 +280,8 @@ final class NewsService
'published_at' => $article->published_at ?? \now(),
])->save();
$this->articleService->createForumThread($article);
$this->invalidatePublicCache();
return $article->fresh(['author', 'category', 'tags', 'relatedEntities']);
@@ -312,6 +331,7 @@ final class NewsService
self::RELATION_CHALLENGE => $this->searchChallenges($query, $viewer),
self::RELATION_EVENT => $this->searchEvents($query, $viewer),
self::RELATION_USER => $this->searchUsers($query),
self::RELATION_SOURCE => [],
default => [],
};
}
@@ -321,7 +341,15 @@ final class NewsService
$article->loadMissing('relatedEntities');
return $article->relatedEntities
->map(fn (NewsArticleRelation $relation): ?array => $this->resolveEntityPreview((string) $relation->entity_type, (int) $relation->entity_id, $viewer, (string) ($relation->context_label ?? '')))
->map(function (NewsArticleRelation $relation) use ($viewer): ?array {
$entityType = (string) $relation->entity_type;
if ($entityType === self::RELATION_SOURCE) {
return $this->resolveSourcePreview((string) ($relation->external_url ?? ''), (string) ($relation->context_label ?? ''));
}
return $this->resolveEntityPreview($entityType, (int) $relation->entity_id, $viewer, (string) ($relation->context_label ?? ''));
})
->filter()
->values()
->all();
@@ -380,6 +408,7 @@ final class NewsService
public function invalidatePublicCache(): void
{
Cache::forever(self::PUBLIC_CACHE_VERSION_KEY, $this->publicCacheVersion() + 1);
Cache::forget('news.rss.feed');
}
public function syncRelations(NewsArticle $article, array $relations): void
@@ -387,20 +416,32 @@ final class NewsService
$normalized = \collect($relations)
->map(function (array $relation): ?array {
$entityType = trim(Str::lower((string) ($relation['entity_type'] ?? '')));
$entityId = (int) ($relation['entity_id'] ?? 0);
$externalUrl = $entityType === self::RELATION_SOURCE
? $this->normalizeExternalRelationUrl($relation['external_url'] ?? $relation['entity_id'] ?? null)
: null;
$entityId = $entityType === self::RELATION_SOURCE ? null : (int) ($relation['entity_id'] ?? 0);
if (! array_key_exists($entityType, self::RELATION_LABELS) || $entityId < 1) {
if (! array_key_exists($entityType, self::RELATION_LABELS)) {
return null;
}
if ($entityType === self::RELATION_SOURCE && $externalUrl === null) {
return null;
}
if ($entityType !== self::RELATION_SOURCE && $entityId < 1) {
return null;
}
return [
'entity_type' => $entityType,
'entity_id' => $entityId,
'external_url' => $externalUrl,
'context_label' => Str::limit(trim((string) ($relation['context_label'] ?? '')), 120, ''),
];
})
->filter()
->unique(fn (array $relation): string => $relation['entity_type'] . ':' . $relation['entity_id'])
->unique(fn (array $relation): string => $relation['entity_type'] . ':' . ($relation['entity_type'] === self::RELATION_SOURCE ? ($relation['external_url'] ?? '') : $relation['entity_id']))
->values();
$article->relatedEntities()->delete();
@@ -409,6 +450,7 @@ final class NewsService
$article->relatedEntities()->create([
'entity_type' => $relation['entity_type'],
'entity_id' => $relation['entity_id'],
'external_url' => $relation['external_url'],
'context_label' => $relation['context_label'] !== '' ? $relation['context_label'] : null,
'sort_order' => $index,
]);
@@ -808,6 +850,34 @@ final class NewsService
};
}
private function resolveSourcePreview(string $externalUrl, string $contextLabel): ?array
{
$normalizedUrl = $this->normalizeExternalRelationUrl($externalUrl);
if ($normalizedUrl === null) {
return null;
}
$host = \parse_url($normalizedUrl, PHP_URL_HOST);
$host = \is_string($host) ? preg_replace('/^www\./i', '', $host) : null;
return [
'id' => $normalizedUrl,
'entity_type' => self::RELATION_SOURCE,
'entity_label' => self::RELATION_LABELS[self::RELATION_SOURCE],
'title' => $host ?: 'External source',
'subtitle' => 'Reference link',
'description' => Str::limit($normalizedUrl, 140),
'url' => $normalizedUrl,
'image' => null,
'avatar' => null,
'context_label' => $contextLabel !== '' ? $contextLabel : 'Source link',
'meta' => array_values(array_filter([
$host,
])),
];
}
private function resolveGroupPreview(int $entityId, ?User $viewer, string $contextLabel): ?array
{
$group = Group::query()->with('owner')->find($entityId);
@@ -1017,4 +1087,23 @@ final class NewsService
'meta' => [],
];
}
private function normalizeExternalRelationUrl(mixed $value): ?string
{
$url = trim((string) ($value ?? ''));
if ($url === '') {
return null;
}
if (preg_match('/^\[[^\]]+\]\((https?:\/\/[^)]+)\)$/i', $url, $matches) === 1) {
$url = trim((string) ($matches[1] ?? ''));
}
if ($url === '') {
return null;
}
return Str::limit($url, 2048, '');
}
}

View File

@@ -7,6 +7,9 @@ namespace App\Support\AcademyAnalytics;
final class AcademyAnalyticsContentType
{
public const HOME = 'academy_home';
public const PROMPT_LIBRARY = 'academy_prompt_library';
public const PROMPT_POPULAR = 'academy_prompt_popular';
public const PROMPT_PACK_LIBRARY = 'academy_prompt_pack_library';
public const PROMPT = 'academy_prompt';
public const LESSON = 'academy_lesson';
public const COURSE = 'academy_course';
@@ -22,6 +25,9 @@ final class AcademyAnalyticsContentType
{
return [
self::HOME,
self::PROMPT_LIBRARY,
self::PROMPT_POPULAR,
self::PROMPT_PACK_LIBRARY,
self::PROMPT,
self::LESSON,
self::COURSE,

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Services\ContentSanitizer;
final class ArtworkDescriptionContentValidator
{
/**
* @return list<string>
*/
public static function errors(null|string $value): array
{
$normalized = trim((string) ($value ?? ''));
if ($normalized === '') {
return [];
}
return ContentSanitizer::validate($normalized);
}
}

View File

@@ -19,6 +19,11 @@ final class NewsCoverImage
'quality' => 76,
'suffix' => 'desktop',
],
'large' => [
'width' => 1280,
'quality' => 80,
'suffix' => 'large',
],
];
public static function isManagedPath(?string $path): bool