352 lines
10 KiB
PHP
352 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Gallery;
|
|
use App\Models\Image;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use RuntimeException;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
class SparkboothUploadController extends Controller
|
|
{
|
|
private const ALLOWED_MIMES = [
|
|
'image/jpeg',
|
|
'image/png',
|
|
'image/gif',
|
|
'image/bmp',
|
|
'image/webp',
|
|
];
|
|
|
|
private const MAX_FILE_SIZE = 10240 * 1024; // 10 MB
|
|
|
|
public function store(Request $request)
|
|
{
|
|
$this->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')
|
|
);
|
|
} 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
|
|
): 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, asset('storage/'.$relativePath)];
|
|
}
|
|
|
|
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, asset('storage/'.$relativePath)];
|
|
}
|
|
|
|
/**
|
|
* @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 '<?xml version="1.0" encoding="UTF-8"?><rsp status="ok"'.$urlAttr.' />';
|
|
}
|
|
|
|
$errorAttr = $errorMessage ? '<err msg="'.e($errorMessage).'" />' : '';
|
|
|
|
return '<?xml version="1.0" encoding="UTF-8"?><rsp status="fail">'.$errorAttr.'</rsp>';
|
|
}
|
|
}
|