user(); try { $quota->enforce($user->id); } catch (Throwable $e) { return response()->json([ 'message' => $e->getMessage(), ], Response::HTTP_TOO_MANY_REQUESTS); } $result = $pipeline->initSession($user->id, (string) $request->ip()); $audit->log($user->id, 'upload_init_issued', (string) $request->ip(), [ 'session_id' => $result->sessionId, ]); return response()->json([ 'session_id' => $result->sessionId, 'upload_token' => $result->token, 'status' => $result->status, ], Response::HTTP_OK); } public function finish( UploadFinishRequest $request, UploadPipelineService $pipeline, UploadSessionRepository $sessions, UploadAuditService $audit ) { $user = $request->user(); $sessionId = (string) $request->validated('session_id'); $artworkId = (int) $request->validated('artwork_id'); $session = $sessions->getOrFail($sessionId); $request->artwork(); $validated = $pipeline->validateAndHash($sessionId); if (! $validated->validation->ok || ! $validated->hash) { return response()->json([ 'message' => 'Upload validation failed.', 'reason' => $validated->validation->reason, ], Response::HTTP_UNPROCESSABLE_ENTITY); } $scan = $pipeline->scan($sessionId); if (! $scan->ok) { return response()->json([ 'message' => 'Upload scan failed.', 'reason' => $scan->reason, ], Response::HTTP_UNPROCESSABLE_ENTITY); } try { $status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId) { if ((bool) config('uploads.queue_derivatives', false)) { GenerateDerivativesJob::dispatch($sessionId, $validated->hash, $artworkId)->afterCommit(); return 'queued'; } $pipeline->processAndPublish($sessionId, $validated->hash, $artworkId); // Derivatives are available now; dispatch AI auto-tagging. AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit(); GenerateArtworkEmbeddingJob::dispatch($artworkId, $validated->hash)->afterCommit(); return UploadSessionStatus::PROCESSED; }); $audit->log($user->id, 'upload_finished', $session->ip, [ 'session_id' => $sessionId, 'hash' => $validated->hash, 'artwork_id' => $artworkId, 'status' => $status, ]); return response()->json([ 'artwork_id' => $artworkId, 'status' => $status, ], Response::HTTP_OK); } catch (Throwable $e) { Log::error('Upload finish failed', [ 'session_id' => $sessionId, 'artwork_id' => $artworkId, 'error' => $e->getMessage(), ]); return response()->json([ 'message' => 'Upload finish failed.', ], Response::HTTP_INTERNAL_SERVER_ERROR); } } public function chunk(UploadChunkRequest $request, UploadChunkService $chunks) { $user = $request->user(); $chunkFile = $request->file('chunk'); // Debug: log uploaded file object details to help diagnose missing chunk try { if (! $chunkFile) { logger()->warning('Chunk upload: no file present on request', [ 'session_id' => (string) $request->input('session_id'), 'headers' => $request->headers->all(), ]); } else { logger()->warning('Chunk upload file details', [ 'session_id' => (string) $request->input('session_id'), 'client_name' => $chunkFile->getClientOriginalName() ?? null, 'client_size' => $chunkFile->getSize() ?? null, 'error' => $chunkFile->getError(), 'realpath' => $chunkFile->getRealPath(), ]); } } catch (\Throwable $e) { logger()->warning('Chunk upload debug logging failed', ['error' => $e->getMessage()]); } try { // Use getPathname() — this returns the PHP temp filename even when // getRealPath() may be false (platform/stream wrappers can cause // getRealPath() to return false). getPathname() is safe for reading // the uploaded chunk file. $chunkPath = $chunkFile ? $chunkFile->getPathname() : ''; $result = $chunks->appendChunk( (string) $request->input('session_id'), (string) $chunkPath, (int) $request->input('offset'), (int) $request->input('chunk_size'), (int) $request->input('total_size'), (int) $user->id, (string) $request->ip() ); return response()->json([ 'session_id' => $result->sessionId, 'status' => $result->status, 'received_bytes' => $result->receivedBytes, 'total_bytes' => $result->totalBytes, 'progress' => $result->progress, ], Response::HTTP_OK); } catch (\Throwable $e) { logger()->warning('Upload chunk failed', [ 'session_id' => (string) $request->input('session_id'), 'error' => $e->getMessage(), ]); // Include the underlying error message in the response during debugging // so the frontend can show a useful description. Remove or hide this // in production if you prefer more generic errors. return response()->json([ 'message' => 'Upload chunk failed.', 'error' => $e->getMessage(), ], Response::HTTP_UNPROCESSABLE_ENTITY); } } public function status(string $id, UploadStatusRequest $request, UploadStatusService $statusService, UploadAuditService $audit) { $user = $request->user(); $payload = $statusService->get($id); $audit->log($user->id, 'upload_status_checked', (string) $request->ip(), [ 'session_id' => $id, 'status' => $payload['status'], ]); return response()->json([ 'session_id' => $payload['session_id'], 'status' => $payload['status'], 'progress' => $payload['progress'], 'failure_reason' => $payload['failure_reason'], 'received_bytes' => $payload['received_bytes'] ?? 0, ], Response::HTTP_OK); } public function cancel(UploadCancelRequest $request, UploadCancelService $cancel) { $user = $request->user(); try { $result = $cancel->cancel( (string) $request->input('session_id'), (int) $user->id, (string) $request->ip() ); return response()->json([ 'session_id' => $result['session_id'], 'status' => $result['status'], ], Response::HTTP_OK); } catch (\Throwable $e) { logger()->warning('Upload cancel failed', [ 'session_id' => (string) $request->input('session_id'), 'error' => $e->getMessage(), ]); return response()->json([ 'message' => 'Upload cancel failed.', ], Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * Preload an upload draft: validate main file, create draft and store files. * * Returns JSON: { upload_id, status, expires_at } */ public function preload(Request $request, UploadDraftServiceInterface $draftService, ArchiveInspectorService $archiveInspector, DraftQuotaService $draftQuotaService) { $user = $request->user(); $request->validate([ 'main' => ['required', 'file'], 'screenshots' => ['sometimes', 'array'], 'screenshots.*' => ['file', 'image', 'max:5120'], ]); $main = $request->file('main'); // Detect type from mime $mime = (string) $main->getClientMimeType(); $type = null; if (str_starts_with($mime, 'image/')) { $type = 'image'; } elseif (in_array($mime, ['application/zip', 'application/x-zip-compressed', 'application/x-tar', 'application/x-gzip', 'application/x-rar-compressed', 'application/octet-stream'])) { $type = 'archive'; } if ($type === null) { return response()->json([ 'message' => 'Invalid main file type.', 'errors' => [ 'main' => ['The main file must be an image or archive.'], ], ], Response::HTTP_UNPROCESSABLE_ENTITY); } if ($type === 'archive') { $validator = Validator::make($request->all(), [ 'screenshots' => ['required', 'array', 'min:1'], 'screenshots.*' => ['file', 'image', 'max:5120'], ]); if ($validator->fails()) { return response()->json([ 'message' => 'The given data was invalid.', 'errors' => $validator->errors(), ], Response::HTTP_UNPROCESSABLE_ENTITY); } $inspection = $archiveInspector->inspect((string) $main->getPathname()); if (! $inspection->valid) { return response()->json([ 'message' => 'Archive inspection failed.', 'reason' => $inspection->reason, 'stats' => $inspection->stats, ], Response::HTTP_UNPROCESSABLE_ENTITY); } } $incomingFiles = [$main]; if ($type === 'archive' && $request->hasFile('screenshots')) { foreach ($request->file('screenshots') as $screenshot) { $incomingFiles[] = $screenshot; } } $mainHash = $draftService->calculateHash((string) $main->getPathname()); try { $warnings = $draftQuotaService->assertCanCreateDraft($user, [ 'files' => $incomingFiles, 'main_hash' => $mainHash, ]); } catch (DraftQuotaException $e) { return response()->json([ 'message' => $e->machineCode(), 'code' => $e->machineCode(), ], $e->httpStatus()); } // Create draft record (meta-only) and store main file via service $draft = $draftService->createDraft(['user_id' => $user->id, 'type' => $type]); try { $mainInfo = $draftService->storeMainFile($draft['id'], $main); // If archive, allow optional screenshots to be uploaded in the same request if ($type === 'archive' && $request->hasFile('screenshots')) { foreach ($request->file('screenshots') as $ss) { try { $draftService->storeScreenshot($draft['id'], $ss); } catch (Throwable $e) { // Keep controller thin: log and continue logger()->warning('Screenshot store failed during preload', ['error' => $e->getMessage(), 'draft' => $draft['id']]); } } } // Set expiration (default 7 days) and return info $ttlDays = (int) config('uploads.draft_ttl_days', 7); $expiresAt = Carbon::now()->addDays($ttlDays); $draftService->setExpiration($draft['id'], $expiresAt); VirusScanJob::dispatch($draft['id']); $response = [ 'upload_id' => $draft['id'], 'status' => 'draft', 'expires_at' => $expiresAt->toISOString(), ]; if (! empty($warnings)) { $response['warnings'] = array_values($warnings); } return response()->json($response, Response::HTTP_OK); } catch (Throwable $e) { logger()->error('Upload preload failed', ['error' => $e->getMessage()]); return response()->json(['message' => 'Preload failed.'], Response::HTTP_INTERNAL_SERVER_ERROR); } } public function autosave(string $id, Request $request) { $user = $request->user(); $upload = DB::table('uploads')->where('id', $id)->first(); if (! $upload) { return response()->json(['message' => 'Upload draft not found.'], Response::HTTP_NOT_FOUND); } if ((int) $upload->user_id !== (int) $user->id) { return response()->json(['message' => 'Forbidden.'], Response::HTTP_FORBIDDEN); } if ((string) $upload->status !== 'draft') { return response()->json([ 'message' => 'Only draft uploads can be autosaved.', ], Response::HTTP_UNPROCESSABLE_ENTITY); } $validated = $request->validate([ 'title' => ['nullable', 'string', 'max:255'], 'category_id' => ['nullable', 'exists:categories,id'], 'description' => ['nullable', 'string'], 'tags' => ['nullable', 'array'], 'license' => ['nullable', 'string'], 'nsfw' => ['nullable', 'boolean'], ]); $updates = []; foreach (['title', 'category_id', 'description', 'tags', 'license', 'nsfw'] as $field) { if (array_key_exists($field, $validated)) { $updates[$field] = $validated[$field]; } } $dirty = []; foreach ($updates as $field => $value) { $current = $upload->{$field} ?? null; if ($field === 'tags') { $current = $current ? json_decode((string) $current, true) : null; } if ($field === 'nsfw') { $current = is_null($current) ? null : (bool) $current; $value = is_null($value) ? null : (bool) $value; } if ($current !== $value) { $dirty[$field] = $value; } } if (array_key_exists('tags', $dirty)) { $dirty['tags'] = json_encode($dirty['tags']); } if (! empty($dirty)) { $dirty['updated_at'] = now(); DB::table('uploads')->where('id', $id)->update($dirty); $upload = DB::table('uploads')->where('id', $id)->first(); } return response()->json([ 'success' => true, 'updated_at' => (string) ($upload->updated_at ?? now()->toDateTimeString()), ], Response::HTTP_OK); } public function processingStatus(string $id, Request $request) { $user = $request->user(); $upload = DB::table('uploads')->where('id', $id)->first(); if (! $upload) { return response()->json(['message' => 'Upload not found.'], Response::HTTP_NOT_FOUND); } if ((int) $upload->user_id !== (int) $user->id) { return response()->json(['message' => 'Forbidden.'], Response::HTTP_FORBIDDEN); } $status = (string) ($upload->status ?? 'draft'); $isScanned = (bool) ($upload->is_scanned ?? false); $previewReady = ! empty($upload->preview_path); $hasTags = (bool) ($upload->has_tags ?? false); $processingState = (string) ($upload->processing_state ?? 'pending_scan'); return response()->json([ 'id' => (string) $upload->id, 'status' => $status, 'is_scanned' => $isScanned, 'preview_ready' => $previewReady, 'has_tags' => $hasTags, 'processing_state' => $processingState, ], Response::HTTP_OK); } public function publish(string $id, Request $request, PublishService $publishService) { $user = $request->user(); $validated = $request->validate([ 'title' => ['nullable', 'string', 'max:150'], 'description' => ['nullable', 'string'], ]); if (ctype_digit($id)) { $artworkId = (int) $id; $artwork = Artwork::query()->find($artworkId); if (! $artwork) { return response()->json(['message' => 'Artwork not found.'], Response::HTTP_NOT_FOUND); } if ((int) $artwork->user_id !== (int) $user->id) { return response()->json(['message' => 'Forbidden.'], Response::HTTP_FORBIDDEN); } $title = trim((string) ($validated['title'] ?? $artwork->title ?? '')); if ($title === '') { $title = 'Untitled artwork'; } $slugBase = Str::slug($title); if ($slugBase === '') { $slugBase = 'artwork'; } $slug = $slugBase; $suffix = 2; while (Artwork::query()->where('slug', $slug)->where('id', '!=', $artwork->id)->exists()) { $slug = $slugBase . '-' . $suffix; $suffix++; } $artwork->title = $title; if (array_key_exists('description', $validated)) { $artwork->description = $validated['description']; } $artwork->slug = $slug; $artwork->is_public = true; $artwork->is_approved = true; $artwork->published_at = now(); $artwork->save(); // Record upload activity event try { \App\Models\ActivityEvent::record( actorId: (int) $user->id, type: \App\Models\ActivityEvent::TYPE_UPLOAD, targetType: \App\Models\ActivityEvent::TARGET_ARTWORK, targetId: (int) $artwork->id, ); } catch (\Throwable) {} return response()->json([ 'success' => true, 'artwork_id' => (int) $artwork->id, 'status' => 'published', 'slug' => (string) $artwork->slug, 'published_at' => optional($artwork->published_at)->toISOString(), ], Response::HTTP_OK); } try { $upload = $publishService->publish($id, $user); return response()->json([ 'success' => true, 'upload_id' => (string) $upload->id, 'status' => (string) $upload->status, 'published_at' => optional($upload->published_at)->toISOString(), 'final_path' => (string) ($upload->final_path ?? ''), ], Response::HTTP_OK); } catch (UploadOwnershipException $e) { return response()->json(['message' => $e->getMessage()], Response::HTTP_FORBIDDEN); } catch (UploadNotFoundException $e) { return response()->json(['message' => $e->getMessage()], Response::HTTP_NOT_FOUND); } catch (UploadPublishValidationException $e) { return response()->json(['message' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY); } } }