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') ->label('Upload-Pfad')
->copyable() ->copyable()
->toggleable(), ->toggleable(),
TextColumn::make('sparkbooth_username')
->label('Benutzername')
->copyable()
->toggleable(),
TextColumn::make('created_at') TextColumn::make('created_at')
->label('Angelegt') ->label('Angelegt')
->since() ->since()
@@ -62,7 +66,7 @@ class SparkboothConnections extends Page implements HasTable
->color('primary') ->color('primary')
->modalHeading('Upload-Zugangsdaten') ->modalHeading('Upload-Zugangsdaten')
->modalSubmitAction(false) ->modalSubmitAction(false)
->modalCancelActionLabel('Schließen') ->modalCancelActionLabel('Schliessen')
->modalContent(function (Gallery $record) { ->modalContent(function (Gallery $record) {
$plainToken = $record->regenerateUploadToken(); $plainToken = $record->regenerateUploadToken();
@@ -71,11 +75,14 @@ class SparkboothConnections extends Page implements HasTable
'upload_token' => $plainToken, 'upload_token' => $plainToken,
'upload_url' => route('api.sparkbooth.upload'), 'upload_url' => route('api.sparkbooth.upload'),
'gallery_url' => route('gallery.show', $record), 'gallery_url' => route('gallery.show', $record),
'sparkbooth_username' => $record->sparkbooth_username,
'sparkbooth_password' => $record->sparkbooth_password,
'response_format' => $record->sparkbooth_response_format,
]; ];
Notification::make() Notification::make()
->title('Upload-Token wurde erneuert.') ->title('Zugangsdaten aktualisiert.')
->body('Bitte verwende den neuen Token in Sparkbooth.') ->body('Der Upload-Token wurde erneuert. Username/Passwort bleiben unveraendert.')
->success() ->success()
->send(); ->send();
@@ -83,6 +90,9 @@ class SparkboothConnections extends Page implements HasTable
}), }),
]) ])
->emptyStateHeading('Keine Sparkbooth-Verbindungen') ->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 App\Models\Gallery;
use BackedEnum; use BackedEnum;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Concerns\InteractsWithForms;
@@ -62,6 +63,23 @@ class SparkboothSetup extends Page implements HasForms
Toggle::make('upload_enabled') Toggle::make('upload_enabled')
->label('Uploads aktivieren') ->label('Uploads aktivieren')
->default(true), ->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'); ->statePath('data');
} }
@@ -70,6 +88,9 @@ class SparkboothSetup extends Page implements HasForms
{ {
$data = $this->form->getState(); $data = $this->form->getState();
$sparkboothUsername = $this->normalizeUsername($data['sparkbooth_username'] ?? '');
$sparkboothPassword = Str::random(24);
$gallery = new Gallery([ $gallery = new Gallery([
'name' => $data['name'], 'name' => $data['name'],
'title' => $data['title'], 'title' => $data['title'],
@@ -78,6 +99,9 @@ class SparkboothSetup extends Page implements HasForms
'allow_ai_styles' => (bool) $data['allow_ai_styles'], 'allow_ai_styles' => (bool) $data['allow_ai_styles'],
'allow_print' => (bool) $data['allow_print'], 'allow_print' => (bool) $data['allow_print'],
'upload_enabled' => (bool) $data['upload_enabled'], '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(); $gallery->slug = Str::uuid()->toString();
@@ -91,6 +115,9 @@ class SparkboothSetup extends Page implements HasForms
'upload_token' => $plainToken, 'upload_token' => $plainToken,
'upload_url' => route('api.sparkbooth.upload'), 'upload_url' => route('api.sparkbooth.upload'),
'gallery_url' => route('gallery.show', $gallery), 'gallery_url' => route('gallery.show', $gallery),
'sparkbooth_username' => $sparkboothUsername,
'sparkbooth_password' => $sparkboothPassword,
'response_format' => $gallery->sparkbooth_response_format,
]; ];
Notification::make() Notification::make()
@@ -114,4 +141,11 @@ class SparkboothSetup extends Page implements HasForms
->url(route('filament.admin.pages.dashboard')), ->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\Gallery;
use App\Models\Image; use App\Models\Image;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\File; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class SparkboothUploadController extends Controller 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) public function store(Request $request)
{ {
$request->validate([ $this->validateRequest($request);
'token' => ['required', 'string'],
'file' => ['required', 'file', 'mimes:jpeg,png,gif,bmp,webp', 'max:10240'],
'filename' => ['nullable', 'string'],
]);
$gallery = $this->resolveGalleryByToken($request->string('token')); [$gallery, $authMethod] = $this->resolveGallery($request);
if (! $gallery) { if (! $gallery) {
return response()->json(['error' => 'Invalid token.'], Response::HTTP_FORBIDDEN); return $this->respondError($request, 'Invalid credentials.', Response::HTTP_FORBIDDEN);
} }
if (! $gallery->upload_enabled) { 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)) { if ($authMethod === 'token'
return response()->json(['error' => 'Upload token expired.'], Response::HTTP_FORBIDDEN); && $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'); [$file, $base64Payload] = $this->extractMediaPayload($request);
$safeName = $this->buildFilename($file->getClientOriginalExtension(), $request->input('filename'));
$relativePath = trim($gallery->images_path, '/').'/'.$safeName;
$destinationPath = public_path('storage/'.dirname($relativePath));
if (! File::exists($destinationPath)) { if (! $file && $base64Payload === null) {
File::makeDirectory($destinationPath, 0755, true); 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, 'gallery_id' => $gallery->id,
'path' => $relativePath, 'path' => $relativePath,
'is_public' => true, 'is_public' => true,
]); ]);
return response()->json([ return $this->respondSuccess($request, $publicUrl, $gallery);
'message' => 'Upload ok', }
'image_id' => $image->id,
'url' => asset('storage/'.$relativePath), 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 private function resolveGalleryByToken(string $token): ?Gallery
{ {
$galleries = Gallery::query() $galleries = Gallery::query()
@@ -75,13 +126,226 @@ class SparkboothUploadController extends Controller
return null; 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'); $extension = strtolower($extension ?: 'jpg');
$base = $preferred $base = $preferred
? Str::slug(pathinfo($preferred, PATHINFO_FILENAME)) ? Str::slug(pathinfo($preferred, PATHINFO_FILENAME))
: 'sparkbooth_'.now()->format('Ymd_His').'_'.Str::random(6); : 'sparkbooth_'.now()->format('Ymd_His').'_'.Str::random(6);
if ($base === '') {
$base = 'sparkbooth_'.now()->format('Ymd_His').'_'.Str::random(6);
}
return $base.'.'.$extension; 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_enabled',
'upload_token_hash', 'upload_token_hash',
'upload_token_expires_at', 'upload_token_expires_at',
'sparkbooth_username',
'sparkbooth_password',
'sparkbooth_response_format',
]; ];
protected function casts(): array protected function casts(): array
@@ -39,6 +42,8 @@ class Gallery extends Model
'access_duration_minutes' => 'int', 'access_duration_minutes' => 'int',
'upload_enabled' => 'bool', 'upload_enabled' => 'bool',
'upload_token_expires_at' => 'datetime', 'upload_token_expires_at' => 'datetime',
'sparkbooth_password' => 'encrypted',
'sparkbooth_response_format' => 'string',
]; ];
} }
@@ -60,4 +65,13 @@ class Gallery extends Model
return $token; return $token;
} }
public function regenerateSparkboothPassword(): string
{
$password = \Illuminate\Support\Str::random(24);
$this->sparkbooth_password = $password;
$this->save();
return $password;
}
} }

