sparkbooth anbindung gefixt

This commit is contained in:
2025-12-07 17:39:23 +01:00
parent 08ee2205f5
commit b0d835d142
10 changed files with 597 additions and 40 deletions

View File

@@ -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.');
}
}

View File

@@ -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();
}
}

View File

@@ -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 '<?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>';
}
}

View File

@@ -26,6 +26,9 @@ class Gallery extends Model
'upload_enabled',
'upload_token_hash',
'upload_token_expires_at',
'sparkbooth_username',
'sparkbooth_password',
'sparkbooth_response_format',
];
protected function casts(): array
@@ -39,6 +42,8 @@ class Gallery extends Model
'access_duration_minutes' => 'int',
'upload_enabled' => 'bool',
'upload_token_expires_at' => 'datetime',
'sparkbooth_password' => 'encrypted',
'sparkbooth_response_format' => 'string',
];
}
@@ -60,4 +65,13 @@ class Gallery extends Model
return $token;
}
public function regenerateSparkboothPassword(): string
{
$password = \Illuminate\Support\Str::random(24);
$this->sparkbooth_password = $password;
$this->save();
return $password;
}
}