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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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