View File

@@ -35,6 +35,9 @@ class GalleryFactory extends Factory
'upload_enabled' => false, 'upload_enabled' => false,
'upload_token_hash' => null, 'upload_token_hash' => null,
'upload_token_expires_at' => null, 'upload_token_expires_at' => null,
'sparkbooth_username' => 'spark-'.$this->faker->regexify('[a-z0-9]{6}'),
'sparkbooth_password' => 'pw-'.$this->faker->regexify('[A-Za-z0-9]{10}'),
'sparkbooth_response_format' => 'json',
]; ];
} }
} }

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('galleries', function (Blueprint $table): void {
$table->string('sparkbooth_username')->nullable()->after('upload_token_expires_at');
$table->text('sparkbooth_password')->nullable()->after('sparkbooth_username');
$table->string('sparkbooth_response_format', 10)->default('json')->after('sparkbooth_password');
$table->unique('sparkbooth_username');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('galleries', function (Blueprint $table): void {
$table->dropUnique(['sparkbooth_username']);
$table->dropColumn([
'sparkbooth_username',
'sparkbooth_password',
'sparkbooth_response_format',
]);
});
}
};

View File

@@ -33,6 +33,9 @@ class GallerySeeder extends Seeder
'upload_enabled' => false, 'upload_enabled' => false,
'upload_token_hash' => null, 'upload_token_hash' => null,
'upload_token_expires_at' => null, 'upload_token_expires_at' => null,
'sparkbooth_username' => null,
'sparkbooth_password' => null,
'sparkbooth_response_format' => 'json',
] ]
); );
} }

