diff --git a/app/Filament/Pages/SparkboothConnections.php b/app/Filament/Pages/SparkboothConnections.php
index 84d819c..058b082 100644
--- a/app/Filament/Pages/SparkboothConnections.php
+++ b/app/Filament/Pages/SparkboothConnections.php
@@ -50,6 +50,10 @@ class SparkboothConnections extends Page implements HasTable
->label('Upload-Pfad')
->copyable()
->toggleable(),
+ TextColumn::make('sparkbooth_username')
+ ->label('Benutzername')
+ ->copyable()
+ ->toggleable(),
TextColumn::make('created_at')
->label('Angelegt')
->since()
@@ -62,7 +66,7 @@ class SparkboothConnections extends Page implements HasTable
->color('primary')
->modalHeading('Upload-Zugangsdaten')
->modalSubmitAction(false)
- ->modalCancelActionLabel('Schließen')
+ ->modalCancelActionLabel('Schliessen')
->modalContent(function (Gallery $record) {
$plainToken = $record->regenerateUploadToken();
@@ -71,11 +75,14 @@ class SparkboothConnections extends Page implements HasTable
'upload_token' => $plainToken,
'upload_url' => route('api.sparkbooth.upload'),
'gallery_url' => route('gallery.show', $record),
+ 'sparkbooth_username' => $record->sparkbooth_username,
+ 'sparkbooth_password' => $record->sparkbooth_password,
+ 'response_format' => $record->sparkbooth_response_format,
];
Notification::make()
- ->title('Upload-Token wurde erneuert.')
- ->body('Bitte verwende den neuen Token in Sparkbooth.')
+ ->title('Zugangsdaten aktualisiert.')
+ ->body('Der Upload-Token wurde erneuert. Username/Passwort bleiben unveraendert.')
->success()
->send();
@@ -83,6 +90,9 @@ class SparkboothConnections extends Page implements HasTable
}),
])
->emptyStateHeading('Keine Sparkbooth-Verbindungen')
- ->emptyStateDescription('Lege eine neue Verbindung an oder aktiviere Uploads für eine Galerie.');
+ ->emptyStateDescription('Lege eine neue Verbindung an oder aktiviere Uploads fuer eine Galerie.');
}
}
+
+
+
diff --git a/app/Filament/Pages/SparkboothSetup.php b/app/Filament/Pages/SparkboothSetup.php
index c52713d..8efb1e6 100644
--- a/app/Filament/Pages/SparkboothSetup.php
+++ b/app/Filament/Pages/SparkboothSetup.php
@@ -4,6 +4,7 @@ namespace App\Filament\Pages;
use App\Models\Gallery;
use BackedEnum;
+use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
@@ -62,6 +63,23 @@ class SparkboothSetup extends Page implements HasForms
Toggle::make('upload_enabled')
->label('Uploads aktivieren')
->default(true),
+ TextInput::make('sparkbooth_username')
+ ->label('Sparkbooth Benutzername')
+ ->helperText('Wird in Sparkbooth unter „Username“ eingetragen. Erlaubt sind Buchstaben, Zahlen sowie ._-')
+ ->default(fn (): string => 'spark-'.Str::lower(Str::random(6)))
+ ->required()
+ ->maxLength(64)
+ ->rule('regex:/^[A-Za-z0-9._-]+$/')
+ ->unique(table: Gallery::class, column: 'sparkbooth_username'),
+ Select::make('sparkbooth_response_format')
+ ->label('Standard-Antwortformat')
+ ->helperText('Sparkbooth kann JSON oder XML erwarten. JSON ist empfohlen.')
+ ->options([
+ 'json' => 'JSON',
+ 'xml' => 'XML',
+ ])
+ ->default('json')
+ ->required(),
])
->statePath('data');
}
@@ -70,6 +88,9 @@ class SparkboothSetup extends Page implements HasForms
{
$data = $this->form->getState();
+ $sparkboothUsername = $this->normalizeUsername($data['sparkbooth_username'] ?? '');
+ $sparkboothPassword = Str::random(24);
+
$gallery = new Gallery([
'name' => $data['name'],
'title' => $data['title'],
@@ -78,6 +99,9 @@ class SparkboothSetup extends Page implements HasForms
'allow_ai_styles' => (bool) $data['allow_ai_styles'],
'allow_print' => (bool) $data['allow_print'],
'upload_enabled' => (bool) $data['upload_enabled'],
+ 'sparkbooth_username' => $sparkboothUsername,
+ 'sparkbooth_password' => $sparkboothPassword,
+ 'sparkbooth_response_format' => $data['sparkbooth_response_format'] ?? 'json',
]);
$gallery->slug = Str::uuid()->toString();
@@ -91,6 +115,9 @@ class SparkboothSetup extends Page implements HasForms
'upload_token' => $plainToken,
'upload_url' => route('api.sparkbooth.upload'),
'gallery_url' => route('gallery.show', $gallery),
+ 'sparkbooth_username' => $sparkboothUsername,
+ 'sparkbooth_password' => $sparkboothPassword,
+ 'response_format' => $gallery->sparkbooth_response_format,
];
Notification::make()
@@ -114,4 +141,11 @@ class SparkboothSetup extends Page implements HasForms
->url(route('filament.admin.pages.dashboard')),
];
}
+
+ protected function normalizeUsername(string $value): string
+ {
+ $clean = preg_replace('/[^A-Za-z0-9._-]/', '', $value) ?? '';
+
+ return Str::of($clean)->lower()->trim()->value();
+ }
}
diff --git a/app/Http/Controllers/Api/SparkboothUploadController.php b/app/Http/Controllers/Api/SparkboothUploadController.php
index cb43d2c..92d32c5 100644
--- a/app/Http/Controllers/Api/SparkboothUploadController.php
+++ b/app/Http/Controllers/Api/SparkboothUploadController.php
@@ -6,59 +6,110 @@ use App\Http\Controllers\Controller;
use App\Models\Gallery;
use App\Models\Image;
use Illuminate\Http\Request;
-use Illuminate\Support\Facades\File;
+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)
{
- $request->validate([
- 'token' => ['required', 'string'],
- 'file' => ['required', 'file', 'mimes:jpeg,png,gif,bmp,webp', 'max:10240'],
- 'filename' => ['nullable', 'string'],
- ]);
+ $this->validateRequest($request);
- $gallery = $this->resolveGalleryByToken($request->string('token'));
+ [$gallery, $authMethod] = $this->resolveGallery($request);
if (! $gallery) {
- return response()->json(['error' => 'Invalid token.'], Response::HTTP_FORBIDDEN);
+ return $this->respondError($request, 'Invalid credentials.', Response::HTTP_FORBIDDEN);
}
if (! $gallery->upload_enabled) {
- return response()->json(['error' => 'Uploads are disabled for this gallery.'], Response::HTTP_FORBIDDEN);
+ return $this->respondError($request, 'Uploads are disabled for this gallery.', Response::HTTP_FORBIDDEN, $gallery);
}
- if ($gallery->upload_token_expires_at && now()->greaterThanOrEqualTo($gallery->upload_token_expires_at)) {
- return response()->json(['error' => 'Upload token expired.'], Response::HTTP_FORBIDDEN);
+ 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 = $request->file('file');
- $safeName = $this->buildFilename($file->getClientOriginalExtension(), $request->input('filename'));
- $relativePath = trim($gallery->images_path, '/').'/'.$safeName;
- $destinationPath = public_path('storage/'.dirname($relativePath));
+ [$file, $base64Payload] = $this->extractMediaPayload($request);
- if (! File::exists($destinationPath)) {
- File::makeDirectory($destinationPath, 0755, true);
+ if (! $file && $base64Payload === null) {
+ return $this->respondError($request, 'No media payload provided.', Response::HTTP_BAD_REQUEST, $gallery);
}
- $file->move($destinationPath, basename($relativePath));
+ 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 = Image::create([
+ Image::create([
'gallery_id' => $gallery->id,
'path' => $relativePath,
'is_public' => true,
]);
- return response()->json([
- 'message' => 'Upload ok',
- 'image_id' => $image->id,
- 'url' => asset('storage/'.$relativePath),
+ 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()
@@ -75,13 +126,226 @@ class SparkboothUploadController extends Controller
return null;
}
- private function buildFilename(string $extension, ?string $preferred = null): string
+ 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 '
Upload Token
-{{ $upload_token }}
-Trage diesen Token in Sparkbooth unter „Upload Secret“ ein.
+Sparkbooth Benutzername
+{{ $sparkbooth_username ?? '—' }}
+Eintragen im Sparkbooth Custom Upload Dialog unter „Username“.
Sparkbooth Passwort
+{{ $sparkbooth_password ?? '—' }}
+Eintragen unter „Password“.
+Galerie-Link
{{ $gallery_url }}
Slug: {{ $gallery['slug'] }}, Pfad: storage/{{ $gallery['images_path'] }}
Antwortformat
+{{ strtoupper($response_format ?? 'JSON') }}
+Muss mit „JSON Response“ oder „XML Response“ in Sparkbooth übereinstimmen.
+Legacy Upload Token
+{{ $upload_token }}
+Optionales Secret (Feld „token“) für ältere Integrationen.
+Sparkbooth Hinweise
+Upload Token
-{{ $result['upload_token'] }}
-Trage diesen Token in Sparkbooth unter „Upload Secret“ ein.
+Sparkbooth Benutzername
+{{ $result['sparkbooth_username'] }}
+Eintragen in Sparkbooth ➜ Settings ➜ Upload ➜ Custom Upload ➜ Username.
Sparkbooth Passwort
+{{ $result['sparkbooth_password'] }}
+Eintragen in Sparkbooth unter „Password“.
+Galerie-Link
{{ $result['gallery_url'] }}
Slug: {{ $result['gallery']['slug'] }}, Pfad: storage/{{ $result['gallery']['images_path'] }}
Standard-Antwortformat
+{{ strtoupper($result['response_format']) }}
+Muss mit der Auswahl „JSON Response“ oder „XML Response“ in Sparkbooth übereinstimmen.
+Legacy Upload Token
+{{ $result['upload_token'] }}
+Optional: Für bestehende Integrationen nutzbar (Feld „token“).
+Sparkbooth Hinweise
+Sparkbooth Beispiel (Custom Upload)
+Sparkbooth Beispiel (Custom Upload)
-POST {{ $result['upload_url'] }}
-Content-Type: multipart/form-data
-token={{ $result['upload_token'] }}
-file=@your-photo.jpg
+curl -X POST {{ $result['upload_url'] }} \
+ -F "media=@your-photo.jpg" \
+ -F "username={{ $result['sparkbooth_username'] }}" \
+ -F "password={{ $result['sparkbooth_password'] }}" \
+ -F "name=Guest" \
+ -F "message=Thanks!"
+ Erwartete Server Responses
+{
+ "status": true,
+ "error": null,
+ "url": "{{ $result['gallery_url'] }}"
+}
+ <?xml version="1.0" encoding="UTF-8"?>
+<rsp status="ok" url="{{ $result['gallery_url'] }}" />
+