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;
}
}