View File

@@ -6,15 +6,44 @@
<div class="grid gap-4 md:grid-cols-2"> <div class="grid gap-4 md:grid-cols-2">
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5"> <div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Upload Token</p> <p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Sparkbooth Benutzername</p>
<p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $upload_token }}</p> <p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $sparkbooth_username ?? '—' }}</p>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Trage diesen Token in Sparkbooth unter Upload Secret“ ein.</p> <p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Eintragen im Sparkbooth Custom Upload Dialog unter „Username“.</p>
</div> </div>
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Sparkbooth Passwort</p>
<p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $sparkbooth_password ?? '—' }}</p>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Eintragen unter „Password“.</p>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5"> <div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Galerie-Link</p> <p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Galerie-Link</p>
<p class="mt-1 break-all font-semibold text-gray-900 dark:text-white">{{ $gallery_url }}</p> <p class="mt-1 break-all font-semibold text-gray-900 dark:text-white">{{ $gallery_url }}</p>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Slug: {{ $gallery['slug'] }}, Pfad: storage/{{ $gallery['images_path'] }}</p> <p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Slug: {{ $gallery['slug'] }}, Pfad: storage/{{ $gallery['images_path'] }}</p>
</div> </div>
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Antwortformat</p>
<p class="mt-1 font-semibold text-gray-900 dark:text-white">{{ strtoupper($response_format ?? 'JSON') }}</p>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Muss mit „JSON Response“ oder „XML Response“ in Sparkbooth übereinstimmen.</p>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Legacy Upload Token</p>
<p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $upload_token }}</p>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Optionales Secret (Feld „token“) für ältere Integrationen.</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Sparkbooth Hinweise</p>
<ul class="mt-2 list-disc space-y-1 pl-4 text-xs text-gray-600 dark:text-gray-300">
<li>Uploader „Custom Upload“ wählen.</li>
<li>Username &amp; Password wie oben eintragen.</li>
<li>Falls JSON Response aktiviert ist, hier ebenfalls JSON wählen (gleiches gilt für XML).</li>
</ul>
</div>
</div> </div>
<div class="grid gap-4 md:grid-cols-2"> <div class="grid gap-4 md:grid-cols-2">

View File

@@ -15,15 +15,44 @@
<div class="mt-4 grid gap-4 md:grid-cols-2"> <div class="mt-4 grid gap-4 md:grid-cols-2">
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5"> <div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Upload Token</p> <p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Sparkbooth Benutzername</p>
<p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $result['upload_token'] }}</p> <p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $result['sparkbooth_username'] }}</p>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Trage diesen Token in Sparkbooth unter „Upload Secret“ ein.</p> <p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Eintragen in Sparkbooth Settings Upload Custom Upload Username.</p>
</div> </div>
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Sparkbooth Passwort</p>
<p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $result['sparkbooth_password'] }}</p>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Eintragen in Sparkbooth unter „Password“.</p>
</div>
</div>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5"> <div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Galerie-Link</p> <p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Galerie-Link</p>
<p class="mt-1 break-all font-semibold text-gray-900 dark:text-white">{{ $result['gallery_url'] }}</p> <p class="mt-1 break-all font-semibold text-gray-900 dark:text-white">{{ $result['gallery_url'] }}</p>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Slug: {{ $result['gallery']['slug'] }}, Pfad: storage/{{ $result['gallery']['images_path'] }}</p> <p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Slug: {{ $result['gallery']['slug'] }}, Pfad: storage/{{ $result['gallery']['images_path'] }}</p>
</div> </div>
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Standard-Antwortformat</p>
<p class="mt-1 font-semibold text-gray-900 dark:text-white">{{ strtoupper($result['response_format']) }}</p>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Muss mit der Auswahl „JSON Response“ oder „XML Response“ in Sparkbooth übereinstimmen.</p>
</div>
</div>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Legacy Upload Token</p>
<p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $result['upload_token'] }}</p>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Optional: Für bestehende Integrationen nutzbar (Feld „token“).</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Sparkbooth Hinweise</p>
<ul class="mt-2 list-disc space-y-1 pl-4 text-xs text-gray-600 dark:text-gray-300">
<li>Uploader: „Custom Upload“ wählen.</li>
<li>URL: {{ $result['upload_url'] }}</li>
<li>Username/Password eintragen, optional Message.</li>
</ul>
</div>
</div> </div>
<div class="mt-6 grid gap-4 md:grid-cols-2"> <div class="mt-6 grid gap-4 md:grid-cols-2">
@@ -41,14 +70,30 @@
</div> </div>
</div> </div>
<div class="mt-6"> <div class="mt-6 space-y-4">
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Sparkbooth Beispiel (Custom Upload)</p> <div>
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Sparkbooth Beispiel (Custom Upload)</p>
<pre class="mt-2 rounded-xl border border-gray-200 bg-gray-900 p-4 text-xs text-gray-100 dark:border-white/10"> <pre class="mt-2 rounded-xl border border-gray-200 bg-gray-900 p-4 text-xs text-gray-100 dark:border-white/10">
POST {{ $result['upload_url'] }} curl -X POST {{ $result['upload_url'] }} \
Content-Type: multipart/form-data -F "media=@your-photo.jpg" \
token={{ $result['upload_token'] }} -F "username={{ $result['sparkbooth_username'] }}" \
file=@your-photo.jpg -F "password={{ $result['sparkbooth_password'] }}" \
-F "name=Guest" \
-F "message=Thanks!"
</pre> </pre>
</div>
<div>
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Erwartete Server Responses</p>
<div class="grid gap-4 md:grid-cols-2">
<pre class="rounded-xl border border-gray-200 bg-gray-900 p-4 text-xs text-gray-100 dark:border-white/10">{
"status": true,
"error": null,
"url": "{{ $result['gallery_url'] }}"
}</pre>
<pre class="rounded-xl border border-gray-200 bg-gray-900 p-4 text-xs text-gray-100 dark:border-white/10">&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;rsp status="ok" url="{{ $result['gallery_url'] }}" /&gt;</pre>
</div>
</div>
</div> </div>
</div> </div>
@endif @endif

