Upload beautify
This commit is contained in:
25
app/Uploads/Commands/CleanupUploadsCommand.php
Normal file
25
app/Uploads/Commands/CleanupUploadsCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
42
app/Uploads/Exceptions/DraftQuotaException.php
Normal file
42
app/Uploads/Exceptions/DraftQuotaException.php
Normal 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);
|
||||
}
|
||||
}
|
||||
11
app/Uploads/Exceptions/UploadNotFoundException.php
Normal file
11
app/Uploads/Exceptions/UploadNotFoundException.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Uploads\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class UploadNotFoundException extends RuntimeException
|
||||
{
|
||||
}
|
||||
11
app/Uploads/Exceptions/UploadOwnershipException.php
Normal file
11
app/Uploads/Exceptions/UploadOwnershipException.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Uploads\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class UploadOwnershipException extends RuntimeException
|
||||
{
|
||||
}
|
||||
11
app/Uploads/Exceptions/UploadPublishValidationException.php
Normal file
11
app/Uploads/Exceptions/UploadPublishValidationException.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Uploads\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class UploadPublishValidationException extends RuntimeException
|
||||
{
|
||||
}
|
||||
117
app/Uploads/Jobs/PreviewGenerationJob.php
Normal file
117
app/Uploads/Jobs/PreviewGenerationJob.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
127
app/Uploads/Jobs/TagAnalysisJob.php
Normal file
127
app/Uploads/Jobs/TagAnalysisJob.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
93
app/Uploads/Jobs/VirusScanJob.php
Normal file
93
app/Uploads/Jobs/VirusScanJob.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
205
app/Uploads/Services/ArchiveInspectorService.php
Normal file
205
app/Uploads/Services/ArchiveInspectorService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
55
app/Uploads/Services/CleanupService.php
Normal file
55
app/Uploads/Services/CleanupService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
89
app/Uploads/Services/DraftQuotaService.php
Normal file
89
app/Uploads/Services/DraftQuotaService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
49
app/Uploads/Services/FileMoveService.php
Normal file
49
app/Uploads/Services/FileMoveService.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
46
app/Uploads/Services/InspectionResult.php
Normal file
46
app/Uploads/Services/InspectionResult.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
140
app/Uploads/Services/PublishService.php
Normal file
140
app/Uploads/Services/PublishService.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
app/Uploads/Services/SlugService.php
Normal file
37
app/Uploads/Services/SlugService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user