Compare commits

...

3 Commits

Author SHA1 Message Date
klevze 5af95f6533 Optimize academy 2026-06-09 13:16:01 +02:00
klevze f89ee937c0 fixed sanitazer and academy 2026-06-05 16:53:20 +02:00
klevze 15870ddb1f Allow heading tags (h1-h6) in ContentSanitizer so news editor headings render 2026-06-04 07:52:57 +02:00
294 changed files with 24328 additions and 3113 deletions
+30
View File
@@ -329,6 +329,30 @@ TURNSTILE_FAIL_OPEN=false
TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify
TURNSTILE_TIMEOUT=5 TURNSTILE_TIMEOUT=5
ENHANCE_DISK=public
ENHANCE_SOURCE_PREFIX=enhance/sources
ENHANCE_OUTPUT_PREFIX=enhance/outputs
ENHANCE_PREVIEW_PREFIX=enhance/previews
ENHANCE_ENGINE=stub
ENHANCE_MAX_UPLOAD_MB=20
ENHANCE_MAX_INPUT_WIDTH=4096
ENHANCE_MAX_INPUT_HEIGHT=4096
ENHANCE_MAX_OUTPUT_WIDTH=8192
ENHANCE_MAX_OUTPUT_HEIGHT=8192
ENHANCE_DAILY_LIMIT=10
ENHANCE_QUEUE=default
ENHANCE_COMPLETED_EXPIRES_AFTER_DAYS=30
ENHANCE_FAILED_EXPIRES_AFTER_DAYS=7
ENHANCE_DELETED_FILE_GRACE_DAYS=1
ENHANCE_CLEANUP_CHUNK_SIZE=100
ENHANCE_STUCK_PROCESSING_AFTER_MINUTES=30
ENHANCE_STUCK_QUEUED_AFTER_MINUTES=60
ENHANCE_STUB_SHOW_WARNING=true
ENHANCE_WORKER_URL=
ENHANCE_WORKER_TIMEOUT=300
ENHANCE_WORKER_TOKEN=
ENHANCE_WORKER_MAX_DOWNLOAD_MB=60
AWS_ACCESS_KEY_ID= AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY= AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1 AWS_DEFAULT_REGION=us-east-1
@@ -375,6 +399,12 @@ GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=/auth/google/callback GOOGLE_REDIRECT_URI=/auth/google/callback
# Optional light theme feature
# Set LIGHT_THEME_ENABLED=true to allow a light theme in the client-side toggle.
# Set LIGHT_THEME_SHOW_SWITCH=true to display the theme switch in the toolbar.
LIGHT_THEME_ENABLED=false
LIGHT_THEME_SHOW_SWITCH=false
# Discord — https://discord.com/developers/applications # Discord — https://discord.com/developers/applications
DISCORD_CLIENT_ID= DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET= DISCORD_CLIENT_SECRET=
@@ -92,9 +92,9 @@ final class AcademyBillingHealthCommand extends Command
*/ */
private function buildReport(): array private function buildReport(): array
{ {
$stripeKey = (string) config('cashier.key', ''); $stripeKey = $this->configuredString(config('cashier.key'));
$stripeSecret = (string) config('cashier.secret', env('STRIPE_SECRET', '')); $stripeSecret = $this->firstConfiguredString(config('cashier.secret'), env('STRIPE_SECRET'));
$webhookSecret = (string) config('cashier.webhook.secret', env('STRIPE_WEBHOOK_SECRET', '')); $webhookSecret = $this->firstConfiguredString(config('cashier.webhook.secret'), env('STRIPE_WEBHOOK_SECRET'));
$currency = trim((string) config('cashier.currency', env('CASHIER_CURRENCY', ''))); $currency = trim((string) config('cashier.currency', env('CASHIER_CURRENCY', '')));
$currencyLocale = trim((string) config('cashier.currency_locale', env('CASHIER_CURRENCY_LOCALE', ''))); $currencyLocale = trim((string) config('cashier.currency_locale', env('CASHIER_CURRENCY_LOCALE', '')));
$academyEnabled = (bool) config('academy.enabled', true); $academyEnabled = (bool) config('academy.enabled', true);
@@ -285,4 +285,21 @@ final class AcademyBillingHealthCommand extends Command
return self::SUCCESS; return self::SUCCESS;
} }
private function firstConfiguredString(mixed ...$values): string
{
foreach ($values as $value) {
$value = $this->configuredString($value);
if ($value !== '') {
return $value;
}
}
return '';
}
private function configuredString(mixed $value): string
{
return is_string($value) ? trim($value) : '';
}
} }
@@ -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);
}
}
@@ -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,
],
];
}
}
@@ -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';
}
}
@@ -7,6 +7,7 @@ namespace App\Console\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use App\Services\News\NewsService;
use cPad\Plugins\News\Models\NewsArticle; use cPad\Plugins\News\Models\NewsArticle;
final class PublishScheduledNewsCommand extends Command 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.'; protected $description = 'Publish scheduled News articles whose publish time has passed.';
public function __construct(private readonly NewsService $news)
{
parent::__construct();
}
public function handle(): int public function handle(): int
{ {
$dryRun = (bool) $this->option('dry-run'); $dryRun = (bool) $this->option('dry-run');
@@ -60,11 +66,7 @@ final class PublishScheduledNewsCommand extends Command
return; return;
} }
$article->forceFill([ $this->news->publish($article);
'editorial_status' => NewsArticle::EDITORIAL_STATUS_PUBLISHED,
'status' => 'published',
'published_at' => $article->published_at ?? $now,
])->save();
$published++; $published++;
$this->line(sprintf('Published News article #%d: "%s"', $article->id, $article->title)); $this->line(sprintf('Published News article #%d: "%s"', $article->id, $article->title));
@@ -12,7 +12,14 @@ use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory; use App\Support\Seo\SeoFactory;
use Laravel\Cashier\Checkout; use Laravel\Cashier\Checkout;
use Laravel\Cashier\Subscription; use Laravel\Cashier\Subscription;
use Stripe\Exception\InvalidRequestException;
use Illuminate\Support\Facades\Log;
use App\Mail\AcademyAccessIssue;
use App\Models\StaffApplication;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
final class AcademyBillingController extends Controller final class AcademyBillingController extends Controller
{ {
public function __construct( public function __construct(
@@ -48,6 +55,7 @@ final class AcademyBillingController extends Controller
'activePlanKey' => $activePlan['key'] ?? null, 'activePlanKey' => $activePlan['key'] ?? null,
'activePlanLabel' => $activePlan['label'] ?? null, 'activePlanLabel' => $activePlan['label'] ?? null,
'catalog' => $this->catalog(), 'catalog' => $this->catalog(),
'missingRemote' => $this->plans->missingRemotePriceIds(),
'links' => [ 'links' => [
'login' => \route('login'), 'login' => \route('login'),
'pricing' => \route('academy.pricing'), 'pricing' => \route('academy.pricing'),
@@ -64,7 +72,7 @@ final class AcademyBillingController extends Controller
'isGuest' => $user === null, 'isGuest' => $user === null,
'isSubscriber' => $user?->hasAcademyCreatorAccess() || $user?->hasAcademyProAccess(), 'isSubscriber' => $user?->hasAcademyCreatorAccess() || $user?->hasAcademyProAccess(),
], ],
])->rootView('collections'); ])->rootView('academy');
} }
public function checkout(\Illuminate\Http\Request $request): Checkout|\Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse public function checkout(\Illuminate\Http\Request $request): Checkout|\Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
@@ -110,6 +118,46 @@ final class AcademyBillingController extends Controller
} }
if ($this->access->hasActiveAcademySubscription($user)) { if ($this->access->hasActiveAcademySubscription($user)) {
// If the user already has an Academy subscription, allow an in-place upgrade
// (e.g. Creator -> Pro) by swapping the subscription to the requested price.
$subscription = $this->academySubscription($user);
$currentPlan = $this->activePlan($user);
// If current plan exists and the requested plan ranks higher, perform swap.
if ($currentPlan !== null && ($this->planRank((string) $plan['tier']) > $this->planRank((string) $currentPlan['tier']))) {
try {
if ($subscription instanceof Subscription) {
$subscription->swap((string) $plan['stripe_price_id']);
}
return \redirect()->route('academy.billing.account')->with('success', 'Subscription upgraded — your new plan is active.');
} catch (\Throwable $e) {
$context = [
'user_id' => $user->id ?? null,
'user_email' => $user->email ?? null,
'stripe_id' => $user->stripe_id ?? null,
'route' => 'academy.billing.checkout',
'attempt' => 'swap_subscription',
'plan_key' => $plan['key'] ?? null,
'plan_price_id' => $plan['stripe_price_id'] ?? null,
'request_ip' => $request->ip(),
'user_agent' => $request->header('User-Agent'),
'exception_class' => \get_class($e),
'exception_message' => $e->getMessage(),
'exception_code' => $e->getCode(),
'exception_trace' => \method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
];
if (method_exists($e, 'getStripeCode')) {
$context['stripe_code'] = $e->getStripeCode();
}
Log::error('Academy billing: failed to swap subscription for upgrade', $context);
return $this->checkoutErrorResponse($request, $e);
}
}
return \redirect()->route('academy.billing.portal'); return \redirect()->route('academy.billing.portal');
} }
@@ -133,8 +181,91 @@ final class AcademyBillingController extends Controller
'academy_tier' => (string) $plan['tier'], 'academy_tier' => (string) $plan['tier'],
], ],
]); ]);
} catch (InvalidRequestException $e) {
// Stripe returned a request error (e.g. missing/deleted customer). Try to recover once by
// clearing stored `stripe_id`, recreating the customer in Stripe and retrying the checkout.
if (str_contains($e->getMessage(), 'No such customer')) {
try {
$user->forceFill(['stripe_id' => null])->save();
// Create a fresh Stripe customer and persist the id
if (method_exists($user, 'createAsStripeCustomer')) {
$user->createAsStripeCustomer();
} else {
// fallback to createOrGet behavior
$user->createOrGetStripeCustomer();
}
return $user
->newSubscription($this->plans->subscriptionName(), (string) $plan['stripe_price_id'])
->withMetadata([
'skinbase_module' => 'academy',
'user_id' => (string) $user->id,
'academy_plan' => (string) $plan['key'],
'academy_tier' => (string) $plan['tier'],
])
->checkout([
'success_url' => \route('academy.billing.success').'?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => \route('academy.billing.cancel'),
'allow_promotion_codes' => true,
'metadata' => [
'skinbase_module' => 'academy',
'user_id' => (string) $user->id,
'academy_plan' => (string) $plan['key'],
'academy_tier' => (string) $plan['tier'],
],
]);
} catch (\Throwable $inner) {
$context = [
'user_id' => $user->id ?? null,
'user_email' => $user->email ?? null,
'stripe_id' => $user->stripe_id ?? null,
'route' => 'academy.billing.checkout',
'attempt' => 'recreate_customer_and_checkout',
'plan_key' => $plan['key'] ?? null,
'plan_price_id' => $plan['stripe_price_id'] ?? null,
'request_ip' => $request->ip(),
'user_agent' => $request->header('User-Agent'),
'exception_class' => \get_class($inner),
'exception_message' => $inner->getMessage(),
'exception_code' => $inner->getCode(),
'exception_trace' => \method_exists($inner, 'getTraceAsString') ? $inner->getTraceAsString() : null,
];
if (method_exists($inner, 'getStripeCode')) {
$context['stripe_code'] = $inner->getStripeCode();
}
Log::error('Academy billing: failed to recover Stripe customer and start checkout', $context);
return $this->checkoutErrorResponse($request, $inner);
}
}
// Not a recoverable customer-missing error; rethrow to be handled below
throw $e;
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
\report($exception); $context = [
'user_id' => $user->id ?? null,
'user_email' => $user->email ?? null,
'stripe_id' => $user->stripe_id ?? null,
'route' => 'academy.billing.checkout',
'attempt' => 'start_checkout',
'plan_key' => $plan['key'] ?? null,
'plan_price_id' => $plan['stripe_price_id'] ?? null,
'request_ip' => $request->ip(),
'user_agent' => $request->header('User-Agent'),
'exception_class' => \get_class($exception),
'exception_message' => $exception->getMessage(),
'exception_code' => $exception->getCode(),
'exception_trace' => \method_exists($exception, 'getTraceAsString') ? $exception->getTraceAsString() : null,
];
if (method_exists($exception, 'getStripeCode')) {
$context['stripe_code'] = $exception->getStripeCode();
}
Log::error('Academy billing: unexpected error starting checkout', $context);
return $this->checkoutErrorResponse($request, $exception); return $this->checkoutErrorResponse($request, $exception);
} }
@@ -161,7 +292,68 @@ final class AcademyBillingController extends Controller
return \redirect()->route('academy.billing.account')->with('error', 'No Stripe billing profile is connected to this account yet.'); return \redirect()->route('academy.billing.account')->with('error', 'No Stripe billing profile is connected to this account yet.');
} }
try {
return $user->redirectToBillingPortal(\route('academy.billing.account')); return $user->redirectToBillingPortal(\route('academy.billing.account'));
} catch (\Exception $e) {
// If the Stripe customer was deleted or invalid, attempt a recovery similar to checkout.
if ($e instanceof \Stripe\Exception\InvalidRequestException && str_contains($e->getMessage(), 'No such customer')) {
try {
$user->forceFill(['stripe_id' => null])->save();
if (method_exists($user, 'createAsStripeCustomer')) {
$user->createAsStripeCustomer();
} else {
$user->createOrGetStripeCustomer();
}
return $user->redirectToBillingPortal(\route('academy.billing.account'));
} catch (\Throwable $inner) {
$context = [
'user_id' => $user->id ?? null,
'user_email' => $user->email ?? null,
'stripe_id' => $user->stripe_id ?? null,
'route' => 'academy.billing.portal',
'attempt' => 'recreate_customer_and_redirect',
'request_ip' => $request->ip(),
'user_agent' => $request->header('User-Agent'),
'exception_class' => \get_class($inner),
'exception_message' => $inner->getMessage(),
'exception_code' => $inner->getCode(),
'exception_trace' => \method_exists($inner, 'getTraceAsString') ? $inner->getTraceAsString() : null,
];
if (method_exists($inner, 'getStripeCode')) {
$context['stripe_code'] = $inner->getStripeCode();
}
Log::error('Academy billing: failed to recover Stripe customer and open billing portal', $context);
return \redirect()->route('academy.billing.account')->with('error', 'Could not open the subscription manager. Please email academy@skinbase.org with your account details and checkout session id if available.');
}
}
$context = [
'user_id' => $user->id ?? null,
'user_email' => $user->email ?? null,
'stripe_id' => $user->stripe_id ?? null,
'route' => 'academy.billing.portal',
'attempt' => 'redirect_to_portal',
'request_ip' => $request->ip(),
'user_agent' => $request->header('User-Agent'),
'exception_class' => \get_class($e),
'exception_message' => $e->getMessage(),
'exception_code' => $e->getCode(),
'exception_trace' => \method_exists($e, 'getTraceAsString') ? $e->getTraceAsString() : null,
];
if (method_exists($e, 'getStripeCode')) {
$context['stripe_code'] = $e->getStripeCode();
}
Log::error('Academy billing: could not open Stripe billing portal', $context);
return \redirect()->route('academy.billing.account')->with('error', 'Could not open the subscription manager. Please email academy@skinbase.org with your account details and checkout session id if available.');
}
} }
public function success(\Illuminate\Http\Request $request): \Inertia\Response public function success(\Illuminate\Http\Request $request): \Inertia\Response
@@ -180,9 +372,10 @@ final class AcademyBillingController extends Controller
'pricing' => \route('academy.pricing'), 'pricing' => \route('academy.pricing'),
'account' => $user ? \route('academy.billing.account') : null, 'account' => $user ? \route('academy.billing.account') : null,
'academy' => \route('academy.index'), 'academy' => \route('academy.index'),
'reportIssue' => $user ? \route('academy.billing.report_issue') : null,
], ],
'sessionId' => $request->query('session_id'), 'sessionId' => $request->query('session_id'),
])->rootView('collections'); ])->rootView('academy');
} }
public function cancel(): \Inertia\Response public function cancel(): \Inertia\Response
@@ -195,7 +388,103 @@ final class AcademyBillingController extends Controller
'pricing' => \route('academy.pricing'), 'pricing' => \route('academy.pricing'),
'academy' => \route('academy.index'), 'academy' => \route('academy.index'),
], ],
])->rootView('collections'); ])->rootView('academy');
}
public function reportIssue(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
{
/** @var User|null $user */
$user = $request->user();
if (! $user instanceof User) {
return redirect()->route('login');
}
$validated = $request->validate([
'message' => ['nullable', 'string', 'max:2000'],
'session_id' => ['nullable', 'string'],
'issue_type' => ['nullable', 'string', 'in:billing,payment,upgrade,downgrade,cancel,access,other'],
'contact_email' => ['nullable', 'email:rfc', 'max:255'],
]);
$payload = [
'id' => (string) Str::uuid(),
'submitted_at' => now()->toISOString(),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'data' => [
'topic' => 'contact',
'name' => (string) ($user->name ?: $user->username ?: 'Academy billing user'),
'email' => (string) ($validated['contact_email'] ?? $user->email),
'message' => $validated['message'] ?? null,
'issue_type' => $validated['issue_type'] ?? 'billing',
'session_id' => $validated['session_id'] ?? $request->query('session_id'),
'source' => 'academy_billing',
'user_id' => (string) $user->id,
'account_email' => (string) $user->email,
'current_url' => $request->fullUrl(),
],
];
try {
try {
Storage::append('staff_applications.jsonl', json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
} catch (\Throwable $e) {
// best-effort store; do not fail the user when file storage is unavailable
}
$application = null;
try {
$application = StaffApplication::create([
'id' => $payload['id'],
'topic' => 'contact',
'name' => $payload['data']['name'],
'email' => $payload['data']['email'],
'role' => 'academy_billing_support',
'portfolio' => null,
'message' => $payload['data']['message'],
'payload' => $payload,
'ip' => $payload['ip'],
'user_agent' => $payload['user_agent'],
]);
} catch (\Throwable $e) {
// ignore DB errors and fall back to a lightweight model for mail
}
$to = config('mail.from.address');
if ($to) {
if (! $application) {
$application = new StaffApplication([
'topic' => 'contact',
'name' => $payload['data']['name'],
'email' => $payload['data']['email'],
'role' => 'academy_billing_support',
'message' => $payload['data']['message'],
'payload' => $payload,
'ip' => $payload['ip'],
'user_agent' => $payload['user_agent'],
]);
$application->id = $payload['id'];
$application->created_at = now();
}
Mail::to($to)->send(new AcademyAccessIssue(
$user,
$payload['data']['message'] ?? null,
$payload['data']['session_id'] ?? null,
$payload['data']['issue_type'] ?? null,
$payload['data']['email'] ?? null,
));
}
return redirect()->back()->with('success', 'Support request sent — we will verify and activate your access shortly.');
} catch (\Throwable $e) {
report($e);
return redirect()->back()->with('error', 'Could not send the support request. Please try again later or email academy@skinbase.org.');
}
} }
public function account(\Illuminate\Http\Request $request): \Inertia\Response public function account(\Illuminate\Http\Request $request): \Inertia\Response
@@ -230,8 +519,10 @@ final class AcademyBillingController extends Controller
'portal' => \route('academy.billing.portal'), 'portal' => \route('academy.billing.portal'),
'pricing' => \route('academy.pricing'), 'pricing' => \route('academy.pricing'),
'academy' => \route('academy.index'), 'academy' => \route('academy.index'),
'checkout' => \route('academy.billing.checkout'),
'reportIssue' => \route('academy.billing.report_issue'),
], ],
])->rootView('collections'); ])->rootView('academy');
} }
/** /**
@@ -279,6 +570,7 @@ final class AcademyBillingController extends Controller
'price_display' => $plan['price_display'], 'price_display' => $plan['price_display'],
'configured' => $plan['configured'], 'configured' => $plan['configured'],
'price_id_valid' => $plan['price_id_valid'], 'price_id_valid' => $plan['price_id_valid'],
'remote_price_exists' => $plan['remote_price_exists'] ?? false,
]] : []; ]] : [];
return [ return [
@@ -64,7 +64,7 @@ final class AcademyChallengeController extends Controller
'isGuest' => $request->user() === null, 'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
], ],
])->rootView('collections'); ])->rootView('academy');
} }
public function show(Request $request, string $slug): Response public function show(Request $request, string $slug): Response
@@ -126,6 +126,6 @@ final class AcademyChallengeController extends Controller
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false), 'isLocked' => (bool) ($payload['locked'] ?? false),
], ],
])->rootView('collections'); ])->rootView('academy');
} }
} }
@@ -47,7 +47,7 @@ final class AcademyChallengeSubmissionController extends Controller
'published_at' => $artwork->published_at?->toISOString(), 'published_at' => $artwork->published_at?->toISOString(),
])->values()->all(), ])->values()->all(),
'submitUrl' => route('academy.challenges.submit.store', ['slug' => $challenge->slug]), 'submitUrl' => route('academy.challenges.submit.store', ['slug' => $challenge->slug]),
])->rootView('collections'); ])->rootView('academy');
} }
public function store(StoreAcademyChallengeSubmissionRequest $request, string $slug): RedirectResponse public function store(StoreAcademyChallengeSubmissionRequest $request, string $slug): RedirectResponse
@@ -85,6 +85,13 @@ final class AcademyCourseController extends Controller
'featuredCourses' => $featuredCourses->all(), 'featuredCourses' => $featuredCourses->all(),
'filters' => $filters, 'filters' => $filters,
'pricingUrl' => route('academy.pricing'), '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' => [ 'analytics' => [
'enabled' => true, 'enabled' => true,
'contentType' => null, 'contentType' => null,
@@ -95,7 +102,7 @@ final class AcademyCourseController extends Controller
'isGuest' => $request->user() === null, 'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
], ],
])->rootView('collections'); ])->rootView('academy');
} }
public function show(Request $request, AcademyCourse $course): Response public function show(Request $request, AcademyCourse $course): Response
@@ -211,6 +218,6 @@ final class AcademyCourseController extends Controller
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => false, 'isLocked' => false,
], ],
])->rootView('collections'); ])->rootView('academy');
} }
} }
@@ -119,6 +119,6 @@ final class AcademyCourseLessonController extends Controller
], ],
'outline' => $courseOutline, 'outline' => $courseOutline,
], ],
])->rootView('collections'); ])->rootView('academy');
} }
} }
@@ -60,10 +60,16 @@ final class AcademyHomeController extends Controller
return Inertia::render('Academy/Index', [ return Inertia::render('Academy/Index', [
'seo' => $seo, 'seo' => $seo,
'pricingUrl' => route('academy.pricing'), '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' => [ 'links' => [
'lessons' => route('academy.lessons.index'), 'lessons' => route('academy.lessons.index'),
'courses' => route('academy.courses.index'), 'courses' => route('academy.courses.index'),
'prompts' => route('academy.prompts.index'), 'prompts' => route('academy.prompts.index'),
'promptPopular' => route('academy.prompts.popular'),
'packs' => route('academy.packs.index'), 'packs' => route('academy.packs.index'),
'challenges' => route('academy.challenges.index'), 'challenges' => route('academy.challenges.index'),
], ],
@@ -92,6 +98,6 @@ final class AcademyHomeController extends Controller
'isGuest' => $request->user() === null, 'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
], ],
])->rootView('collections'); ])->rootView('academy');
} }
} }
@@ -13,6 +13,7 @@ use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyInteractionService; use App\Services\Academy\AcademyInteractionService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType; use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory; use App\Support\Seo\SeoFactory;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Inertia\Inertia; use Inertia\Inertia;
@@ -27,7 +28,7 @@ final class AcademyLessonController extends Controller
private readonly AcademyInteractionService $interactions, 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); 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); $this->analytics->trackSearch((string) $filters['q'], (int) $lessons->total(), array_filter($filters), $request);
} }
if ($request->expectsJson()) {
return response()->json($lessons);
}
$seo = app(SeoFactory::class) $seo = app(SeoFactory::class)
->collectionListing( ->collectionListing(
'Academy Lessons — Skinbase', 'Academy Lessons — Skinbase',
@@ -78,10 +83,21 @@ final class AcademyLessonController extends Controller
'title' => 'Academy lessons', 'title' => 'Academy lessons',
'description' => 'Step-by-step tutorials and workflow guides for AI-assisted creative work on Skinbase.', 'description' => 'Step-by-step tutorials and workflow guides for AI-assisted creative work on Skinbase.',
'seo' => $seo, 'seo' => $seo,
'breadcrumbs' => [
['label' => 'Academy', 'href' => route('academy.index')],
['label' => 'Lessons', 'href' => route('academy.lessons.index')],
],
'items' => $lessons, 'items' => $lessons,
'filters' => $filters, 'filters' => $filters,
'categories' => $this->cache->categoriesByType('lesson'), 'categories' => $this->cache->categoriesByType('lesson'),
'pricingUrl' => route('academy.pricing'), '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' => [ 'analytics' => [
'enabled' => true, 'enabled' => true,
'contentType' => filled($filters['q'] ?? null) ? AcademyAnalyticsContentType::SEARCH : null, 'contentType' => filled($filters['q'] ?? null) ? AcademyAnalyticsContentType::SEARCH : null,
@@ -96,7 +112,7 @@ final class AcademyLessonController extends Controller
'isGuest' => $request->user() === null, 'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
], ],
])->rootView('collections'); ])->rootView('academy');
} }
public function show(Request $request, string $slug): Response public function show(Request $request, string $slug): Response
@@ -204,6 +220,6 @@ final class AcademyLessonController extends Controller
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false), 'isLocked' => (bool) ($payload['locked'] ?? false),
], ],
])->rootView('collections'); ])->rootView('academy');
} }
} }
@@ -79,6 +79,6 @@ final class AcademyPricingController extends Controller
'isGuest' => $request->user() === null, 'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
], ],
])->rootView('collections'); ])->rootView('academy');
} }
} }
@@ -10,11 +10,13 @@ use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyAnalyticsService; use App\Services\Academy\AcademyAnalyticsService;
use App\Services\Academy\AcademyCacheService; use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyInteractionService; use App\Services\Academy\AcademyInteractionService;
use App\Services\Academy\AcademyPopularityService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType; use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory; use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -25,6 +27,7 @@ final class AcademyPromptController extends Controller
private readonly AcademyCacheService $cache, private readonly AcademyCacheService $cache,
private readonly AcademyAnalyticsService $analytics, private readonly AcademyAnalyticsService $analytics,
private readonly AcademyInteractionService $interactions, private readonly AcademyInteractionService $interactions,
private readonly AcademyPopularityService $popularity,
) { ) {
} }
@@ -86,16 +89,32 @@ final class AcademyPromptController extends Controller
return Inertia::render('Academy/List', [ return Inertia::render('Academy/List', [
'pageType' => 'prompts', 'pageType' => 'prompts',
'promptView' => 'library',
'title' => 'Prompt library', 'title' => 'Prompt library',
'description' => 'Reusable prompt templates for wallpapers, worlds, mascots, covers, and digital art workflows.', 'description' => 'Reusable prompt templates for wallpapers, worlds, mascots, covers, and digital art workflows.',
'seo' => $seo, 'seo' => $seo,
'breadcrumbs' => [
['label' => 'Academy', 'href' => route('academy.index')],
['label' => 'Prompt Library', 'href' => route('academy.prompts.index')],
],
'items' => $prompts, 'items' => $prompts,
'filters' => $filters, 'filters' => $filters,
'categories' => $this->cache->categoriesByType('prompt'), 'categories' => $this->cache->categoriesByType('prompt'),
'pricingUrl' => route('academy.pricing'), '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' => [ 'analytics' => [
'enabled' => true, 'enabled' => true,
'contentType' => filled($filters['q'] ?? null) ? AcademyAnalyticsContentType::SEARCH : null, 'contentType' => AcademyAnalyticsContentType::PROMPT_LIBRARY,
'contentId' => null, 'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'), 'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_prompts_index', 'pageName' => 'academy_prompts_index',
@@ -107,7 +126,187 @@ final class AcademyPromptController extends Controller
'isGuest' => $request->user() === null, 'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
], ],
])->rootView('collections'); ])->rootView('academy');
}
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('academy');
}
/**
* @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 public function show(Request $request, string $slug): Response
@@ -167,7 +366,7 @@ final class AcademyPromptController extends Controller
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false), 'isLocked' => (bool) ($payload['locked'] ?? false),
], ],
])->rootView('collections'); ])->rootView('academy');
} }
/** /**
@@ -201,4 +400,70 @@ final class AcademyPromptController extends Controller
], ],
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []); ], 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();
}
} }
@@ -29,7 +29,6 @@ final class AcademyPromptPackController extends Controller
abort_unless((bool) config('academy.enabled', true), 404); abort_unless((bool) config('academy.enabled', true), 404);
$packs = AcademyPromptPack::query() $packs = AcademyPromptPack::query()
->with('prompts')
->active() ->active()
->published() ->published()
->latest('published_at') ->latest('published_at')
@@ -57,7 +56,7 @@ final class AcademyPromptPackController extends Controller
'pricingUrl' => route('academy.pricing'), 'pricingUrl' => route('academy.pricing'),
'analytics' => [ 'analytics' => [
'enabled' => true, 'enabled' => true,
'contentType' => null, 'contentType' => AcademyAnalyticsContentType::PROMPT_PACK_LIBRARY,
'contentId' => null, 'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'), 'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_packs_index', 'pageName' => 'academy_packs_index',
@@ -65,7 +64,7 @@ final class AcademyPromptPackController extends Controller
'isGuest' => $request->user() === null, 'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
], ],
])->rootView('collections'); ])->rootView('academy');
} }
public function show(Request $request, string $slug): Response public function show(Request $request, string $slug): Response
@@ -111,6 +110,6 @@ final class AcademyPromptPackController extends Controller
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false), 'isLocked' => (bool) ($payload['locked'] ?? false),
], ],
])->rootView('collections'); ])->rootView('academy');
} }
} }
@@ -30,6 +30,7 @@ final class ArtworkTagController extends Controller
$queueConnection = (string) config('queue.default', 'sync'); $queueConnection = (string) config('queue.default', 'sync');
$visionEnabled = (bool) config('vision.enabled', true); $visionEnabled = (bool) config('vision.enabled', true);
$autoTaggingEnabled = (bool) config('vision.auto_tagging.enabled', false);
$queuedCount = 0; $queuedCount = 0;
$failedCount = 0; $failedCount = 0;
@@ -56,7 +57,7 @@ final class ArtworkTagController extends Controller
$triggered = false; $triggered = false;
$shouldTrigger = request()->boolean('trigger', false); $shouldTrigger = request()->boolean('trigger', false);
if ($shouldTrigger && $visionEnabled && ! empty($artwork->hash) && $queuedCount === 0) { if ($shouldTrigger && $visionEnabled && $autoTaggingEnabled && ! empty($artwork->hash) && $queuedCount === 0) {
AutoTagArtworkJob::dispatch((int) $artwork->id, (string) $artwork->hash); AutoTagArtworkJob::dispatch((int) $artwork->id, (string) $artwork->hash);
$triggered = true; $triggered = true;
$queuedCount = max(1, $queuedCount); $queuedCount = max(1, $queuedCount);
@@ -89,6 +90,7 @@ final class ArtworkTagController extends Controller
'queued_jobs' => $queuedCount, 'queued_jobs' => $queuedCount,
'failed_jobs' => $failedCount, 'failed_jobs' => $failedCount,
'triggered' => $triggered, 'triggered' => $triggered,
'auto_tagging_enabled' => $autoTaggingEnabled,
'ai_tag_count' => (int) $tags->where('is_ai', true)->count(), 'ai_tag_count' => (int) $tags->where('is_ai', true)->count(),
'total_tag_count' => (int) $tags->count(), 'total_tag_count' => (int) $tags->count(),
], ],
@@ -53,7 +53,12 @@ class LinkPreviewController extends Controller
return response()->json(['error' => 'Invalid URL.'], 422); 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); $resolved = gethostbyname($host);
if ($this->isBlockedIp($resolved)) { if ($this->isBlockedIp($resolved)) {
return response()->json(['error' => 'URL not allowed.'], 422); return response()->json(['error' => 'URL not allowed.'], 422);
@@ -47,7 +47,9 @@ use App\Uploads\Exceptions\DraftQuotaException;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\Group; use App\Models\Group;
use App\Services\GroupArtworkReviewService; use App\Services\GroupArtworkReviewService;
use App\Support\ArtworkDescriptionContentValidator;
use App\Services\Worlds\WorldSubmissionService; use App\Services\Worlds\WorldSubmissionService;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Str; use Illuminate\Support\Str;
final class UploadController extends Controller final class UploadController extends Controller
@@ -237,10 +239,18 @@ final class UploadController extends Controller
} }
// Derivatives are available now; dispatch AI auto-tagging. // Derivatives are available now; dispatch AI auto-tagging.
if ((bool) config('vision.auto_tagging.enabled', false)) {
AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit(); AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit();
}
if ((bool) config('vision.upload.maturity.enabled', false)) {
DetectArtworkMaturityJob::dispatch($artworkId, $validated->hash)->afterCommit(); DetectArtworkMaturityJob::dispatch($artworkId, $validated->hash)->afterCommit();
}
if ((bool) config('vision.upload.embeddings.enabled', true)) {
GenerateArtworkEmbeddingJob::dispatch($artworkId, $validated->hash)->afterCommit(); GenerateArtworkEmbeddingJob::dispatch($artworkId, $validated->hash)->afterCommit();
}
if ((bool) config('vision.upload.ai_assist.enabled', false)) {
AnalyzeArtworkAiAssistJob::dispatch($artworkId)->afterCommit(); AnalyzeArtworkAiAssistJob::dispatch($artworkId)->afterCommit();
}
return UploadSessionStatus::PROCESSED; return UploadSessionStatus::PROCESSED;
}); });
@@ -534,6 +544,8 @@ final class UploadController extends Controller
'nsfw' => ['nullable', 'boolean'], 'nsfw' => ['nullable', 'boolean'],
]); ]);
$this->ensureValidArtworkDescription($validated);
$updates = []; $updates = [];
foreach (['title', 'category_id', 'description', 'tags', 'license', 'nsfw'] as $field) { foreach (['title', 'category_id', 'description', 'tags', 'license', 'nsfw'] as $field) {
if (array_key_exists($field, $validated)) { if (array_key_exists($field, $validated)) {
@@ -635,6 +647,8 @@ final class UploadController extends Controller
'world_submissions.*.source_surface' => ['nullable', 'string', 'max:80'], 'world_submissions.*.source_surface' => ['nullable', 'string', 'max:80'],
]); ]);
$this->ensureValidArtworkDescription($validated);
$mode = $validated['mode'] ?? 'now'; $mode = $validated['mode'] ?? 'now';
$visibility = $validated['visibility'] ?? 'public'; $visibility = $validated['visibility'] ?? 'public';
@@ -814,6 +828,8 @@ final class UploadController extends Controller
'world_submissions.*.source_surface' => ['nullable', 'string', 'max:80'], 'world_submissions.*.source_surface' => ['nullable', 'string', 'max:80'],
]); ]);
$this->ensureValidArtworkDescription($validated);
if (! ctype_digit($id)) { if (! ctype_digit($id)) {
return response()->json(['message' => 'Artwork review submission requires an artwork draft id.'], Response::HTTP_UNPROCESSABLE_ENTITY); return response()->json(['message' => 'Artwork review submission requires an artwork draft id.'], Response::HTTP_UNPROCESSABLE_ENTITY);
} }
@@ -842,4 +858,13 @@ final class UploadController extends Controller
'group_review_status' => (string) $artwork->group_review_status, 'group_review_status' => (string) $artwork->group_review_status,
], Response::HTTP_OK); ], Response::HTTP_OK);
} }
private function ensureValidArtworkDescription(array $validated): void
{
foreach (ArtworkDescriptionContentValidator::errors($validated['description'] ?? null) as $message) {
throw ValidationException::withMessages([
'description' => [$message],
]);
}
}
} }
@@ -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.');
}
}
@@ -42,6 +42,7 @@ class RegisteredUserController extends Controller
return view('auth.register', [ return view('auth.register', [
'prefillEmail' => (string) $request->query('email', ''), 'prefillEmail' => (string) $request->query('email', ''),
'page_canonical' => route('register'),
'turnstile' => [ 'turnstile' => [
'enabled' => $this->turnstileVerifier->isEnabled(), 'enabled' => $this->turnstileVerifier->isEnabled(),
'siteKey' => $this->turnstileVerifier->siteKey(), 'siteKey' => $this->turnstileVerifier->siteKey(),
+208
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,
];
}
}
+18 -1
View File
@@ -79,6 +79,7 @@ class GroupController extends Controller
{ {
$this->authorize('view', $group); $this->authorize('view', $group);
$section = in_array($section, ['overview', 'artworks', 'collections', 'members', 'about', 'posts', 'projects', 'releases', 'challenges', 'events', 'activity'], true) ? $section : 'overview';
$viewer = $request->user(); $viewer = $request->user();
$group->loadMissing('owner.profile'); $group->loadMissing('owner.profile');
$members = collect($this->memberships->mapMembers($group, $viewer)) $members = collect($this->memberships->mapMembers($group, $viewer))
@@ -89,7 +90,8 @@ class GroupController extends Controller
return Inertia::render('Group/GroupShow', [ return Inertia::render('Group/GroupShow', [
'group' => $groupPayload, 'group' => $groupPayload,
'section' => in_array($section, ['overview', 'artworks', 'collections', 'members', 'about', 'posts', 'projects', 'releases', 'challenges', 'events', 'activity'], true) ? $section : 'overview', 'section' => $section,
'seo' => $this->seoPayload($group, $section),
'featuredArtworks' => $this->groups->featuredArtworkCards($group), 'featuredArtworks' => $this->groups->featuredArtworkCards($group),
'artworks' => $this->groups->publicArtworkCards($group), 'artworks' => $this->groups->publicArtworkCards($group),
'featuredCollections' => $this->groups->featuredCollectionCards($group, $viewer), 'featuredCollections' => $this->groups->featuredCollectionCards($group, $viewer),
@@ -140,4 +142,19 @@ class GroupController extends Controller
{ {
return $this->show($request, $group, 'activity'); return $this->show($request, $group, 'activity');
} }
private function seoPayload(Group $group, string $section): array
{
$canonical = $section === 'overview'
? route('groups.show', ['group' => $group])
: route('groups.section', ['group' => $group, 'section' => $section]);
$sectionLabel = $section === 'overview' ? '' : ' '.ucfirst($section);
return [
'title' => trim($group->name.$sectionLabel.' - Skinbase'),
'description' => $group->headline ?: $group->bio ?: 'Skinbase group',
'canonical' => $canonical,
'og_url' => $canonical,
];
}
} }
@@ -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 . '"',
]);
}
}
+10 -2
View File
@@ -79,20 +79,28 @@ class UserController extends Controller
} }
} }
$allowedLegacyMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if ($request->hasFile('personal_picture')) { if ($request->hasFile('personal_picture')) {
$f = $request->file('personal_picture'); $f = $request->file('personal_picture');
$name = $user->id . '.' . $f->getClientOriginalExtension(); if (in_array($f->getMimeType(), $allowedLegacyMimes, true)) {
$ext = $f->guessExtension() ?: 'jpg';
$name = $user->id . '.' . $ext;
$f->move(public_path('user-picture'), $name); $f->move(public_path('user-picture'), $name);
$profileUpdates['cover_image'] = $name; $profileUpdates['cover_image'] = $name;
$user->picture = $name; $user->picture = $name;
} }
}
if ($request->hasFile('emotion_icon')) { if ($request->hasFile('emotion_icon')) {
$f = $request->file('emotion_icon'); $f = $request->file('emotion_icon');
$name = $user->id . '.' . $f->getClientOriginalExtension(); if (in_array($f->getMimeType(), $allowedLegacyMimes, true)) {
$ext = $f->guessExtension() ?: 'jpg';
$name = $user->id . '.' . $ext;
$f->move(public_path('emotion'), $name); $f->move(public_path('emotion'), $name);
$user->eicon = $name; $user->eicon = $name;
} }
}
// Save core user fields // Save core user fields
$user->save(); $user->save();
@@ -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,
];
}
}
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Moderation;
use App\Http\Controllers\Controller;
use App\Models\StaffApplication;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class StaffApplicationsController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->query('q', '')),
'topic' => trim((string) $request->query('topic', 'all')),
];
$query = StaffApplication::query()->latest('created_at');
if ($filters['q'] !== '') {
$search = $filters['q'];
$query->where(function ($builder) use ($search): void {
$builder
->where('name', 'like', '%' . $search . '%')
->orWhere('email', 'like', '%' . $search . '%')
->orWhere('role', 'like', '%' . $search . '%')
->orWhere('message', 'like', '%' . $search . '%');
});
}
if ($filters['topic'] !== '' && $filters['topic'] !== 'all') {
$query->where('topic', $filters['topic']);
}
$items = $query
->paginate(20)
->withQueryString()
->through(fn (StaffApplication $application): array => $this->serializeApplication($application));
$stats = [
'total' => StaffApplication::query()->count(),
'applications' => StaffApplication::query()->where('topic', 'application')->count(),
'bug' => StaffApplication::query()->where('topic', 'bug')->count(),
'contact' => StaffApplication::query()->where('topic', 'contact')->count(),
'other' => StaffApplication::query()->whereNotIn('topic', ['application', 'bug', 'contact'])->count(),
];
$topics = StaffApplication::query()
->select('topic')
->distinct()
->orderBy('topic')
->pluck('topic')
->values()
->all();
return Inertia::render('Moderation/StaffApplications/Index', [
'title' => 'Staff Applications',
'items' => $items,
'filters' => $filters,
'stats' => $stats,
'topics' => $topics,
'endpoints' => [
'index' => route('admin.staff-applications.index'),
],
])->rootView('moderation');
}
public function show(StaffApplication $staffApplication): Response
{
return Inertia::render('Moderation/StaffApplications/Show', [
'title' => 'Staff Application',
'item' => $this->serializeApplication($staffApplication, true),
'backUrl' => route('admin.staff-applications.index'),
])->rootView('moderation');
}
private function serializeApplication(StaffApplication $application, bool $detailed = false): array
{
return [
'id' => (string) $application->id,
'topic' => (string) ($application->topic ?: 'contact'),
'name' => (string) ($application->name ?: 'Unknown'),
'email' => (string) ($application->email ?: ''),
'role' => $application->role,
'portfolio' => $application->portfolio,
'message' => $application->message,
'ip' => $application->ip,
'user_agent' => $application->user_agent,
'created_at' => optional($application->created_at)?->toIso8601String(),
'payload' => $detailed ? ($application->payload ?? []) : [],
'show_url' => route('admin.staff-applications.show', ['staffApplication' => $application]),
];
}
}
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Moderation;
use App\Http\Controllers\Controller;
use App\Models\Story;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class StoriesController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->query('q', '')),
'status' => trim((string) $request->query('status', 'all')),
];
$query = Story::query()
->with('creator:id,name,username')
->latest('created_at')
->latest('id');
if ($filters['q'] !== '') {
$search = $filters['q'];
$query->where(function ($builder) use ($search): void {
$builder
->where('title', 'like', '%' . $search . '%')
->orWhere('slug', 'like', '%' . $search . '%')
->orWhereHas('creator', function ($creatorQuery) use ($search): void {
$creatorQuery
->where('name', 'like', '%' . $search . '%')
->orWhere('username', 'like', '%' . $search . '%');
});
});
}
if ($filters['status'] !== '' && $filters['status'] !== 'all') {
$query->where('status', $filters['status']);
}
$stories = $query
->paginate(24)
->withQueryString()
->through(fn (Story $story): array => $this->serializeStory($story));
$statsQuery = Story::query();
if ($filters['q'] !== '') {
$search = $filters['q'];
$statsQuery->where(function ($builder) use ($search): void {
$builder
->where('title', 'like', '%' . $search . '%')
->orWhere('slug', 'like', '%' . $search . '%')
->orWhereHas('creator', function ($creatorQuery) use ($search): void {
$creatorQuery
->where('name', 'like', '%' . $search . '%')
->orWhere('username', 'like', '%' . $search . '%');
});
});
}
$stats = [
'total' => (clone $statsQuery)->count(),
'published' => (clone $statsQuery)->where('status', 'published')->count(),
'draft' => (clone $statsQuery)->where('status', 'draft')->count(),
'scheduled' => (clone $statsQuery)->where('status', 'scheduled')->count(),
'pending_review' => (clone $statsQuery)->where('status', 'pending_review')->count(),
'archived' => (clone $statsQuery)->where('status', 'archived')->count(),
];
return Inertia::render('Moderation/Stories', [
'title' => 'Stories',
'stories' => $stories,
'filters' => $filters,
'stats' => $stats,
'endpoints' => [
'index' => route('admin.stories'),
],
])->rootView('moderation');
}
private function serializeStory(Story $story): array
{
return [
'id' => (int) $story->id,
'title' => (string) ($story->title ?: 'Untitled story'),
'slug' => (string) $story->slug,
'excerpt' => $story->excerpt,
'status' => (string) ($story->status ?: 'draft'),
'published_at' => optional($story->published_at)?->toIso8601String(),
'created_at' => optional($story->created_at)?->toIso8601String(),
'cover_url' => $story->coverUrl,
'public_url' => $story->url,
'open_url' => $story->status === 'published' ? $story->url : null,
'creator' => $story->creator ? [
'id' => (int) $story->creator->id,
'name' => (string) $story->creator->name,
'username' => (string) $story->creator->username,
] : null,
];
}
}
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Moderation;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Inertia\Response;
final class UsernameQueueController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->query('q', '')),
'status' => trim((string) $request->query('status', 'pending')),
];
$requestColumns = Schema::hasTable('username_approval_requests')
? Schema::getColumnListing('username_approval_requests')
: [];
$query = DB::table('username_approval_requests as requests')
->leftJoin('users', 'users.id', '=', 'requests.user_id')
->select([
'requests.id',
'requests.user_id',
'requests.requested_username',
'requests.status',
'requests.context',
'requests.similar_to',
'requests.review_note',
'requests.reviewed_at',
'requests.created_at',
'users.username as current_username',
'users.name as current_name',
])
->orderByDesc('requests.created_at');
if ($filters['status'] !== '' && $filters['status'] !== 'all') {
$query->where('requests.status', $filters['status']);
}
if ($filters['q'] !== '') {
$search = $filters['q'];
$query->where(function ($builder) use ($search): void {
$builder
->where('requests.requested_username', 'like', '%' . $search . '%')
->orWhere('requests.context', 'like', '%' . $search . '%')
->orWhere('users.username', 'like', '%' . $search . '%')
->orWhere('users.name', 'like', '%' . $search . '%');
});
}
$requests = $query->paginate(20)->withQueryString()->through(function ($row): array {
return [
'id' => (int) $row->id,
'user_id' => $row->user_id !== null ? (int) $row->user_id : null,
'requested_username' => (string) $row->requested_username,
'status' => (string) ($row->status ?? 'pending'),
'context' => $row->context ?? null,
'similar_to' => $row->similar_to ?? null,
'review_note' => $row->review_note ?? null,
'reviewed_at' => $this->serializeTimestamp($row->reviewed_at ?? null),
'created_at' => $this->serializeTimestamp($row->created_at ?? null),
'current_username' => $row->current_username,
'current_name' => $row->current_name,
'approve_url' => route('api.admin.usernames.approve', ['id' => $row->id]),
'reject_url' => route('api.admin.usernames.reject', ['id' => $row->id]),
];
});
$stats = [
'total' => Schema::hasTable('username_approval_requests') ? DB::table('username_approval_requests')->count() : 0,
'pending' => Schema::hasTable('username_approval_requests') ? DB::table('username_approval_requests')->where('status', 'pending')->count() : 0,
'approved' => Schema::hasTable('username_approval_requests') ? DB::table('username_approval_requests')->where('status', 'approved')->count() : 0,
'rejected' => Schema::hasTable('username_approval_requests') ? DB::table('username_approval_requests')->where('status', 'rejected')->count() : 0,
];
return Inertia::render('Moderation/UsernameQueue', [
'title' => 'Username Queue',
'requests' => $requests,
'stats' => $stats,
'filters' => $filters,
'options' => [
'statuses' => [
['value' => 'all', 'label' => 'All statuses'],
['value' => 'pending', 'label' => 'Pending'],
['value' => 'approved', 'label' => 'Approved'],
['value' => 'rejected', 'label' => 'Rejected'],
],
],
'endpoints' => [
'index' => route('admin.usernames'),
'refresh' => route('admin.usernames'),
],
])->rootView('moderation');
}
private function serializeTimestamp(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}
try {
return \Illuminate\Support\Carbon::parse((string) $value)->toIso8601String();
} catch (\Throwable) {
return null;
}
}
}
@@ -132,6 +132,32 @@ class NewsController extends Controller
] + $this->sidebarData()); ] + $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} // Article page — /news/{slug}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -173,6 +199,7 @@ class NewsController extends Controller
return; return;
} }
try {
NewsView::create([ NewsView::create([
'article_id' => $article->id, 'article_id' => $article->id,
'user_id' => $userId, 'user_id' => $userId,
@@ -181,6 +208,12 @@ class NewsController extends Controller
]); ]);
$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) { if ($canReadSession) {
$request->session()->put($session, true); $request->session()->put($session, true);
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\News;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use cPad\Plugins\News\Models\NewsArticle; use cPad\Plugins\News\Models\NewsArticle;
class NewsRssController extends Controller class NewsRssController extends Controller
@@ -14,13 +15,17 @@ class NewsRssController extends Controller
*/ */
public function feed(): Response public function feed(): Response
{ {
$ttl = max(60, (int) config('news.rss_cache_ttl', 300));
$xml = Cache::remember('news.rss.feed', $ttl, function (): string {
$articles = NewsArticle::with('author', 'category') $articles = NewsArticle::with('author', 'category')
->published() ->published()
->orderByDesc('published_at') ->orderByDesc('published_at')
->limit(config('news.rss_limit', 25)) ->limit(config('news.rss_limit', 25))
->get(); ->get();
$xml = $this->buildRss($articles); return $this->buildRss($articles);
});
return response($xml, 200, [ return response($xml, 200, [
'Content-Type' => 'application/rss+xml; charset=UTF-8', 'Content-Type' => 'application/rss+xml; charset=UTF-8',
@@ -6,11 +6,13 @@ namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\AcademyContentMetricDaily; use App\Models\AcademyContentMetricDaily;
use App\Models\AcademyEvent;
use App\Models\AcademySearchLog; use App\Models\AcademySearchLog;
use App\Services\Academy\AcademyAnalyticsContentResolver; use App\Services\Academy\AcademyAnalyticsContentResolver;
use App\Services\Academy\AcademyContentIntelligenceService; use App\Services\Academy\AcademyContentIntelligenceService;
use App\Services\Academy\AcademyPopularityService; use App\Services\Academy\AcademyPopularityService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType; use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\AcademyAnalytics\AcademyAnalyticsEventType;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -29,6 +31,9 @@ final class AcademyAdminAnalyticsController extends Controller
public function overview(Request $request): Response public function overview(Request $request): Response
{ {
[$from, $to, $range] = $this->resolveDateRange($request); [$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) $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') ->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), 'courseStarts' => (int) ($summary?->starts ?? 0),
'upgradeClicks' => (int) ($summary?->upgrade_clicks ?? 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)), 'topContent' => $this->serializeContentRows($this->popularity->topContent($from, $to, 8)),
'topWeek' => $this->serializeContentRows($this->popularity->topContent(now()->subDays(6)->startOfDay(), now()->endOfDay(), 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.'); 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 public function lessons(Request $request): Response
{ {
return $this->renderContentPage($request, AcademyAnalyticsContentType::LESSON, 'Lesson analytics', 'Lesson engagement, starts, completions, and drop-off signals.'); 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, 'access' => $access,
'content_type' => $contentType, 'content_type' => $contentType,
], ],
'summary' => $contentType === AcademyAnalyticsContentType::PROMPT_LIBRARY
? $this->contentSummary(AcademyAnalyticsContentType::PROMPT_LIBRARY, $from, $to)
: null,
'rows' => $serializedRows, 'rows' => $serializedRows,
'contentTypeOptions' => [ 'contentTypeOptions' => [
['value' => '', 'label' => 'All content'], ['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::PROMPT, 'label' => 'Prompts'],
['value' => AcademyAnalyticsContentType::LESSON, 'label' => 'Lessons'], ['value' => AcademyAnalyticsContentType::LESSON, 'label' => 'Lessons'],
['value' => AcademyAnalyticsContentType::COURSE, 'label' => 'Courses'], ['value' => AcademyAnalyticsContentType::COURSE, 'label' => 'Courses'],
@@ -359,7 +389,134 @@ final class AcademyAdminAnalyticsController extends Controller
private function metricsQuery(Carbon $from, Carbon $to) private function metricsQuery(Carbon $from, Carbon $to)
{ {
return AcademyContentMetricDaily::query() 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' => 'Overview', 'href' => route('admin.academy.analytics.overview')],
['label' => 'Intelligence', 'href' => route('admin.academy.analytics.intelligence')], ['label' => 'Intelligence', 'href' => route('admin.academy.analytics.intelligence')],
['label' => 'Content', 'href' => route('admin.academy.analytics.content')], ['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' => 'Prompts', 'href' => route('admin.academy.analytics.prompts')],
['label' => 'Lessons', 'href' => route('admin.academy.analytics.lessons')], ['label' => 'Lessons', 'href' => route('admin.academy.analytics.lessons')],
['label' => 'Courses', 'href' => route('admin.academy.analytics.courses')], ['label' => 'Courses', 'href' => route('admin.academy.analytics.courses')],
@@ -17,6 +17,7 @@ use App\Models\AcademyBadge;
use App\Models\AcademyCategory; use App\Models\AcademyCategory;
use App\Models\AcademyChallenge; use App\Models\AcademyChallenge;
use App\Models\AcademyChallengeSubmission; use App\Models\AcademyChallengeSubmission;
use App\Models\AcademyContentMetricDaily;
use App\Models\AcademyCourse; use App\Models\AcademyCourse;
use App\Models\AcademyCourseLesson; use App\Models\AcademyCourseLesson;
use App\Models\AcademyCourseSection; use App\Models\AcademyCourseSection;
@@ -31,6 +32,7 @@ use App\Services\Academy\AcademyAdminBillingOverviewService;
use App\Services\Academy\AcademyCacheService; use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyCourseLessonOrderingService; use App\Services\Academy\AcademyCourseLessonOrderingService;
use App\Services\Academy\AcademyLessonMarkdownRenderer; use App\Services\Academy\AcademyLessonMarkdownRenderer;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -604,34 +606,48 @@ final class AcademyAdminController extends Controller
$meta = $this->resourceMeta($resource); $meta = $this->resourceMeta($resource);
$search = trim((string) request()->query('search', '')); $search = trim((string) request()->query('search', ''));
$query = $meta['model']::query(); $query = $meta['model']::query();
$filters = [
'search' => $search,
];
$summary = null;
if ($resource === 'courses') { if ($resource === 'courses') {
$query->withCount('courseLessons'); $query->withCount('courseLessons');
if ($search !== '') { if ($search !== '') {
$query->where(function ($builder) use ($search): void { $this->applyCourseAdminSearch($query, $search);
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
} }
$query->orderByDesc('is_featured') $query->orderByDesc('is_featured')
->orderBy('order_num') ->orderBy('order_num')
->orderByDesc('updated_at') ->orderByDesc('updated_at')
->orderByDesc('id'); ->orderByDesc('id');
} elseif ($resource === 'prompts') {
$query->with('category');
$query->withSum(['metrics as total_views' => function ($builder): void {
$builder->where('content_type', AcademyAnalyticsContentType::PROMPT);
}], 'views');
$promptFilters = [
'category' => (string) request()->query('category', 'all'),
'featured' => (string) request()->query('featured', 'all'),
'prompt_of_week' => (string) request()->query('prompt_of_week', 'all'),
'active' => (string) request()->query('active', 'all'),
'access_level' => (string) request()->query('access_level', 'all'),
'difficulty' => (string) request()->query('difficulty', 'all'),
'order' => (string) request()->query('order', 'updated_desc'),
];
$filters = array_merge($filters, $promptFilters);
$this->applyPromptAdminSearch($query, $search);
$this->applyPromptAdminFilters($query, $promptFilters);
$this->applyPromptAdminOrdering($query, $promptFilters['order']);
$summary = $this->promptAdminSummary($search, $promptFilters);
} else { } else {
$query->latest('updated_at'); $query->latest('updated_at');
} }
if ($resource === 'prompts') {
$query->with('category');
}
if ($resource === 'lessons') { if ($resource === 'lessons') {
$query->with('courses:id,title'); $query->with('courses:id,title');
} }
@@ -646,48 +662,191 @@ final class AcademyAdminController extends Controller
'items' => $items, 'items' => $items,
'columns' => $meta['columns'], 'columns' => $meta['columns'],
'createUrl' => route($meta['route_base'].'.create'), 'createUrl' => route($meta['route_base'].'.create'),
'filters' => [ 'filters' => $filters,
'search' => $search, 'filterOptions' => $resource === 'prompts' ? [
'categories' => $this->promptAdminCategoryFilterOptions(),
'difficulty' => $this->filterOptionsWithAll($this->difficultyOptions(), 'All difficulties'),
'access' => $this->filterOptionsWithAll($this->accessOptions(), 'All access levels'),
'featured' => [
['value' => 'all', 'label' => 'Any featured state'],
['value' => 'yes', 'label' => 'Featured only'],
['value' => 'no', 'label' => 'Not featured'],
], ],
'promptOfWeek' => [
['value' => 'all', 'label' => 'Any weekly state'],
['value' => 'yes', 'label' => 'Prompt of the week'],
['value' => 'no', 'label' => 'Not prompt of the week'],
],
'active' => [
['value' => 'all', 'label' => 'Any visibility state'],
['value' => 'active', 'label' => 'Active only'],
['value' => 'inactive', 'label' => 'Inactive only'],
],
'order' => [
['value' => 'updated_desc', 'label' => 'Updated newest'],
['value' => 'updated_asc', 'label' => 'Updated oldest'],
['value' => 'views_desc', 'label' => 'Most viewed'],
['value' => 'title_asc', 'label' => 'Title A-Z'],
['value' => 'title_desc', 'label' => 'Title Z-A'],
['value' => 'access_asc', 'label' => 'Access'],
['value' => 'difficulty_asc', 'label' => 'Difficulty'],
['value' => 'featured_desc', 'label' => 'Featured first'],
],
] : null,
'summary' => $resource === 'courses' ? [ 'summary' => $resource === 'courses' ? [
'total' => (int) $items->total(), 'total' => (int) $items->total(),
'published' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void { 'published' => (int) (clone $meta['model']::query())->tap(fn ($builder) => $this->applyCourseAdminSearch($builder, $search))->where('status', AcademyCourse::STATUS_PUBLISHED)->count(),
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%'; 'featured' => (int) (clone $meta['model']::query())->tap(fn ($builder) => $this->applyCourseAdminSearch($builder, $search))->where('is_featured', true)->count(),
'drafts' => (int) (clone $meta['model']::query())->tap(fn ($builder) => $this->applyCourseAdminSearch($builder, $search))->where('status', AcademyCourse::STATUS_DRAFT)->count(),
$builder->where(function ($inner) use ($like): void { ] : $summary,
$inner->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
})->where('status', AcademyCourse::STATUS_PUBLISHED)->count(),
'featured' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where(function ($inner) use ($like): void {
$inner->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
})->where('is_featured', true)->count(),
'drafts' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$builder->where(function ($inner) use ($like): void {
$inner->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
})->where('status', AcademyCourse::STATUS_DRAFT)->count(),
] : null,
]); ]);
} }
private function applyCourseAdminSearch($query, string $search): void
{
if ($search === '') {
return;
}
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$query->where(function ($builder) use ($like): void {
$builder->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('subtitle', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('description', 'like', $like);
});
}
private function applyPromptAdminSearch($query, string $search): void
{
if ($search === '') {
return;
}
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
$query->where(function ($builder) use ($like): void {
$builder->where('title', 'like', $like)
->orWhere('slug', 'like', $like)
->orWhere('excerpt', 'like', $like)
->orWhere('prompt', 'like', $like)
->orWhere('negative_prompt', 'like', $like)
->orWhere('usage_notes', 'like', $like)
->orWhere('workflow_notes', 'like', $like)
->orWhereHas('category', function ($categoryQuery) use ($like): void {
$categoryQuery->where('name', 'like', $like);
});
});
}
private function applyPromptAdminFilters($query, array $filters, bool $includeAccessFilter = true): void
{
$category = (string) ($filters['category'] ?? 'all');
$featured = (string) ($filters['featured'] ?? 'all');
$promptOfWeek = (string) ($filters['prompt_of_week'] ?? 'all');
$active = (string) ($filters['active'] ?? 'all');
$accessLevel = (string) ($filters['access_level'] ?? 'all');
$difficulty = (string) ($filters['difficulty'] ?? 'all');
if ($category === 'uncategorized') {
$query->whereNull('category_id');
} elseif ($category !== '' && $category !== 'all' && ctype_digit($category)) {
$query->where('category_id', (int) $category);
}
if ($featured === 'yes') {
$query->where('featured', true);
} elseif ($featured === 'no') {
$query->where('featured', false);
}
if ($promptOfWeek === 'yes') {
$query->where('prompt_of_week', true);
} elseif ($promptOfWeek === 'no') {
$query->where('prompt_of_week', false);
}
if ($active === 'active') {
$query->where('active', true);
} elseif ($active === 'inactive') {
$query->where('active', false);
}
if ($includeAccessFilter && in_array($accessLevel, ['free', 'creator', 'pro'], true)) {
$query->where('access_level', $accessLevel);
}
if (in_array($difficulty, array_column($this->difficultyOptions(), 'value'), true)) {
$query->where('difficulty', $difficulty);
}
}
private function applyPromptAdminOrdering($query, string $order): void
{
match ($order) {
'updated_asc' => $query->orderBy('updated_at')->orderBy('id'),
'views_desc' => $query->orderByDesc('total_views')->orderByDesc('updated_at')->orderByDesc('id'),
'title_asc' => $query->orderBy('title')->orderByDesc('updated_at'),
'title_desc' => $query->orderByDesc('title')->orderByDesc('updated_at'),
'access_asc' => $query->orderByRaw("FIELD(access_level, 'free', 'creator', 'pro')")->orderBy('title'),
'difficulty_asc' => $query->orderBy('difficulty')->orderBy('title'),
'featured_desc' => $query->orderByDesc('featured')->orderByDesc('prompt_of_week')->orderBy('title'),
default => $query->orderByDesc('updated_at')->orderByDesc('id'),
};
}
private function promptAdminSummary(string $search, array $filters): array
{
$summaryQuery = AcademyPromptTemplate::query();
$accessSummaryQuery = AcademyPromptTemplate::query();
$this->applyPromptAdminSearch($summaryQuery, $search);
$this->applyPromptAdminFilters($summaryQuery, $filters);
$this->applyPromptAdminSearch($accessSummaryQuery, $search);
$this->applyPromptAdminFilters($accessSummaryQuery, $filters, false);
return [
'total' => (int) $summaryQuery->count(),
'active' => (int) (clone $summaryQuery)->where('active', true)->count(),
'featured' => (int) (clone $summaryQuery)->where('featured', true)->count(),
'promptOfWeek' => (int) (clone $summaryQuery)->where('prompt_of_week', true)->count(),
'access' => [
'free' => (int) (clone $accessSummaryQuery)->where('access_level', 'free')->count(),
'creator' => (int) (clone $accessSummaryQuery)->where('access_level', 'creator')->count(),
'pro' => (int) (clone $accessSummaryQuery)->where('access_level', 'pro')->count(),
],
];
}
private function promptAdminCategoryFilterOptions(): array
{
return AcademyCategory::query()
->where('type', 'prompt')
->orderBy('order_num')
->orderBy('name')
->get()
->map(fn (AcademyCategory $category): array => ['value' => (string) $category->id, 'label' => $category->name])
->prepend(['value' => 'uncategorized', 'label' => 'Uncategorized'])
->prepend(['value' => 'all', 'label' => 'All categories'])
->values()
->all();
}
private function filterOptionsWithAll(array $options, string $allLabel): array
{
return collect($options)
->map(fn (array $option): array => [
'value' => (string) ($option['value'] ?? ''),
'label' => (string) ($option['label'] ?? $option['value'] ?? ''),
])
->prepend(['value' => 'all', 'label' => $allLabel])
->values()
->all();
}
private function renderForm(string $resource, Model $record): Response private function renderForm(string $resource, Model $record): Response
{ {
$meta = $this->resourceMeta($resource); $meta = $this->resourceMeta($resource);
@@ -767,6 +926,17 @@ final class AcademyAdminController extends Controller
]; ];
} }
if ($resource === 'challenges') {
return [
'links' => array_filter([
'preview' => $record->exists ? route('academy.challenges.show', ['slug' => $record->slug]) : null,
]),
'coverUploadUrl' => route('api.studio.academy.lessons.media.upload'),
'coverDeleteUrl' => route('api.studio.academy.lessons.media.destroy'),
'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
];
}
if ($resource !== 'lessons') { if ($resource !== 'lessons') {
return []; return [];
} }
@@ -882,7 +1052,7 @@ final class AcademyAdminController extends Controller
'singular' => 'prompt template', 'singular' => 'prompt template',
'subtitle' => 'Manage prompt previews, premium prompts, and prompt of the week.', 'subtitle' => 'Manage prompt previews, premium prompts, and prompt of the week.',
'route_base' => 'admin.academy.prompts', 'route_base' => 'admin.academy.prompts',
'columns' => ['title', 'difficulty', 'access_level', 'prompt_of_week', 'active'], 'columns' => ['title', 'category_name', 'difficulty', 'access_level', 'prompt_of_week', 'active'],
'fields' => [ 'fields' => [
['name' => 'category_id', 'label' => 'Category', 'type' => 'select', 'options' => $this->categoryOptions('prompt')], ['name' => 'category_id', 'label' => 'Category', 'type' => 'select', 'options' => $this->categoryOptions('prompt')],
['name' => 'title', 'label' => 'Title', 'type' => 'text'], ['name' => 'title', 'label' => 'Title', 'type' => 'text'],
@@ -896,6 +1066,7 @@ final class AcademyAdminController extends Controller
['name' => 'placeholders', 'label' => 'Placeholders JSON', 'type' => 'json'], ['name' => 'placeholders', 'label' => 'Placeholders JSON', 'type' => 'json'],
['name' => 'helper_prompts', 'label' => 'Helper Prompts JSON', 'type' => 'json'], ['name' => 'helper_prompts', 'label' => 'Helper Prompts JSON', 'type' => 'json'],
['name' => 'prompt_variants', 'label' => 'Prompt Variants JSON', 'type' => 'json'], ['name' => 'prompt_variants', 'label' => 'Prompt Variants JSON', 'type' => 'json'],
['name' => 'filled_examples', 'label' => 'Filled Examples JSON', 'type' => 'json'],
['name' => 'difficulty', 'label' => 'Difficulty', 'type' => 'select', 'options' => $this->difficultyOptions()], ['name' => 'difficulty', 'label' => 'Difficulty', 'type' => 'select', 'options' => $this->difficultyOptions()],
['name' => 'access_level', 'label' => 'Access', 'type' => 'select', 'options' => $this->accessOptions()], ['name' => 'access_level', 'label' => 'Access', 'type' => 'select', 'options' => $this->accessOptions()],
['name' => 'aspect_ratio', 'label' => 'Aspect Ratio', 'type' => 'text'], ['name' => 'aspect_ratio', 'label' => 'Aspect Ratio', 'type' => 'text'],
@@ -1037,6 +1208,7 @@ final class AcademyAdminController extends Controller
'active' => (bool) $model->active, 'active' => (bool) $model->active,
'preview_image_url' => $this->resolvePromptPreviewImageUrl((string) ($model->preview_image ?? '')), 'preview_image_url' => $this->resolvePromptPreviewImageUrl((string) ($model->preview_image ?? '')),
'comparisons_count' => count($this->serializePromptToolNotes((array) ($model->tool_notes ?? []))), 'comparisons_count' => count($this->serializePromptToolNotes((array) ($model->tool_notes ?? []))),
'views_count' => (int) ($model->total_views ?? 0),
'tags' => array_values(array_filter(array_map(static fn ($tag): string => trim((string) $tag), (array) ($model->tags ?? [])))), 'tags' => array_values(array_filter(array_map(static fn ($tag): string => trim((string) $tag), (array) ($model->tags ?? [])))),
'updated_at' => optional($model->updated_at)->toIso8601String(), 'updated_at' => optional($model->updated_at)->toIso8601String(),
'preview_url' => route('academy.prompts.show', ['slug' => $model->slug]), 'preview_url' => route('academy.prompts.show', ['slug' => $model->slug]),
@@ -1156,6 +1328,7 @@ final class AcademyAdminController extends Controller
'placeholders' => $this->encodePrettyJsonForForm($record->placeholders), 'placeholders' => $this->encodePrettyJsonForForm($record->placeholders),
'helper_prompts' => $this->encodePrettyJsonForForm($record->helper_prompts), 'helper_prompts' => $this->encodePrettyJsonForForm($record->helper_prompts),
'prompt_variants' => $this->encodePrettyJsonForForm($record->prompt_variants), 'prompt_variants' => $this->encodePrettyJsonForForm($record->prompt_variants),
'filled_examples' => $this->encodePrettyJsonForForm($record->filled_examples),
'difficulty' => (string) ($record->difficulty ?? 'beginner'), 'difficulty' => (string) ($record->difficulty ?? 'beginner'),
'access_level' => (string) ($record->access_level ?? 'free'), 'access_level' => (string) ($record->access_level ?? 'free'),
'aspect_ratio' => (string) ($record->aspect_ratio ?? ''), 'aspect_ratio' => (string) ($record->aspect_ratio ?? ''),
@@ -1200,6 +1373,7 @@ final class AcademyAdminController extends Controller
'voting_starts_at' => optional($record->voting_starts_at)?->format('Y-m-d\TH:i'), 'voting_starts_at' => optional($record->voting_starts_at)?->format('Y-m-d\TH:i'),
'voting_ends_at' => optional($record->voting_ends_at)?->format('Y-m-d\TH:i'), 'voting_ends_at' => optional($record->voting_ends_at)?->format('Y-m-d\TH:i'),
'cover_image' => (string) ($record->cover_image ?? ''), 'cover_image' => (string) ($record->cover_image ?? ''),
'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($record->cover_image ?? '')),
'prize_text' => (string) ($record->prize_text ?? ''), 'prize_text' => (string) ($record->prize_text ?? ''),
'required_tags' => implode(', ', (array) ($record->required_tags ?? [])), 'required_tags' => implode(', ', (array) ($record->required_tags ?? [])),
'allowed_categories' => implode(', ', (array) ($record->allowed_categories ?? [])), 'allowed_categories' => implode(', ', (array) ($record->allowed_categories ?? [])),
@@ -2162,6 +2336,7 @@ final class AcademyAdminController extends Controller
$validated['placeholders'] = $this->normalizePromptPlaceholders($validated['placeholders'] ?? null); $validated['placeholders'] = $this->normalizePromptPlaceholders($validated['placeholders'] ?? null);
$validated['helper_prompts'] = $this->normalizePromptHelperPrompts($validated['helper_prompts'] ?? null); $validated['helper_prompts'] = $this->normalizePromptHelperPrompts($validated['helper_prompts'] ?? null);
$validated['prompt_variants'] = $this->normalizePromptVariants($validated['prompt_variants'] ?? null); $validated['prompt_variants'] = $this->normalizePromptVariants($validated['prompt_variants'] ?? null);
$validated['filled_examples'] = $this->normalizePromptFilledExamples($validated['filled_examples'] ?? null);
$validated['tool_notes'] = $this->normalizePromptToolNotes((array) ($validated['tool_notes'] ?? [])); $validated['tool_notes'] = $this->normalizePromptToolNotes((array) ($validated['tool_notes'] ?? []));
$previousToolNotes = $this->normalizePromptToolNotes((array) ($prompt?->tool_notes ?? [])); $previousToolNotes = $this->normalizePromptToolNotes((array) ($prompt?->tool_notes ?? []));
@@ -2370,6 +2545,56 @@ final class AcademyAdminController extends Controller
->all(); ->all();
} }
/**
* @return array<int, array<string, mixed>>
*/
private function normalizePromptFilledExamples(mixed $filledExamples): array
{
if (! is_array($filledExamples)) {
return [];
}
return collect($filledExamples)
->filter(static fn ($example): bool => is_array($example))
->map(function (array $example): array {
return [
'title' => $this->nullableTrimmedString($example['title'] ?? null),
'description' => $this->nullableTrimmedString($example['description'] ?? null),
'placeholder_values' => collect(is_array($example['placeholder_values'] ?? null) ? $example['placeholder_values'] : [])
->mapWithKeys(function ($value, $key): array {
$normalizedKey = trim((string) $key);
if ($normalizedKey === '') {
return [];
}
$normalizedValue = $this->normalizePromptJsonValue($value);
if ($normalizedValue === null || $normalizedValue === '' || $normalizedValue === []) {
return [];
}
return [$normalizedKey => $normalizedValue];
})
->all(),
'prompt' => $this->nullableTrimmedString($example['prompt'] ?? null),
'negative_prompt' => $this->nullableTrimmedString($example['negative_prompt'] ?? null),
];
})
->filter(function (array $example): bool {
return collect([
$example['title'] ?? null,
$example['description'] ?? null,
$example['prompt'] ?? null,
$example['negative_prompt'] ?? null,
$example['placeholder_values'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []);
})
->take(5)
->values()
->all();
}
/** /**
* @return array<int, string> * @return array<int, string>
*/ */
@@ -27,8 +27,10 @@ class FeaturedArtworkAdminController extends Controller
{ {
$isAdminSurface = $request->routeIs('admin.artworks.featured.*'); $isAdminSurface = $request->routeIs('admin.artworks.featured.*');
$routePrefix = $isAdminSurface ? 'admin.artworks.featured.' : 'admin.cp.artworks.featured.'; $routePrefix = $isAdminSurface ? 'admin.artworks.featured.' : 'admin.cp.artworks.featured.';
$pageName = $isAdminSurface ? 'Moderation/FeaturedArtworks' : 'Collection/FeaturedArtworksAdmin';
$rootView = $isAdminSurface ? 'moderation' : 'collections';
return Inertia::render($isAdminSurface ? 'Admin/FeaturedArtworks' : 'Collection/FeaturedArtworksAdmin', array_merge( return Inertia::render($pageName, array_merge(
$this->featuredArtworks->pageProps(), $this->featuredArtworks->pageProps(),
[ [
'endpoints' => [ 'endpoints' => [
@@ -49,7 +51,7 @@ class FeaturedArtworkAdminController extends Controller
'robots' => 'index,follow', 'robots' => 'index,follow',
], ],
], ],
))->rootView($isAdminSurface ? 'admin' : 'collections'); ))->rootView($rootView);
} }
public function search(Request $request): JsonResponse public function search(Request $request): JsonResponse
@@ -18,6 +18,7 @@ use App\Services\TagService;
use App\Services\ArtworkVersioningService; use App\Services\ArtworkVersioningService;
use App\Services\Studio\StudioArtworkQueryService; use App\Services\Studio\StudioArtworkQueryService;
use App\Services\Studio\StudioBulkActionService; use App\Services\Studio\StudioBulkActionService;
use App\Support\ArtworkDescriptionContentValidator;
use App\Services\Tags\TagDiscoveryService; use App\Services\Tags\TagDiscoveryService;
use App\Services\Worlds\WorldSubmissionService; use App\Services\Worlds\WorldSubmissionService;
use Carbon\Carbon; use Carbon\Carbon;
@@ -164,6 +165,8 @@ final class StudioArtworksApiController extends Controller
'evolution_note' => 'sometimes|nullable|string|max:1200', 'evolution_note' => 'sometimes|nullable|string|max:1200',
]); ]);
$this->ensureValidArtworkDescription($validated);
$hasAttributionUpdates = array_key_exists('group', $validated) $hasAttributionUpdates = array_key_exists('group', $validated)
|| array_key_exists('primary_author_user_id', $validated) || array_key_exists('primary_author_user_id', $validated)
|| array_key_exists('contributor_user_ids', $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 public function evolutionOptions(Request $request, int $id): JsonResponse
{ {
$artwork = $request->user()->artworks()->findOrFail($id); $artwork = $request->user()->artworks()->findOrFail($id);
@@ -95,7 +95,13 @@ final class StudioController extends Controller
{ {
$provider = $this->content->provider('artworks'); $provider = $this->content->provider('artworks');
$prefs = $this->preferences->forUser($request->user()); $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']; $listing['default_view'] = $prefs['default_content_view'];
return Inertia::render('Studio/StudioArtworks', [ return Inertia::render('Studio/StudioArtworks', [
@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace App\Http\Controllers\Studio; namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\News\NewsService; use App\Services\News\NewsService;
use App\Support\AvatarUrl;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -46,6 +48,8 @@ final class StudioNewsController extends Controller
{ {
$this->authorizeNews($request); $this->authorizeNews($request);
$user = $request->user();
return Inertia::render('Studio/StudioNewsEditor', [ return Inertia::render('Studio/StudioNewsEditor', [
'title' => 'Create article', 'title' => 'Create article',
'description' => 'Draft a new News story with editorial workflow, SEO metadata, and related entity links.', 'description' => 'Draft a new News story with editorial workflow, SEO metadata, and related entity links.',
@@ -61,11 +65,14 @@ final class StudioNewsController extends Controller
'storeUrl' => route('studio.news.store'), 'storeUrl' => route('studio.news.store'),
'coverUploadUrl' => route('api.studio.news.media.upload'), 'coverUploadUrl' => route('api.studio.news.media.upload'),
'coverDeleteUrl' => route('api.studio.news.media.destroy'), 'coverDeleteUrl' => route('api.studio.news.media.destroy'),
'bodyMediaUploadUrl' => route('api.studio.news.media.upload'),
'bodyMediaDeleteUrl' => route('api.studio.news.media.destroy'),
'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'), 'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
'entitySearchUrl' => route('studio.news.entity-search'), 'entitySearchUrl' => route('studio.news.entity-search'),
'categoriesUrl' => route('studio.news.categories'), 'categoriesUrl' => route('studio.news.categories'),
'tagsUrl' => route('studio.news.tags'), 'tagsUrl' => route('studio.news.tags'),
'defaultAuthor' => $this->news->searchEntities('user', (string) $request->user()->username)[0] ?? null, 'defaultAuthor' => $this->mapDefaultAuthor($user),
'defaultPublishedAt' => now()->format('Y-m-d\TH:i'),
]); ]);
} }
@@ -96,6 +103,8 @@ final class StudioNewsController extends Controller
'relationTypeOptions' => $this->news->relationTypeOptions(), 'relationTypeOptions' => $this->news->relationTypeOptions(),
'coverUploadUrl' => route('api.studio.news.media.upload'), 'coverUploadUrl' => route('api.studio.news.media.upload'),
'coverDeleteUrl' => route('api.studio.news.media.destroy'), 'coverDeleteUrl' => route('api.studio.news.media.destroy'),
'bodyMediaUploadUrl' => route('api.studio.news.media.upload'),
'bodyMediaDeleteUrl' => route('api.studio.news.media.destroy'),
'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'), 'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
'updateUrl' => route('studio.news.update', ['article' => $article->id]), 'updateUrl' => route('studio.news.update', ['article' => $article->id]),
'destroyUrl' => route('studio.news.destroy', ['article' => $article->id]), 'destroyUrl' => route('studio.news.destroy', ['article' => $article->id]),
@@ -250,6 +259,29 @@ final class StudioNewsController extends Controller
]); ]);
} }
private function mapDefaultAuthor(mixed $user): ?array
{
if (! $user instanceof User) {
return null;
}
$user->loadMissing('profile');
return [
'id' => (int) $user->id,
'entity_type' => 'user',
'entity_label' => 'User',
'title' => (string) ($user->name ?: $user->username),
'subtitle' => $user->username ? '@' . $user->username : null,
'description' => Str::limit(trim((string) ($user->profile?->bio ?? '')), 120),
'url' => $user->username ? route('profile.show', ['username' => $user->username]) : null,
'image' => null,
'avatar' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash ?? null, 96),
'context_label' => 'Profile',
'meta' => [],
];
}
public function storeCategory(Request $request): RedirectResponse public function storeCategory(Request $request): RedirectResponse
{ {
$this->authorizeNews($request); $this->authorizeNews($request);
@@ -377,10 +409,41 @@ final class StudioNewsController extends Controller
'og_image' => ['nullable', 'string', 'max:2048'], 'og_image' => ['nullable', 'string', 'max:2048'],
'relations' => ['nullable', 'array', 'max:12'], 'relations' => ['nullable', 'array', 'max:12'],
'relations.*.entity_type' => ['required_with:relations', Rule::in(array_column($this->news->relationTypeOptions(), 'value'))], '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'], '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'])) { if (($validated['editorial_status'] ?? null) === NewsArticle::EDITORIAL_STATUS_SCHEDULED && empty($validated['published_at'])) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'published_at' => 'Scheduled articles need a publish date and time.', 'published_at' => 'Scheduled articles need a publish date and time.',
@@ -390,6 +453,25 @@ final class StudioNewsController extends Controller
return $validated; 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 private function tagPayload(): array
{ {
return NewsTag::query() return NewsTag::query()
@@ -46,6 +46,7 @@ final class StudioNewsMediaApiController extends Controller
'size_bytes' => $stored['size_bytes'], 'size_bytes' => $stored['size_bytes'],
'mobile_url' => $stored['mobile_url'], 'mobile_url' => $stored['mobile_url'],
'desktop_url' => $stored['desktop_url'], 'desktop_url' => $stored['desktop_url'],
'large_url' => $stored['large_url'],
'srcset' => $stored['srcset'], 'srcset' => $stored['srcset'],
]); ]);
} catch (RuntimeException $e) { } catch (RuntimeException $e) {
@@ -855,19 +855,26 @@ class ProfileController extends Controller
} }
} }
$allowedImageMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if ($request->hasFile('emoticon')) { if ($request->hasFile('emoticon')) {
$file = $request->file('emoticon'); $file = $request->file('emoticon');
$fname = $file->getClientOriginalName(); if (in_array($file->getMimeType(), $allowedImageMimes, true)) {
$path = \Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-emoticons/'.$user->id, $file, $fname); $ext = $file->guessExtension() ?: 'jpg';
$fname = $user->id . '_emoticon_' . time() . '.' . $ext;
\Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-emoticons/'.$user->id, $file, $fname);
try { try {
\Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['eicon' => $fname]); \Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['eicon' => $fname]);
} catch (\Exception $e) {} } catch (\Exception $e) {}
} }
}
if ($request->hasFile('photo')) { if ($request->hasFile('photo')) {
$file = $request->file('photo'); $file = $request->file('photo');
$fname = $file->getClientOriginalName(); if (in_array($file->getMimeType(), $allowedImageMimes, true)) {
$path = \Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-picture/'.$user->id, $file, $fname); $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')) { if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) {
$profileUpdates['cover_image'] = $fname; $profileUpdates['cover_image'] = $fname;
} else { } else {
@@ -876,6 +883,7 @@ class ProfileController extends Controller
} catch (\Exception $e) {} } catch (\Exception $e) {}
} }
} }
}
try { try {
if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) { if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) {
@@ -50,7 +50,8 @@ class TopAuthorsController extends Controller
}); });
$page_title = 'Top Creators'; $page_title = 'Top Creators';
$page_canonical = route('creators.top');
return view('web.authors.top', compact('page_title', 'authors', 'metric')); return view('web.authors.top', compact('page_title', 'page_canonical', 'authors', 'metric'));
} }
} }
@@ -65,6 +65,7 @@ final class DiscoverController extends Controller
return view('web.discover.index', [ return view('web.discover.index', [
'artworks' => $results, 'artworks' => $results,
'page_title' => 'Trending Artworks', 'page_title' => 'Trending Artworks',
'page_canonical' => $this->canonicalRoute('discover.trending'),
'section' => 'trending', 'section' => 'trending',
'description' => 'The most-viewed artworks on Skinbase over the past 7 days.', 'description' => 'The most-viewed artworks on Skinbase over the past 7 days.',
'icon' => 'fa-fire', 'icon' => 'fa-fire',
@@ -97,6 +98,7 @@ final class DiscoverController extends Controller
return view('web.discover.index', [ return view('web.discover.index', [
'artworks' => $results, 'artworks' => $results,
'page_title' => 'Rising Now', 'page_title' => 'Rising Now',
'page_canonical' => $this->canonicalRoute('discover.rising'),
'section' => 'rising', 'section' => 'rising',
'description' => 'Fastest growing artworks right now.', 'description' => 'Fastest growing artworks right now.',
'icon' => 'fa-rocket', 'icon' => 'fa-rocket',
@@ -119,6 +121,7 @@ final class DiscoverController extends Controller
return view('web.discover.index', [ return view('web.discover.index', [
'artworks' => $results, 'artworks' => $results,
'page_title' => 'Fresh Uploads', 'page_title' => 'Fresh Uploads',
'page_canonical' => $this->canonicalRoute('discover.fresh'),
'section' => 'fresh', 'section' => 'fresh',
'description' => 'The latest artworks just uploaded to Skinbase.', 'description' => 'The latest artworks just uploaded to Skinbase.',
'icon' => 'fa-bolt', 'icon' => 'fa-bolt',
@@ -138,6 +141,7 @@ final class DiscoverController extends Controller
return view('web.discover.index', [ return view('web.discover.index', [
'artworks' => $results, 'artworks' => $results,
'page_title' => 'Top Rated Artworks', 'page_title' => 'Top Rated Artworks',
'page_canonical' => $this->canonicalRoute('discover.top-rated'),
'section' => 'top-rated', 'section' => 'top-rated',
'description' => 'The most-loved artworks on Skinbase, ranked by community favourites.', 'description' => 'The most-loved artworks on Skinbase, ranked by community favourites.',
'icon' => 'fa-medal', 'icon' => 'fa-medal',
@@ -157,6 +161,7 @@ final class DiscoverController extends Controller
return view('web.discover.index', [ return view('web.discover.index', [
'artworks' => $results, 'artworks' => $results,
'page_title' => 'Most Downloaded', 'page_title' => 'Most Downloaded',
'page_canonical' => $this->canonicalRoute('discover.most-downloaded'),
'section' => 'most-downloaded', 'section' => 'most-downloaded',
'description' => 'All-time most downloaded artworks on Skinbase.', 'description' => 'All-time most downloaded artworks on Skinbase.',
'icon' => 'fa-download', 'icon' => 'fa-download',
@@ -178,9 +183,9 @@ final class DiscoverController extends Controller
'categories:id,name,slug,content_type_id,parent_id,sort_order', 'categories:id,name,slug,content_type_id,parent_id,sort_order',
'categories.contentType:id,slug,name', 'categories.contentType:id,slug,name',
]) ])
->whereRaw('MONTH(published_at) = ?', [$today->month]) ->whereMonth('published_at', $today->month)
->whereRaw('DAY(published_at) = ?', [$today->day]) ->whereDay('published_at', $today->day)
->whereRaw('YEAR(published_at) < ?', [$today->year]) ->whereYear('published_at', '<', $today->year)
->orderMissingThumbnailsLast() ->orderMissingThumbnailsLast()
->orderByDesc('published_at') ->orderByDesc('published_at')
->paginate($perPage) ->paginate($perPage)
@@ -191,6 +196,7 @@ final class DiscoverController extends Controller
return view('web.discover.index', [ return view('web.discover.index', [
'artworks' => $artworks, 'artworks' => $artworks,
'page_title' => 'On This Day', 'page_title' => 'On This Day',
'page_canonical' => $this->canonicalRoute('discover.on-this-day'),
'section' => 'on-this-day', 'section' => 'on-this-day',
'description' => 'Artworks published on ' . $today->format('F j') . ' in previous years.', 'description' => 'Artworks published on ' . $today->format('F j') . ' in previous years.',
'icon' => 'fa-calendar-day', 'icon' => 'fa-calendar-day',
@@ -246,6 +252,7 @@ final class DiscoverController extends Controller
return view('web.creators.rising', [ return view('web.creators.rising', [
'creators' => $creators, 'creators' => $creators,
'page_title' => 'Rising Creators — Skinbase', 'page_title' => 'Rising Creators — Skinbase',
'page_canonical' => $this->canonicalRoute('creators.rising'),
]); ]);
} }
@@ -327,6 +334,7 @@ final class DiscoverController extends Controller
return view('web.discover.index', [ return view('web.discover.index', [
'artworks' => collect(), 'artworks' => collect(),
'page_title' => 'Following Feed', 'page_title' => 'Following Feed',
'page_canonical' => $this->canonicalRoute('discover.following'),
'section' => 'following', 'section' => 'following',
'description' => 'Follow some creators to see their work here.', 'description' => 'Follow some creators to see their work here.',
'icon' => 'fa-user-group', 'icon' => 'fa-user-group',
@@ -366,6 +374,7 @@ final class DiscoverController extends Controller
return view('web.discover.index', [ return view('web.discover.index', [
'artworks' => $artworks, 'artworks' => $artworks,
'page_title' => 'Following Feed', 'page_title' => 'Following Feed',
'page_canonical' => $this->canonicalRoute('discover.following'),
'section' => 'following', 'section' => 'following',
'description' => 'The latest artworks from creators you follow.', 'description' => 'The latest artworks from creators you follow.',
'icon' => 'fa-user-group', 'icon' => 'fa-user-group',
@@ -388,6 +397,11 @@ final class DiscoverController extends Controller
return ! $items || $items->isEmpty(); return ! $items || $items->isEmpty();
} }
private function canonicalRoute(string $routeName): string
{
return route($routeName);
}
private function paginatorHasNoRisingMomentum($paginator): bool private function paginatorHasNoRisingMomentum($paginator): bool
{ {
if (! is_object($paginator) || ! method_exists($paginator, 'getCollection')) { if (! is_object($paginator) || ! method_exists($paginator, 'getCollection')) {
+22
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;
}
}
@@ -111,7 +111,7 @@ class UpsertAcademyLessonRequest extends FormRequest
'cover_image' => ['nullable', 'string', 'max:2048'], 'cover_image' => ['nullable', 'string', 'max:2048'],
'article_cover_image' => ['nullable', 'string', 'max:2048'], 'article_cover_image' => ['nullable', 'string', 'max:2048'],
'tags' => ['nullable', 'array'], 'tags' => ['nullable', 'array'],
'tags.*' => ['string', 'max:100'], 'tags.*' => ['string', 'max:200'],
'video_url' => ['nullable', 'string', 'max:2048'], 'video_url' => ['nullable', 'string', 'max:2048'],
'reading_minutes' => ['required', 'integer', 'min:1', 'max:999'], 'reading_minutes' => ['required', 'integer', 'min:1', 'max:999'],
'featured' => ['required', 'boolean'], 'featured' => ['required', 'boolean'],
@@ -27,6 +27,7 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
'placeholders' => $this->normalizePlaceholders($this->input('placeholders')), 'placeholders' => $this->normalizePlaceholders($this->input('placeholders')),
'helper_prompts' => $this->normalizeHelperPrompts($this->input('helper_prompts')), 'helper_prompts' => $this->normalizeHelperPrompts($this->input('helper_prompts')),
'prompt_variants' => $this->normalizePromptVariants($this->input('prompt_variants')), 'prompt_variants' => $this->normalizePromptVariants($this->input('prompt_variants')),
'filled_examples' => $this->normalizeFilledExamples($this->input('filled_examples')),
'tool_notes' => collect($this->input('tool_notes', [])) 'tool_notes' => collect($this->input('tool_notes', []))
->filter(static fn ($note): bool => is_array($note) || is_string($note)) ->filter(static fn ($note): bool => is_array($note) || is_string($note))
->map(function ($note): array|string { ->map(function ($note): array|string {
@@ -59,8 +60,10 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
$promptId = $this->route('academyPromptTemplate')?->id; $promptId = $this->route('academyPromptTemplate')?->id;
return [ return [
'category_id' => ['nullable', 'integer', 'exists:academy_categories,id'], // Require either an existing category selection or a new category name.
'new_category_name' => ['nullable', 'string', 'max:120'], 'category_id' => ['nullable', 'integer', 'exists:academy_categories,id', 'required_without:new_category_name'],
'new_category_name' => ['nullable', 'string', 'max:120', 'required_without:category_id'],
'title' => ['required', 'string', 'max:180'], 'title' => ['required', 'string', 'max:180'],
'slug' => ['required', 'string', 'max:180', Rule::unique('academy_prompt_templates', 'slug')->ignore($promptId)], 'slug' => ['required', 'string', 'max:180', Rule::unique('academy_prompt_templates', 'slug')->ignore($promptId)],
'excerpt' => ['nullable', 'string'], 'excerpt' => ['nullable', 'string'],
@@ -112,6 +115,12 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
'prompt_variants.*.risk_notes' => ['nullable', 'array'], 'prompt_variants.*.risk_notes' => ['nullable', 'array'],
'prompt_variants.*.risk_notes.*' => ['nullable', 'string'], 'prompt_variants.*.risk_notes.*' => ['nullable', 'string'],
'prompt_variants.*.active' => ['nullable', 'boolean'], 'prompt_variants.*.active' => ['nullable', 'boolean'],
'filled_examples' => ['nullable', 'array', 'max:5'],
'filled_examples.*.title' => ['nullable', 'string', 'max:180'],
'filled_examples.*.description' => ['nullable', 'string'],
'filled_examples.*.placeholder_values' => ['nullable', 'array'],
'filled_examples.*.prompt' => ['required_with:filled_examples', 'string'],
'filled_examples.*.negative_prompt' => ['nullable', 'string'],
'difficulty' => ['required', 'string', Rule::in((array) config('academy.difficulty_levels', []))], 'difficulty' => ['required', 'string', Rule::in((array) config('academy.difficulty_levels', []))],
'access_level' => ['required', 'string', Rule::in(['free', 'creator', 'pro'])], 'access_level' => ['required', 'string', Rule::in(['free', 'creator', 'pro'])],
'aspect_ratio' => ['nullable', 'string', 'max:20'], 'aspect_ratio' => ['nullable', 'string', 'max:20'],
@@ -283,6 +292,53 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest
->all(); ->all();
} }
private function normalizeFilledExamples(mixed $value): mixed
{
$value = $this->decodeStructuredInput($value);
if ($value === null) {
return [];
}
if (! is_array($value)) {
return $value;
}
$value = $this->normalizeStructuredObjectList($value, ['title', 'description', 'placeholder_values', 'prompt', 'negative_prompt']);
return collect($value)
->values()
->map(function ($example): mixed {
if (! is_array($example)) {
return $example;
}
return [
'title' => $this->normalizeOptionalString($example['title'] ?? null),
'description' => $this->normalizeOptionalString($example['description'] ?? null),
'placeholder_values' => is_array($example['placeholder_values'] ?? null) ? $example['placeholder_values'] : [],
'prompt' => $this->normalizeOptionalString($example['prompt'] ?? null),
'negative_prompt' => $this->normalizeOptionalString($example['negative_prompt'] ?? null),
];
})
->filter(function ($example): bool {
if (! is_array($example)) {
return true;
}
return collect([
$example['title'] ?? null,
$example['description'] ?? null,
$example['prompt'] ?? null,
$example['negative_prompt'] ?? null,
$example['placeholder_values'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []);
})
->take(5)
->values()
->all();
}
private function normalizePromptVariants(mixed $value): mixed private function normalizePromptVariants(mixed $value): mixed
{ {
$value = $this->decodeStructuredInput($value); $value = $this->decodeStructuredInput($value);
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Http\Requests\Artworks; namespace App\Http\Requests\Artworks;
use App\Support\ArtworkDescriptionContentValidator;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class ArtworkCreateRequest extends FormRequest 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 private function denyAsNotFound(): void
{ {
throw new NotFoundHttpException(); throw new NotFoundHttpException();
@@ -3,7 +3,9 @@
namespace App\Http\Requests\Dashboard; namespace App\Http\Requests\Dashboard;
use App\Models\Artwork; use App\Models\Artwork;
use App\Support\ArtworkDescriptionContentValidator;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class UpdateArtworkRequest extends FormRequest 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 public function artwork(): Artwork
{ {
if (! $this->artwork) { if (! $this->artwork) {
@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\Http\Requests\Manage; namespace App\Http\Requests\Manage;
use App\Support\ArtworkDescriptionContentValidator;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Validator;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class ManageArtworkUpdateRequest extends FormRequest 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 public function artwork(): object
{ {
if (! $this->artwork) { if (! $this->artwork) {
@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\Http\Requests\Studio; namespace App\Http\Requests\Studio;
use App\Support\ArtworkDescriptionContentValidator;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
final class ApplyArtworkAiAssistRequest extends FormRequest 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'])], '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);
}
});
}
} }
+4
View File
@@ -54,6 +54,10 @@ final class AutoTagArtworkJob implements ShouldQueue
public function handle(TagService $tagService, TagNormalizer $normalizer, ?VisionService $vision = null): void public function handle(TagService $tagService, TagNormalizer $normalizer, ?VisionService $vision = null): void
{ {
if (! (bool) config('vision.auto_tagging.enabled', false)) {
return;
}
$vision ??= app(VisionService::class); $vision ??= app(VisionService::class);
if (! $vision->isEnabled()) { if (! $vision->isEnabled()) {
+119
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;
}
}
}
+8
View File
@@ -54,11 +54,19 @@ final class GenerateDerivativesJob implements ShouldQueue
} }
// Auto-tagging is async and must never block publish. // Auto-tagging is async and must never block publish.
if ((bool) config('vision.auto_tagging.enabled', false)) {
AutoTagArtworkJob::dispatch($this->artworkId, $this->hash)->afterCommit(); AutoTagArtworkJob::dispatch($this->artworkId, $this->hash)->afterCommit();
}
if ((bool) config('vision.upload.maturity.enabled', false)) {
DetectArtworkMaturityJob::dispatch($this->artworkId, $this->hash)->afterCommit(); DetectArtworkMaturityJob::dispatch($this->artworkId, $this->hash)->afterCommit();
}
if ((bool) config('vision.upload.embeddings.enabled', true)) {
GenerateArtworkEmbeddingJob::dispatch($this->artworkId, $this->hash)->afterCommit(); GenerateArtworkEmbeddingJob::dispatch($this->artworkId, $this->hash)->afterCommit();
}
if ((bool) config('vision.upload.ai_assist.enabled', false)) {
AnalyzeArtworkAiAssistJob::dispatch($this->artworkId)->afterCommit(); AnalyzeArtworkAiAssistJob::dispatch($this->artworkId)->afterCommit();
} }
}
public function failed(\Throwable $exception): void public function failed(\Throwable $exception): void
{ {
+77 -9
View File
@@ -9,9 +9,11 @@ use App\Models\RecArtworkRec;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/** /**
* Compute tag-based (+ category boost) similarity for artworks. * Compute tag-based (+ category boost) similarity for artworks.
@@ -30,6 +32,7 @@ final class RecComputeSimilarByTagsJob implements ShouldQueue
public function __construct( public function __construct(
private readonly ?int $artworkId = null, private readonly ?int $artworkId = null,
private readonly int $batchSize = 200, private readonly int $batchSize = 200,
private readonly ?int $afterArtworkId = null,
) { ) {
$queue = (string) config('recommendations.queue', 'default'); $queue = (string) config('recommendations.queue', 'default');
if ($queue !== '') { if ($queue !== '') {
@@ -37,6 +40,22 @@ final class RecComputeSimilarByTagsJob implements ShouldQueue
} }
} }
/**
* @return array<int, object>
*/
public function middleware(): array
{
if ($this->artworkId === null) {
return [];
}
return [
(new WithoutOverlapping('rec-similar-tags:'.$this->artworkId))
->expireAfter($this->timeout + 60)
->dontRelease(),
];
}
public function handle(): void public function handle(): void
{ {
$modelVersion = (string) config('recommendations.similarity.model_version', 'sim_v1'); $modelVersion = (string) config('recommendations.similarity.model_version', 'sim_v1');
@@ -51,19 +70,68 @@ final class RecComputeSimilarByTagsJob implements ShouldQueue
->pluck('cnt', 'tag_id') ->pluck('cnt', 'tag_id')
->all(); ->all();
$query = Artwork::query()->public()->published()->select('id', 'user_id');
if ($this->artworkId !== null) { if ($this->artworkId !== null) {
$query->where('id', $this->artworkId); $artwork = Artwork::query()->public()->published()->select('id', 'user_id')->find($this->artworkId);
if (! $artwork instanceof Artwork) {
return;
} }
$query->chunkById($this->batchSize, function ($artworks) use ( $this->processArtworkSafely($artwork, $tagFreqs, $modelVersion, $candidatePool, $maxPerAuthor, $resultLimit);
$tagFreqs, $modelVersion, $candidatePool, $maxPerAuthor, $resultLimit
) { return;
foreach ($artworks as $artwork) { }
$this->processArtwork($artwork, $tagFreqs, $modelVersion, $candidatePool, $maxPerAuthor, $resultLimit);
$artworks = Artwork::query()
->public()
->published()
->select('id', 'user_id')
->when($this->afterArtworkId !== null, fn ($query) => $query->where('id', '>', $this->afterArtworkId))
->orderBy('id')
->limit($this->batchSize)
->get();
if ($artworks->isEmpty()) {
return;
}
foreach ($artworks as $artwork) {
$this->processArtworkSafely($artwork, $tagFreqs, $modelVersion, $candidatePool, $maxPerAuthor, $resultLimit);
}
if ($artworks->count() === $this->batchSize) {
static::dispatch(null, $this->batchSize, (int) $artworks->last()->id);
}
}
public function failed(\Throwable $exception): void
{
Log::error('[RecComputeSimilarByTags] Job failed permanently.', [
'artwork_id' => $this->artworkId,
'batch_size' => $this->batchSize,
'after_artwork_id' => $this->afterArtworkId,
'attempts' => $this->attempts(),
'exception_class' => $exception::class,
'exception_message' => $exception->getMessage(),
]);
}
private function processArtworkSafely(
Artwork $artwork,
array $tagFreqs,
string $modelVersion,
int $candidatePool,
int $maxPerAuthor,
int $resultLimit,
): void {
try {
$this->processArtwork($artwork, $tagFreqs, $modelVersion, $candidatePool, $maxPerAuthor, $resultLimit);
} catch (\Throwable $exception) {
Log::warning("[RecComputeSimilarByTags] Failed for artwork {$artwork->id}: {$exception->getMessage()}", [
'artwork_id' => $artwork->id,
'exception_class' => $exception::class,
]);
} }
});
} }
private function processArtwork( private function processArtwork(
+95 -9
View File
@@ -9,6 +9,7 @@ use App\Models\RecArtworkRec;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -25,7 +26,10 @@ final class RecComputeSimilarHybridJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 2; // This recompute is idempotent and already guards per-artwork execution.
// Keep retries to a minimum so transient failures do not turn into
// Horizon's max-attempt exception noise.
public int $tries = 1;
public int $timeout = 900; public int $timeout = 900;
public function __construct( public function __construct(
@@ -38,6 +42,24 @@ final class RecComputeSimilarHybridJob implements ShouldQueue
} }
} }
/**
* @return array<int, object>
*/
public function middleware(): array
{
if ($this->artworkId === null) {
return [];
}
return [
// Many artwork lifecycle events can queue this same recompute burstily.
// Keep only one in flight per artwork and drop overlapping duplicates.
(new WithoutOverlapping('rec-similar-hybrid:'.$this->artworkId))
->expireAfter($this->timeout + 60)
->dontRelease(),
];
}
public function handle(): void public function handle(): void
{ {
$modelVersion = (string) config('recommendations.similarity.model_version', 'sim_v1'); $modelVersion = (string) config('recommendations.similarity.model_version', 'sim_v1');
@@ -50,26 +72,90 @@ final class RecComputeSimilarHybridJob implements ShouldQueue
? (array) config('recommendations.similarity.weights_with_vector') ? (array) config('recommendations.similarity.weights_with_vector')
: (array) config('recommendations.similarity.weights_without_vector'); : (array) config('recommendations.similarity.weights_without_vector');
$query = Artwork::query()->public()->published()->select('id', 'user_id');
if ($this->artworkId !== null) { if ($this->artworkId !== null) {
$query->where('id', $this->artworkId); $artwork = Artwork::query()
->public()
->published()
->select('id', 'user_id')
->find($this->artworkId);
if (! $artwork instanceof Artwork) {
return;
} }
$query->chunkById($this->batchSize, function ($artworks) use ( $this->processArtworkSafely(
collect([$artwork]),
$modelVersion,
$vectorEnabled,
$resultLimit,
$maxPerAuthor,
$minCatsTop12,
$weights,
);
return;
}
Artwork::query()
->public()
->published()
->select('id', 'user_id')
->chunkById($this->batchSize, function ($artworks) use (
$modelVersion, $vectorEnabled, $resultLimit, $maxPerAuthor, $minCatsTop12, $weights $modelVersion, $vectorEnabled, $resultLimit, $maxPerAuthor, $minCatsTop12, $weights
) { ) {
$this->processArtworkSafely(
$artworks,
$modelVersion,
$vectorEnabled,
$resultLimit,
$maxPerAuthor,
$minCatsTop12,
$weights,
);
});
}
public function failed(\Throwable $exception): void
{
Log::error('[RecComputeSimilarHybrid] Job failed permanently.', [
'artwork_id' => $this->artworkId,
'batch_size' => $this->batchSize,
'attempts' => $this->attempts(),
'exception_class' => $exception::class,
'exception_message' => $exception->getMessage(),
]);
}
/**
* @param iterable<Artwork> $artworks
*/
private function processArtworkSafely(
iterable $artworks,
string $modelVersion,
bool $vectorEnabled,
int $resultLimit,
int $maxPerAuthor,
int $minCatsTop12,
array $weights,
): void {
foreach ($artworks as $artwork) { foreach ($artworks as $artwork) {
try { try {
$this->processArtwork( $this->processArtwork(
$artwork, $modelVersion, $vectorEnabled, $resultLimit, $artwork,
$maxPerAuthor, $minCatsTop12, $weights $modelVersion,
$vectorEnabled,
$resultLimit,
$maxPerAuthor,
$minCatsTop12,
$weights,
); );
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::warning("[RecComputeSimilarHybrid] Failed for artwork {$artwork->id}: {$e->getMessage()}"); Log::warning("[RecComputeSimilarHybrid] Failed for artwork {$artwork->id}: {$e->getMessage()}", [
'artwork_id' => $artwork->id,
'exception_class' => $e::class,
]);
} }
} }
});
} }
private function processArtwork( private function processArtwork(
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
final class AcademyAccessIssue extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public readonly User $user,
public readonly ?string $message = null,
public readonly ?string $sessionId = null,
public readonly ?string $issueType = null,
public readonly ?string $contactEmail = null,
)
{
}
public function build(): self
{
$subject = 'Academy support request'.($this->issueType ? ' ['.$this->issueType.']' : '').' from '.$this->user->email;
$replyTo = trim((string) ($this->contactEmail ?: $this->user->email));
$mail = $this->subject($subject)
->view('emails.academy_access_issue')
->with([
'user' => $this->user,
'message' => $this->message,
'sessionId' => $this->sessionId,
'issueType' => $this->issueType,
'contactEmail' => $this->contactEmail,
]);
if ($replyTo !== '') {
$mail->replyTo($replyTo);
}
return $mail;
}
}
+7
View File
@@ -28,6 +28,7 @@ class AcademyPromptTemplate extends Model
'placeholders', 'placeholders',
'helper_prompts', 'helper_prompts',
'prompt_variants', 'prompt_variants',
'filled_examples',
'difficulty', 'difficulty',
'access_level', 'access_level',
'aspect_ratio', 'aspect_ratio',
@@ -49,6 +50,7 @@ class AcademyPromptTemplate extends Model
'placeholders' => 'array', 'placeholders' => 'array',
'helper_prompts' => 'array', 'helper_prompts' => 'array',
'prompt_variants' => 'array', 'prompt_variants' => 'array',
'filled_examples' => 'array',
'featured' => 'boolean', 'featured' => 'boolean',
'prompt_of_week' => 'boolean', 'prompt_of_week' => 'boolean',
'active' => 'boolean', 'active' => 'boolean',
@@ -75,6 +77,11 @@ class AcademyPromptTemplate extends Model
return $this->hasMany(AcademySavedPrompt::class, 'prompt_template_id'); return $this->hasMany(AcademySavedPrompt::class, 'prompt_template_id');
} }
public function metrics(): HasMany
{
return $this->hasMany(AcademyContentMetricDaily::class, 'content_id');
}
public function packs(): BelongsToMany public function packs(): BelongsToMany
{ {
return $this->belongsToMany(AcademyPromptPack::class, 'academy_prompt_pack_items', 'prompt_template_id', 'pack_id') return $this->belongsToMany(AcademyPromptPack::class, 'academy_prompt_pack_items', 'prompt_template_id', 'pack_id')
+157
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);
}
}
+55
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;
}
}
+3
View File
@@ -11,6 +11,7 @@ use App\Models\AcademyChallengeSubmission;
use App\Models\AcademyLesson; use App\Models\AcademyLesson;
use App\Models\AcademyPromptPack; use App\Models\AcademyPromptPack;
use App\Models\AcademyPromptTemplate; use App\Models\AcademyPromptTemplate;
use App\Models\EnhanceJob;
use App\Models\Collection; use App\Models\Collection;
use App\Models\Group; use App\Models\Group;
use App\Models\NovaCard; use App\Models\NovaCard;
@@ -25,6 +26,7 @@ use App\Policies\AcademyChallengeSubmissionPolicy;
use App\Policies\AcademyLessonPolicy; use App\Policies\AcademyLessonPolicy;
use App\Policies\AcademyPromptPackPolicy; use App\Policies\AcademyPromptPackPolicy;
use App\Policies\AcademyPromptTemplatePolicy; use App\Policies\AcademyPromptTemplatePolicy;
use App\Policies\EnhanceJobPolicy;
use App\Policies\CollectionPolicy; use App\Policies\CollectionPolicy;
use App\Policies\GroupPolicy; use App\Policies\GroupPolicy;
use App\Policies\NovaCardPolicy; use App\Policies\NovaCardPolicy;
@@ -43,6 +45,7 @@ class AuthServiceProvider extends ServiceProvider
AcademyLesson::class => AcademyLessonPolicy::class, AcademyLesson::class => AcademyLessonPolicy::class,
AcademyPromptPack::class => AcademyPromptPackPolicy::class, AcademyPromptPack::class => AcademyPromptPackPolicy::class,
AcademyPromptTemplate::class => AcademyPromptTemplatePolicy::class, AcademyPromptTemplate::class => AcademyPromptTemplatePolicy::class,
EnhanceJob::class => EnhanceJobPolicy::class,
Collection::class => CollectionPolicy::class, Collection::class => CollectionPolicy::class,
Group::class => GroupPolicy::class, Group::class => GroupPolicy::class,
NovaCard::class => NovaCardPolicy::class, NovaCard::class => NovaCardPolicy::class,
@@ -92,6 +92,121 @@ final class AcademyAccessService
return $this->activeAcademySubscription($user) instanceof Subscription; 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 public function canAccessLesson(?User $user, AcademyLesson $lesson): bool
{ {
return $this->canAccessContent($user, (string) $lesson->access_level); return $this->canAccessContent($user, (string) $lesson->access_level);
@@ -229,7 +344,18 @@ final class AcademyAccessService
$previewImage = $this->promptPreviewImagePayload((string) ($prompt->preview_image ?? '')); $previewImage = $this->promptPreviewImagePayload((string) ($prompt->preview_image ?? ''));
$documentation = $this->promptDocumentationPayload($prompt->documentation); $documentation = $this->promptDocumentationPayload($prompt->documentation);
$placeholders = $this->promptPlaceholdersPayload((array) ($prompt->placeholders ?? [])); $placeholders = $this->promptPlaceholdersPayload((array) ($prompt->placeholders ?? []));
$allFilledExamples = $this->promptFilledExamplesPayload((array) ($prompt->filled_examples ?? []));
$filledExamplesTotal = count($allFilledExamples);
$hasFullFilledExamplesAccess = (bool) (($viewer?->hasAcademyProAccess() ?? false) || ($viewer?->hasStaffAccess() ?? false));
$hasPartialFilledExamplesAccess = (bool) ($viewer?->hasAcademyCreatorAccess() ?? false);
$visibleFilledExamples = match (true) {
! $includeFull => [],
$hasFullFilledExamplesAccess => $allFilledExamples,
$hasPartialFilledExamplesAccess => array_slice($allFilledExamples, 0, 2),
default => [],
};
$hasPlaceholderInputs = $this->promptHasPlaceholderInputs((string) $prompt->prompt, $placeholders); $hasPlaceholderInputs = $this->promptHasPlaceholderInputs((string) $prompt->prompt, $placeholders);
$hasFilledExamples = $allFilledExamples !== [];
$hasHelperPrompts = $this->promptHelperPromptsPayload((array) ($prompt->helper_prompts ?? [])) !== []; $hasHelperPrompts = $this->promptHelperPromptsPayload((array) ($prompt->helper_prompts ?? [])) !== [];
$hasPromptVariants = $this->promptVariantsPayload((array) ($prompt->prompt_variants ?? [])) !== []; $hasPromptVariants = $this->promptVariantsPayload((array) ($prompt->prompt_variants ?? [])) !== [];
$helperPrompts = $authorized && $includeFull $helperPrompts = $authorized && $includeFull
@@ -252,6 +378,12 @@ final class AcademyAccessService
'documentation' => $documentation, 'documentation' => $documentation,
'placeholders' => $placeholders, 'placeholders' => $placeholders,
'has_placeholder_inputs' => $hasPlaceholderInputs, 'has_placeholder_inputs' => $hasPlaceholderInputs,
'filled_examples' => $visibleFilledExamples,
'has_filled_examples' => $hasFilledExamples,
'filled_examples_total' => $filledExamplesTotal,
'can_access_filled_examples' => ($hasFullFilledExamplesAccess || $hasPartialFilledExamplesAccess) && $includeFull,
'has_more_filled_examples' => $filledExamplesTotal > count($visibleFilledExamples),
'has_full_filled_examples_access' => $hasFullFilledExamplesAccess,
'has_helper_prompts' => $hasHelperPrompts, 'has_helper_prompts' => $hasHelperPrompts,
'has_prompt_variants' => $hasPromptVariants, 'has_prompt_variants' => $hasPromptVariants,
'helper_prompts' => $helperPrompts, 'helper_prompts' => $helperPrompts,
@@ -281,6 +413,47 @@ final class AcademyAccessService
]; ];
} }
/**
* @param array<int, mixed> $filledExamples
* @return array<int, array<string, mixed>>
*/
private function promptFilledExamplesPayload(array $filledExamples): array
{
return collect($filledExamples)
->filter(static fn ($example): bool => is_array($example))
->map(function (array $example): array {
return [
'title' => $this->nullableTrimmedString($example['title'] ?? null),
'description' => $this->nullableTrimmedString($example['description'] ?? null),
'placeholder_values' => collect(is_array($example['placeholder_values'] ?? null) ? $example['placeholder_values'] : [])
->mapWithKeys(function ($value, $key): array {
$normalizedKey = trim((string) $key);
if ($normalizedKey === '') {
return [];
}
return [$normalizedKey => $value];
})
->all(),
'prompt' => trim((string) ($example['prompt'] ?? '')),
'negative_prompt' => $this->nullableTrimmedString($example['negative_prompt'] ?? null),
];
})
->filter(function (array $example): bool {
return collect([
$example['title'] ?? null,
$example['description'] ?? null,
$example['prompt'] ?? null,
$example['negative_prompt'] ?? null,
$example['placeholder_values'] ?? null,
])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []);
})
->take(5)
->values()
->all();
}
/** /**
* @param mixed $documentation * @param mixed $documentation
* @return array<string, mixed> * @return array<string, mixed>
@@ -633,6 +806,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 private function isAcademyAdmin(User $user): bool
{ {
return $user->hasStaffAccess() || $user->isModerator(); return $user->hasStaffAccess() || $user->isModerator();
@@ -42,6 +42,9 @@ final class AcademyAnalyticsContentResolver
if (! $contentId) { if (! $contentId) {
return match ($contentType) { return match ($contentType) {
AcademyAnalyticsContentType::HOME => 'Academy Home', 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::SEARCH => 'Academy Search',
AcademyAnalyticsContentType::UPGRADE => 'Academy Upgrade', AcademyAnalyticsContentType::UPGRADE => 'Academy Upgrade',
default => 'Unknown Academy Content', default => 'Unknown Academy Content',
@@ -5,8 +5,11 @@ declare(strict_types=1);
namespace App\Services\Academy; namespace App\Services\Academy;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use RuntimeException; use RuntimeException;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient;
final class AcademyBillingPlanService final class AcademyBillingPlanService
{ {
@@ -64,6 +67,7 @@ final class AcademyBillingPlanService
$plan['stripe_price_id'] = trim((string) ($plan['stripe_price_id'] ?? '')); $plan['stripe_price_id'] = trim((string) ($plan['stripe_price_id'] ?? ''));
$plan['configured'] = $plan['stripe_price_id'] !== ''; $plan['configured'] = $plan['stripe_price_id'] !== '';
$plan['price_id_valid'] = $this->isValidPriceId($plan['stripe_price_id']); $plan['price_id_valid'] = $this->isValidPriceId($plan['stripe_price_id']);
$plan['remote_price_exists'] = $this->remotePriceExists($plan['stripe_price_id']);
$plan['price_display'] = $plan['amount'] !== '' ? $plan['amount'].' '.$plan['currency'] : null; $plan['price_display'] = $plan['amount'] !== '' ? $plan['amount'].' '.$plan['currency'] : null;
return $plan; return $plan;
@@ -145,4 +149,86 @@ final class AcademyBillingPlanService
return preg_match('/^price_[A-Za-z0-9]+$/', $priceId) === 1; return preg_match('/^price_[A-Za-z0-9]+$/', $priceId) === 1;
} }
public function remotePriceExists(?string $priceId): ?bool
{
$priceId = trim((string) $priceId);
if ($priceId === '') {
return false;
}
// Avoid calling Stripe in local/testing environments — assume exists there.
if (app()->environment(['local', 'testing'])) {
return true;
}
$cacheKey = 'academy.remote_price_exists:'.md5($priceId);
return Cache::remember($cacheKey, 300, function () use ($priceId): ?bool {
try {
$secret = $this->stripeSecret();
if ($secret === null) {
return null;
}
$client = new StripeClient($secret);
$price = $client->prices->retrieve($priceId, []);
// If Stripe returned an object with an id, it exists. Also ensure product exists where possible.
if (is_object($price) && ! empty($price->id)) {
return true;
}
return false;
} catch (InvalidRequestException $e) {
report($e);
return false;
} catch (\Throwable $e) {
report($e);
// Auth, network, or transient Stripe failures should not make
// public pricing look fully misconfigured.
return null;
}
});
}
private function stripeSecret(): ?string
{
foreach ([config('cashier.secret'), env('STRIPE_SECRET')] as $candidate) {
if (! is_string($candidate)) {
continue;
}
$candidate = trim($candidate);
if ($candidate !== '') {
return $candidate;
}
}
return null;
}
/**
* @return array<int, string>
*/
public function missingRemotePriceIds(?string $planKey = null): array
{
if ($planKey !== null) {
$plan = $this->plan($planKey);
return $plan !== null && $this->remotePriceExists($plan['stripe_price_id'] ?? '') === false
? [$this->normalizePlanKey($planKey)]
: [];
}
return collect(array_keys($this->plans()))
->filter(fn (string $key): bool => $this->remotePriceExists($this->plan($key)['stripe_price_id'] ?? '') === false)
->values()
->all();
}
} }
@@ -45,7 +45,7 @@ final class AcademyPopularityService
public function queryBetween(Carbon $from, Carbon $to): Builder public function queryBetween(Carbon $from, Carbon $to): Builder
{ {
return AcademyContentMetricDaily::query() 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 public function topContent(Carbon $from, Carbon $to, int $limit = 10): Collection
+83 -2
View File
@@ -32,14 +32,27 @@ class ContentSanitizer
public const EMOJI_DENSITY_MAX = 0.40; public const EMOJI_DENSITY_MAX = 0.40;
// HTML tags we allow in the final rendered output // HTML tags we allow in the final rendered output
// Include heading tags so editor-produced headings (h1-h6) are preserved.
private const ALLOWED_TAGS = [ private const ALLOWED_TAGS = [
'p', 'br', 'strong', 'em', 'code', 'pre', 'p', 'br', 'strong', 'em', 'code', 'pre',
'a', 'ul', 'ol', 'li', 'blockquote', 'del', 'a', 'ul', 'ol', 'li', 'blockquote', 'del',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
// Image and embed-related tags used by the rich editor
'figure', 'figcaption', 'img', 'picture', 'source', 'iframe',
// Basic structural/inline helpers sometimes produced by embeds
'div', 'span'
]; ];
// Allowed attributes per tag // Allowed attributes per tag
private const ALLOWED_ATTRS = [ private const ALLOWED_ATTRS = [
'a' => ['href', 'title', 'rel', 'target'], 'a' => ['href', 'title', 'rel', 'target'],
'img' => ['src', 'srcset', 'sizes', 'alt', 'title', 'loading', 'decoding', 'width', 'height', 'style', 'class', 'data-width'],
'source' => ['srcset', 'src', 'type', 'media', 'sizes'],
'figure' => ['class', 'data-rich-image', 'data-platform', 'data-video-embed', 'data-social-embed', 'data-artwork-embed'],
'figcaption' => ['class'],
'iframe' => ['src', 'title', 'loading', 'frameborder', 'allow', 'allowfullscreen', 'referrerpolicy'],
'div' => ['class', 'data-href', 'data-show-text'],
'span' => ['class'],
]; ];
private static ?MarkdownConverter $converter = null; private static ?MarkdownConverter $converter = null;
@@ -259,14 +272,82 @@ class ContentSanitizer
$allowedAttrs = self::ALLOWED_ATTRS[$tag] ?? []; $allowedAttrs = self::ALLOWED_ATTRS[$tag] ?? [];
$attrsToRemove = []; $attrsToRemove = [];
foreach ($child->attributes as $attr) { foreach ($child->attributes as $attr) {
if (! in_array($attr->nodeName, $allowedAttrs, true)) { $name = $attr->nodeName;
$attrsToRemove[] = $attr->nodeName;
// Allow data-* attributes and class on allowed tags
if (str_starts_with($name, 'data-') || $name === 'class') {
continue;
}
if (! in_array($name, $allowedAttrs, true)) {
$attrsToRemove[] = $name;
} }
} }
foreach ($attrsToRemove as $attrName) { foreach ($attrsToRemove as $attrName) {
$child->removeAttribute($attrName); $child->removeAttribute($attrName);
} }
// Validate URL-like attributes for image/source/iframe
if ($tag === 'img') {
$src = $child->getAttribute('src');
if ($src && ! static::isSafeUrl($src)) {
$toUnwrap[] = $child;
continue;
}
// Validate srcset: ensure each URL is safe; if not, remove the attribute
$srcset = $child->getAttribute('srcset');
if ($srcset) {
$parts = array_map('trim', explode(',', $srcset));
$valid = true;
foreach ($parts as $part) {
if ($part === '') {
continue;
}
// Each part: "url [descriptor]"
$pieces = preg_split('/\s+/', $part);
$url = $pieces[0] ?? '';
if ($url !== '' && ! static::isSafeUrl($url)) {
$valid = false;
break;
}
}
if (! $valid) {
$child->removeAttribute('srcset');
}
}
}
if ($tag === 'source') {
$src = $child->getAttribute('src') ?: $child->getAttribute('srcset');
if ($src) {
// For srcset allow comma-separated list; validate each
$values = array_map('trim', explode(',', $src));
$valid = true;
foreach ($values as $v) {
if ($v === '') continue;
$pieces = preg_split('/\s+/', $v);
$url = $pieces[0] ?? '';
if ($url !== '' && ! static::isSafeUrl($url)) {
$valid = false;
break;
}
}
if (! $valid) {
$toUnwrap[] = $child;
continue;
}
}
}
if ($tag === 'iframe') {
$src = $child->getAttribute('src');
if ($src && ! static::isSafeUrl($src)) {
$toUnwrap[] = $child;
continue;
}
}
// Force external links to be safe // Force external links to be safe
if ($tag === 'a') { if ($tag === 'a') {
if (! $allowLinks) { if (! $allowLinks) {
+12
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;
}
@@ -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.'),
};
}
}
@@ -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,
) {
}
}
+265
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);
}
}
@@ -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.');
}
}
}
+128
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,
];
}
}
@@ -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);
}
}
@@ -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,
);
}
}
+13 -3
View File
@@ -761,12 +761,12 @@ final class HomepageService
/** /**
* Latest 5 news posts from the forum news category. * Latest 5 news posts from the forum news category.
*/ */
public function getNews(int $limit = 5): array public function getNews(int $limit = 10): array
{ {
return Cache::remember("homepage.news.{$limit}", self::CACHE_TTL, function () use ($limit): array { return Cache::remember("homepage.news.{$limit}", self::CACHE_TTL, function () use ($limit): array {
try { try {
$articles = NewsArticle::query() $articles = NewsArticle::query()
->with('category') ->with(['category', 'author'])
->published() ->published()
->editorialOrder() ->editorialOrder()
->limit($limit) ->limit($limit)
@@ -779,7 +779,17 @@ final class HomepageService
'date' => $article->published_at, 'date' => $article->published_at,
'url' => route('news.show', ['slug' => $article->slug]), 'url' => route('news.show', ['slug' => $article->slug]),
'eyebrow' => $article->category?->name ?: $article->type_label, 'eyebrow' => $article->category?->name ?: $article->type_label,
'excerpt' => Str::limit(strip_tags((string) ($article->excerpt ?: $article->rendered_content)), 120), 'type' => $article->type ?? null,
'type_label' => $article->type_label ?? null,
'category' => $article->category ? ['name' => $article->category->name, 'slug' => $article->category->slug] : null,
'is_featured' => (bool) ($article->is_featured ?? false),
'is_pinned' => (bool) ($article->is_pinned ?? false),
'cover_url' => $article->cover_url ?? null,
'cover_mobile_url' => $article->cover_mobile_url ?? null,
'cover_srcset' => $article->cover_srcset ?? null,
'excerpt' => Str::limit(strip_tags((string) ($article->excerpt ?: $article->rendered_content)), 135),
'author' => $article->author ? ['name' => $article->author->name ?? $article->author->username, 'username' => $article->author->username ?? null] : null,
'views' => isset($article->views) ? (int) $article->views : 0,
])->values()->all(); ])->values()->all();
} }
@@ -75,6 +75,7 @@ final class NewsCoverImageService
'size_bytes' => strlen($masterEncoded), 'size_bytes' => strlen($masterEncoded),
'mobile_url' => NewsCoverImage::variantUrl($path, 'mobile'), 'mobile_url' => NewsCoverImage::variantUrl($path, 'mobile'),
'desktop_url' => NewsCoverImage::variantUrl($path, 'desktop'), 'desktop_url' => NewsCoverImage::variantUrl($path, 'desktop'),
'large_url' => NewsCoverImage::variantUrl($path, 'large'),
'srcset' => NewsCoverImage::srcset($path), 'srcset' => NewsCoverImage::srcset($path),
]; ];
} }
+98 -9
View File
@@ -26,6 +26,7 @@ use cPad\Plugins\News\Models\NewsArticle;
use cPad\Plugins\News\Models\NewsArticleRelation; use cPad\Plugins\News\Models\NewsArticleRelation;
use cPad\Plugins\News\Models\NewsCategory; use cPad\Plugins\News\Models\NewsCategory;
use cPad\Plugins\News\Models\NewsTag; use cPad\Plugins\News\Models\NewsTag;
use cPad\Plugins\News\Services\NewsArticleService;
final class NewsService final class NewsService
{ {
@@ -39,6 +40,7 @@ final class NewsService
public const RELATION_CHALLENGE = 'challenge'; public const RELATION_CHALLENGE = 'challenge';
public const RELATION_EVENT = 'event'; public const RELATION_EVENT = 'event';
public const RELATION_USER = 'user'; public const RELATION_USER = 'user';
public const RELATION_SOURCE = 'source';
public const RELATION_LABELS = [ public const RELATION_LABELS = [
self::RELATION_GROUP => 'Group', self::RELATION_GROUP => 'Group',
@@ -49,10 +51,15 @@ final class NewsService
self::RELATION_CHALLENGE => 'Challenge', self::RELATION_CHALLENGE => 'Challenge',
self::RELATION_EVENT => 'Event', self::RELATION_EVENT => 'Event',
self::RELATION_USER => 'Profile', self::RELATION_USER => 'Profile',
self::RELATION_SOURCE => 'Source',
]; ];
private ?bool $artworkStatsViewsColumnExists = null; private ?bool $artworkStatsViewsColumnExists = null;
public function __construct(private readonly NewsArticleService $articleService)
{
}
public function articleTypeOptions(): array public function articleTypeOptions(): array
{ {
return \collect(NewsArticle::TYPE_LABELS) return \collect(NewsArticle::TYPE_LABELS)
@@ -224,12 +231,22 @@ final class NewsService
'og_description' => (string) ($article->og_description ?? ''), 'og_description' => (string) ($article->og_description ?? ''),
'og_image' => (string) ($article->og_image ?? ''), 'og_image' => (string) ($article->og_image ?? ''),
'relations' => $article->relatedEntities 'relations' => $article->relatedEntities
->map(fn (NewsArticleRelation $relation): array => [ ->map(function (NewsArticleRelation $relation) use ($viewer): array {
'entity_type' => (string) $relation->entity_type, $entityType = (string) $relation->entity_type;
'entity_id' => (int) $relation->entity_id, $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 ?? ''), 'context_label' => (string) ($relation->context_label ?? ''),
'preview' => $this->resolveEntityPreview((string) $relation->entity_type, (int) $relation->entity_id, $viewer), 'preview' => $entityType === self::RELATION_SOURCE
]) ? $this->resolveSourcePreview($externalUrl, (string) ($relation->context_label ?? ''))
: $this->resolveEntityPreview($entityType, (int) $relation->entity_id, $viewer),
];
})
->values() ->values()
->all(), ->all(),
]; ];
@@ -263,6 +280,8 @@ final class NewsService
'published_at' => $article->published_at ?? \now(), 'published_at' => $article->published_at ?? \now(),
])->save(); ])->save();
$this->articleService->createForumThread($article);
$this->invalidatePublicCache(); $this->invalidatePublicCache();
return $article->fresh(['author', 'category', 'tags', 'relatedEntities']); return $article->fresh(['author', 'category', 'tags', 'relatedEntities']);
@@ -312,6 +331,7 @@ final class NewsService
self::RELATION_CHALLENGE => $this->searchChallenges($query, $viewer), self::RELATION_CHALLENGE => $this->searchChallenges($query, $viewer),
self::RELATION_EVENT => $this->searchEvents($query, $viewer), self::RELATION_EVENT => $this->searchEvents($query, $viewer),
self::RELATION_USER => $this->searchUsers($query), self::RELATION_USER => $this->searchUsers($query),
self::RELATION_SOURCE => [],
default => [], default => [],
}; };
} }
@@ -321,7 +341,15 @@ final class NewsService
$article->loadMissing('relatedEntities'); $article->loadMissing('relatedEntities');
return $article->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() ->filter()
->values() ->values()
->all(); ->all();
@@ -380,6 +408,7 @@ final class NewsService
public function invalidatePublicCache(): void public function invalidatePublicCache(): void
{ {
Cache::forever(self::PUBLIC_CACHE_VERSION_KEY, $this->publicCacheVersion() + 1); Cache::forever(self::PUBLIC_CACHE_VERSION_KEY, $this->publicCacheVersion() + 1);
Cache::forget('news.rss.feed');
} }
public function syncRelations(NewsArticle $article, array $relations): void public function syncRelations(NewsArticle $article, array $relations): void
@@ -387,20 +416,32 @@ final class NewsService
$normalized = \collect($relations) $normalized = \collect($relations)
->map(function (array $relation): ?array { ->map(function (array $relation): ?array {
$entityType = trim(Str::lower((string) ($relation['entity_type'] ?? ''))); $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 null;
} }
return [ return [
'entity_type' => $entityType, 'entity_type' => $entityType,
'entity_id' => $entityId, 'entity_id' => $entityId,
'external_url' => $externalUrl,
'context_label' => Str::limit(trim((string) ($relation['context_label'] ?? '')), 120, ''), 'context_label' => Str::limit(trim((string) ($relation['context_label'] ?? '')), 120, ''),
]; ];
}) })
->filter() ->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(); ->values();
$article->relatedEntities()->delete(); $article->relatedEntities()->delete();
@@ -409,6 +450,7 @@ final class NewsService
$article->relatedEntities()->create([ $article->relatedEntities()->create([
'entity_type' => $relation['entity_type'], 'entity_type' => $relation['entity_type'],
'entity_id' => $relation['entity_id'], 'entity_id' => $relation['entity_id'],
'external_url' => $relation['external_url'],
'context_label' => $relation['context_label'] !== '' ? $relation['context_label'] : null, 'context_label' => $relation['context_label'] !== '' ? $relation['context_label'] : null,
'sort_order' => $index, '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 private function resolveGroupPreview(int $entityId, ?User $viewer, string $contextLabel): ?array
{ {
$group = Group::query()->with('owner')->find($entityId); $group = Group::query()->with('owner')->find($entityId);
@@ -1017,4 +1087,23 @@ final class NewsService
'meta' => [], '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, '');
}
} }
+2
View File
@@ -61,12 +61,14 @@ final class RSSFeedBuilder
string $channelLink, string $channelLink,
string $feedUrl, string $feedUrl,
Collection $items, Collection $items,
?string $canonicalUrl = null,
): Response { ): Response {
$xml = view('rss.channel', [ $xml = view('rss.channel', [
'channelTitle' => trim($channelTitle) . ' — Skinbase', 'channelTitle' => trim($channelTitle) . ' — Skinbase',
'channelDescription' => $channelDescription, 'channelDescription' => $channelDescription,
'channelLink' => $channelLink, 'channelLink' => $channelLink,
'feedUrl' => $feedUrl, 'feedUrl' => $feedUrl,
'canonicalUrl' => $canonicalUrl ?: $feedUrl,
'items' => $items, 'items' => $items,
'buildDate' => now()->toRfc2822String(), 'buildDate' => now()->toRfc2822String(),
])->render(); ])->render();
+41 -8
View File
@@ -15,6 +15,7 @@ use App\Models\User;
use App\Services\Artworks\ArtworkDraftService; use App\Services\Artworks\ArtworkDraftService;
use App\Services\Artworks\ArtworkPublicationService; use App\Services\Artworks\ArtworkPublicationService;
use App\Services\Maturity\ArtworkMaturityService; use App\Services\Maturity\ArtworkMaturityService;
use App\Services\Studio\StudioAiAssistService;
use App\Services\TagService; use App\Services\TagService;
use Carbon\CarbonInterface; use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Collection as EloquentCollection;
@@ -189,7 +190,9 @@ final class UploadQueueService
$item = $this->itemQuery()->findOrFail($itemId); $item = $this->itemQuery()->findOrFail($itemId);
$item->forceFill([ $item->forceFill([
'processing_stage' => UploadBatchItem::STAGE_MATURITY_CHECK, 'processing_stage' => $this->uploadMaturityEnabled()
? UploadBatchItem::STAGE_MATURITY_CHECK
: UploadBatchItem::STAGE_FINALIZED,
'error_code' => null, 'error_code' => null,
'error_message' => null, 'error_message' => null,
'processed_at' => now(), 'processed_at' => now(),
@@ -290,7 +293,7 @@ final class UploadQueueService
'apply_category' => $this->applyCategory($item, (int) ($params['category_id'] ?? 0)), 'apply_category' => $this->applyCategory($item, (int) ($params['category_id'] ?? 0)),
'apply_tags' => $this->applyTags($item, (array) ($params['tags'] ?? [])), 'apply_tags' => $this->applyTags($item, (array) ($params['tags'] ?? [])),
'set_visibility' => $this->setVisibility($item, (string) ($params['visibility'] ?? '')), 'set_visibility' => $this->setVisibility($item, (string) ($params['visibility'] ?? '')),
'generate_ai' => $this->retryProcessing($item), 'generate_ai' => $this->requestAiGeneration($item),
default => throw ValidationException::withMessages([ default => throw ValidationException::withMessages([
'action' => ['Unsupported upload queue action.'], 'action' => ['Unsupported upload queue action.'],
]), ]),
@@ -341,16 +344,40 @@ final class UploadQueueService
$item->forceFill([ $item->forceFill([
'status' => UploadBatchItem::STATUS_PROCESSING, 'status' => UploadBatchItem::STATUS_PROCESSING,
'processing_stage' => UploadBatchItem::STAGE_MATURITY_CHECK, 'processing_stage' => $this->uploadMaturityEnabled()
? UploadBatchItem::STAGE_MATURITY_CHECK
: UploadBatchItem::STAGE_FINALIZED,
'error_code' => null, 'error_code' => null,
'error_message' => null, 'error_message' => null,
'is_ready_to_publish' => false, 'is_ready_to_publish' => false,
])->save(); ])->save();
if ((bool) config('vision.auto_tagging.enabled', false)) {
AutoTagArtworkJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit(); AutoTagArtworkJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
}
if ($this->uploadMaturityEnabled()) {
DetectArtworkMaturityJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit(); DetectArtworkMaturityJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
}
if ((bool) config('vision.upload.embeddings.enabled', true)) {
GenerateArtworkEmbeddingJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit(); GenerateArtworkEmbeddingJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
}
if ((bool) config('vision.upload.ai_assist.enabled', false)) {
AnalyzeArtworkAiAssistJob::dispatch((int) $artwork->id, true)->afterCommit(); AnalyzeArtworkAiAssistJob::dispatch((int) $artwork->id, true)->afterCommit();
}
return $this->refreshItem((int) $item->id);
}
private function requestAiGeneration(UploadBatchItem $item): UploadBatchItem
{
$artwork = $item->artwork;
if (! $artwork || trim((string) ($artwork->hash ?? '')) === '' || trim((string) ($artwork->file_path ?? '')) === '') {
throw ValidationException::withMessages([
'item' => ['This item cannot generate AI suggestions safely. Re-upload the original file instead.'],
]);
}
app(StudioAiAssistService::class)->queueAnalysis($artwork, true);
return $this->refreshItem((int) $item->id); return $this->refreshItem((int) $item->id);
} }
@@ -543,13 +570,13 @@ final class UploadQueueService
$maturityStatus = Str::lower((string) ($artwork?->maturity_status ?? ArtworkMaturityService::STATUS_CLEAR)); $maturityStatus = Str::lower((string) ($artwork?->maturity_status ?? ArtworkMaturityService::STATUS_CLEAR));
$maturityAiStatus = Str::lower((string) ($artwork?->maturity_ai_status ?? ArtworkMaturityService::AI_STATUS_NOT_REQUESTED)); $maturityAiStatus = Str::lower((string) ($artwork?->maturity_ai_status ?? ArtworkMaturityService::AI_STATUS_NOT_REQUESTED));
$aiStatus = Str::lower((string) ($artwork?->ai_status ?? '')); $aiStatus = Str::lower((string) ($artwork?->ai_status ?? ''));
$visionEnabled = (bool) config('vision.enabled', true); $uploadMaturityEnabled = $this->uploadMaturityEnabled();
$maturityPending = $visionEnabled && in_array($maturityAiStatus, [ $maturityPending = $uploadMaturityEnabled && in_array($maturityAiStatus, [
ArtworkMaturityService::AI_STATUS_PENDING, ArtworkMaturityService::AI_STATUS_PENDING,
ArtworkMaturityService::AI_STATUS_NOT_REQUESTED, ArtworkMaturityService::AI_STATUS_NOT_REQUESTED,
], true); ], true);
$maturityFailed = $visionEnabled && $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED; $maturityFailed = $uploadMaturityEnabled && $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED;
$needsReview = $maturityStatus === ArtworkMaturityService::STATUS_SUSPECTED || $maturityFailed; $needsReview = $maturityStatus === ArtworkMaturityService::STATUS_SUSPECTED || $maturityFailed;
$needsMetadata = ! $hasTitle || ! $hasCategory; $needsMetadata = ! $hasTitle || ! $hasCategory;
$blockingUploadFailure = ! $hasProcessedMedia && ($this->nullableString($item->error_code) !== null || $this->nullableText($item->error_message) !== null); $blockingUploadFailure = ! $hasProcessedMedia && ($this->nullableString($item->error_code) !== null || $this->nullableText($item->error_message) !== null);
@@ -634,9 +661,9 @@ final class UploadQueueService
} }
if ($maturityStatus === ArtworkMaturityService::STATUS_SUSPECTED) { if ($maturityStatus === ArtworkMaturityService::STATUS_SUSPECTED) {
$missing[] = 'Needs maturity review'; $missing[] = 'Needs maturity review';
} elseif ((bool) config('vision.enabled', true) && in_array($maturityAiStatus, [ArtworkMaturityService::AI_STATUS_PENDING, ArtworkMaturityService::AI_STATUS_NOT_REQUESTED], true)) { } elseif ($this->uploadMaturityEnabled() && in_array($maturityAiStatus, [ArtworkMaturityService::AI_STATUS_PENDING, ArtworkMaturityService::AI_STATUS_NOT_REQUESTED], true)) {
$missing[] = 'Maturity analysis pending'; $missing[] = 'Maturity analysis pending';
} elseif ((bool) config('vision.enabled', true) && $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED) { } elseif ($this->uploadMaturityEnabled() && $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED) {
$missing[] = 'Maturity check failed'; $missing[] = 'Maturity check failed';
} }
@@ -690,6 +717,12 @@ final class UploadQueueService
return (int) round((collect($checks)->filter()->count() / count($checks)) * 100); return (int) round((collect($checks)->filter()->count() / count($checks)) * 100);
} }
private function uploadMaturityEnabled(): bool
{
return (bool) config('vision.enabled', true)
&& (bool) config('vision.upload.maturity.enabled', false);
}
private function normalizeDefaults(array $defaults): array private function normalizeDefaults(array $defaults): array
{ {
$visibility = (string) ($defaults['visibility'] ?? Artwork::VISIBILITY_PUBLIC); $visibility = (string) ($defaults['visibility'] ?? Artwork::VISIBILITY_PUBLIC);
@@ -7,6 +7,9 @@ namespace App\Support\AcademyAnalytics;
final class AcademyAnalyticsContentType final class AcademyAnalyticsContentType
{ {
public const HOME = 'academy_home'; 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 PROMPT = 'academy_prompt';
public const LESSON = 'academy_lesson'; public const LESSON = 'academy_lesson';
public const COURSE = 'academy_course'; public const COURSE = 'academy_course';
@@ -22,6 +25,9 @@ final class AcademyAnalyticsContentType
{ {
return [ return [
self::HOME, self::HOME,
self::PROMPT_LIBRARY,
self::PROMPT_POPULAR,
self::PROMPT_PACK_LIBRARY,
self::PROMPT, self::PROMPT,
self::LESSON, self::LESSON,
self::COURSE, self::COURSE,
@@ -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);
}
}
+5
View File
@@ -19,6 +19,11 @@ final class NewsCoverImage
'quality' => 76, 'quality' => 76,
'suffix' => 'desktop', 'suffix' => 'desktop',
], ],
'large' => [
'width' => 1280,
'quality' => 80,
'suffix' => 'large',
],
]; ];
public static function isManagedPath(?string $path): bool public static function isManagedPath(?string $path): bool
+1 -2
View File
@@ -29,13 +29,12 @@ return Application::configure(basePath: dirname(__DIR__))
]); ]);
$middleware->validateCsrfTokens(except: [ $middleware->validateCsrfTokens(except: [
'chat_post',
'chat_post/*',
'api/art/*/view', 'api/art/*/view',
'stripe/*', 'stripe/*',
]); ]);
$middleware->web(append: [ $middleware->web(append: [
\App\Http\Middleware\SecurityHeaders::class,
\App\Http\Middleware\RedirectLegacyProfileSubdomain::class, \App\Http\Middleware\RedirectLegacyProfileSubdomain::class,
\App\Http\Middleware\TrackOnlineVisitor::class, \App\Http\Middleware\TrackOnlineVisitor::class,
\App\Http\Middleware\UpdateLastVisit::class, \App\Http\Middleware\UpdateLastVisit::class,
+17
View File
@@ -2055,6 +2055,7 @@
"resources/js/Pages/Admin/Academy/AnalyticsOverview.jsx": [], "resources/js/Pages/Admin/Academy/AnalyticsOverview.jsx": [],
"resources/js/Pages/Admin/Academy/AnalyticsSearch.jsx": [], "resources/js/Pages/Admin/Academy/AnalyticsSearch.jsx": [],
"resources/js/Pages/Admin/Academy/Billing.jsx": [], "resources/js/Pages/Admin/Academy/Billing.jsx": [],
"resources/js/Pages/Admin/Academy/ChallengeEditor.jsx": [],
"resources/js/Pages/Admin/Academy/CourseBuilder.jsx": [], "resources/js/Pages/Admin/Academy/CourseBuilder.jsx": [],
"resources/js/Pages/Admin/Academy/CourseEditor.jsx": [], "resources/js/Pages/Admin/Academy/CourseEditor.jsx": [],
"resources/js/Pages/Admin/Academy/CrudForm.jsx": [], "resources/js/Pages/Admin/Academy/CrudForm.jsx": [],
@@ -2096,6 +2097,9 @@
"resources/js/Pages/Collection/SavedCollections.jsx": [], "resources/js/Pages/Collection/SavedCollections.jsx": [],
"resources/js/Pages/Community/CommunityActivityPage.jsx": [], "resources/js/Pages/Community/CommunityActivityPage.jsx": [],
"resources/js/Pages/Community/LatestCommentsPage.jsx": [], "resources/js/Pages/Community/LatestCommentsPage.jsx": [],
"resources/js/Pages/Enhance/Create.jsx": [],
"resources/js/Pages/Enhance/Index.jsx": [],
"resources/js/Pages/Enhance/Show.jsx": [],
"resources/js/Pages/Feed/FollowingFeed.jsx": [], "resources/js/Pages/Feed/FollowingFeed.jsx": [],
"resources/js/Pages/Feed/HashtagFeed.jsx": [], "resources/js/Pages/Feed/HashtagFeed.jsx": [],
"resources/js/Pages/Feed/SavedFeed.jsx": [], "resources/js/Pages/Feed/SavedFeed.jsx": [],
@@ -2142,6 +2146,13 @@
"resources/js/Pages/Messages/Index.jsx": [], "resources/js/Pages/Messages/Index.jsx": [],
"resources/js/Pages/Moderation/AiBiographyAdmin.jsx": [], "resources/js/Pages/Moderation/AiBiographyAdmin.jsx": [],
"resources/js/Pages/Moderation/ArtworkMaturityQueue.jsx": [], "resources/js/Pages/Moderation/ArtworkMaturityQueue.jsx": [],
"resources/js/Pages/Moderation/Enhance/Index.jsx": [],
"resources/js/Pages/Moderation/Enhance/Show.jsx": [],
"resources/js/Pages/Moderation/FeaturedArtworks.jsx": [],
"resources/js/Pages/Moderation/StaffApplications/Index.jsx": [],
"resources/js/Pages/Moderation/StaffApplications/Show.jsx": [],
"resources/js/Pages/Moderation/Stories.jsx": [],
"resources/js/Pages/Moderation/UsernameQueue.jsx": [],
"resources/js/Pages/Moderation/WorldWebStoriesIndex.jsx": [], "resources/js/Pages/Moderation/WorldWebStoriesIndex.jsx": [],
"resources/js/Pages/Moderation/WorldWebStoryEditor.jsx": [], "resources/js/Pages/Moderation/WorldWebStoryEditor.jsx": [],
"resources/js/Pages/News/NewsComments.jsx": [], "resources/js/Pages/News/NewsComments.jsx": [],
@@ -2275,6 +2286,9 @@
"resources/js/components/docs/FaqSearchInput.jsx": [], "resources/js/components/docs/FaqSearchInput.jsx": [],
"resources/js/components/docs/QuickstartChecklist.jsx": [], "resources/js/components/docs/QuickstartChecklist.jsx": [],
"resources/js/components/docs/QuickstartNextSteps.jsx": [], "resources/js/components/docs/QuickstartNextSteps.jsx": [],
"resources/js/components/enhance/BeforeAfterSlider.jsx": [],
"resources/js/components/enhance/EnhanceStatusBadge.jsx": [],
"resources/js/components/enhance/EnhanceStubWarning.jsx": [],
"resources/js/components/forum/AuthorBadge.jsx": [], "resources/js/components/forum/AuthorBadge.jsx": [],
"resources/js/components/forum/Breadcrumbs.jsx": [], "resources/js/components/forum/Breadcrumbs.jsx": [],
"resources/js/components/forum/CategoryCard.jsx": [], "resources/js/components/forum/CategoryCard.jsx": [],
@@ -2377,6 +2391,7 @@
"resources/js/components/upload/ScreenshotUploader.jsx": [], "resources/js/components/upload/ScreenshotUploader.jsx": [],
"resources/js/components/upload/StudioStatusBar.jsx": [], "resources/js/components/upload/StudioStatusBar.jsx": [],
"resources/js/components/upload/UploadActions.jsx": [], "resources/js/components/upload/UploadActions.jsx": [],
"resources/js/components/upload/UploadDescriptionEditor.jsx": [],
"resources/js/components/upload/UploadDropzone.jsx": [], "resources/js/components/upload/UploadDropzone.jsx": [],
"resources/js/components/upload/UploadOverlay.jsx": [], "resources/js/components/upload/UploadOverlay.jsx": [],
"resources/js/components/upload/UploadSidebar.jsx": [], "resources/js/components/upload/UploadSidebar.jsx": [],
@@ -2457,7 +2472,9 @@
"resources/js/lib/useNavContext.js": [], "resources/js/lib/useNavContext.js": [],
"resources/js/lib/worldAnalytics.js": [], "resources/js/lib/worldAnalytics.js": [],
"resources/js/ssr.jsx": [], "resources/js/ssr.jsx": [],
"resources/js/utils/contentValidation.js": [],
"resources/js/utils/emojiFlood.js": [], "resources/js/utils/emojiFlood.js": [],
"resources/js/utils/enhanceFormatting.js": [],
"resources/js/utils/flagUrl.js": [], "resources/js/utils/flagUrl.js": [],
"resources/js/utils/scheduleCountdown.js": [], "resources/js/utils/scheduleCountdown.js": [],
"resources/js/utils/studioEvents.js": [] "resources/js/utils/studioEvents.js": []
+4425 -1419
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -10,7 +10,6 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"alexusmai/laravel-file-manager": "*",
"composer/installers": "^2.3", "composer/installers": "^2.3",
"gumlet/php-image-resize": "*", "gumlet/php-image-resize": "*",
"inertiajs/inertia-laravel": "^1.0", "inertiajs/inertia-laravel": "^1.0",
Generated
+1 -144
View File
@@ -4,67 +4,8 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "ae4cbbbd3390e2a18df6cb08a6caf6aa", "content-hash": "541533f2d5a6c0c966730bac8f9c2b37",
"packages": [ "packages": [
{
"name": "alexusmai/laravel-file-manager",
"version": "3.3.3",
"source": {
"type": "git",
"url": "https://github.com/alexusmai/laravel-file-manager.git",
"reference": "74bebe32d821d19c1c026545af7e4043fe074aba"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/alexusmai/laravel-file-manager/zipball/74bebe32d821d19c1c026545af7e4043fe074aba",
"reference": "74bebe32d821d19c1c026545af7e4043fe074aba",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-zip": "*",
"intervention/image-laravel": "^1.2.0",
"laravel/framework": "^9.0|^10.0|^11.0|^12.0|^13.0",
"league/flysystem": "^3.0",
"php": "^8.1"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Alexusmai\\LaravelFileManager\\FileManagerServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Alexusmai\\LaravelFileManager\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Aleksandr Manekin",
"email": "alexusmai@gmail.com",
"role": "Developer"
}
],
"description": "File manager for Laravel",
"homepage": "https://github.com/alexusami/laravel-file-manager",
"keywords": [
"file",
"laravel",
"manager"
],
"support": {
"issues": "https://github.com/alexusmai/laravel-file-manager/issues",
"source": "https://github.com/alexusmai/laravel-file-manager/tree/3.3.3"
},
"time": "2026-05-12T10:06:23+00:00"
},
{ {
"name": "aws/aws-crt-php", "name": "aws/aws-crt-php",
"version": "v1.2.7", "version": "v1.2.7",
@@ -1921,90 +1862,6 @@
], ],
"time": "2026-05-01T08:20:10+00:00" "time": "2026-05-01T08:20:10+00:00"
}, },
{
"name": "intervention/image-laravel",
"version": "1.5.9",
"source": {
"type": "git",
"url": "https://github.com/Intervention/image-laravel.git",
"reference": "a760b041e5133fd81509414f4955c93ffefb4a7b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Intervention/image-laravel/zipball/a760b041e5133fd81509414f4955c93ffefb4a7b",
"reference": "a760b041e5133fd81509414f4955c93ffefb4a7b",
"shasum": ""
},
"require": {
"illuminate/http": "^8|^9|^10|^11|^12|^13",
"illuminate/routing": "^8|^9|^10|^11|^12|^13",
"illuminate/support": "^8|^9|^10|^11|^12|^13",
"intervention/image": "^3.11",
"php": "^8.1"
},
"require-dev": {
"ext-fileinfo": "*",
"orchestra/testbench": "^8.18 || ^9.9 || ^10.6",
"phpunit/phpunit": "^10.0 || ^11.0 || ^12.0"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Image": "Intervention\\Image\\Laravel\\Facades\\Image"
},
"providers": [
"Intervention\\Image\\Laravel\\ServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Intervention\\Image\\Laravel\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Oliver Vogel",
"email": "oliver@intervention.io",
"homepage": "https://intervention.io/"
}
],
"description": "Laravel Integration of Intervention Image",
"homepage": "https://image.intervention.io/",
"keywords": [
"gd",
"image",
"imagick",
"laravel",
"resize",
"thumbnail",
"watermark"
],
"support": {
"issues": "https://github.com/Intervention/image-laravel/issues",
"source": "https://github.com/Intervention/image-laravel/tree/1.5.9"
},
"funding": [
{
"url": "https://paypal.me/interventionio",
"type": "custom"
},
{
"url": "https://github.com/Intervention",
"type": "github"
},
{
"url": "https://ko-fi.com/interventionphp",
"type": "ko_fi"
}
],
"time": "2026-03-24T15:10:30+00:00"
},
{ {
"name": "jaybizzle/crawler-detect", "name": "jaybizzle/crawler-detect",
"version": "v1.3.11", "version": "v1.3.11",
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
return [
'disk' => env('ENHANCE_DISK', env('FILESYSTEM_DISK', 'public')),
'source_prefix' => env('ENHANCE_SOURCE_PREFIX', 'enhance/sources'),
'output_prefix' => env('ENHANCE_OUTPUT_PREFIX', 'enhance/outputs'),
'preview_prefix' => env('ENHANCE_PREVIEW_PREFIX', 'enhance/previews'),
'default_engine' => env('ENHANCE_ENGINE', 'stub'),
'max_upload_mb' => (int) env('ENHANCE_MAX_UPLOAD_MB', 20),
'max_input_width' => (int) env('ENHANCE_MAX_INPUT_WIDTH', 4096),
'max_input_height' => (int) env('ENHANCE_MAX_INPUT_HEIGHT', 4096),
'max_output_width' => (int) env('ENHANCE_MAX_OUTPUT_WIDTH', 8192),
'max_output_height' => (int) env('ENHANCE_MAX_OUTPUT_HEIGHT', 8192),
'allowed_mimes' => [
'image/jpeg',
'image/png',
'image/webp',
],
'allowed_modes' => [
'standard',
'artwork',
'photo',
'illustration',
],
'allowed_scales' => [2, 4],
'daily_limit' => (int) env('ENHANCE_DAILY_LIMIT', 10),
'queue' => env('ENHANCE_QUEUE', 'default'),
'lifecycle' => [
'completed_expires_after_days' => (int) env('ENHANCE_COMPLETED_EXPIRES_AFTER_DAYS', 30),
'failed_expires_after_days' => (int) env('ENHANCE_FAILED_EXPIRES_AFTER_DAYS', 7),
'deleted_file_grace_days' => (int) env('ENHANCE_DELETED_FILE_GRACE_DAYS', 1),
'cleanup_chunk_size' => (int) env('ENHANCE_CLEANUP_CHUNK_SIZE', 100),
],
'health' => [
'stuck_processing_after_minutes' => (int) env('ENHANCE_STUCK_PROCESSING_AFTER_MINUTES', 30),
'stuck_queued_after_minutes' => (int) env('ENHANCE_STUCK_QUEUED_AFTER_MINUTES', 60),
],
'stub' => [
'show_warning' => filter_var(env('ENHANCE_STUB_SHOW_WARNING', true), FILTER_VALIDATE_BOOL),
],
'external_worker' => [
'url' => env('ENHANCE_WORKER_URL'),
'timeout' => (int) env('ENHANCE_WORKER_TIMEOUT', 300),
'token' => env('ENHANCE_WORKER_TOKEN'),
'max_download_mb' => (int) env('ENHANCE_WORKER_MAX_DOWNLOAD_MB', 60),
],
];
-166
View File
@@ -1,166 +0,0 @@
<?php
use Alexusmai\LaravelFileManager\Services\ConfigService\DefaultConfigRepository;
use Alexusmai\LaravelFileManager\Services\ACLService\ConfigACLRepository;
return [
/**
* Set Config repository
*
* Default - DefaultConfigRepository get config from this file
*/
'configRepository' => DefaultConfigRepository::class,
/**
* ACL rules repository
*
* Default - ConfigACLRepository (see rules in - aclRules)
*/
'aclRepository' => ConfigACLRepository::class,
//********* Default configuration for DefaultConfigRepository **************
/**
* LFM Route prefix
* !!! WARNING - if you change it, you should compile frontend with new prefix(baseUrl) !!!
*/
'routePrefix' => 'file-manager',
/**
* List of disk names that you want to use
* (from config/filesystems)
*/
'diskList' => ['public'],
/**
* Default disk for left manager
*
* null - auto select the first disk in the disk list
*/
'leftDisk' => null,
/**
* Default disk for right manager
*
* null - auto select the first disk in the disk list
*/
'rightDisk' => null,
/**
* Default path for left manager
*
* null - root directory
*/
'leftPath' => null,
/**
* Default path for right manager
*
* null - root directory
*/
'rightPath' => null,
/**
* File manager modules configuration
*
* 1 - only one file manager window
* 2 - one file manager window with directories tree module
* 3 - two file manager windows
*/
'windowsConfig' => 2,
/**
* File upload - Max file size in KB
*
* null - no restrictions
*/
'maxUploadFileSize' => null,
/**
* File upload - Allow these file types
*
* [] - no restrictions
*/
'allowFileTypes' => [],
/**
* Show / Hide system files and folders
*/
'hiddenFiles' => true,
/***************************************************************************
* Middleware
*
* Add your middleware name to array -> ['web', 'auth', 'admin']
* !!!! RESTRICT ACCESS FOR NON ADMIN USERS !!!!
*/
'middleware' => ['web'],
/***************************************************************************
* ACL mechanism ON/OFF
*
* default - false(OFF)
*/
'acl' => false,
/**
* Hide files and folders from file-manager if user doesn't have access
*
* ACL access level = 0
*/
'aclHideFromFM' => true,
/**
* ACL strategy
*
* blacklist - Allow everything(access - 2 - r/w) that is not forbidden by the ACL rules list
*
* whitelist - Deny anything(access - 0 - deny), that not allowed by the ACL rules list
*/
'aclStrategy' => 'blacklist',
/**
* ACL Rules cache
*
* null or value in minutes
*/
'aclRulesCache' => null,
//********* Default configuration for DefaultConfigRepository END **********
/***************************************************************************
* ACL rules list - used for default ACL repository (ConfigACLRepository)
*
* 1 it's user ID
* null - for not authenticated user
*
* 'disk' => 'disk-name'
*
* 'path' => 'folder-name'
* 'path' => 'folder1*' - select folder1, folder12, folder1/sub-folder, ...
* 'path' => 'folder2/*' - select folder2/sub-folder,... but not select folder2 !!!
* 'path' => 'folder-name/file-name.jpg'
* 'path' => 'folder-name/*.jpg'
*
* * - wildcard
*
* access: 0 - deny, 1 - read, 2 - read/write
*/
'aclRules' => [
null => [
//['disk' => 'public', 'path' => '/', 'access' => 2],
],
1 => [
//['disk' => 'public', 'path' => 'images/arch*.jpg', 'access' => 2],
//['disk' => 'public', 'path' => 'files/*', 'access' => 1],
],
],
/**
* Enable slugification of filenames of uploaded files.
*
*/
'slugifyNames' => false,
];
+12
View File
@@ -0,0 +1,12 @@
<?php
return [
// Whether the optional `light` theme is enabled for the site. When false,
// front-end theme toggle will only expose the default theme.
'enabled' => env('LIGHT_THEME_ENABLED', false),
// Whether the toolbar should render the light-theme switch. This is
// controlled separately so you can enable the theme without showing the
// global switch to visitors/admins.
'show_toolbar_switch' => env('LIGHT_THEME_SHOW_SWITCH', false),
];
+16
View File
@@ -5,6 +5,22 @@ declare(strict_types=1);
return [ return [
'enabled' => env('VISION_ENABLED', true), 'enabled' => env('VISION_ENABLED', true),
'auto_tagging' => [
'enabled' => env('VISION_AUTO_TAGGING_ENABLED', false),
],
'upload' => [
'embeddings' => [
'enabled' => env('VISION_UPLOAD_EMBEDDINGS_ENABLED', true),
],
'maturity' => [
'enabled' => env('VISION_UPLOAD_MATURITY_ENABLED', false),
],
'ai_assist' => [
'enabled' => env('VISION_UPLOAD_AI_ASSIST_ENABLED', false),
],
],
'queue' => env('VISION_QUEUE', 'default'), 'queue' => env('VISION_QUEUE', 'default'),
'clip' => [ 'clip' => [
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('enhance_jobs', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('artwork_id')->nullable()->constrained('artworks')->nullOnDelete();
$table->string('status', 32)->default('pending');
$table->string('engine', 64)->default('stub');
$table->string('mode', 32)->default('standard');
$table->unsignedTinyInteger('scale')->default(2);
$table->string('source_disk', 64)->nullable();
$table->string('source_path')->nullable();
$table->string('source_hash', 128)->nullable();
$table->unsignedInteger('input_width')->nullable();
$table->unsignedInteger('input_height')->nullable();
$table->unsignedBigInteger('input_filesize')->nullable();
$table->string('input_mime', 128)->nullable();
$table->string('output_disk', 64)->nullable();
$table->string('output_path')->nullable();
$table->string('output_hash', 128)->nullable();
$table->unsignedInteger('output_width')->nullable();
$table->unsignedInteger('output_height')->nullable();
$table->unsignedBigInteger('output_filesize')->nullable();
$table->string('output_mime', 128)->nullable();
$table->string('preview_disk', 64)->nullable();
$table->string('preview_path')->nullable();
$table->unsignedInteger('processing_seconds')->nullable();
$table->text('error_message')->nullable();
$table->json('metadata')->nullable();
$table->timestamp('queued_at')->nullable();
$table->timestamp('started_at')->nullable();
$table->timestamp('finished_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['user_id', 'status']);
$table->index(['status', 'created_at']);
$table->index(['artwork_id']);
});
}
public function down(): void
{
Schema::dropIfExists('enhance_jobs');
}
};
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Add a composite unique index on (article_id, ip) to news_views so that
* duplicate view inserts at the DB level are impossible even if the session
* guard is bypassed (e.g. server restart mid-request).
*
* A separate unique index on (article_id, user_id) is added for logged-in
* users, skipping NULL user_ids so guest records don't conflict.
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('news_views')) {
return;
}
$driver = DB::getDriverName();
Schema::table('news_views', function (Blueprint $table) use ($driver): void {
// De-duplicate existing rows before adding the index.
// Keep only the earliest record per (article_id, ip) pair.
if ($driver === 'sqlite') {
DB::statement("
DELETE FROM news_views
WHERE ip IS NOT NULL
AND id IN (
SELECT nv1.id
FROM news_views nv1
INNER JOIN news_views nv2
ON nv2.article_id = nv1.article_id
AND nv2.ip = nv1.ip
AND nv2.id < nv1.id
WHERE nv1.ip IS NOT NULL
)
");
} else {
DB::statement("
DELETE nv1 FROM news_views nv1
INNER JOIN news_views nv2
ON nv2.article_id = nv1.article_id
AND nv2.ip = nv1.ip
AND nv2.id < nv1.id
WHERE nv1.ip IS NOT NULL
");
}
// De-duplicate by (article_id, user_id) for authenticated users.
if ($driver === 'sqlite') {
DB::statement("
DELETE FROM news_views
WHERE user_id IS NOT NULL
AND id IN (
SELECT nv1.id
FROM news_views nv1
INNER JOIN news_views nv2
ON nv2.article_id = nv1.article_id
AND nv2.user_id = nv1.user_id
AND nv2.id < nv1.id
WHERE nv1.user_id IS NOT NULL
)
");
} else {
DB::statement("
DELETE nv1 FROM news_views nv1
INNER JOIN news_views nv2
ON nv2.article_id = nv1.article_id
AND nv2.user_id = nv1.user_id
AND nv2.id < nv1.id
WHERE nv1.user_id IS NOT NULL
");
}
if (! $this->indexExists('news_views', 'news_views_article_ip_unique')) {
$table->unique(['article_id', 'ip'], 'news_views_article_ip_unique');
}
if (! $this->indexExists('news_views', 'news_views_article_user_unique')) {
$table->unique(['article_id', 'user_id'], 'news_views_article_user_unique');
}
});
}
public function down(): void
{
if (! Schema::hasTable('news_views')) {
return;
}
Schema::table('news_views', function (Blueprint $table): void {
if ($this->indexExists('news_views', 'news_views_article_ip_unique')) {
$table->dropUnique('news_views_article_ip_unique');
}
if ($this->indexExists('news_views', 'news_views_article_user_unique')) {
$table->dropUnique('news_views_article_user_unique');
}
});
}
private function indexExists(string $table, string $indexName): bool
{
if (DB::getDriverName() === 'sqlite') {
return collect(DB::select("PRAGMA index_list('{$table}')"))
->contains(static fn (object $row): bool => ($row->name ?? null) === $indexName);
}
$indexes = DB::select("SHOW INDEX FROM `{$table}` WHERE Key_name = ?", [$indexName]);
return count($indexes) > 0;
}
};
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('news_article_relations')) {
return;
}
Schema::table('news_article_relations', function (Blueprint $table): void {
if (! Schema::hasColumn('news_article_relations', 'external_url')) {
$table->string('external_url', 2048)->nullable()->after('entity_id');
}
});
Schema::table('news_article_relations', function (Blueprint $table): void {
$table->unsignedBigInteger('entity_id')->nullable()->change();
});
}
public function down(): void
{
if (! Schema::hasTable('news_article_relations')) {
return;
}
DB::table('news_article_relations')
->whereNotNull('external_url')
->delete();
Schema::table('news_article_relations', function (Blueprint $table): void {
if (Schema::hasColumn('news_article_relations', 'external_url')) {
$table->dropColumn('external_url');
}
});
Schema::table('news_article_relations', function (Blueprint $table): void {
$table->unsignedBigInteger('entity_id')->nullable(false)->change();
});
}
};
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('academy_prompt_templates', function (Blueprint $table): void {
if (! Schema::hasColumn('academy_prompt_templates', 'filled_examples')) {
$table->json('filled_examples')->nullable()->after('prompt_variants');
}
});
}
public function down(): void
{
Schema::table('academy_prompt_templates', function (Blueprint $table): void {
if (Schema::hasColumn('academy_prompt_templates', 'filled_examples')) {
$table->dropColumn('filled_examples');
}
});
}
};
+580
View File
@@ -0,0 +1,580 @@
Skinbase Enhance Setup Guide
===========================
This guide explains how to set up, enable, verify, and operate the Skinbase Enhance module end to end.
Use this document when you need to:
- enable Enhance in a local environment
- switch from stub mode to the external worker
- run the worker in Pillow mode or Real-ESRGAN mode
- configure queues, cleanup, and health checks
- understand what the module stores and how it behaves in production
What the module does
--------------------
Enhance accepts an uploaded or selected source image, creates an Enhance job, processes that job through the configured engine, stores the generated output on the Enhance storage disk, and keeps the original source file untouched.
Current supported engines on the Laravel side:
- `ENHANCE_ENGINE=stub`
- `ENHANCE_ENGINE=external_worker`
Current supported worker engines:
- `WORKER_ENGINE=pillow`
- `WORKER_ENGINE=realesrgan-ncnn`
- `WORKER_ENGINE=realesrgan`
`WORKER_ENGINE=realesrgan` currently aliases to `realesrgan-ncnn`.
Architecture
------------
The Enhance module is split into two layers.
Laravel application:
- accepts the Enhance request
- validates allowed file types, dimensions, scales, and modes
- stores job records
- dispatches the job to the queue
- owns permanent storage for Enhance sources and outputs
- exposes moderation, cleanup, and health commands
Optional external worker:
- downloads the copied Enhance source image
- upscales it with Pillow or Real-ESRGAN
- exposes a temporary internal result URL
- deletes its temporary result after Laravel confirms download
Laravel remains the source of truth. The worker is temporary processing only.
Default behavior and limits
---------------------------
Default config values from [config/enhance.php](config/enhance.php):
- disk: `ENHANCE_DISK` or the app filesystem default
- source prefix: `enhance/sources`
- output prefix: `enhance/outputs`
- preview prefix: `enhance/previews`
- default engine: `stub`
- allowed MIME types: `image/jpeg`, `image/png`, `image/webp`
- allowed modes: `standard`, `artwork`, `photo`, `illustration`
- allowed scales: `2`, `4`
- daily limit: `10`
- default queue: `default`
Lifecycle defaults:
- completed jobs expire after `30` days
- failed jobs expire after `7` days
- deleted job files get a `1` day grace period
Health defaults:
- processing jobs are considered stuck after `30` minutes
- queued jobs are considered stale after `60` minutes
Prerequisites
-------------
Laravel requirements:
- the application boots normally
- database migrations are current
- the configured filesystem disk is writable
- a queue worker or Horizon is running for the queue you assign to Enhance
- Laravel scheduler is enabled if you want automatic cleanup
Worker requirements for `external_worker` mode:
- Docker or a Python runtime for `services/enhance-worker`
- network access from Laravel to the worker URL
- a shared bearer token between Laravel and the worker
- for Real-ESRGAN mode, the `realesrgan-ncnn-vulkan` binary and model files
Setup paths
-----------
There are three practical ways to run Enhance.
1. Stub mode
- safest local starting point
- exercises the Laravel flow without a real AI runtime
- no worker needed
2. External worker with Pillow
- good for local integration testing and CI-like validation
- real HTTP worker contract
- deterministic fallback upscale path
- no Real-ESRGAN runtime files required
3. External worker with Real-ESRGAN
- production-oriented path
- uses the `realesrgan-ncnn-vulkan` CLI runtime
- requires runtime files and a verified worker host
Laravel setup
-------------
Minimum env for stub mode:
```env
ENHANCE_ENGINE=stub
ENHANCE_QUEUE=default
```
Recommended env for external worker mode:
```env
ENHANCE_ENGINE=external_worker
ENHANCE_QUEUE=enhance
ENHANCE_WORKER_URL=http://127.0.0.1:8095
ENHANCE_WORKER_TIMEOUT=900
ENHANCE_WORKER_TOKEN=change-this-token
ENHANCE_WORKER_MAX_DOWNLOAD_MB=60
```
Optional Laravel env keys you may tune:
```env
ENHANCE_DISK=public
ENHANCE_SOURCE_PREFIX=enhance/sources
ENHANCE_OUTPUT_PREFIX=enhance/outputs
ENHANCE_PREVIEW_PREFIX=enhance/previews
ENHANCE_MAX_UPLOAD_MB=20
ENHANCE_MAX_INPUT_WIDTH=4096
ENHANCE_MAX_INPUT_HEIGHT=4096
ENHANCE_MAX_OUTPUT_WIDTH=8192
ENHANCE_MAX_OUTPUT_HEIGHT=8192
ENHANCE_DAILY_LIMIT=10
ENHANCE_COMPLETED_EXPIRES_AFTER_DAYS=30
ENHANCE_FAILED_EXPIRES_AFTER_DAYS=7
ENHANCE_DELETED_FILE_GRACE_DAYS=1
ENHANCE_CLEANUP_CHUNK_SIZE=100
ENHANCE_STUCK_PROCESSING_AFTER_MINUTES=30
ENHANCE_STUCK_QUEUED_AFTER_MINUTES=60
ENHANCE_STUB_SHOW_WARNING=true
```
After changing env:
```bash
php artisan config:clear
```
Queue setup
-----------
Enhance jobs run on `ENHANCE_QUEUE`.
If you keep the default queue:
```bash
php artisan queue:work --queue=default
```
If you use a dedicated Enhance queue:
```bash
php artisan queue:work --queue=enhance,default
```
If Horizon or workers do not consume the configured queue, Enhance jobs will stay queued.
Scheduler and cleanup
---------------------
Enhance cleanup is scheduled from [routes/console.php](routes/console.php) and runs:
```bash
php artisan enhance:cleanup --force
```
Useful cleanup and health commands:
```bash
php artisan enhance:health
php artisan enhance:health --json
php artisan enhance:cleanup --dry-run
php artisan enhance:cleanup --force
```
Cleanup only removes files under the configured Enhance prefixes. It does not delete artwork originals or unrelated storage paths.
Enable stub mode
----------------
Use stub mode first if you want to validate the Laravel module without introducing worker runtime variables.
1. Set:
```env
ENHANCE_ENGINE=stub
ENHANCE_QUEUE=default
```
2. Clear config:
```bash
php artisan config:clear
```
3. Start a queue worker:
```bash
php artisan queue:work --queue=default
```
4. Check health:
```bash
php artisan enhance:health
```
5. Open `/enhance/create`, submit a small image, and verify the job completes.
Enable external worker mode with Pillow
---------------------------------------
This is the safest real integration path because it exercises the HTTP worker contract without requiring Real-ESRGAN files.
1. Start the worker:
```bash
cd services/enhance-worker
docker compose -f docker-compose.example.yml up --build
```
2. Confirm worker health:
```bash
curl http://127.0.0.1:8095/health
```
Expected result:
- `status: ok`
- `engine: pillow`
3. Set Laravel env:
```env
ENHANCE_ENGINE=external_worker
ENHANCE_QUEUE=enhance
ENHANCE_WORKER_URL=http://127.0.0.1:8095
ENHANCE_WORKER_TIMEOUT=600
ENHANCE_WORKER_TOKEN=change-this-token
ENHANCE_WORKER_MAX_DOWNLOAD_MB=60
```
4. Clear config and start queue workers:
```bash
php artisan config:clear
php artisan queue:work --queue=enhance,default
```
5. Verify the Laravel side:
```bash
php artisan enhance:health
php artisan test --filter=EnhanceExternalWorker
```
Enable external worker mode with Real-ESRGAN
--------------------------------------------
This is the production-oriented setup.
1. Install or mount the runtime files inside [services/enhance-worker](services/enhance-worker):
```bash
cd services/enhance-worker
bash scripts/download-realesrgan-ncnn.sh
bash scripts/verify-realesrgan.sh
```
Required file locations:
- binary: `bin/realesrgan-ncnn-vulkan`
- models: `models/*.param` and `models/*.bin`
2. Start the Real-ESRGAN worker:
```bash
docker compose -f docker-compose.realesrgan.example.yml up --build
```
3. Confirm worker health:
```bash
curl http://127.0.0.1:8095/health
```
Expected result when ready:
- `status: ok`
- `engine: realesrgan-ncnn`
Expected result when runtime files are missing or invalid:
- `status: degraded`
4. Set Laravel env:
```env
ENHANCE_ENGINE=external_worker
ENHANCE_QUEUE=enhance
ENHANCE_WORKER_URL=http://127.0.0.1:8095
ENHANCE_WORKER_TIMEOUT=900
ENHANCE_WORKER_TOKEN=change-this-token
ENHANCE_WORKER_MAX_DOWNLOAD_MB=60
```
5. Clear config and start the queue worker:
```bash
php artisan config:clear
php artisan queue:work --queue=enhance,default
```
6. Verify through the application by submitting a small image from `/enhance/create`.
Worker configuration
--------------------
Main worker env values:
```env
WORKER_HOST=0.0.0.0
WORKER_PORT=8095
WORKER_TOKEN=change-this-token
WORKER_ENGINE=pillow
WORKER_DEVICE=cpu
WORKER_MAX_UPLOAD_MB=20
WORKER_MAX_INPUT_WIDTH=4096
WORKER_MAX_INPUT_HEIGHT=4096
WORKER_MAX_OUTPUT_WIDTH=8192
WORKER_MAX_OUTPUT_HEIGHT=8192
WORKER_TMP_DIR=/app/storage/tmp
WORKER_OUTPUT_DIR=/app/storage/output
WORKER_RESULT_TTL_MINUTES=60
```
Real-ESRGAN-specific worker env values:
```env
WORKER_REALESRGAN_BIN=/app/bin/realesrgan-ncnn-vulkan
WORKER_REALESRGAN_MODEL_DIR=/app/models
WORKER_REALESRGAN_DEFAULT_MODEL=realesrgan-x4plus
WORKER_REALESRGAN_ANIME_MODEL=realesrgan-x4plus-anime
WORKER_REALESRGAN_TILE=0
WORKER_REALESRGAN_TTA=false
WORKER_REALESRGAN_VERBOSE=false
WORKER_REALESRGAN_TIMEOUT_SECONDS=900
WORKER_REALESRGAN_PREPROCESS_MAX_PIXELS=16777216
WORKER_REALESRGAN_OUTPUT_EXT=webp
WORKER_REALESRGAN_ALLOW_MODEL_FALLBACK=true
```
Compatibility values kept by the worker:
```env
WORKER_MODEL_DIR=/app/app/models
WORKER_DEFAULT_MODEL=realesrgan-x4plus
```
Worker behavior
---------------
Worker request contract:
- endpoint: `POST /v1/upscale`
- bearer auth required
- accepted output formats: `webp`, `png`, `jpg`
- allowed scales: `2`, `4`
- allowed modes: `standard`, `artwork`, `photo`, `illustration`
Health and temp file endpoints:
- `GET /health`
- `GET /v1/results/{filename}`
- `DELETE /v1/results/{filename}`
Mode and scale behavior
-----------------------
Real-ESRGAN mode mapping:
- `standard` -> default model
- `artwork` -> default model
- `photo` -> default model
- `illustration` -> anime model when available
Fallback behavior:
- if the requested model exists, it is used
- if it is missing and fallback is enabled, the default model is used
- if it is missing and fallback is disabled, processing fails safely
Scale behavior:
- `4x` returns native 4x output
- `2x` currently runs the 4x model and then downsamples to 2x
Storage and data flow
---------------------
Laravel stores Enhance files under the configured prefixes:
- sources under `enhance/sources`
- outputs under `enhance/outputs`
- previews under `enhance/previews`
The worker does not permanently store Enhance results.
When `ENHANCE_ENGINE=external_worker`:
1. Laravel prepares a copied Enhance source file.
2. Laravel sends the worker a temporary URL or a temporary signed internal route.
3. The worker downloads the source file.
4. The worker processes the image.
5. The worker exposes a temporary result URL.
6. Laravel downloads, validates, and stores the final output.
7. Laravel instructs the worker to delete the temporary result.
Verification checklist
----------------------
Laravel verification:
```bash
php artisan config:clear
php artisan enhance:health
php artisan enhance:health --json
```
Worker verification:
```bash
curl http://127.0.0.1:8095/health
```
Queue verification:
```bash
php artisan queue:work --queue=enhance,default
```
Application verification:
1. Open `/enhance/create`.
2. Upload or choose a small source image.
3. Select `2x` or `4x` and a valid mode.
4. Submit the job.
5. Confirm the job transitions from queued to completed.
6. Confirm output exists on the Enhance disk.
7. Confirm the source file and original artwork remain untouched.
Health states
-------------
Laravel health command helps identify:
- configured engine
- queue usage
- stuck jobs
- lifecycle status
Worker `/health` helps identify:
- current worker engine
- maximum input and output limits
- Real-ESRGAN binary availability
- Real-ESRGAN model directory readiness
- available Real-ESRGAN models
If the worker is in Real-ESRGAN mode and health returns `degraded`, do not treat the runtime as production-ready yet.
Troubleshooting
---------------
`Worker URL is missing.`
- set `ENHANCE_WORKER_URL`
- clear config
`Worker token is missing.`
- set `ENHANCE_WORKER_TOKEN`
- make sure the worker uses the same `WORKER_TOKEN`
`Worker is unavailable.`
- confirm the worker is reachable
- confirm the worker container is running
- confirm the URL points to the worker base URL
`Upscale engine is not available. Check model files and worker installation.`
- confirm `WORKER_ENGINE=realesrgan-ncnn`
- confirm `bin/realesrgan-ncnn-vulkan` exists and is executable
- confirm the required `.param` and `.bin` model files exist
- run `bash scripts/verify-realesrgan.sh`
Jobs stay queued
- confirm queue workers consume `ENHANCE_QUEUE`
- if using `enhance`, run workers with `--queue=enhance,default`
`status: degraded` from worker health
- verify binary and model directory paths
- verify runtime files are mounted inside the container
- fall back to `WORKER_ENGINE=pillow` until the runtime is fixed
Operations and safety notes
---------------------------
- Keep the worker bound to `127.0.0.1` or a private container network.
- Do not expose the worker publicly.
- Use a strong shared token.
- Keep Enhance on a dedicated queue when load increases.
- Keep cleanup enabled so stale outputs and failed files do not accumulate.
- Do not commit Real-ESRGAN binary or model weight files unless explicitly approved.
- The worker only serves generated files from its own output directory.
- The worker rejects unsupported source URLs and unsafe output paths.
- Original artwork files are never replaced by the Enhance flow.
Production rollout recommendation
---------------------------------
Recommended rollout sequence:
1. Enable `ENHANCE_ENGINE=stub` and verify the Laravel workflow.
2. Move to `ENHANCE_ENGINE=external_worker` with `WORKER_ENGINE=pillow`.
3. Verify queue, storage, cleanup, and health behavior.
4. Install Real-ESRGAN runtime files and switch the worker to `WORKER_ENGINE=realesrgan-ncnn`.
5. Confirm worker health is `ok` before calling the runtime production-ready.
Related docs
------------
- operational notes: [docs/enhance.md](docs/enhance.md)
- worker runtime docs: [services/enhance-worker/README.md](services/enhance-worker/README.md)
+105
View File
@@ -0,0 +1,105 @@
Skinbase Enhance
================
Operational notes for the Enhance v1/v1.1 module.
For full setup, enablement, worker configuration, verification, and production rollout guidance, see [docs/enhance-setup.md](docs/enhance-setup.md).
Configuration
-------------
- `ENHANCE_ENGINE=stub` keeps Enhance in preview mode for local and workflow testing.
- `ENHANCE_ENGINE=external_worker` uses the prepared external worker adapter boundary.
- `ENHANCE_QUEUE=default` is the current safe default.
- `ENHANCE_QUEUE=enhance` is supported when you want a dedicated queue later.
- If you switch to `ENHANCE_QUEUE=enhance`, Horizon or any worker process must explicitly consume the `enhance` queue in production.
Helpful commands
----------------
```bash
php artisan enhance:health
php artisan enhance:health --json
php artisan enhance:cleanup --dry-run
php artisan enhance:cleanup --force
php artisan queue:work --queue=enhance,default
```
Cleanup behavior
----------------
- Enhance cleanup only removes files under the configured Enhance prefixes:
- `enhance/sources`
- `enhance/outputs`
- `enhance/previews`
- Cleanup never deletes artwork originals, thumbnails, avatars, or other non-Enhance paths.
- Completed jobs can expire automatically via `ENHANCE_COMPLETED_EXPIRES_AFTER_DAYS`.
- Failed jobs can have stale files pruned via `ENHANCE_FAILED_EXPIRES_AFTER_DAYS`.
- Soft-deleted jobs respect `ENHANCE_DELETED_FILE_GRACE_DAYS` before file cleanup.
Scheduler
---------
- The app schedules `php artisan enhance:cleanup --force` daily from `routes/console.php`.
- If you disable Laravel's scheduler in an environment, run cleanup manually or through external cron.
Queue and Horizon reminder
--------------------------
- Stub mode still dispatches queued Enhance jobs and exercises the same lifecycle.
- If workers only consume `default` and you later move Enhance to a dedicated queue, completed jobs will stall in `queued` until `enhance` is added to the worker queue list.
External Worker v1
------------------
- Set `ENHANCE_ENGINE=external_worker` to switch Laravel from the stub processor to the HTTP worker integration.
- Recommended local Laravel env:
```env
ENHANCE_ENGINE=external_worker
ENHANCE_WORKER_URL=http://127.0.0.1:8095
ENHANCE_WORKER_TIMEOUT=600
ENHANCE_WORKER_TOKEN=change-this-token
ENHANCE_WORKER_MAX_DOWNLOAD_MB=60
ENHANCE_QUEUE=enhance
```
- Keep the worker bound to `127.0.0.1` or a private container network. Do not expose it publicly.
- Laravel sends the worker a short-lived source URL. If the storage disk cannot issue temporary URLs, Laravel falls back to a temporary signed internal route that serves only the copied Enhance source file.
- Laravel remains the source of truth: the worker only returns a temporary result, Laravel downloads it, validates it, stores it on the Enhance disk, and then asks the worker to delete the temporary file.
- Useful commands after enabling the worker:
```bash
php artisan config:clear
php artisan enhance:health
php artisan queue:work --queue=enhance,default
php artisan test --filter=EnhanceExternalWorker
```
Real-ESRGAN Runtime
-------------------
- Laravel still uses `ENHANCE_ENGINE=external_worker`. It does not need to know whether the worker uses Pillow or Real-ESRGAN internally.
- Use `WORKER_ENGINE=pillow` for local development, CI, and fallback operation.
- Use `WORKER_ENGINE=realesrgan-ncnn` for the real ncnn-vulkan runtime path.
- If worker health reports `status: degraded`, keep Laravel on the stub processor or a Pillow worker until the Real-ESRGAN runtime is verified.
- Do not expose the worker port publicly.
- Move to the `enhance` Horizon queue only after the worker is healthy and verified in your environment.
- Real-ESRGAN runtime files are not committed. Install them locally or in deployment with the worker scripts:
```bash
cd services/enhance-worker
bash scripts/download-realesrgan-ncnn.sh
bash scripts/verify-realesrgan.sh
```
- Recommended Laravel env when using the real worker:
```env
ENHANCE_ENGINE=external_worker
ENHANCE_QUEUE=enhance
ENHANCE_WORKER_URL=http://127.0.0.1:8095
ENHANCE_WORKER_TIMEOUT=900
ENHANCE_WORKER_TOKEN=change-this-token
ENHANCE_WORKER_MAX_DOWNLOAD_MB=60
```
+275
View File
@@ -0,0 +1,275 @@
APP_NAME=SkinbaseNova
APP_ENV=local
APP_KEY=base64:TAMmcAnL05vnhSV7wBoDoSc/Pv42LNQtX6B6lGc3HBk=
APP_DEBUG=true
APP_URL=http://skinbase26.test
DEBUGBAR_ENABLED=true
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
#DB_HOST=10.255.255.254
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=projekti_2026_skinbase
DB_USERNAME=projekti
DB_PASSWORD=2Xf5TM3P1IeNTfhs
LEGACY_DB_HOST=127.0.0.1
LEGACY_DB_DATABASE=projekti_old_skinbase
SESSION_DRIVER=database
SESSION_LIFETIME=3600
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_HOST=smtp-pulse.com
MAIL_PORT=587
MAIL_USERNAME=info@skinbase.org
MAIL_PASSWORD=ML2BBL958fdCMMc
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS='info@skinbase.org'
MAIL_FROM_NAME="Skinbase"
AWS_ACCESS_KEY_ID=9d9292110fb4f68b2e4bc1fa55d6b2a3
AWS_SECRET_ACCESS_KEY=0a1d8d8a38eb9a15ff23eac0c5e993c1
AWS_DEFAULT_REGION=eu2
AWS_BUCKET=skinbase
AWS_USE_PATH_STYLE_ENDPOINT=true
AWS_ENDPOINT=https://eu2.contabostorage.com
VISION_VECTOR_GATEWAY_ENABLED=true
VISION_VECTOR_GATEWAY_URL=https://vision.klevze.net
VISION_VECTOR_GATEWAY_API_KEY=jQZ96c2B2QRjsFZiPZXMYCid6lVdsyxF
VISION_VECTOR_GATEWAY_COLLECTION=images
VISION_VECTOR_GATEWAY_TIMEOUT=20
VISION_VECTOR_GATEWAY_CONNECT_TIMEOUT=5
VISION_VECTOR_GATEWAY_RETRIES=1
VISION_VECTOR_GATEWAY_RETRY_DELAY_MS=250
VITE_APP_NAME="${APP_NAME}"
SKINBASE_STORAGE_ROOT=D:/Sites/Skinbase26/public/files/thumb
ARTWORKS_LOCAL_ORIGINALS_ROOT=D:/Sites/Skinbase26/public/files/originals
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=https://meili.klevze.si
MEILISEARCH_KEY=0d0df27b-dd25-4855-b6a6-3786755475c6
#MEILISEARCH_KEY=a474f24de92941aac24441b4d7ee71ce4feb8e7a3157d4f6e6a42877cb2a563c
MEILI_PREFIX=skinbase_prod_
# Discovery rollout profile (Phase 8 lock)
DISCOVERY_ALGO_VERSION=clip-cosine-v1
DISCOVERY_V2_ENABLED=true
DISCOVERY_V2_ALGO_VERSION=clip-cosine-v2-adaptive
DISCOVERY_V2_CACHE_VERSION=cache-v2
DISCOVERY_V2_CACHE_TTL_MINUTES=15
DISCOVERY_V2_ROLLOUT_PERCENTAGE=10
DISCOVERY_RANKING_WEIGHTS_VERSION_CLIP_COSINE_V2=rank-w-v2-prod-1
DISCOVERY_RANKING_W1_CLIP_COSINE_V2=0.52
DISCOVERY_RANKING_W2_CLIP_COSINE_V2=0.23
DISCOVERY_RANKING_W3_CLIP_COSINE_V2=0.15
DISCOVERY_RANKING_W4_CLIP_COSINE_V2=0.10
DISCOVERY_ROLLOUT_ENABLED=true
DISCOVERY_ROLLOUT_BASELINE_ALGO_VERSION=clip-cosine-v1
DISCOVERY_ROLLOUT_CANDIDATE_ALGO_VERSION=clip-cosine-v2
DISCOVERY_ROLLOUT_ACTIVE_GATE=g10
DISCOVERY_ROLLOUT_GATE_10_PERCENT=10
DISCOVERY_ROLLOUT_GATE_50_PERCENT=50
DISCOVERY_ROLLOUT_GATE_100_PERCENT=100
DISCOVERY_FORCE_ALGO_VERSION=
DISCOVERY_EVAL_SAVE_RATE_INFORMATIONAL=true
# Emergency rollback preset (uncomment to force baseline immediately)
# DISCOVERY_FORCE_ALGO_VERSION=clip-cosine-v1
# DISCOVERY_ROLLOUT_ACTIVE_GATE=g10
# DISCOVERY_ROLLOUT_ENABLED=true
UPLOAD_SCAN_ENABLED=false
UPLOAD_SCAN_COMMAND=clamscan
IMAGE_DRIVER=gd
SKINBASE_UPLOADS_V2=true
# Vision / AI auto-tagging (local defaults)
VISION_ENABLED=true
VISION_QUEUE=default
VISION_IMAGE_VARIANT=lg
VISION_API_KEY=${VISION_VECTOR_GATEWAY_API_KEY}
CLIP_BASE_URL=https://vision.klevze.net
CLIP_ANALYZE_ENDPOINT=/analyze/clip
YOLO_BASE_URL=https://vision.klevze.net
YOLO_ANALYZE_ENDPOINT=/analyze/yolo
VISION_GATEWAY_URL=https://vision.klevze.net
VISION_GATEWAY_API_KEY=${VISION_VECTOR_GATEWAY_API_KEY}
VISION_GATEWAY_TIMEOUT=60
VISION_GATEWAY_CONNECT_TIMEOUT=5
SCOUT_QUEUE_CONNECTION=database
SCOUT_QUEUE_NAME=default
# ─── Early-Stage Growth System ───────────────────────────────────────────────
# Set NOVA_EARLY_GROWTH_ENABLED=false to instantly revert to normal behaviour.
# NOVA_EARLY_GROWTH_MODE: off | light | aggressive
NOVA_EARLY_GROWTH_ENABLED=true
NOVA_EARLY_GROWTH_MODE=aggressive
# Module toggles (only active when NOVA_EARLY_GROWTH_ENABLED=true)
NOVA_EGS_ADAPTIVE_WINDOW=true
NOVA_EGS_GRID_FILLER=true
NOVA_EGS_SPOTLIGHT=true
NOVA_EGS_ACTIVITY_LAYER=false
# AdaptiveTimeWindow thresholds
NOVA_EGS_UPLOADS_PER_DAY_NARROW=10
NOVA_EGS_UPLOADS_PER_DAY_WIDE=3
NOVA_EGS_WINDOW_NARROW_DAYS=7
NOVA_EGS_WINDOW_MEDIUM_DAYS=30
NOVA_EGS_WINDOW_WIDE_DAYS=90
# GridFiller minimum items per page
NOVA_EGS_GRID_MIN_RESULTS=12
# Auto-disable when site reaches organic scale
NOVA_EGS_AUTO_DISABLE=false
NOVA_EGS_AUTO_DISABLE_UPLOADS=50
NOVA_EGS_AUTO_DISABLE_USERS=500
# Cache TTLs (seconds)
NOVA_EGS_SPOTLIGHT_TTL=3600
NOVA_EGS_BLEND_TTL=300
NOVA_EGS_WINDOW_TTL=600
NOVA_EGS_ACTIVITY_TTL=1800
GOOGLE_CLIENT_ID="252720311278-fgjgrv3bue9upgqfp91ihbpunoqlpjvf.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="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"
GOOGLE_REDIRECT_URI=https://skinbase26.test/auth/google/callback
# Discord — https://discord.com/developers/applications
DISCORD_CLIENT_ID=1478852108869570731
DISCORD_CLIENT_SECRET=k9OgyZrwNqT_UwZgwvHTRdEw8DXStKLN
DISCORD_REDIRECT_URI=https://skinbase26.test/auth/discord/callback
CP_ENABLE_CORS=false
BROADCAST_CONNECTION=reverb
REVERB_APP_ID=376489
REVERB_APP_KEY=jm0pq3ikcu3yequsbioc
REVERB_APP_SECRET=68sq4tc5lqhxuavxgqlt
# internal Reverb server bind
REVERB_SERVER_HOST=127.0.0.1
REVERB_SERVER_PORT=8080
# public host behind Cloudflare / Apache
REVERB_HOST=ws.skinbase.org
REVERB_PORT=443
REVERB_SCHEME=https
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
MESSAGING_REALTIME=true
CLOUDFLARE_ZONE_ID=2fead03fca715d3f44f567c671dc554d
CLOUDFLARE_API_TOKEN=cfut_Bd7THUxtJTHvOb66xDXhzp4uEm8IoaHZAkkOBnbVbcda524d
NOVA_CARDS_PUBLIC_DISK=s3
NOVA_CARDS_PLAYWRIGHT_RENDER=true
AWS_URL=https://cdn.skinbase.org
SEO_META_KEYWORDS=false
#SENTRY_LARAVEL_DSN=https://f3774714982b12b53cfc3e70e1883595@o106088.ingest.us.sentry.io/4511307411816448
#SENTRY_SEND_DEFAULT_PII=true
#SENTRY_TRACES_SAMPLE_RATE=1.0
SKINBASE_ACADEMY_ENABLED=true
SKINBASE_ACADEMY_PAYMENTS_ENABLED=true
SKINBASE_ACADEMY_CHALLENGES_ENABLED=true
SKINBASE_ACADEMY_BADGES_ENABLED=true
# Stripe / Cashier
STRIPE_KEY=pk_test_51TYk1SBlXOyRoJYFUxa4PsycgqcfajPUMKCAFwXle5edjB2dIg7CvwO3upI6P83ya5blD4CvhSiStY0kP8jyJbAp00zn9cPlii
STRIPE_SECRET=sk_test_51TYk1SBlXOyRoJYFn0PvoXYvRa5KkGh5Q9PkMD3SgTKiBEibjnZsnZmKH098y38tQU8n14Fy1WyLrsuUAkgz1DtZ00MaOIwWBt
STRIPE_WEBHOOK_SECRET=whsec_IeFGaq7AK27RWwXXchyaWyPqSJ08cBsW
CASHIER_CURRENCY=eur
CASHIER_CURRENCY_LOCALE=sl_SI
# Academy billing price IDs
ACADEMY_CREATOR_MONTHLY_PRICE_ID=price_xxx
ACADEMY_PRO_MONTHLY_PRICE_ID=price_1TYmkTBlXOyRoJYFfY8al4j2
ACADEMY_BILLING_ENABLED=true
ACADEMY_STRIPE_SUBSCRIPTION_NAME=academy
# Registration anti-spam
REGISTRATION_IP_PER_MINUTE_LIMIT=3
REGISTRATION_IP_PER_DAY_LIMIT=20
REGISTRATION_EMAIL_PER_MINUTE_LIMIT=6
REGISTRATION_EMAIL_COOLDOWN_MINUTES=30
REGISTRATION_VERIFY_TOKEN_TTL_HOURS=24
REGISTRATION_ENABLE_TURNSTILE=true
REGISTRATION_DISPOSABLE_DOMAINS_ENABLED=true
REGISTRATION_TURNSTILE_SUSPICIOUS_ATTEMPTS=2
REGISTRATION_TURNSTILE_ATTEMPT_WINDOW_MINUTES=30
REGISTRATION_EMAIL_GLOBAL_SEND_PER_MINUTE=30
REGISTRATION_MONTHLY_EMAIL_LIMIT=10000
TURNSTILE_SITE_KEY=0x4AAAAAADI6Ruu4X2IpmLrF
TURNSTILE_SECRET_KEY=0x4AAAAAADI6RlHFGscerV8DhIUwykRcbgE
TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify
TURNSTILE_TIMEOUT=5
TURNSTILE_ENABLED=true
TURNSTILE_FAIL_OPEN=false
ENHANCE_DISK=public
ENHANCE_SOURCE_PREFIX=enhance/sources
ENHANCE_OUTPUT_PREFIX=enhance/outputs
ENHANCE_PREVIEW_PREFIX=enhance/previews
ENHANCE_ENGINE=stub
ENHANCE_MAX_UPLOAD_MB=20
ENHANCE_MAX_INPUT_WIDTH=4096
ENHANCE_MAX_INPUT_HEIGHT=4096
ENHANCE_MAX_OUTPUT_WIDTH=8192
ENHANCE_MAX_OUTPUT_HEIGHT=8192
ENHANCE_DAILY_LIMIT=10
ENHANCE_QUEUE=default
ENHANCE_WORKER_URL=
ENHANCE_WORKER_TIMEOUT=300
ENHANCE_WORKER_TOKEN=
-8
View File
@@ -1,8 +0,0 @@
<?php
// TEMPORARY — delete after use
if (function_exists('opcache_reset')) {
opcache_reset();
echo "OPcache reset OK — " . date('H:i:s');
} else {
echo "opcache_reset() not available";
}
+16
View File
@@ -1583,6 +1583,22 @@
tab-size: 2; tab-size: 2;
} }
.academy-lesson-prose :not(pre) > code {
display: inline-block;
padding: 0.14em 0.46em 0.16em;
border: 1px solid rgba(125, 211, 252, 0.18);
border-radius: 0.38rem;
background: rgba(56, 189, 248, 0.08);
color: rgb(186 230 253);
font-family: ui-monospace, 'Cascadia Code', 'Fira Code', Menlo, Monaco, Consolas, monospace;
font-size: 0.875em;
font-weight: 500;
line-height: 1.5;
vertical-align: baseline;
white-space: normal;
word-break: break-word;
}
.academy-lesson-prose pre::after { .academy-lesson-prose pre::after {
inset: 3rem 0 auto 0; inset: 3rem 0 auto 0;
background: linear-gradient(90deg, rgba(56, 189, 248, 0), rgba(56, 189, 248, 0.26), rgba(56, 189, 248, 0)); background: linear-gradient(90deg, rgba(56, 189, 248, 0), rgba(56, 189, 248, 0.26), rgba(56, 189, 248, 0));
+2 -1
View File
@@ -16,6 +16,7 @@ const buildAdminNavGroups = (isAdmin) => [
{ label: 'All Users', href: '/moderation/users', icon: 'fa-solid fa-users' }, { label: 'All Users', href: '/moderation/users', icon: 'fa-solid fa-users' },
{ label: 'Staff', href: '/moderation/users?role=admin', icon: 'fa-solid fa-shield-halved' }, { label: 'Staff', href: '/moderation/users?role=admin', icon: 'fa-solid fa-shield-halved' },
{ label: 'Moderators', href: '/moderation/users?role=moderator', icon: 'fa-solid fa-user-shield' }, { label: 'Moderators', href: '/moderation/users?role=moderator', icon: 'fa-solid fa-user-shield' },
{ label: 'Staff Applications', href: '/moderation/staff-applications', icon: 'fa-solid fa-user-check' },
], ],
}, },
{ {
@@ -23,12 +24,12 @@ const buildAdminNavGroups = (isAdmin) => [
items: [ items: [
{ label: 'Stories', href: '/moderation/stories', icon: 'fa-solid fa-feather-pointed' }, { label: 'Stories', href: '/moderation/stories', icon: 'fa-solid fa-feather-pointed' },
{ label: 'Artworks', href: '/moderation/artworks', icon: 'fa-solid fa-images' }, { label: 'Artworks', href: '/moderation/artworks', icon: 'fa-solid fa-images' },
{ label: 'Enhance Jobs', href: '/moderation/enhance', icon: 'fa-solid fa-up-right-and-down-left-from-center' },
{ label: 'Featured Artworks', href: '/moderation/artworks/featured', icon: 'fa-solid fa-star' }, { label: 'Featured Artworks', href: '/moderation/artworks/featured', icon: 'fa-solid fa-star' },
{ label: 'Web Stories', href: '/moderation/web-stories', icon: 'fa-solid fa-book-open-reader' }, { label: 'Web Stories', href: '/moderation/web-stories', icon: 'fa-solid fa-book-open-reader' },
{ label: 'Homepage Announcements', href: '/moderation/homepage/announcements', icon: 'fa-solid fa-bullhorn' }, { label: 'Homepage Announcements', href: '/moderation/homepage/announcements', icon: 'fa-solid fa-bullhorn' },
{ label: 'Upload Queue', href: '/moderation/uploads', icon: 'fa-solid fa-cloud-arrow-up' }, { label: 'Upload Queue', href: '/moderation/uploads', icon: 'fa-solid fa-cloud-arrow-up' },
{ label: 'Username Queue', href: '/moderation/usernames/moderation', icon: 'fa-solid fa-id-badge' }, { label: 'Username Queue', href: '/moderation/usernames/moderation', icon: 'fa-solid fa-id-badge' },
{ label: 'AI Biography', href: '/moderation/ai-biography', icon: 'fa-solid fa-wand-magic-sparkles' },
], ],
}, },
{ {
+139 -8
View File
@@ -1,5 +1,5 @@
import React from 'react' import React, { useState, useRef, useEffect } from 'react'
import { Head, Link } from '@inertiajs/react' import { Head, Link, useForm, usePage } from '@inertiajs/react'
import AccessBadge from '../../../components/academy/billing/AccessBadge' import AccessBadge from '../../../components/academy/billing/AccessBadge'
function formatDate(iso) { function formatDate(iso) {
@@ -12,6 +12,72 @@ function formatDate(iso) {
} }
export default function AcademyBillingAccount({ currentTier, isSubscribed, subscription, activePlan = null, links = {} }) { export default function AcademyBillingAccount({ currentTier, isSubscribed, subscription, activePlan = null, links = {} }) {
const { flash, auth } = usePage().props
const { data, setData, post, processing } = useForm({
issue_type: 'billing',
contact_email: auth?.user?.email || '',
message: '',
session_id: null,
})
function IssueTypeDropdown({ value, onChange }) {
const options = [
{ value: 'billing', label: 'Billing question' },
{ value: 'payment', label: 'Payment problem' },
{ value: 'upgrade', label: 'Upgrade problem' },
{ value: 'downgrade', label: 'Downgrade problem' },
{ value: 'cancel', label: 'Cancellation problem' },
{ value: 'access', label: 'Access not updated' },
{ value: 'other', label: 'Other' },
]
const [open, setOpen] = useState(false)
const ref = useRef(null)
useEffect(() => {
function onDoc(e) {
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [])
const current = options.find((o) => o.value === value) || options[0]
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((s) => !s)}
className="w-full text-left rounded-xl border border-amber-300/20 bg-black/20 p-3 text-sm text-amber-50 flex items-center justify-between"
>
<span>{current.label}</span>
<svg className="ml-2 h-4 w-4 text-amber-100/70" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 8l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
{open ? (
<div className="absolute left-0 top-full mt-2 w-full rounded-xl border border-white/10 bg-[#10192e] shadow-2xl z-50 overflow-hidden">
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
onChange(opt.value)
setOpen(false)
}}
className={`w-full text-left px-4 py-3 text-sm ${opt.value === value ? 'bg-white/[0.03] text-white' : 'text-slate-300 hover:bg-white/[0.02]'}`}
>
{opt.label}
</button>
))}
</div>
) : null}
</div>
)
}
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
const endsAt = formatDate(subscription?.endsAt) const endsAt = formatDate(subscription?.endsAt)
const onGracePeriod = subscription?.onGracePeriod === true const onGracePeriod = subscription?.onGracePeriod === true
const subscriptionActive = subscription?.active === true const subscriptionActive = subscription?.active === true
@@ -21,6 +87,16 @@ export default function AcademyBillingAccount({ currentTier, isSubscribed, subsc
<Head title="Academy Subscription" /> <Head title="Academy Subscription" />
<div className="mx-auto max-w-[1280px] space-y-8"> <div className="mx-auto max-w-[1280px] space-y-8">
{flash?.error ? (
<section className="rounded-[20px] border border-rose-300/20 bg-rose-300/8 p-4">
<p className="font-semibold text-rose-100">{flash.error}</p>
</section>
) : null}
{flash?.success ? (
<section className="rounded-[20px] border border-emerald-300/20 bg-emerald-300/8 p-4">
<p className="font-semibold text-emerald-100">{flash.success}</p>
</section>
) : null}
{/* Header */} {/* Header */}
<section className="rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(7,17,31,0.95),rgba(12,24,45,0.9),rgba(15,23,42,0.96))] p-8 shadow-[0_32px_100px_rgba(2,6,23,0.42)] md:p-10"> <section className="rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(7,17,31,0.95),rgba(12,24,45,0.9),rgba(15,23,42,0.96))] p-8 shadow-[0_32px_100px_rgba(2,6,23,0.42)] md:p-10">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
@@ -42,12 +118,12 @@ export default function AcademyBillingAccount({ currentTier, isSubscribed, subsc
<section className="rounded-[30px] border border-amber-300/25 bg-amber-300/[0.06] px-6 py-5"> <section className="rounded-[30px] border border-amber-300/25 bg-amber-300/[0.06] px-6 py-5">
<p className="font-semibold text-amber-100">Your subscription was cancelled and will end on {endsAt}.</p> <p className="font-semibold text-amber-100">Your subscription was cancelled and will end on {endsAt}.</p>
<p className="mt-2 text-sm leading-6 text-amber-100/75">You still have full access until that date. Open the subscription portal to resume your plan if you change your mind.</p> <p className="mt-2 text-sm leading-6 text-amber-100/75">You still have full access until that date. Open the subscription portal to resume your plan if you change your mind.</p>
<Link <a
href={links.portal} href={links.portal}
className="mt-4 inline-flex items-center rounded-full border border-amber-300/30 bg-amber-300/12 px-5 py-2.5 text-sm font-semibold text-amber-100 transition hover:bg-amber-300/20" className="mt-4 inline-flex items-center rounded-full border border-amber-300/30 bg-amber-300/12 px-5 py-2.5 text-sm font-semibold text-amber-100 transition hover:bg-amber-300/20"
> >
Resume subscription Resume subscription
</Link> </a>
</section> </section>
) : null} ) : null}
@@ -123,20 +199,75 @@ export default function AcademyBillingAccount({ currentTier, isSubscribed, subsc
<aside className="space-y-3 rounded-[32px] border border-white/10 bg-black/20 p-6"> <aside className="space-y-3 rounded-[32px] border border-white/10 bg-black/20 p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300">Manage</p> <p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300">Manage</p>
<p className="text-xs leading-6 text-slate-400"> <p className="text-xs leading-6 text-slate-400">
Use the subscription portal to upgrade, downgrade, or cancel. Changes take effect at your next billing date. Use the subscription portal to cancel or manage billing details. Plan upgrades are handled here on Skinbase.
</p> </p>
<Link {/* Use a plain anchor to perform a full navigation to Stripe (avoid Inertia XHR/CORS) */}
<a
href={links.portal} href={links.portal}
className="mt-2 inline-flex w-full items-center justify-center rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18" className="mt-2 inline-flex w-full items-center justify-center rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18"
> >
Upgrade, downgrade or cancel Open billing portal
</Link> </a>
<Link <Link
href={links.pricing || '/academy/pricing'} href={links.pricing || '/academy/pricing'}
className="inline-flex w-full items-center justify-center rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]" className="inline-flex w-full items-center justify-center rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]"
> >
Compare plans Compare plans
</Link> </Link>
{/* Quick upgrade form: allow Creator -> Pro upgrade in one click (full POST, not Inertia) */}
{activePlan?.tier === 'creator' ? (
<form action={links.checkout} method="POST" data-no-inertia className="mt-2">
<input type="hidden" name="_token" value={getCsrfToken()} />
<input type="hidden" name="plan" value="pro_monthly" />
<button type="submit" className="inline-flex w-full items-center justify-center rounded-full border border-emerald-300/25 bg-emerald-300/10 px-5 py-3 text-sm font-semibold text-emerald-100 transition hover:bg-emerald-300/18">Upgrade to Pro now</button>
</form>
) : null}
{links.reportIssue ? (
<div className="mt-3 rounded-2xl border border-amber-300/20 bg-amber-300/8 p-4">
<p className="text-sm font-semibold text-amber-100">Need help with billing or access?</p>
<p className="mt-1 text-xs leading-5 text-amber-100/80">
Send a quick report here if payment, access, or subscription changes do not behave as expected.
</p>
<form
onSubmit={(event) => {
event.preventDefault()
post(links.reportIssue, { preserveScroll: true })
}}
className="mt-3 space-y-3"
>
<div className="grid gap-3">
<label className="space-y-1 relative">
<span className="text-xs font-medium text-amber-100/80">Issue type</span>
{/* Custom dropdown to avoid native browser option styling */}
<IssueTypeDropdown value={data.issue_type} onChange={(v) => setData('issue_type', v)} />
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-amber-100/80">Reply email</span>
<input
type="email"
value={data.contact_email}
onChange={(event) => setData('contact_email', event.target.value)}
placeholder="you@example.com"
className="w-full rounded-xl border border-amber-300/20 bg-black/20 p-3 text-sm text-amber-50 placeholder:text-amber-100/40"
/>
</label>
</div>
<textarea
value={data.message}
onChange={(event) => setData('message', event.target.value)}
placeholder="Describe the issue you hit, what you expected, and anything already charged or missing"
className="min-h-[96px] w-full rounded-xl border border-amber-300/20 bg-black/20 p-3 text-sm text-amber-50 placeholder:text-amber-100/40"
/>
<button
type="submit"
disabled={processing}
className="inline-flex w-full items-center justify-center rounded-full border border-amber-300/30 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-300/18 disabled:cursor-not-allowed disabled:opacity-60"
>
{processing ? 'Sending report...' : 'Send support report'}
</button>
</form>
</div>
) : null}
<Link <Link
href={links.academy || '/academy'} href={links.academy || '/academy'}
className="inline-flex w-full items-center justify-center rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]" className="inline-flex w-full items-center justify-center rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]"
@@ -83,7 +83,7 @@ function SidePanel({ currentTier, isSubscribed, activePlanLabel, activePlanPrice
) )
} }
export default function AcademyBillingPricing({ seo, billingEnabled, currentTier, isSubscribed, activePlanKey = null, activePlanLabel = null, catalog = [], links = {}, analytics }) { export default function AcademyBillingPricing({ seo, billingEnabled, currentTier, isSubscribed, activePlanKey = null, activePlanLabel = null, catalog = [], links = {}, analytics, missingRemote = [] }) {
const { auth, errors, flash } = usePage().props const { auth, errors, flash } = usePage().props
useAcademyPageAnalytics(analytics) useAcademyPageAnalytics(analytics)
@@ -151,6 +151,12 @@ export default function AcademyBillingPricing({ seo, billingEnabled, currentTier
{errors?.plan ? <p className="mt-4 text-sm font-medium text-rose-200">{errors.plan}</p> : null} {errors?.plan ? <p className="mt-4 text-sm font-medium text-rose-200">{errors.plan}</p> : null}
{flash?.error ? <p className="mt-4 rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm font-medium text-rose-100">{flash.error}</p> : null} {flash?.error ? <p className="mt-4 rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm font-medium text-rose-100">{flash.error}</p> : null}
{flash?.success ? <p className="mt-4 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm font-medium text-emerald-100">{flash.success}</p> : null} {flash?.success ? <p className="mt-4 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm font-medium text-emerald-100">{flash.success}</p> : null}
{Array.isArray(missingRemote) && missingRemote.length > 0 ? (
<div className="mt-4 rounded-2xl border border-amber-300/20 bg-amber-300/8 px-4 py-3 text-sm font-medium text-amber-50">
<p className="font-semibold">Purchases temporarily disabled:</p>
<p className="mt-1 text-xs">The following plans could not be verified in Stripe: {missingRemote.join(', ')}</p>
</div>
) : null}
</div> </div>
<SidePanel <SidePanel

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