View File

@@ -0,0 +1,117 @@
<?php
namespace Tests\Feature;
use App\Models\Gallery;
use App\Models\Image;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class SparkboothUploadTest extends TestCase
{
use RefreshDatabase;
public function test_json_upload_with_credentials_succeeds(): void
{
Storage::fake('public');
$gallery = Gallery::factory()->create([
'upload_enabled' => true,
'images_path' => 'uploads/test',
'sparkbooth_username' => 'spark-user',
'sparkbooth_password' => 'secret-123',
'sparkbooth_response_format' => 'json',
]);
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
$response = $this->postJson(route('api.sparkbooth.upload'), [
'username' => 'spark-user',
'password' => 'secret-123',
'media' => $file,
'name' => 'Guest',
'message' => 'Hallo',
]);
$response->assertOk()
->assertJson([
'status' => true,
'error' => null,
])
->assertJsonStructure(['url']);
$image = Image::first();
$this->assertNotNull($image);
$this->assertSame($gallery->id, $image->gallery_id);
$this->assertStringStartsWith('uploads/test/sparkbooth_', $image->path);
Storage::disk('public')->assertExists($image->path);
}
public function test_xml_response_for_invalid_credentials(): void
{
Storage::fake('public');
Gallery::factory()->create([
'upload_enabled' => true,
'images_path' => 'uploads/test',
'sparkbooth_username' => 'spark-user',
'sparkbooth_password' => 'secret-123',
'sparkbooth_response_format' => 'xml',
]);
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
$response = $this->post(route('api.sparkbooth.upload'), [
'username' => 'spark-user',
'password' => 'wrong',
'media' => $file,
'response_format' => 'xml',
]);
$response->assertStatus(403);
$response->assertHeader('Content-Type', 'application/xml');
$this->assertStringContainsString('<rsp status="fail">', $response->getContent());
}
public function test_base64_upload_with_token_succeeds(): void
{
Storage::fake('public');
$gallery = Gallery::factory()->create([
'upload_enabled' => true,
'images_path' => 'uploads/base64',
'sparkbooth_response_format' => 'json',
]);
$token = 'tokentest123';
$gallery->setUploadToken($token);
$gallery->save();
$fake = UploadedFile::fake()->image('inline.png', 20, 20);
$binary = base64_encode(file_get_contents($fake->getRealPath()));
$dataUri = 'data:image/png;base64,'.$binary;
$response = $this->postJson(route('api.sparkbooth.upload'), [
'token' => $token,
'media' => $dataUri,
'filename' => 'custom.png',
]);
$response->assertOk()
->assertJson([
'status' => true,
'error' => null,
])
->assertJsonStructure(['url']);
Storage::disk('public')->assertExists('uploads/base64/custom.png');
$this->assertDatabaseHas('images', [
'gallery_id' => $gallery->id,
'path' => 'uploads/base64/custom.png',
]);
}
}