Upload beautify

This commit is contained in:
2026-02-14 15:14:12 +01:00
parent e129618910
commit 79192345e3
249 changed files with 24436 additions and 1021 deletions

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Uploads\Commands;
use App\Uploads\Services\CleanupService;
use Illuminate\Console\Command;
final class CleanupUploadsCommand extends Command
{
protected $signature = 'uploads:cleanup {--limit=100 : Maximum drafts to clean in one run}';
protected $description = 'Delete stale draft uploads and their temporary directories';
public function handle(CleanupService $cleanupService): int
{
$limit = (int) $this->option('limit');
$deleted = $cleanupService->cleanupStaleDrafts($limit);
$this->info("Uploads cleanup deleted {$deleted} draft(s).");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Uploads\Exceptions;
use RuntimeException;
final class DraftQuotaException extends RuntimeException
{
public function __construct(
private readonly string $machineCode,
private readonly int $httpStatus,
) {
parent::__construct($machineCode);
}
public function machineCode(): string
{
return $this->machineCode;
}
public function httpStatus(): int
{
return $this->httpStatus;
}
public static function draftLimit(): self
{
return new self('draft_limit', 429);
}
public static function storageLimit(): self
{
return new self('storage_limit', 413);
}
public static function duplicateUpload(): self
{
return new self('duplicate_upload', 422);
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Uploads\Exceptions;
use RuntimeException;
final class UploadNotFoundException extends RuntimeException
{
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Uploads\Exceptions;
use RuntimeException;
final class UploadOwnershipException extends RuntimeException
{
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Uploads\Exceptions;
use RuntimeException;
final class UploadPublishValidationException extends RuntimeException
{
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Uploads\Jobs;
use App\Services\Upload\PreviewService;
use App\Uploads\Jobs\TagAnalysisJob;
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\DB;
use Illuminate\Support\Facades\Storage;
final class PreviewGenerationJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
public int $timeout = 45;
public function __construct(private readonly string $uploadId)
{
}
public function handle(PreviewService $previewService): void
{
$upload = DB::table('uploads')->where('id', $this->uploadId)->first();
if (! $upload) {
return;
}
if ((string) $upload->status !== 'draft' || ! (bool) $upload->is_scanned) {
return;
}
$this->advanceProcessingState('generating_preview', ['pending_scan', 'scanning', 'generating_preview']);
$previewData = null;
if ((string) $upload->type === 'image') {
$main = DB::table('upload_files')
->where('upload_id', $this->uploadId)
->where('type', 'main')
->orderBy('id')
->first(['path']);
if (! $main || ! Storage::disk('local')->exists((string) $main->path)) {
return;
}
$previewData = $previewService->generateFromImage($this->uploadId, (string) $main->path);
} elseif ((string) $upload->type === 'archive') {
$screenshot = DB::table('upload_files')
->where('upload_id', $this->uploadId)
->where('type', 'screenshot')
->orderBy('id')
->first(['path']);
$previewData = $previewService->generateFromArchive($this->uploadId, $screenshot?->path ? (string) $screenshot->path : null);
} else {
return;
}
$previewPath = (string) ($previewData['preview_path'] ?? '');
if ($previewPath === '') {
return;
}
DB::table('uploads')->where('id', $this->uploadId)->update([
'preview_path' => $previewPath,
'updated_at' => now(),
]);
$this->advanceProcessingState('analyzing_tags', ['pending_scan', 'scanning', 'generating_preview', 'analyzing_tags']);
DB::table('upload_files')
->where('upload_id', $this->uploadId)
->where('type', 'preview')
->delete();
DB::table('upload_files')->insert([
'upload_id' => $this->uploadId,
'path' => $previewPath,
'type' => 'preview',
'hash' => null,
'size' => Storage::disk('local')->exists($previewPath) ? Storage::disk('local')->size($previewPath) : null,
'mime' => 'image/webp',
'created_at' => now(),
]);
TagAnalysisJob::dispatch($this->uploadId);
}
/**
* @param array<int, string> $allowedCurrentStates
*/
private function advanceProcessingState(string $targetState, array $allowedCurrentStates): void
{
DB::table('uploads')
->where('id', $this->uploadId)
->where('status', 'draft')
->where(function ($query) use ($allowedCurrentStates): void {
$query->whereNull('processing_state')
->orWhereIn('processing_state', $allowedCurrentStates);
})
->update([
'processing_state' => $targetState,
'updated_at' => now(),
]);
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\Uploads\Jobs;
use App\Services\Upload\TagAnalysisService;
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\DB;
final class TagAnalysisJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
public int $timeout = 30;
public function __construct(private readonly string $uploadId)
{
}
public function handle(TagAnalysisService $analysis): void
{
$upload = DB::table('uploads')->where('id', $this->uploadId)->first();
if (! $upload) {
return;
}
if ((string) $upload->status !== 'draft') {
return;
}
if (! (bool) $upload->is_scanned) {
return;
}
if (empty($upload->preview_path)) {
return;
}
$this->advanceProcessingState('analyzing_tags', ['pending_scan', 'scanning', 'generating_preview', 'analyzing_tags']);
$main = DB::table('upload_files')
->where('upload_id', $this->uploadId)
->where('type', 'main')
->orderBy('id')
->first(['path']);
$filename = $main ? basename((string) $main->path) : '';
$categoryContext = null;
if (! empty($upload->category_id)) {
$category = DB::table('categories')->where('id', (int) $upload->category_id)->first(['name', 'slug']);
if ($category) {
$categoryContext = (string) ($category->name ?: $category->slug);
}
}
$tags = $analysis->analyze($filename, (string) $upload->preview_path, $categoryContext);
DB::transaction(function () use ($tags): void {
DB::table('upload_tags')->where('upload_id', $this->uploadId)->delete();
foreach ($tags as $row) {
$tagName = (string) ($row['tag'] ?? '');
if ($tagName === '') {
continue;
}
$slug = $tagName;
$tag = DB::table('tags')->where('slug', $slug)->first(['id']);
if (! $tag) {
$tagId = DB::table('tags')->insertGetId([
'name' => $tagName,
'slug' => $slug,
'usage_count' => 0,
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
} else {
$tagId = (int) $tag->id;
}
DB::table('upload_tags')->insert([
'upload_id' => $this->uploadId,
'tag_id' => $tagId,
'confidence' => (float) ($row['confidence'] ?? 0.0),
'source' => (string) ($row['source'] ?? 'manual'),
'created_at' => now(),
'updated_at' => now(),
]);
}
DB::table('uploads')->where('id', $this->uploadId)->update([
'has_tags' => true,
'processing_state' => DB::raw("CASE WHEN processing_state IN ('ready','published','rejected') THEN processing_state ELSE 'ready' END"),
'updated_at' => now(),
]);
});
}
/**
* @param array<int, string> $allowedCurrentStates
*/
private function advanceProcessingState(string $targetState, array $allowedCurrentStates): void
{
DB::table('uploads')
->where('id', $this->uploadId)
->where('status', 'draft')
->where(function ($query) use ($allowedCurrentStates): void {
$query->whereNull('processing_state')
->orWhereIn('processing_state', $allowedCurrentStates);
})
->update([
'processing_state' => $targetState,
'updated_at' => now(),
]);
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Uploads\Jobs;
use App\Services\Uploads\UploadScanService;
use App\Uploads\Jobs\PreviewGenerationJob;
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\DB;
use Illuminate\Support\Facades\Storage;
final class VirusScanJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
public int $timeout = 30;
public function __construct(private readonly string $uploadId)
{
}
public function handle(UploadScanService $scanner): void
{
$upload = DB::table('uploads')->where('id', $this->uploadId)->first();
if (! $upload || (string) $upload->status !== 'draft') {
return;
}
$this->advanceProcessingState('scanning', ['pending_scan', 'scanning']);
$files = DB::table('upload_files')
->where('upload_id', $this->uploadId)
->whereIn('type', ['main', 'screenshot'])
->get(['path']);
foreach ($files as $file) {
$path = (string) ($file->path ?? '');
if ($path === '' || ! Storage::disk('local')->exists($path)) {
continue;
}
$absolute = Storage::disk('local')->path($path);
$result = $scanner->scan($absolute);
if (! $result->ok) {
DB::table('uploads')->where('id', $this->uploadId)->update([
'status' => 'rejected',
'processing_state' => 'rejected',
'updated_at' => now(),
]);
Storage::disk('local')->deleteDirectory('tmp/drafts/' . $this->uploadId);
return;
}
}
DB::table('uploads')->where('id', $this->uploadId)->update([
'is_scanned' => true,
'updated_at' => now(),
]);
$this->advanceProcessingState('generating_preview', ['pending_scan', 'scanning', 'generating_preview']);
PreviewGenerationJob::dispatch($this->uploadId);
}
/**
* @param array<int, string> $allowedCurrentStates
*/
private function advanceProcessingState(string $targetState, array $allowedCurrentStates): void
{
DB::table('uploads')
->where('id', $this->uploadId)
->where('status', 'draft')
->where(function ($query) use ($allowedCurrentStates): void {
$query->whereNull('processing_state')
->orWhereIn('processing_state', $allowedCurrentStates);
})
->update([
'processing_state' => $targetState,
'updated_at' => now(),
]);
}
}

View File

@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace App\Uploads\Services;
use RuntimeException;
use ZipArchive;
final class ArchiveInspectorService
{
private const MAX_DEPTH = 5;
private const MAX_FILES = 5000;
private const MAX_UNCOMPRESSED_BYTES = 524288000; // 500 MB
private const MAX_RATIO = 50.0;
/** @var array<int, string> */
private const BLOCKED_EXTENSIONS = [
'php',
'exe',
'sh',
'bat',
'js',
'ps1',
'cmd',
'vbs',
'jar',
'elf',
];
public function inspect(string $archivePath): \App\Uploads\Services\InspectionResult
{
$zip = new ZipArchive();
$opened = $zip->open($archivePath);
if ($opened !== true) {
throw new RuntimeException('Unable to read archive metadata.');
}
$files = 0;
$maxDepth = 0;
$totalUncompressed = 0;
$totalCompressed = 0;
for ($index = 0; $index < $zip->numFiles; $index++) {
$stat = $zip->statIndex($index);
if (! is_array($stat)) {
continue;
}
$entryName = (string) ($stat['name'] ?? '');
$uncompressedSize = (int) ($stat['size'] ?? 0);
$compressedSize = (int) ($stat['comp_size'] ?? 0);
$isDirectory = str_ends_with($entryName, '/');
$pathCheck = $this->validatePath($entryName);
if ($pathCheck !== null) {
return $this->closeAndFail($zip, $pathCheck, $files, $maxDepth, $totalUncompressed, $totalCompressed);
}
if ($this->isSymlink($zip, $index)) {
return $this->closeAndFail($zip, 'Archive contains a symlink entry.', $files, $maxDepth, $totalUncompressed, $totalCompressed);
}
$depth = $this->depthForEntry($entryName, $isDirectory);
$maxDepth = max($maxDepth, $depth);
if ($maxDepth > self::MAX_DEPTH) {
return $this->closeAndFail($zip, 'Archive directory depth exceeds 5.', $files, $maxDepth, $totalUncompressed, $totalCompressed);
}
if (! $isDirectory) {
$files++;
if ($files > self::MAX_FILES) {
return $this->closeAndFail($zip, 'Archive file count exceeds 5000.', $files, $maxDepth, $totalUncompressed, $totalCompressed);
}
$extension = strtolower((string) pathinfo($entryName, PATHINFO_EXTENSION));
if (in_array($extension, self::BLOCKED_EXTENSIONS, true)) {
return $this->closeAndFail($zip, 'Archive contains blocked executable/script file.', $files, $maxDepth, $totalUncompressed, $totalCompressed);
}
$totalUncompressed += max(0, $uncompressedSize);
$totalCompressed += max(0, $compressedSize);
if ($totalUncompressed > self::MAX_UNCOMPRESSED_BYTES) {
return $this->closeAndFail($zip, 'Archive uncompressed size exceeds 500 MB.', $files, $maxDepth, $totalUncompressed, $totalCompressed);
}
$ratio = $this->compressionRatio($totalUncompressed, $totalCompressed);
if ($ratio > self::MAX_RATIO) {
return $this->closeAndFail($zip, 'Archive compression ratio exceeds safety threshold.', $files, $maxDepth, $totalUncompressed, $totalCompressed);
}
}
}
$stats = $this->stats($files, $maxDepth, $totalUncompressed, $totalCompressed);
$zip->close();
return \App\Uploads\Services\InspectionResult::pass($stats);
}
private function validatePath(string $entryName): ?string
{
$normalized = str_replace('\\', '/', $entryName);
if ($normalized === '' || str_contains($normalized, "\0")) {
return 'Archive contains invalid entry path.';
}
if (
strlen($entryName) >= 3
&& ctype_alpha($entryName[0])
&& $entryName[1] === ':'
&& in_array($entryName[2], ['\\', '/'], true)
) {
return 'Archive entry contains drive-letter absolute path.';
}
if (str_starts_with($normalized, '/') || str_starts_with($normalized, '\\')) {
return 'Archive entry contains absolute path.';
}
if (str_contains($entryName, '../') || str_contains($entryName, '..\\') || str_contains($normalized, '../')) {
return 'Archive entry contains path traversal sequence.';
}
$segments = array_filter(explode('/', trim($normalized, '/')), static fn (string $segment): bool => $segment !== '');
foreach ($segments as $segment) {
if ($segment === '..') {
return 'Archive entry contains parent traversal segment.';
}
}
return null;
}
private function isSymlink(ZipArchive $zip, int $index): bool
{
$attributes = 0;
$opsys = 0;
if (! $zip->getExternalAttributesIndex($index, $opsys, $attributes)) {
return false;
}
if ($opsys !== ZipArchive::OPSYS_UNIX) {
return false;
}
$mode = ($attributes >> 16) & 0xF000;
return $mode === 0xA000;
}
private function depthForEntry(string $entryName, bool $isDirectory): int
{
$normalized = trim(str_replace('\\', '/', $entryName), '/');
if ($normalized === '') {
return 0;
}
$segments = array_values(array_filter(explode('/', $normalized), static fn (string $segment): bool => $segment !== ''));
if ($segments === []) {
return 0;
}
return max(0, count($segments) - ($isDirectory ? 0 : 1));
}
private function compressionRatio(int $uncompressed, int $compressed): float
{
if ($uncompressed <= 0) {
return 0.0;
}
if ($compressed <= 0) {
return (float) $uncompressed;
}
return $uncompressed / $compressed;
}
/**
* @return array{files:int,depth:int,size:int,ratio:float}
*/
private function stats(int $files, int $depth, int $size, int $compressed): array
{
return [
'files' => $files,
'depth' => $depth,
'size' => $size,
'ratio' => $this->compressionRatio($size, $compressed),
];
}
private function closeAndFail(ZipArchive $zip, string $reason, int $files, int $depth, int $size, int $compressed): \App\Uploads\Services\InspectionResult
{
$stats = $this->stats($files, $depth, $size, $compressed);
$zip->close();
return \App\Uploads\Services\InspectionResult::fail($reason, $stats);
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Uploads\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
final class CleanupService
{
public function cleanupStaleDrafts(int $limit = 100): int
{
$limit = max(1, min(100, $limit));
$now = now();
$inactiveThreshold = $now->copy()->subDay();
$drafts = DB::table('uploads')
->select(['id'])
->where('status', 'draft')
->where(function ($query) use ($now, $inactiveThreshold): void {
$query->where('expires_at', '<', $now)
->orWhere(function ($inner) use ($inactiveThreshold): void {
$inner->where('updated_at', '<', $inactiveThreshold)
->where('status', '!=', 'published');
});
})
->orderBy('updated_at')
->limit($limit)
->get();
if ($drafts->isEmpty()) {
Log::info('Upload cleanup completed', ['deleted' => 0]);
return 0;
}
$deleted = 0;
DB::transaction(function () use ($drafts, &$deleted): void {
foreach ($drafts as $draft) {
$uploadId = (string) $draft->id;
DB::table('uploads')->where('id', $uploadId)->delete();
Storage::disk('local')->deleteDirectory('tmp/drafts/' . $uploadId);
$deleted++;
}
});
Log::info('Upload cleanup completed', ['deleted' => $deleted]);
return $deleted;
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Uploads\Services;
use App\Models\User;
use App\Uploads\Exceptions\DraftQuotaException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
final class DraftQuotaService
{
/**
* @param array{files:array<int, UploadedFile>, main_hash:string} $incomingFiles
* @return array<int, string>
*/
public function assertCanCreateDraft(User $user, array $incomingFiles): array
{
$maxDrafts = max(1, (int) config('uploads.draft_quota.max_drafts_per_user', 10));
$maxStorageMb = max(1, (int) config('uploads.draft_quota.max_draft_storage_mb_per_user', 1024));
$maxStorageBytes = $maxStorageMb * 1024 * 1024;
$policy = (string) config('uploads.draft_quota.duplicate_hash_policy', 'block');
$warnings = [];
$draftCount = DB::table('uploads')
->where('user_id', (int) $user->id)
->where('status', 'draft')
->count();
if ($draftCount >= $maxDrafts) {
throw DraftQuotaException::draftLimit();
}
$currentDraftStorage = (int) DB::table('upload_files as uf')
->join('uploads as u', 'u.id', '=', 'uf.upload_id')
->where('u.user_id', (int) $user->id)
->where('u.status', 'draft')
->sum(DB::raw('COALESCE(uf.size, 0)'));
$incomingSize = $this->incomingSizeBytes((array) ($incomingFiles['files'] ?? []));
if (($currentDraftStorage + $incomingSize) > $maxStorageBytes) {
throw DraftQuotaException::storageLimit();
}
$mainHash = strtolower(trim((string) ($incomingFiles['main_hash'] ?? '')));
if ($mainHash !== '' && $this->publishedMainHashExists($mainHash)) {
if ($policy === 'warn') {
$warnings[] = 'duplicate_hash';
} else {
throw DraftQuotaException::duplicateUpload();
}
}
return $warnings;
}
/**
* @param array<int, UploadedFile> $files
*/
private function incomingSizeBytes(array $files): int
{
$total = 0;
foreach ($files as $file) {
if (! $file instanceof UploadedFile) {
continue;
}
$size = $file->getSize();
if (is_numeric($size)) {
$total += (int) $size;
}
}
return max(0, $total);
}
private function publishedMainHashExists(string $hash): bool
{
return DB::table('upload_files as uf')
->join('uploads as u', 'u.id', '=', 'uf.upload_id')
->where('uf.type', 'main')
->where('uf.hash', $hash)
->where('u.status', 'published')
->exists();
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Uploads\Services;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
final class FileMoveService
{
/**
* Copy draft directory to final location using staging dir; source remains untouched.
*/
public function promoteDraft(string $uploadId, string $targetRelativeDir): void
{
$disk = Storage::disk('local');
$sourceRelativeDir = 'tmp/drafts/' . $uploadId;
if (! $disk->exists($sourceRelativeDir)) {
throw new RuntimeException('Draft directory not found.');
}
$targetRelativeDir = trim($targetRelativeDir, '/');
$stagingRelativeDir = $targetRelativeDir . '.__staging';
if ($disk->exists($targetRelativeDir)) {
throw new RuntimeException('Target publish directory already exists.');
}
if ($disk->exists($stagingRelativeDir)) {
$disk->deleteDirectory($stagingRelativeDir);
}
$sourceAbs = $disk->path($sourceRelativeDir);
$stagingAbs = $disk->path($stagingRelativeDir);
$targetAbs = $disk->path($targetRelativeDir);
if (! File::copyDirectory($sourceAbs, $stagingAbs)) {
throw new RuntimeException('Failed to stage files for publish.');
}
if (! File::moveDirectory($stagingAbs, $targetAbs, false)) {
$disk->deleteDirectory($stagingRelativeDir);
throw new RuntimeException('Failed to move staged files to final location.');
}
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Uploads\Services;
final class InspectionResult
{
/**
* @param array{files:int,depth:int,size:int,ratio:float} $stats
*/
public function __construct(
public readonly bool $valid,
public readonly ?string $reason,
public readonly array $stats,
) {
}
/**
* @param array{files:int,depth:int,size:int,ratio:float} $stats
*/
public static function pass(array $stats): self
{
return new self(true, null, $stats);
}
/**
* @param array{files:int,depth:int,size:int,ratio:float} $stats
*/
public static function fail(string $reason, array $stats): self
{
return new self(false, $reason, $stats);
}
/**
* @return array{valid:bool,reason:?string,stats:array{files:int,depth:int,size:int,ratio:float}}
*/
public function toArray(): array
{
return [
'valid' => $this->valid,
'reason' => $this->reason,
'stats' => $this->stats,
];
}
}

View File

@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace App\Uploads\Services;
use App\Models\Upload;
use App\Models\User;
use App\Uploads\Exceptions\UploadNotFoundException;
use App\Uploads\Exceptions\UploadOwnershipException;
use App\Uploads\Exceptions\UploadPublishValidationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
final class PublishService
{
public function __construct(
private readonly FileMoveService $fileMoveService,
private readonly SlugService $slugService,
)
{
}
public function publish(string $uploadId, User $user): Upload
{
$upload = Upload::query()->find($uploadId);
if (! $upload) {
throw new UploadNotFoundException('Upload not found.');
}
$this->validateBeforePublish($upload, $user);
$mainFile = DB::table('upload_files')
->where('upload_id', $uploadId)
->where('type', 'main')
->orderBy('id')
->first(['path', 'hash']);
if (! $mainFile || empty($mainFile->hash)) {
throw new UploadPublishValidationException('Main file hash is missing.');
}
$hash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) $mainFile->hash));
if ($hash === '' || strlen($hash) < 4) {
throw new UploadPublishValidationException('Invalid main file hash.');
}
$aa = substr($hash, 0, 2);
$bb = substr($hash, 2, 2);
$targetDir = "files/artworks/{$aa}/{$bb}/{$hash}";
$tempPrefix = 'tmp/drafts/' . $uploadId . '/';
$promoted = false;
try {
DB::beginTransaction();
$this->fileMoveService->promoteDraft($uploadId, $targetDir);
$promoted = true;
$files = DB::table('upload_files')->where('upload_id', $uploadId)->get(['id', 'path']);
foreach ($files as $file) {
$oldPath = (string) $file->path;
if (str_starts_with($oldPath, $tempPrefix)) {
$newPath = $targetDir . '/' . ltrim(substr($oldPath, strlen($tempPrefix)), '/');
DB::table('upload_files')->where('id', $file->id)->update(['path' => $newPath]);
}
}
$upload->status = 'published';
$upload->processing_state = 'published';
if (empty($upload->slug)) {
$upload->slug = $this->slugService->makeSlug((string) ($upload->title ?? ''));
}
$upload->published_at = now();
$upload->final_path = $targetDir;
$upload->save();
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
if ($promoted) {
Storage::disk('local')->deleteDirectory($targetDir);
}
throw $e;
}
Storage::disk('local')->deleteDirectory('tmp/drafts/' . $uploadId);
return Upload::query()->findOrFail($uploadId);
}
private function validateBeforePublish(Upload $upload, User $user): void
{
if ((int) $upload->user_id !== (int) $user->id) {
throw new UploadOwnershipException('You do not own this upload.');
}
$role = strtolower((string) ($user->role ?? ''));
$isAdmin = $role === 'admin';
if (! $isAdmin && (string) ($upload->moderation_status ?? 'pending') !== 'approved') {
throw new UploadPublishValidationException('Upload requires moderation approval before publish.');
}
if ((string) $upload->status !== 'draft') {
throw new UploadPublishValidationException('Only draft uploads can be published.');
}
if (! (bool) $upload->is_scanned) {
throw new UploadPublishValidationException('Upload must be scanned before publish.');
}
if (empty($upload->preview_path)) {
throw new UploadPublishValidationException('Preview is required before publish.');
}
if (! (bool) $upload->has_tags) {
throw new UploadPublishValidationException('Tag analysis must complete before publish.');
}
if (empty($upload->title) || empty($upload->category_id)) {
throw new UploadPublishValidationException('Title and category are required before publish.');
}
if ((string) $upload->type === 'archive') {
$hasScreenshot = DB::table('upload_files')
->where('upload_id', (string) $upload->id)
->where('type', 'screenshot')
->exists();
if (! $hasScreenshot) {
throw new UploadPublishValidationException('Archive uploads require at least one screenshot.');
}
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Uploads\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
final class SlugService
{
public function makeSlug(string $title): string
{
$base = Str::slug($title);
if ($base === '') {
$base = 'artwork';
}
$candidate = $base;
$suffix = 2;
while ($this->publishedSlugExists($candidate)) {
$candidate = $base . '-' . $suffix;
$suffix++;
}
return $candidate;
}
private function publishedSlugExists(string $slug): bool
{
return DB::table('uploads')
->where('status', 'published')
->where('slug', $slug)
->exists();
}
}