Implement creator studio and upload updates

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

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Services\Uploads;
use App\DTOs\Uploads\UploadValidationResult;
final class UploadArchiveValidationService
{
public function validate(string $path): UploadValidationResult
{
if (! is_file($path) || ! is_readable($path)) {
return UploadValidationResult::fail('file_unreadable');
}
$size = (int) filesize($path);
$maxBytes = (int) config('uploads.max_archive_size_mb', 0) * 1024 * 1024;
if ($maxBytes > 0 && $size > $maxBytes) {
return UploadValidationResult::fail('file_too_large', null, null, null, $size);
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = (string) ($finfo->file($path) ?: '');
$allowed = array_values(array_unique((array) config('uploads.allowed_archive_mimes', [])));
if ($mime === '' || ! in_array($mime, $allowed, true)) {
return UploadValidationResult::fail('mime_not_allowed', null, null, $mime, $size);
}
return UploadValidationResult::ok(0, 0, $mime, $size);
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Services\Uploads;
use App\Services\Images\SquareThumbnailService;
use Illuminate\Support\Facades\File;
use Intervention\Image\ImageManager as ImageManager;
use Intervention\Image\Interfaces\ImageInterface as InterventionImageInterface;
@@ -14,7 +15,10 @@ final class UploadDerivativesService
private bool $imageAvailable = false;
private ?ImageManager $manager = null;
public function __construct(private readonly UploadStorageService $storage)
public function __construct(
private readonly UploadStorageService $storage,
private readonly SquareThumbnailService $squareThumbnails,
)
{
// Intervention Image v3 uses ImageManager; instantiate appropriate driver
try {
@@ -27,33 +31,32 @@ final class UploadDerivativesService
}
}
public function storeOriginal(string $sourcePath, string $hash, ?string $originalFileName = null): string
/**
* @return array{local_path: string, object_path: string, filename: string, mime: string, size: int, ext: string}
*/
public function storeOriginal(string $sourcePath, string $hash, ?string $originalFileName = null): array
{
$this->assertImageAvailable();
// Preserve original file extension and store with filename = <hash>.<ext>
$dir = $this->storage->ensureHashDirectory('original', $hash);
$origExt = $this->resolveOriginalExtension($sourcePath, $originalFileName);
$target = $dir . DIRECTORY_SEPARATOR . $hash . ($origExt !== '' ? '.' . $origExt : '');
$filename = $hash . ($origExt !== '' ? '.' . $origExt : '');
$target = $this->storage->localOriginalPath($hash, $filename);
// Try a direct copy first (works for images and archives). If that fails,
// fall back to re-encoding image to webp as a last resort.
try {
if (! File::copy($sourcePath, $target)) {
throw new \RuntimeException('Copy failed');
}
} catch (\Throwable $e) {
// Fallback: encode to webp
$quality = (int) config('uploads.quality', 85);
/** @var InterventionImageInterface $img */
$img = $this->manager->read($sourcePath);
$encoder = new \Intervention\Image\Encoders\WebpEncoder($quality);
$encoded = (string) $img->encode($encoder);
$target = $dir . DIRECTORY_SEPARATOR . $hash . '.webp';
File::put($target, $encoded);
if (! File::copy($sourcePath, $target)) {
throw new RuntimeException('Unable to copy original file into local artwork originals storage.');
}
return $target;
$mime = (string) (File::mimeType($target) ?: 'application/octet-stream');
$size = (int) filesize($target);
$objectPath = $this->storage->objectPathForVariant('original', $hash, $filename);
$this->storage->putObjectFromPath($target, $objectPath, $mime);
return [
'local_path' => $target,
'object_path' => $objectPath,
'filename' => $filename,
'mime' => $mime,
'size' => $size,
'ext' => $origExt,
];
}
private function resolveOriginalExtension(string $sourcePath, ?string $originalFileName): string
@@ -90,6 +93,9 @@ final class UploadDerivativesService
};
}
/**
* @return array<string, array{path: string, size: int, mime: string}>
*/
public function generatePublicDerivatives(string $sourcePath, string $hash): array
{
$this->assertImageAvailable();
@@ -99,9 +105,14 @@ final class UploadDerivativesService
foreach ($variants as $variant => $options) {
$variant = (string) $variant;
$dir = $this->storage->ensureHashDirectory($variant, $hash);
// store derivative filename as <hash>.webp per variant directory
$path = $dir . DIRECTORY_SEPARATOR . $hash . '.webp';
if ($variant === 'sq') {
$written[$variant] = $this->generateSquareDerivative($sourcePath, $hash);
continue;
}
$filename = $hash . '.webp';
$objectPath = $this->storage->objectPathForVariant($variant, $hash, $filename);
/** @var InterventionImageInterface $img */
$img = $this->manager->read($sourcePath);
@@ -120,13 +131,51 @@ final class UploadDerivativesService
$encoder = new \Intervention\Image\Encoders\WebpEncoder($quality);
$encoded = (string) $out->encode($encoder);
File::put($path, $encoded);
$written[$variant] = $path;
$this->storage->putObjectContents($objectPath, $encoded, 'image/webp');
$written[$variant] = [
'path' => $objectPath,
'size' => strlen($encoded),
'mime' => 'image/webp',
];
}
return $written;
}
/**
* @param array<string, mixed> $options
* @return array{path: string, size: int, mime: string, result: SquareThumbnailResultData}
*/
public function generateSquareDerivative(string $sourcePath, string $hash, array $options = []): array
{
$filename = $hash . '.webp';
$objectPath = $this->storage->objectPathForVariant('sq', $hash, $filename);
$temporaryPath = tempnam(sys_get_temp_dir(), 'sq-derivative-');
if ($temporaryPath === false) {
throw new RuntimeException('Unable to allocate a temporary file for square derivative generation.');
}
$temporaryWebp = $temporaryPath . '.webp';
rename($temporaryPath, $temporaryWebp);
try {
$result = $this->squareThumbnails->generateFromPath($sourcePath, $temporaryWebp, $options);
$mime = 'image/webp';
$size = (int) filesize($temporaryWebp);
$this->storage->putObjectFromPath($temporaryWebp, $objectPath, $mime);
return [
'path' => $objectPath,
'size' => $size,
'mime' => $mime,
'result' => $result,
];
} finally {
File::delete($temporaryWebp);
}
}
private function assertImageAvailable(): void
{
if (! $this->imageAvailable) {

View File

@@ -12,8 +12,9 @@ use App\Models\Artwork;
use App\Repositories\Uploads\ArtworkFileRepository;
use App\Repositories\Uploads\UploadSessionRepository;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
final class UploadPipelineService
{
@@ -21,6 +22,7 @@ final class UploadPipelineService
private readonly UploadStorageService $storage,
private readonly UploadSessionRepository $sessions,
private readonly UploadValidationService $validator,
private readonly UploadArchiveValidationService $archiveValidator,
private readonly UploadHashService $hasher,
private readonly UploadScanService $scanner,
private readonly UploadAuditService $audit,
@@ -85,6 +87,27 @@ final class UploadPipelineService
return new UploadValidatedFile($validation, $hash);
}
public function validateAndHashArchive(string $sessionId): UploadValidatedFile
{
$session = $this->sessions->getOrFail($sessionId);
$validation = $this->archiveValidator->validate($session->tempPath);
if (! $validation->ok) {
$this->quarantine($session, $validation->reason);
return new UploadValidatedFile($validation, null);
}
$hash = $this->hasher->hashFile($session->tempPath);
$this->sessions->updateStatus($sessionId, UploadSessionStatus::VALIDATED);
$this->sessions->updateProgress($sessionId, 30);
$this->audit->log($session->userId, 'upload_archive_validated', $session->ip, [
'session_id' => $sessionId,
'hash' => $hash,
]);
return new UploadValidatedFile($validation, $hash);
}
public function scan(string $sessionId): UploadScanResult
{
$session = $this->sessions->getOrFail($sessionId);
@@ -104,72 +127,154 @@ final class UploadPipelineService
return $result;
}
public function processAndPublish(string $sessionId, string $hash, int $artworkId, ?string $originalFileName = null): array
public function processAndPublish(
string $sessionId,
string $hash,
int $artworkId,
?string $originalFileName = null,
?string $archiveSessionId = null,
?string $archiveHash = null,
?string $archiveOriginalFileName = null,
array $additionalScreenshotSessions = []
): array
{
$session = $this->sessions->getOrFail($sessionId);
$originalPath = $this->derivatives->storeOriginal($session->tempPath, $hash, $originalFileName);
$origFilename = basename($originalPath);
$originalRelative = $this->storage->sectionRelativePath('original', $hash, $origFilename);
$origMime = File::exists($originalPath) ? File::mimeType($originalPath) : 'application/octet-stream';
$this->artworkFiles->upsert($artworkId, 'orig', $originalRelative, $origMime, (int) filesize($originalPath));
$publicAbsolute = $this->derivatives->generatePublicDerivatives($session->tempPath, $hash);
$publicRelative = [];
foreach ($publicAbsolute as $variant => $absolutePath) {
$filename = $hash . '.webp';
$relativePath = $this->storage->sectionRelativePath($variant, $hash, $filename);
$this->artworkFiles->upsert($artworkId, $variant, $relativePath, 'image/webp', (int) filesize($absolutePath));
$publicRelative[$variant] = $relativePath;
$archiveSession = null;
if (is_string($archiveSessionId) && trim($archiveSessionId) !== '') {
$archiveSession = $this->sessions->getOrFail($archiveSessionId);
}
$dimensions = @getimagesize($session->tempPath);
$width = is_array($dimensions) && isset($dimensions[0]) ? (int) $dimensions[0] : 1;
$height = is_array($dimensions) && isset($dimensions[1]) ? (int) $dimensions[1] : 1;
$resolvedAdditionalScreenshots = collect($additionalScreenshotSessions)
->filter(fn ($payload) => is_array($payload) && is_string($payload['session_id'] ?? null) && is_string($payload['hash'] ?? null))
->values()
->map(function (array $payload): array {
return [
'session' => $this->sessions->getOrFail((string) $payload['session_id']),
'hash' => trim((string) $payload['hash']),
'file_name' => is_string($payload['file_name'] ?? null) ? $payload['file_name'] : null,
];
});
$origExt = strtolower(pathinfo($originalPath, PATHINFO_EXTENSION) ?: '');
$downloadFileName = $origFilename;
$localCleanup = [];
$objectCleanup = [];
if (is_string($originalFileName) && trim($originalFileName) !== '') {
$candidate = basename(str_replace('\\', '/', $originalFileName));
$candidate = preg_replace('/[\x00-\x1F\x7F]/', '', (string) $candidate) ?? '';
$candidate = trim((string) $candidate);
try {
$imageOriginal = $this->derivatives->storeOriginal($session->tempPath, $hash, $originalFileName);
$localCleanup[] = $imageOriginal['local_path'];
$objectCleanup[] = $imageOriginal['object_path'];
if ($candidate !== '') {
$candidateExt = strtolower((string) pathinfo($candidate, PATHINFO_EXTENSION));
if ($candidateExt === '' && $origExt !== '') {
$candidate .= '.' . $origExt;
$archiveOriginal = null;
if ($archiveSession && is_string($archiveHash) && trim($archiveHash) !== '') {
$archiveOriginal = $this->derivatives->storeOriginal($archiveSession->tempPath, trim($archiveHash), $archiveOriginalFileName);
$localCleanup[] = $archiveOriginal['local_path'];
$objectCleanup[] = $archiveOriginal['object_path'];
}
$additionalScreenshotOriginals = [];
foreach ($resolvedAdditionalScreenshots as $index => $payload) {
$storedScreenshot = $this->derivatives->storeOriginal(
$payload['session']->tempPath,
$payload['hash'],
$payload['file_name']
);
$storedScreenshot['variant'] = $this->screenshotVariantName($index + 1);
$additionalScreenshotOriginals[] = $storedScreenshot;
$localCleanup[] = $storedScreenshot['local_path'];
$objectCleanup[] = $storedScreenshot['object_path'];
}
$publicAssets = $this->derivatives->generatePublicDerivatives($session->tempPath, $hash);
foreach ($publicAssets as $asset) {
$objectCleanup[] = $asset['path'];
}
$dimensions = @getimagesize($session->tempPath);
$width = is_array($dimensions) && isset($dimensions[0]) ? (int) $dimensions[0] : 1;
$height = is_array($dimensions) && isset($dimensions[1]) ? (int) $dimensions[1] : 1;
$primaryOriginal = $archiveOriginal ?? $imageOriginal;
$downloadFileName = $this->resolveDownloadFileName($primaryOriginal['filename'], $primaryOriginal['ext'], $archiveOriginal ? $archiveOriginalFileName : $originalFileName);
DB::transaction(function () use ($artworkId, $imageOriginal, $archiveOriginal, $additionalScreenshotOriginals, $publicAssets, $primaryOriginal, $downloadFileName, $hash, $width, $height) {
$this->artworkFiles->upsert($artworkId, 'orig', $primaryOriginal['object_path'], $primaryOriginal['mime'], $primaryOriginal['size']);
$this->artworkFiles->upsert($artworkId, 'orig_image', $imageOriginal['object_path'], $imageOriginal['mime'], $imageOriginal['size']);
if ($archiveOriginal) {
$this->artworkFiles->upsert($artworkId, 'orig_archive', $archiveOriginal['object_path'], $archiveOriginal['mime'], $archiveOriginal['size']);
} else {
$this->artworkFiles->deleteVariant($artworkId, 'orig_archive');
}
$downloadFileName = $candidate;
$this->artworkFiles->deleteScreenshotVariants($artworkId);
foreach ($additionalScreenshotOriginals as $screenshot) {
$this->artworkFiles->upsert($artworkId, $screenshot['variant'], $screenshot['object_path'], $screenshot['mime'], $screenshot['size']);
}
foreach ($publicAssets as $variant => $asset) {
$this->artworkFiles->upsert($artworkId, (string) $variant, $asset['path'], $asset['mime'], $asset['size']);
}
Artwork::query()->whereKey($artworkId)->update([
'file_name' => $downloadFileName,
'file_path' => $primaryOriginal['object_path'],
'file_size' => $primaryOriginal['size'],
'mime_type' => $primaryOriginal['mime'],
'hash' => $hash,
'file_ext' => $primaryOriginal['ext'],
'thumb_ext' => 'webp',
'width' => max(1, $width),
'height' => max(1, $height),
]);
});
$this->storage->deleteLocalFile($session->tempPath);
if ($archiveSession) {
$this->storage->deleteLocalFile($archiveSession->tempPath);
}
foreach ($resolvedAdditionalScreenshots as $payload) {
$this->storage->deleteLocalFile($payload['session']->tempPath);
}
$this->sessions->updateStatus($sessionId, UploadSessionStatus::PROCESSED);
$this->sessions->updateProgress($sessionId, 100);
if ($archiveSession) {
$this->sessions->updateStatus($archiveSession->id, UploadSessionStatus::PROCESSED);
$this->sessions->updateProgress($archiveSession->id, 100);
}
foreach ($resolvedAdditionalScreenshots as $payload) {
$this->sessions->updateStatus($payload['session']->id, UploadSessionStatus::PROCESSED);
$this->sessions->updateProgress($payload['session']->id, 100);
}
$this->audit->log($session->userId, 'upload_processed', $session->ip, [
'session_id' => $sessionId,
'hash' => $hash,
'artwork_id' => $artworkId,
'archive_session_id' => $archiveSession?->id,
'additional_screenshot_session_ids' => $resolvedAdditionalScreenshots->map(fn (array $payload): string => $payload['session']->id)->values()->all(),
]);
return [
'orig' => $primaryOriginal['object_path'],
'orig_image' => $imageOriginal['object_path'],
'orig_archive' => $archiveOriginal['object_path'] ?? null,
'screenshots' => array_map(static fn (array $screenshot): array => [
'variant' => $screenshot['variant'],
'path' => $screenshot['object_path'],
], $additionalScreenshotOriginals),
'public' => array_map(static fn (array $asset): string => $asset['path'], $publicAssets),
];
} catch (\Throwable $e) {
foreach ($localCleanup as $path) {
$this->storage->deleteLocalFile($path);
}
foreach ($objectCleanup as $path) {
$this->storage->deleteObject($path);
}
throw $e;
}
Artwork::query()->whereKey($artworkId)->update([
'file_name' => $downloadFileName,
'file_path' => '',
'file_size' => (int) filesize($originalPath),
'mime_type' => $origMime,
'hash' => $hash,
'file_ext' => $origExt,
'thumb_ext' => 'webp',
'width' => max(1, $width),
'height' => max(1, $height),
]);
$this->sessions->updateStatus($sessionId, UploadSessionStatus::PROCESSED);
$this->sessions->updateProgress($sessionId, 100);
$this->audit->log($session->userId, 'upload_processed', $session->ip, [
'session_id' => $sessionId,
'hash' => $hash,
'artwork_id' => $artworkId,
]);
return [
'orig' => $originalRelative,
'public' => $publicRelative,
];
}
public function originalHashExists(string $hash): bool
@@ -193,4 +298,31 @@ final class UploadPipelineService
'reason' => $reason,
]);
}
private function resolveDownloadFileName(string $storedFilename, string $ext, ?string $preferredFileName): string
{
$downloadFileName = $storedFilename;
if (is_string($preferredFileName) && trim($preferredFileName) !== '') {
$candidate = basename(str_replace('\\', '/', $preferredFileName));
$candidate = preg_replace('/[\x00-\x1F\x7F]/', '', (string) $candidate) ?? '';
$candidate = trim((string) $candidate);
if ($candidate !== '') {
$candidateExt = strtolower((string) pathinfo($candidate, PATHINFO_EXTENSION));
if ($candidateExt === '' && $ext !== '') {
$candidate .= '.' . $ext;
}
$downloadFileName = $candidate;
}
}
return $downloadFileName;
}
private function screenshotVariantName(int $position): string
{
return sprintf('shot%02d', max(1, min(99, $position)));
}
}

View File

@@ -7,11 +7,17 @@ namespace App\Services\Uploads;
use App\DTOs\Uploads\UploadStoredFile;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
final class UploadStorageService
{
public function localOriginalsRoot(): string
{
return rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
}
public function sectionPath(string $section): string
{
$root = rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR);
@@ -76,6 +82,18 @@ final class UploadStorageService
return $dir;
}
public function ensureLocalOriginalHashDirectory(string $hash): string
{
$segments = $this->hashSegments($hash);
$dir = $this->localOriginalsRoot() . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $segments);
if (! File::exists($dir)) {
File::makeDirectory($dir, 0755, true);
}
return $dir;
}
public function sectionRelativePath(string $section, string $hash, string $filename): string
{
$segments = $this->hashSegments($hash);
@@ -84,6 +102,105 @@ final class UploadStorageService
return $section . '/' . implode('/', $segments) . '/' . ltrim($filename, '/');
}
public function localOriginalPath(string $hash, string $filename): string
{
return $this->ensureLocalOriginalHashDirectory($hash) . DIRECTORY_SEPARATOR . ltrim($filename, DIRECTORY_SEPARATOR);
}
public function objectDiskName(): string
{
return (string) config('uploads.object_storage.disk', 's3');
}
public function objectBasePrefix(): string
{
return trim((string) config('uploads.object_storage.prefix', 'artworks'), '/');
}
public function objectPathForVariant(string $variant, string $hash, string $filename): string
{
$segments = implode('/', $this->hashSegments($hash));
$basePrefix = $this->objectBasePrefix();
$normalizedVariant = trim($variant, '/');
if ($normalizedVariant === 'original') {
return sprintf('%s/original/%s/%s', $basePrefix, $segments, ltrim($filename, '/'));
}
return sprintf('%s/%s/%s/%s', $basePrefix, $normalizedVariant, $segments, ltrim($filename, '/'));
}
public function putObjectFromPath(string $sourcePath, string $objectPath, string $contentType, array $extraOptions = []): void
{
$stream = fopen($sourcePath, 'rb');
if ($stream === false) {
throw new RuntimeException('Unable to open source file for object storage upload.');
}
try {
$written = Storage::disk($this->objectDiskName())->put($objectPath, $stream, array_merge([
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => $contentType,
], $extraOptions));
} finally {
fclose($stream);
}
if ($written !== true) {
throw new RuntimeException('Object storage upload failed.');
}
}
public function putObjectContents(string $objectPath, string $contents, string $contentType, array $extraOptions = []): void
{
$written = Storage::disk($this->objectDiskName())->put($objectPath, $contents, array_merge([
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => $contentType,
], $extraOptions));
if ($written !== true) {
throw new RuntimeException('Object storage upload failed.');
}
}
public function deleteObject(string $objectPath): void
{
if ($objectPath === '') {
return;
}
Storage::disk($this->objectDiskName())->delete($objectPath);
}
public function readObject(string $objectPath): ?string
{
if ($objectPath === '') {
return null;
}
$disk = Storage::disk($this->objectDiskName());
if (! $disk->exists($objectPath)) {
return null;
}
$contents = $disk->get($objectPath);
return is_string($contents) && $contents !== '' ? $contents : null;
}
public function deleteLocalFile(?string $path): void
{
if (! is_string($path) || trim($path) === '') {
return;
}
if (File::exists($path)) {
File::delete($path);
}
}
public function originalHashExists(string $hash): bool
{
$segments = $this->hashSegments($hash);