Files
SkinbaseNova/app/Services/Uploads/UploadPipelineService.php
Gregor Klevze dc51d65440 feat: forum rich-text editor, emoji picker, mentions, discover nav, feed, uploads, profile
Forum:
- TipTap WYSIWYG editor with full toolbar
- @emoji-mart/react emoji picker (consistent with tweets)
- @mention autocomplete with user search API
- Fix PHP 8.4 parse errors in Blade templates
- Fix thread data display (paginator items)
- Align forum page widths to max-w-5xl

Discover:
- Extract shared _nav.blade.php partial
- Add missing nav links to for-you page
- Add Following link for authenticated users

Feed/Posts:
- Post model, controllers, policies, migrations
- Feed page components (PostComposer, FeedCard, etc)
- Post reactions, comments, saves, reports, sharing
- Scheduled publishing support
- Link preview controller

Profile:
- Profile page components (ProfileHero, ProfileTabs)
- Profile API controller

Uploads:
- Upload wizard enhancements
- Scheduled publish picker
- Studio status bar and readiness checklist
2026-03-03 09:48:31 +01:00

176 lines
6.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Uploads;
use App\DTOs\Uploads\UploadSessionData;
use App\DTOs\Uploads\UploadInitResult;
use App\DTOs\Uploads\UploadValidatedFile;
use App\DTOs\Uploads\UploadScanResult;
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;
final class UploadPipelineService
{
public function __construct(
private readonly UploadStorageService $storage,
private readonly UploadSessionRepository $sessions,
private readonly UploadValidationService $validator,
private readonly UploadHashService $hasher,
private readonly UploadScanService $scanner,
private readonly UploadAuditService $audit,
private readonly UploadDerivativesService $derivatives,
private readonly ArtworkFileRepository $artworkFiles,
private readonly UploadTokenService $tokens
) {
}
public function initSession(int $userId, string $ip): UploadInitResult
{
$dir = $this->storage->ensureSection('tmp');
$filename = Str::uuid()->toString() . '.upload';
$tempPath = $dir . DIRECTORY_SEPARATOR . $filename;
File::put($tempPath, '');
$sessionId = (string) Str::uuid();
$session = $this->sessions->create($sessionId, $userId, $tempPath, UploadSessionStatus::INIT, $ip);
$token = $this->tokens->generate($sessionId, $userId);
$this->audit->log($userId, 'upload_init', $ip, [
'session_id' => $sessionId,
]);
return new UploadInitResult($session->id, $token, $session->status);
}
public function receiveToTmp(UploadedFile $file, int $userId, string $ip): UploadSessionData
{
$stored = $this->storage->storeUploadedFile($file, 'tmp');
$sessionId = (string) Str::uuid();
$session = $this->sessions->create($sessionId, $userId, $stored->path, UploadSessionStatus::TMP, $ip);
$this->sessions->updateProgress($sessionId, 10);
$this->audit->log($userId, 'upload_received', $ip, [
'session_id' => $sessionId,
'size' => $stored->size,
]);
return $session;
}
public function validateAndHash(string $sessionId): UploadValidatedFile
{
$session = $this->sessions->getOrFail($sessionId);
$validation = $this->validator->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_validated', $session->ip, [
'session_id' => $sessionId,
'hash' => $hash,
]);
return new UploadValidatedFile($validation, $hash);
}
public function scan(string $sessionId): UploadScanResult
{
$session = $this->sessions->getOrFail($sessionId);
$result = $this->scanner->scan($session->tempPath);
if (! $result->ok) {
$this->quarantine($session, $result->reason);
return $result;
}
$this->sessions->updateStatus($sessionId, UploadSessionStatus::SCANNED);
$this->sessions->updateProgress($sessionId, 50);
$this->audit->log($session->userId, 'upload_scanned', $session->ip, [
'session_id' => $sessionId,
]);
return $result;
}
public function processAndPublish(string $sessionId, string $hash, int $artworkId, ?string $originalFileName = null): 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;
}
$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;
$origExt = strtolower(pathinfo($originalPath, PATHINFO_EXTENSION) ?: '');
Artwork::query()->whereKey($artworkId)->update([
'file_name' => $origFilename,
'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
{
return $this->storage->originalHashExists($hash);
}
private function quarantine(UploadSessionData $session, string $reason): void
{
$newPath = $this->storage->moveToSection($session->tempPath, 'quarantine');
$this->sessions->updateTempPath($session->id, $newPath);
$this->sessions->updateStatus($session->id, UploadSessionStatus::QUARANTINED);
$this->sessions->updateFailureReason($session->id, $reason);
$this->sessions->updateProgress($session->id, 0);
$this->audit->log($session->userId, 'upload_quarantined', $session->ip, [
'session_id' => $session->id,
'reason' => $reason,
]);
}
}