validateRequest($request); [$gallery, $authMethod] = $this->resolveGallery($request); if (! $gallery) { return $this->respondError($request, 'Invalid credentials.', Response::HTTP_FORBIDDEN); } if (! $gallery->upload_enabled) { return $this->respondError($request, 'Uploads are disabled for this gallery.', Response::HTTP_FORBIDDEN, $gallery); } if ($authMethod === 'token' && $gallery->upload_token_expires_at && now()->greaterThanOrEqualTo($gallery->upload_token_expires_at) ) { return $this->respondError($request, 'Upload token expired.', Response::HTTP_FORBIDDEN, $gallery); } [$file, $base64Payload] = $this->extractMediaPayload($request); if (! $file && $base64Payload === null) { return $this->respondError($request, 'No media payload provided.', Response::HTTP_BAD_REQUEST, $gallery); } try { [$relativePath, $publicUrl] = $this->persistMedia( $gallery, $file, $base64Payload, $request->input('filename'), $request ); } catch (RuntimeException $exception) { return $this->respondError($request, $exception->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY, $gallery); } Image::create([ 'gallery_id' => $gallery->id, 'path' => $relativePath, 'is_public' => true, ]); return $this->respondSuccess($request, $publicUrl, $gallery); } private function validateRequest(Request $request): void { $request->validate([ 'token' => ['nullable', 'string'], 'username' => ['nullable', 'string'], 'password' => ['nullable', 'string'], 'media' => ['nullable'], 'file' => ['nullable', 'file', 'mimes:jpeg,png,gif,bmp,webp', 'max:10240'], 'filename' => ['nullable', 'string'], 'name' => ['nullable', 'string'], 'email' => ['nullable', 'string'], 'message' => ['nullable', 'string'], 'response_format' => ['nullable', 'in:json,xml'], ]); } /** * @return array{0: ?Gallery, 1: ?string} */ private function resolveGallery(Request $request): array { if ($request->filled('token')) { return [$this->resolveGalleryByToken($request->string('token')), 'token']; } if ($request->filled('username')) { return [ $this->resolveGalleryByCredentials( $request->string('username'), $request->string('password') ), 'credentials', ]; } return [null, null]; } private function resolveGalleryByToken(string $token): ?Gallery { $galleries = Gallery::query() ->whereNotNull('upload_token_hash') ->where('upload_enabled', true) ->get(); foreach ($galleries as $gallery) { if ($gallery->upload_token_hash && Hash::check($token, $gallery->upload_token_hash)) { return $gallery; } } return null; } private function resolveGalleryByCredentials(?string $username, ?string $password): ?Gallery { if (blank($username) || blank($password)) { return null; } $normalized = Str::of($username)->lower()->trim()->value(); $gallery = Gallery::query() ->whereNotNull('sparkbooth_username') ->where('sparkbooth_username', $normalized) ->first(); if (! $gallery) { return null; } if (! hash_equals((string) $gallery->sparkbooth_password, (string) $password)) { return null; } return $gallery; } /** * @return array{0: ?UploadedFile, 1: ?string} */ private function extractMediaPayload(Request $request): array { $file = $request->file('media') ?? $request->file('file'); if ($file) { return [$file, null]; } $payload = $request->input('media'); if ($payload === null || $payload === '') { return [null, null]; } return [null, $payload]; } /** * @return array{0: string, 1: string} */ private function persistMedia( Gallery $gallery, ?UploadedFile $file, ?string $base64Payload, ?string $preferredFilename, Request $request ): array { $directory = trim($gallery->images_path, '/'); if ($directory === '') { $directory = 'uploads'; } if ($file) { $extension = $file->getClientOriginalExtension(); $filename = $this->buildFilename($extension, $preferredFilename); $relativePath = $directory.'/'.$filename; $file->storeAs($directory, $filename, 'public'); return [$relativePath, $this->buildPublicUrl($relativePath, $request)]; } if ($base64Payload === null) { throw new RuntimeException('No media payload provided.'); } [$binary, $extension] = $this->decodeBase64Media($base64Payload); $filename = $this->buildFilename($extension, $preferredFilename); $relativePath = $directory.'/'.$filename; if (! Storage::disk('public')->put($relativePath, $binary)) { throw new RuntimeException('Failed to store uploaded image.'); } return [$relativePath, $this->buildPublicUrl($relativePath, $request)]; } /** * @return array{0: string, 1: string} */ private function decodeBase64Media(string $payload): array { $mime = null; if (preg_match('/^data:(image\\/[^;]+);base64,(.+)$/', $payload, $matches)) { $mime = $matches[1]; $payload = $matches[2]; } $binary = base64_decode($payload, true); if ($binary === false) { throw new RuntimeException('Invalid media payload.'); } if (strlen($binary) > self::MAX_FILE_SIZE) { throw new RuntimeException('File too large.'); } if (! $mime) { $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = $finfo ? finfo_buffer($finfo, $binary) : null; if ($finfo) { finfo_close($finfo); } } if (! $mime || ! $this->isAllowedMime($mime)) { throw new RuntimeException('Unsupported image type.'); } $extension = $this->extensionFromMime($mime) ?? 'jpg'; return [$binary, $extension]; } private function isAllowedMime(string $mime): bool { return in_array(strtolower($mime), self::ALLOWED_MIMES, true); } private function extensionFromMime(string $mime): ?string { return match (strtolower($mime)) { 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/bmp' => 'bmp', 'image/webp' => 'webp', default => null, }; } private function buildFilename(?string $extension, ?string $preferred = null): string { $extension = strtolower($extension ?: 'jpg'); $base = $preferred ? Str::slug(pathinfo($preferred, PATHINFO_FILENAME)) : 'sparkbooth_'.now()->format('Ymd_His').'_'.Str::random(6); if ($base === '') { $base = 'sparkbooth_'.now()->format('Ymd_His').'_'.Str::random(6); } return $base.'.'.$extension; } private function respondSuccess(Request $request, string $url, ?Gallery $gallery = null) { $format = $this->determineResponseFormat($request, $gallery); if ($format === 'xml') { $body = $this->buildXmlResponse('ok', $url); return response($body, Response::HTTP_OK)->header('Content-Type', 'application/xml'); } return response()->json([ 'status' => true, 'error' => null, 'url' => $url, ]); } private function respondError(Request $request, string $message, int $status, ?Gallery $gallery = null) { $format = $this->determineResponseFormat($request, $gallery); if ($format === 'xml') { $body = $this->buildXmlResponse('fail', null, $message); return response($body, $status)->header('Content-Type', 'application/xml'); } return response()->json([ 'status' => false, 'error' => $message, 'url' => null, ], $status); } private function determineResponseFormat(Request $request, ?Gallery $gallery = null): string { $candidate = Str::of((string) $request->input('response_format'))->lower()->value(); if (in_array($candidate, ['json', 'xml'], true)) { return $candidate; } $accept = Str::of((string) $request->header('Accept'))->lower()->value(); if (str_contains($accept, 'application/xml') || str_contains($accept, 'text/xml')) { return 'xml'; } if ($gallery && in_array($gallery->sparkbooth_response_format, ['json', 'xml'], true)) { return $gallery->sparkbooth_response_format; } return 'json'; } private function buildXmlResponse(string $status, ?string $url = null, ?string $errorMessage = null): string { if ($status === 'ok') { $urlAttr = $url ? ' url="'.e($url).'"' : ''; return ''; } $errorAttr = $errorMessage ? '' : ''; return ''.$errorAttr.''; } private function buildPublicUrl(string $relativePath, Request $request): string { $relative = Str::start($relativePath, '/'); $root = config('app.url') ?: $request->getSchemeAndHttpHost(); return rtrim($root, '/').'/storage'.$relative; } }