diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 6ad8f07..6ed710f 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -1 +1 @@ -{"version":2,"defects":{"Tests\\Feature\\Auth\\AuthenticationTest::test_users_can_authenticate_using_the_login_screen":8,"Tests\\Feature\\Auth\\AuthenticationTest::test_users_can_not_authenticate_with_invalid_password":8,"Tests\\Feature\\Auth\\AuthenticationTest::test_users_can_logout":8,"Tests\\Feature\\Auth\\EmailVerificationTest::test_email_verification_screen_can_be_rendered":8,"Tests\\Feature\\Auth\\EmailVerificationTest::test_email_can_be_verified":8,"Tests\\Feature\\Auth\\EmailVerificationTest::test_email_is_not_verified_with_invalid_hash":8,"Tests\\Feature\\Auth\\PasswordConfirmationTest::test_confirm_password_screen_can_be_rendered":8,"Tests\\Feature\\Auth\\PasswordConfirmationTest::test_password_can_be_confirmed":8,"Tests\\Feature\\Auth\\PasswordConfirmationTest::test_password_is_not_confirmed_with_invalid_password":8,"Tests\\Feature\\Auth\\PasswordResetTest::test_reset_password_link_can_be_requested":8,"Tests\\Feature\\Auth\\PasswordResetTest::test_reset_password_screen_can_be_rendered":8,"Tests\\Feature\\Auth\\PasswordResetTest::test_password_can_be_reset_with_valid_token":8,"Tests\\Feature\\Auth\\PasswordUpdateTest::test_password_can_be_updated":8,"Tests\\Feature\\Auth\\PasswordUpdateTest::test_correct_password_must_be_provided_to_update_password":8,"Tests\\Feature\\Auth\\RegistrationTest::test_new_users_can_register":7,"Tests\\Feature\\ProfileTest::test_profile_page_is_displayed":8,"Tests\\Feature\\ProfileTest::test_profile_information_can_be_updated":8,"Tests\\Feature\\ProfileTest::test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged":8,"Tests\\Feature\\ProfileTest::test_user_can_delete_their_account":8,"Tests\\Feature\\ProfileTest::test_correct_password_must_be_provided_to_delete_account":8,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":7,"Tests\\Feature\\Filament\\ResourcePagesTest::test_admin_resource_index_pages_render":7,"Tests\\Feature\\Filament\\ResourcePagesTest::test_admin_resource_create_pages_render":7,"Tests\\Feature\\Filament\\ResourcePagesTest::test_admin_resource_edit_pages_render":7,"Tests\\Feature\\AiStatusTest::test_ai_status_endpoint_returns_correct_structure":8,"Tests\\Feature\\AiStatusTest::test_ai_status_update_disables_unavailable_providers":7,"Tests\\Feature\\AiStatusTest::test_comfyui_check_availability_method":8,"Tests\\Feature\\AiStatusTest::test_runwareai_check_availability_method":8},"times":{"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.001,"Tests\\Feature\\Auth\\AuthenticationTest::test_login_screen_can_be_rendered":2.652,"Tests\\Feature\\Auth\\AuthenticationTest::test_users_can_authenticate_using_the_login_screen":0.844,"Tests\\Feature\\Auth\\AuthenticationTest::test_users_can_not_authenticate_with_invalid_password":0.003,"Tests\\Feature\\Auth\\AuthenticationTest::test_users_can_logout":0.003,"Tests\\Feature\\Auth\\EmailVerificationTest::test_email_verification_screen_can_be_rendered":0.004,"Tests\\Feature\\Auth\\EmailVerificationTest::test_email_can_be_verified":0.003,"Tests\\Feature\\Auth\\EmailVerificationTest::test_email_is_not_verified_with_invalid_hash":0.004,"Tests\\Feature\\Auth\\PasswordConfirmationTest::test_confirm_password_screen_can_be_rendered":0.003,"Tests\\Feature\\Auth\\PasswordConfirmationTest::test_password_can_be_confirmed":0.004,"Tests\\Feature\\Auth\\PasswordConfirmationTest::test_password_is_not_confirmed_with_invalid_password":0.011,"Tests\\Feature\\Auth\\PasswordResetTest::test_reset_password_link_screen_can_be_rendered":0.685,"Tests\\Feature\\Auth\\PasswordResetTest::test_reset_password_link_can_be_requested":0.079,"Tests\\Feature\\Auth\\PasswordResetTest::test_reset_password_screen_can_be_rendered":0.004,"Tests\\Feature\\Auth\\PasswordResetTest::test_password_can_be_reset_with_valid_token":0.003,"Tests\\Feature\\Auth\\PasswordUpdateTest::test_password_can_be_updated":0.003,"Tests\\Feature\\Auth\\PasswordUpdateTest::test_correct_password_must_be_provided_to_update_password":0.003,"Tests\\Feature\\Auth\\RegistrationTest::test_registration_screen_can_be_rendered":0.653,"Tests\\Feature\\Auth\\RegistrationTest::test_new_users_can_register":2.449,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":3.546,"Tests\\Feature\\ProfileTest::test_profile_page_is_displayed":0.003,"Tests\\Feature\\ProfileTest::test_profile_information_can_be_updated":0.008,"Tests\\Feature\\ProfileTest::test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged":0.005,"Tests\\Feature\\ProfileTest::test_user_can_delete_their_account":0.004,"Tests\\Feature\\ProfileTest::test_correct_password_must_be_provided_to_delete_account":0.004,"Tests\\Feature\\Filament\\ResourcePagesTest::test_admin_resource_index_pages_render":6.542,"Tests\\Feature\\Filament\\ResourcePagesTest::test_admin_resource_create_pages_render":3.055,"Tests\\Feature\\Filament\\ResourcePagesTest::test_admin_resource_edit_pages_render":3.132,"Tests\\Feature\\AiStatusTest::test_ai_status_endpoint_returns_correct_structure":0.637,"Tests\\Feature\\AiStatusTest::test_ai_status_update_disables_unavailable_providers":1.569,"Tests\\Feature\\AiStatusTest::test_comfyui_check_availability_method":0.228,"Tests\\Feature\\AiStatusTest::test_runwareai_check_availability_method":0.233}} \ No newline at end of file +{"version":2,"defects":{"Tests\\Feature\\Auth\\AuthenticationTest::test_users_can_authenticate_using_the_login_screen":8,"Tests\\Feature\\Auth\\AuthenticationTest::test_users_can_not_authenticate_with_invalid_password":8,"Tests\\Feature\\Auth\\AuthenticationTest::test_users_can_logout":8,"Tests\\Feature\\Auth\\EmailVerificationTest::test_email_verification_screen_can_be_rendered":8,"Tests\\Feature\\Auth\\EmailVerificationTest::test_email_can_be_verified":8,"Tests\\Feature\\Auth\\EmailVerificationTest::test_email_is_not_verified_with_invalid_hash":8,"Tests\\Feature\\Auth\\PasswordConfirmationTest::test_confirm_password_screen_can_be_rendered":8,"Tests\\Feature\\Auth\\PasswordConfirmationTest::test_password_can_be_confirmed":8,"Tests\\Feature\\Auth\\PasswordConfirmationTest::test_password_is_not_confirmed_with_invalid_password":8,"Tests\\Feature\\Auth\\PasswordResetTest::test_reset_password_link_can_be_requested":8,"Tests\\Feature\\Auth\\PasswordResetTest::test_reset_password_screen_can_be_rendered":8,"Tests\\Feature\\Auth\\PasswordResetTest::test_password_can_be_reset_with_valid_token":8,"Tests\\Feature\\Auth\\PasswordUpdateTest::test_password_can_be_updated":8,"Tests\\Feature\\Auth\\PasswordUpdateTest::test_correct_password_must_be_provided_to_update_password":8,"Tests\\Feature\\Auth\\RegistrationTest::test_new_users_can_register":7,"Tests\\Feature\\ProfileTest::test_profile_page_is_displayed":8,"Tests\\Feature\\ProfileTest::test_profile_information_can_be_updated":8,"Tests\\Feature\\ProfileTest::test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged":8,"Tests\\Feature\\ProfileTest::test_user_can_delete_their_account":8,"Tests\\Feature\\ProfileTest::test_correct_password_must_be_provided_to_delete_account":8,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":7,"Tests\\Feature\\Filament\\ResourcePagesTest::test_admin_resource_index_pages_render":7,"Tests\\Feature\\Filament\\ResourcePagesTest::test_admin_resource_create_pages_render":7,"Tests\\Feature\\Filament\\ResourcePagesTest::test_admin_resource_edit_pages_render":7,"Tests\\Feature\\AiStatusTest::test_ai_status_endpoint_returns_correct_structure":8,"Tests\\Feature\\AiStatusTest::test_ai_status_update_disables_unavailable_providers":7,"Tests\\Feature\\AiStatusTest::test_comfyui_check_availability_method":8,"Tests\\Feature\\AiStatusTest::test_runwareai_check_availability_method":8,"Tests\\Feature\\GalleryAccessTest::test_allows_access_after_correct_password":8,"Tests\\Feature\\GalleryAccessTest::test_redirects_to_access_page_when_password_is_required":7,"Tests\\Feature\\GalleryAccessTest::test_denies_access_when_gallery_expired":8},"times":{"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.001,"Tests\\Feature\\Auth\\AuthenticationTest::test_login_screen_can_be_rendered":2.652,"Tests\\Feature\\Auth\\AuthenticationTest::test_users_can_authenticate_using_the_login_screen":0.844,"Tests\\Feature\\Auth\\AuthenticationTest::test_users_can_not_authenticate_with_invalid_password":0.003,"Tests\\Feature\\Auth\\AuthenticationTest::test_users_can_logout":0.003,"Tests\\Feature\\Auth\\EmailVerificationTest::test_email_verification_screen_can_be_rendered":0.004,"Tests\\Feature\\Auth\\EmailVerificationTest::test_email_can_be_verified":0.003,"Tests\\Feature\\Auth\\EmailVerificationTest::test_email_is_not_verified_with_invalid_hash":0.004,"Tests\\Feature\\Auth\\PasswordConfirmationTest::test_confirm_password_screen_can_be_rendered":0.003,"Tests\\Feature\\Auth\\PasswordConfirmationTest::test_password_can_be_confirmed":0.004,"Tests\\Feature\\Auth\\PasswordConfirmationTest::test_password_is_not_confirmed_with_invalid_password":0.011,"Tests\\Feature\\Auth\\PasswordResetTest::test_reset_password_link_screen_can_be_rendered":0.685,"Tests\\Feature\\Auth\\PasswordResetTest::test_reset_password_link_can_be_requested":0.079,"Tests\\Feature\\Auth\\PasswordResetTest::test_reset_password_screen_can_be_rendered":0.004,"Tests\\Feature\\Auth\\PasswordResetTest::test_password_can_be_reset_with_valid_token":0.003,"Tests\\Feature\\Auth\\PasswordUpdateTest::test_password_can_be_updated":0.003,"Tests\\Feature\\Auth\\PasswordUpdateTest::test_correct_password_must_be_provided_to_update_password":0.003,"Tests\\Feature\\Auth\\RegistrationTest::test_registration_screen_can_be_rendered":0.653,"Tests\\Feature\\Auth\\RegistrationTest::test_new_users_can_register":2.449,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":3.546,"Tests\\Feature\\ProfileTest::test_profile_page_is_displayed":0.003,"Tests\\Feature\\ProfileTest::test_profile_information_can_be_updated":0.008,"Tests\\Feature\\ProfileTest::test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged":0.005,"Tests\\Feature\\ProfileTest::test_user_can_delete_their_account":0.004,"Tests\\Feature\\ProfileTest::test_correct_password_must_be_provided_to_delete_account":0.004,"Tests\\Feature\\Filament\\ResourcePagesTest::test_admin_resource_index_pages_render":6.542,"Tests\\Feature\\Filament\\ResourcePagesTest::test_admin_resource_create_pages_render":3.055,"Tests\\Feature\\Filament\\ResourcePagesTest::test_admin_resource_edit_pages_render":3.132,"Tests\\Feature\\AiStatusTest::test_ai_status_endpoint_returns_correct_structure":0.637,"Tests\\Feature\\AiStatusTest::test_ai_status_update_disables_unavailable_providers":1.569,"Tests\\Feature\\AiStatusTest::test_comfyui_check_availability_method":0.228,"Tests\\Feature\\AiStatusTest::test_runwareai_check_availability_method":0.233,"Tests\\Feature\\GalleryAccessTest::test_redirects_to_access_page_when_password_is_required":9.523,"Tests\\Feature\\GalleryAccessTest::test_allows_access_after_correct_password":3.424,"Tests\\Feature\\GalleryAccessTest::test_denies_access_when_gallery_expired":3.086}} \ No newline at end of file diff --git a/app/Filament/Pages/GlobalSettings.php b/app/Filament/Pages/GlobalSettings.php index 382c44f..67939a0 100644 --- a/app/Filament/Pages/GlobalSettings.php +++ b/app/Filament/Pages/GlobalSettings.php @@ -5,6 +5,8 @@ namespace App\Filament\Pages; use App\Services\PrinterService; use App\Settings\GeneralSettings; use BackedEnum; +use Carbon\Carbon; +use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; @@ -14,6 +16,7 @@ use Filament\Notifications\Notification; use Filament\Pages\Page; use Filament\Schemas\Schema; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Hash; use UnitEnum; class GlobalSettings extends Page implements HasForms @@ -40,7 +43,10 @@ class GlobalSettings extends Page implements HasForms public function mount(GeneralSettings $settings): void { - $this->form->fill($settings->toArray()); + $data = $settings->toArray(); + $data['gallery_password'] = null; + + $this->form->fill($data); } public function form(Schema $schema): Schema @@ -59,6 +65,24 @@ class GlobalSettings extends Page implements HasForms TextInput::make('gallery_heading') ->label(__('filament.resource.setting.form.gallery_heading')) ->required(), + Toggle::make('require_gallery_password') + ->label(__('filament.resource.setting.form.require_gallery_password')) + ->helperText(__('filament.resource.setting.form.require_gallery_password_help')), + TextInput::make('gallery_password') + ->label(__('filament.resource.setting.form.gallery_password')) + ->password() + ->revealable() + ->helperText(__('filament.resource.setting.form.gallery_password_help')) + ->dehydrated(true), + DateTimePicker::make('gallery_expires_at') + ->label(__('filament.resource.setting.form.gallery_expires_at')) + ->native(false) + ->seconds(false), + TextInput::make('gallery_access_duration_minutes') + ->label(__('filament.resource.setting.form.gallery_access_duration_minutes')) + ->numeric() + ->minValue(1) + ->helperText(__('filament.resource.setting.form.gallery_access_duration_help')), TextInput::make('new_image_timespan_minutes') ->label(__('filament.resource.setting.form.new_image_timespan_minutes')) ->numeric() @@ -92,11 +116,39 @@ class GlobalSettings extends Page implements HasForms $data['custom_printer_address'] = null; } + $data['require_gallery_password'] = (bool) Arr::get($data, 'require_gallery_password', false); $data['new_image_timespan_minutes'] = (int) Arr::get($data, 'new_image_timespan_minutes', 0); $data['image_refresh_interval'] = (int) Arr::get($data, 'image_refresh_interval', 0); $data['max_number_of_copies'] = (int) Arr::get($data, 'max_number_of_copies', 0); $data['show_print_button'] = (bool) Arr::get($data, 'show_print_button', false); $data['custom_printer_address'] = $data['custom_printer_address'] ?: null; + $duration = Arr::get($data, 'gallery_access_duration_minutes'); + $data['gallery_access_duration_minutes'] = $duration === null || $duration === '' ? null : (int) $duration; + + $expiresAt = Arr::get($data, 'gallery_expires_at'); + $data['gallery_expires_at'] = $expiresAt ? Carbon::parse($expiresAt) : null; + + $newPassword = Arr::get($data, 'gallery_password'); + $currentHash = $settings->gallery_password_hash; + + if (! $data['require_gallery_password']) { + $data['gallery_password_hash'] = null; + } else { + $data['gallery_password_hash'] = $newPassword + ? Hash::make($newPassword) + : $currentHash; + + if (! $data['gallery_password_hash']) { + Notification::make() + ->title(__('filament.resource.setting.form.gallery_password_missing')) + ->danger() + ->send(); + + return; + } + } + + unset($data['gallery_password']); $settings->fill($data)->save(); diff --git a/app/Filament/Pages/SparkboothSetup.php b/app/Filament/Pages/SparkboothSetup.php new file mode 100644 index 0000000..5b4a08d --- /dev/null +++ b/app/Filament/Pages/SparkboothSetup.php @@ -0,0 +1,107 @@ +schema([ + TextInput::make('name') + ->label('Event-Name') + ->required(), + TextInput::make('title') + ->label('Galerie Titel') + ->required(), + TextInput::make('images_path') + ->label('Upload-Pfad') + ->helperText('Relativ zu public/storage, z.B. uploads/event-xyz') + ->default(fn () => 'uploads/'.Str::slug('event-'.Str::random(4))) + ->required(), + Toggle::make('allow_print') + ->label('Drucken erlauben') + ->default(true), + Toggle::make('allow_ai_styles') + ->label('AI-Stile erlauben') + ->default(true), + Toggle::make('upload_enabled') + ->label('Uploads aktivieren') + ->default(true), + ]) + ->statePath('data'); + } + + public function save(): void + { + $data = $this->form->getState(); + + $gallery = new Gallery([ + 'name' => $data['name'], + 'title' => $data['title'], + 'images_path' => trim($data['images_path'], '/'), + 'is_public' => true, + 'allow_ai_styles' => (bool) $data['allow_ai_styles'], + 'allow_print' => (bool) $data['allow_print'], + 'upload_enabled' => (bool) $data['upload_enabled'], + ]); + + $gallery->slug = Str::uuid()->toString(); + + $plainToken = Str::random(40); + $gallery->setUploadToken($plainToken); + $gallery->save(); + + $this->result = [ + 'gallery' => $gallery->only(['id', 'name', 'slug', 'images_path']), + 'upload_token' => $plainToken, + 'upload_url' => route('api.sparkbooth.upload'), + 'gallery_url' => route('gallery.show', $gallery), + ]; + + Notification::make() + ->title('Galerie erstellt und Upload-Token generiert.') + ->success() + ->send(); + } + + protected function getFormActions(): array + { + return [ + \Filament\Actions\Action::make('save') + ->label('Setup erstellen') + ->submit('save'), + ]; + } +} diff --git a/app/Filament/Resources/Galleries/GalleryResource.php b/app/Filament/Resources/Galleries/GalleryResource.php new file mode 100644 index 0000000..94fc310 --- /dev/null +++ b/app/Filament/Resources/Galleries/GalleryResource.php @@ -0,0 +1,86 @@ + ListGalleries::route('/'), + 'create' => CreateGallery::route('/create'), + 'edit' => EditGallery::route('/{record}/edit'), + ]; + } + + public static function mutateFormDataBeforeCreate(array $data): array + { + $data = self::mutatePassword($data); + $data['slug'] = $data['slug'] ?: Str::uuid()->toString(); + + return $data; + } + + public static function mutateFormDataBeforeSave(array $data): array + { + $data = self::mutatePassword($data); + $data['slug'] = $data['slug'] ?: Str::uuid()->toString(); + + return $data; + } + + private static function mutatePassword(array $data): array + { + $password = $data['password'] ?? null; + unset($data['password']); + + if (! empty($password)) { + $data['password_hash'] = Hash::make($password); + } + + return $data; + } +} diff --git a/app/Filament/Resources/Galleries/Pages/CreateGallery.php b/app/Filament/Resources/Galleries/Pages/CreateGallery.php new file mode 100644 index 0000000..cc77ea0 --- /dev/null +++ b/app/Filament/Resources/Galleries/Pages/CreateGallery.php @@ -0,0 +1,11 @@ +components([ + Section::make('Details') + ->columns(2) + ->schema([ + TextInput::make('name') + ->label('Name') + ->required(), + TextInput::make('slug') + ->label('Slug') + ->disabled() + ->dehydrated(true) + ->helperText('Wird automatisch erzeugt'), + TextInput::make('title') + ->label('Titel') + ->required(), + TextInput::make('images_path') + ->label('Bilder-Pfad') + ->helperText('Relativer Pfad unter public/storage') + ->required(), + Toggle::make('is_public') + ->label('Öffentlich') + ->default(true), + Toggle::make('allow_ai_styles') + ->label('AI-Stile erlauben') + ->default(true), + Toggle::make('allow_print') + ->label('Drucken erlauben') + ->default(true), + Toggle::make('require_password') + ->label('Passwortschutz aktiv') + ->default(false), + TextInput::make('password') + ->label('Neues Passwort') + ->password() + ->revealable() + ->dehydrated(false) + ->helperText('Leer lassen, um das bestehende Passwort zu behalten.'), + DateTimePicker::make('expires_at') + ->label('Ablaufdatum') + ->native(false) + ->seconds(false), + TextInput::make('access_duration_minutes') + ->label('Zugriffsdauer (Minuten)') + ->numeric() + ->minValue(1) + ->nullable() + ->helperText('Optional: Zeitfenster nach dem ersten Unlock.'), + ]), + View::make('filament.components.gallery-link') + ->columnSpanFull() + ->visible(fn (?object $record) => (bool) $record?->id) + ->viewData(fn (?object $record) => [ + 'url' => $record ? URL::route('gallery.show', $record) : null, + ]), + ]); + } +} diff --git a/app/Filament/Resources/Galleries/Tables/GalleriesTable.php b/app/Filament/Resources/Galleries/Tables/GalleriesTable.php new file mode 100644 index 0000000..5547f46 --- /dev/null +++ b/app/Filament/Resources/Galleries/Tables/GalleriesTable.php @@ -0,0 +1,63 @@ +columns([ + TextColumn::make('title') + ->label('Titel') + ->sortable() + ->searchable(), + IconColumn::make('allow_ai_styles') + ->label('AI-Stile') + ->boolean(), + IconColumn::make('allow_print') + ->label('Drucken') + ->boolean(), + IconColumn::make('require_password') + ->label('Passwort') + ->boolean(), + TextColumn::make('expires_at') + ->label('Läuft ab') + ->dateTime() + ->sortable(), + TextColumn::make('created_at') + ->label('Erstellt am') + ->dateTime('d.m.Y H:i') + ->sortable(), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + Action::make('link_details') + ->label('Link-Details') + ->icon('heroicon-o-link') + ->modalHeading('Link-Details') + ->modalContent(fn ($record) => view('filament.components.gallery-link-modal', [ + 'record' => $record, + 'url' => route('gallery.show', $record), + ])) + ->modalSubmitAction(false) + ->modalCancelAction(false), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Http/Controllers/Api/ImageController.php b/app/Http/Controllers/Api/ImageController.php index e7eaec6..940e73b 100644 --- a/app/Http/Controllers/Api/ImageController.php +++ b/app/Http/Controllers/Api/ImageController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api; use App\Api\Plugins\PluginLoader; use App\Http\Controllers\Controller; use App\Models\ApiProvider; +use App\Models\Gallery; use App\Models\Image; use App\Models\Style; use App\Settings\GeneralSettings; @@ -19,35 +20,48 @@ class ImageController extends Controller public function index(Request $request) { - $publicUploadsPath = public_path('storage/uploads'); + $gallery = $this->resolveGallery($request); + + if (! $gallery) { + return response()->json(['error' => 'Gallery not found.'], 404); + } + + $imagesPath = trim($gallery->images_path, '/'); + $publicUploadsPath = public_path('storage/'.$imagesPath); // Ensure the directory exists if (! File::exists($publicUploadsPath)) { File::makeDirectory($publicUploadsPath, 0755, true); } - // Get files from the public/storage/uploads directory + // Get files from the gallery-specific directory $diskFiles = File::files($publicUploadsPath); $diskImagePaths = []; foreach ($diskFiles as $file) { // Store path relative to public/storage/ - $diskImagePaths[] = 'uploads/'.$file->getFilename(); + $diskImagePaths[] = $imagesPath.'/'.$file->getFilename(); } - $dbImagePaths = Image::pluck('path')->toArray(); + $dbImagePaths = Image::where('gallery_id', $gallery->id)->pluck('path')->toArray(); // Add images from disk that are not in the database $imagesToAdd = array_diff($diskImagePaths, $dbImagePaths); foreach ($imagesToAdd as $path) { - Image::create(['path' => $path, 'is_public' => true]); + Image::create([ + 'gallery_id' => $gallery->id, + 'path' => $path, + 'is_public' => true, + ]); } // Remove images from database that are not on disk $imagesToRemove = array_diff($dbImagePaths, $diskImagePaths); - Image::whereIn('path', $imagesToRemove)->delete(); + Image::where('gallery_id', $gallery->id) + ->whereIn('path', $imagesToRemove) + ->delete(); // Fetch images from the database after synchronization - $query = Image::orderBy('updated_at', 'desc'); + $query = Image::where('gallery_id', $gallery->id)->orderBy('updated_at', 'desc'); // If user is not authenticated, filter by is_public, but also include their temporary images if (! auth()->check()) { @@ -85,11 +99,15 @@ class ImageController extends Controller { $request->validate([ 'image' => 'required|image|mimes:jpeg,png,bmp,gif,webp|max:10240', // Max 10MB + 'gallery' => 'required|string|exists:galleries,slug', ]); + $gallery = Gallery::where('slug', $request->string('gallery'))->firstOrFail(); + $imagesPath = trim($gallery->images_path, '/'); + $file = $request->file('image'); $fileName = uniqid().'.'.$file->getClientOriginalExtension(); - $destinationPath = public_path('storage/uploads'); + $destinationPath = public_path('storage/'.$imagesPath); // Ensure the directory exists if (! File::exists($destinationPath)) { @@ -97,9 +115,10 @@ class ImageController extends Controller } $file->move($destinationPath, $fileName); - $relativePath = 'uploads/'.$fileName; // Path relative to public/storage/ + $relativePath = $imagesPath.'/'.$fileName; // Path relative to public/storage/ $image = Image::create([ + 'gallery_id' => $gallery->id, 'path' => $relativePath, 'is_public' => true, ]); @@ -136,9 +155,19 @@ class ImageController extends Controller $request->validate([ 'image_id' => 'required|exists:images,id', 'style_id' => 'nullable|exists:styles,id', + 'gallery' => 'required|string|exists:galleries,slug', ]); - $image = Image::find($request->image_id); + $gallery = $this->resolveGallery($request); + + $image = Image::query() + ->where('id', $request->integer('image_id')) + ->where('gallery_id', optional($gallery)->id) + ->first(); + + if (! $image) { + return response()->json(['error' => __('api.image_not_found')], 404); + } $style = null; if ($request->style_id) { @@ -217,9 +246,13 @@ class ImageController extends Controller { $request->validate([ 'image_id' => 'required|exists:images,id', + 'gallery' => 'required|string|exists:galleries,slug', ]); - $image = Image::find($request->image_id); + $gallery = $this->resolveGallery($request); + $image = Image::where('id', $request->integer('image_id')) + ->where('gallery_id', optional($gallery)->id) + ->first(); if (! $image) { return response()->json(['error' => __('api.image_not_found')], 404); @@ -233,6 +266,16 @@ class ImageController extends Controller public function deleteImage(Image $image) { + request()->validate([ + 'gallery' => 'required|string|exists:galleries,slug', + ]); + + $gallery = $this->resolveGallery(request()); + + if ($gallery && (int) $image->gallery_id !== (int) $gallery->id) { + return response()->json(['error' => __('api.image_not_found')], 404); + } + try { // Delete from the public/storage directory File::delete(public_path('storage/'.$image->path)); @@ -244,14 +287,46 @@ class ImageController extends Controller } } + public function deleteStyled(Request $request, Image $image) + { + $request->validate([ + 'gallery' => 'required|string|exists:galleries,slug', + ]); + + $gallery = $this->resolveGallery($request); + + if ($gallery && (int) $image->gallery_id !== (int) $gallery->id) { + return response()->json(['error' => __('api.image_not_found')], 404); + } + + if (! $image->is_temp) { + return response()->json(['error' => __('api.image_not_found')], 404); + } + + try { + File::delete(public_path('storage/'.$image->path)); + $image->delete(); + + return response()->json(['message' => __('api.image_deleted_successfully')]); + } catch (\Exception $e) { + return response()->json(['error' => $e->getMessage()], 500); + } + } + public function getStatus(Request $request) { $request->validate([ 'image_id' => 'required|exists:images,id', 'api_provider_name' => 'required|string', + 'gallery' => 'sometimes|string|exists:galleries,slug', ]); - $image = Image::find($request->image_id); + $gallery = $this->resolveGallery($request); + + $image = Image::query() + ->where('id', $request->integer('image_id')) + ->when($gallery, fn ($q) => $q->where('gallery_id', $gallery->id)) + ->first(); $apiProvider = ApiProvider::where('name', $request->api_provider_name)->first(); if (! $image || ! $apiProvider) { @@ -268,14 +343,26 @@ class ImageController extends Controller } } - public function fetchStyledImage(string $promptId) + public function fetchStyledImage(Request $request, string $promptId) { + $request->validate([ + 'gallery' => 'required|string|exists:galleries,slug', + ]); + + $gallery = $this->resolveGallery($request); + Log::info('fetchStyledImage called.', ['prompt_id' => $promptId]); try { // Find the image associated with the prompt_id, eagerly loading relationships - $image = Image::with(['style.aiModel' => function ($query) { + $imageQuery = Image::with(['style.aiModel' => function ($query) { $query->with('primaryApiProvider'); - }])->where('comfyui_prompt_id', $promptId)->first(); + }])->where('comfyui_prompt_id', $promptId); + + if ($gallery) { + $imageQuery->where('gallery_id', $gallery->id); + } + + $image = $imageQuery->first(); if (! $image) { Log::warning('fetchStyledImage: Image not found for prompt_id.', ['prompt_id' => $promptId]); @@ -325,18 +412,20 @@ class ImageController extends Controller $decodedImage = base64_decode(preg_replace('#^data:image/\w+;base64, #i', '', $base64Image)); $newImageName = 'styled_'.uniqid().'.png'; - $newImagePathRelative = 'uploads/'.$newImageName; + $galleryPath = trim($image->gallery?->images_path ?: 'uploads', '/'); + $newImagePathRelative = $galleryPath.'/'.$newImageName; $newImageFullPath = public_path('storage/'.$newImagePathRelative); - if (! File::exists(public_path('storage/uploads'))) { - File::makeDirectory(public_path('storage/uploads'), 0755, true); - Log::info('Created uploads directory.', ['path' => public_path('storage/uploads')]); + if (! File::exists(public_path('storage/'.$galleryPath))) { + File::makeDirectory(public_path('storage/'.$galleryPath), 0755, true); + Log::info('Created uploads directory.', ['path' => public_path('storage/'.$galleryPath)]); } File::put($newImageFullPath, $decodedImage); // Save using File facade Log::info('Image saved to disk.', ['path' => $newImageFullPath]); $newImage = Image::create([ + 'gallery_id' => $image->gallery_id, 'path' => $newImagePathRelative, // Store relative path 'original_image_id' => $image->id, 'style_id' => $style->id, @@ -416,4 +505,25 @@ class ImageController extends Controller return response()->json(['comfyui_url' => rtrim($apiProvider->api_url, '/')]); } + + private function resolveGallery(Request $request): ?Gallery + { + $routeGallery = $request->route('gallery'); + + if ($routeGallery instanceof Gallery) { + return $routeGallery; + } + + $slug = $routeGallery; + + if (! $slug) { + $slug = $request->query('gallery'); + } + + if (! $slug) { + return Gallery::first(); + } + + return Gallery::where('slug', $slug)->first(); + } } diff --git a/app/Http/Controllers/Api/SparkboothUploadController.php b/app/Http/Controllers/Api/SparkboothUploadController.php new file mode 100644 index 0000000..cb43d2c --- /dev/null +++ b/app/Http/Controllers/Api/SparkboothUploadController.php @@ -0,0 +1,87 @@ +validate([ + 'token' => ['required', 'string'], + 'file' => ['required', 'file', 'mimes:jpeg,png,gif,bmp,webp', 'max:10240'], + 'filename' => ['nullable', 'string'], + ]); + + $gallery = $this->resolveGalleryByToken($request->string('token')); + + if (! $gallery) { + return response()->json(['error' => 'Invalid token.'], Response::HTTP_FORBIDDEN); + } + + if (! $gallery->upload_enabled) { + return response()->json(['error' => 'Uploads are disabled for this gallery.'], Response::HTTP_FORBIDDEN); + } + + if ($gallery->upload_token_expires_at && now()->greaterThanOrEqualTo($gallery->upload_token_expires_at)) { + return response()->json(['error' => 'Upload token expired.'], Response::HTTP_FORBIDDEN); + } + + $file = $request->file('file'); + $safeName = $this->buildFilename($file->getClientOriginalExtension(), $request->input('filename')); + $relativePath = trim($gallery->images_path, '/').'/'.$safeName; + $destinationPath = public_path('storage/'.dirname($relativePath)); + + if (! File::exists($destinationPath)) { + File::makeDirectory($destinationPath, 0755, true); + } + + $file->move($destinationPath, basename($relativePath)); + + $image = 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), + ]); + } + + 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 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); + + return $base.'.'.$extension; + } +} diff --git a/app/Http/Controllers/DownloadController.php b/app/Http/Controllers/DownloadController.php index d2a1c93..7a5aab9 100644 --- a/app/Http/Controllers/DownloadController.php +++ b/app/Http/Controllers/DownloadController.php @@ -14,11 +14,16 @@ class DownloadController extends Controller { $request->validate([ 'image_path' => 'required|string', + 'gallery' => 'required|string|exists:galleries,slug', ]); + $gallery = \App\Models\Gallery::where('slug', $request->string('gallery'))->firstOrFail(); + $resolvedPath = $this->resolveImagePath($request->input('image_path')); - if (! $resolvedPath || ! File::exists($resolvedPath)) { + $expectedFragment = DIRECTORY_SEPARATOR.trim($gallery->images_path, '/').DIRECTORY_SEPARATOR; + + if (! $resolvedPath || ! File::exists($resolvedPath) || ! str_contains($resolvedPath, $expectedFragment)) { Log::error("DownloadController: Image file not found at {$resolvedPath}"); return response()->json(['error' => 'Image file not found.'], 404); diff --git a/app/Http/Controllers/GalleryAccessController.php b/app/Http/Controllers/GalleryAccessController.php new file mode 100644 index 0000000..0d9a076 --- /dev/null +++ b/app/Http/Controllers/GalleryAccessController.php @@ -0,0 +1,110 @@ +resolveGallery($request, $gallery); + + if (! $gallery) { + abort(404, 'Gallery not found'); + } + + $expiresAt = $gallery->expires_at ?? $settings->gallery_expires_at; + + $expired = $expiresAt !== null + && Carbon::now()->greaterThanOrEqualTo(Carbon::parse($expiresAt)); + + return Inertia::render('GalleryAccess', [ + 'gallery' => [ + 'id' => $gallery->id, + 'slug' => $gallery->slug, + 'title' => $gallery->title, + ], + 'requiresPassword' => (bool) ($gallery->require_password ?? $settings->require_gallery_password), + 'expiresAt' => $expiresAt, + 'accessDurationMinutes' => $gallery->access_duration_minutes ?? $settings->gallery_access_duration_minutes, + 'expired' => $expired, + 'flashMessage' => $request->session()->get('gallery_access_message'), + ]); + } + + public function store(GalleryAccessRequest $request, GeneralSettings $settings, ?Gallery $gallery = null): RedirectResponse + { + $gallery = $this->resolveGallery($request, $gallery); + + if (! $gallery) { + abort(404, 'Gallery not found'); + } + + if ($this->isExpired($gallery, $settings)) { + return redirect() + ->route('gallery.access.show', $gallery) + ->with('gallery_access_message', __('api.gallery.expired')); + } + + $requiresPassword = $gallery->require_password ?? $settings->require_gallery_password; + $passwordHash = $gallery->password_hash ?? $settings->gallery_password_hash; + + if (! $requiresPassword || ! $passwordHash) { + EnsureGalleryAccess::grantForGallery($request, $gallery, $settings); + + return redirect()->route('gallery.show', $gallery); + } + + if (! Hash::check($request->input('password'), $passwordHash)) { + return redirect() + ->route('gallery.access.show', $gallery) + ->with('gallery_access_message', __('api.gallery.invalid_password')); + } + + EnsureGalleryAccess::grantForGallery($request, $gallery, $settings); + + return redirect()->route('gallery.show', $gallery); + } + + private function resolveGallery(Request $request, ?Gallery $gallery = null): ?Gallery + { + if ($gallery instanceof Gallery) { + return $gallery; + } + + $slug = $request->route('gallery'); + + if (! $slug) { + return Gallery::first(); + } + + if ($slug instanceof Gallery) { + return $slug; + } + + return Gallery::where('slug', $slug)->first(); + } + + private function isExpired(?Gallery $gallery, GeneralSettings $settings): bool + { + $expiresAt = $gallery?->expires_at ?? $settings->gallery_expires_at; + + if ($expiresAt) { + $expiresAt = Carbon::parse($expiresAt); + + return Carbon::now()->greaterThanOrEqualTo($expiresAt); + } + + return false; + } +} diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 64088d9..da5dc0b 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -2,29 +2,48 @@ namespace App\Http\Controllers; +use App\Models\Gallery; use App\Models\Image; use App\Settings\GeneralSettings; use Carbon\Carbon; +use Illuminate\Http\Request; use Inertia\Inertia; class HomeController extends Controller { public function __construct(private GeneralSettings $settings) {} - public function index() + public function index(Request $request, ?Gallery $gallery = null) { - $galleryHeading = $this->settings->gallery_heading; + $gallery = $gallery ?: Gallery::first(); + + if (! $gallery) { + abort(404, 'Gallery not found'); + } + + $galleryHeading = $gallery->title ?: $this->settings->gallery_heading; $newImageTimespanMinutes = $this->settings->new_image_timespan_minutes; - $images = Image::all()->map(function ($image) use ($newImageTimespanMinutes) { - $image->is_new = Carbon::parse($image->created_at)->diffInMinutes(Carbon::now()) <= $newImageTimespanMinutes; - $image->path = 'storage/'.$image->path; + $images = Image::where('gallery_id', $gallery->id) + ->orderByDesc('updated_at') + ->get() + ->map(function ($image) use ($newImageTimespanMinutes) { + $image->is_new = Carbon::parse($image->created_at)->diffInMinutes(Carbon::now()) <= $newImageTimespanMinutes; + $image->path = asset('storage/'.$image->path); - return $image; - }); + return $image; + }); return Inertia::render('Home', [ 'galleryHeading' => $galleryHeading, + 'gallery' => [ + 'id' => $gallery->id, + 'slug' => $gallery->slug, + 'title' => $galleryHeading, + 'allow_ai_styles' => $gallery->allow_ai_styles, + 'allow_print' => $gallery->allow_print, + 'images_path' => $gallery->images_path, + ], 'images' => $images, ]); } diff --git a/app/Http/Controllers/PrintController.php b/app/Http/Controllers/PrintController.php index 76a28e9..02bf53d 100644 --- a/app/Http/Controllers/PrintController.php +++ b/app/Http/Controllers/PrintController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Models\Gallery; use App\Services\PrinterService; use App\Settings\GeneralSettings; use Illuminate\Http\Request; @@ -16,9 +17,25 @@ class PrintController extends Controller $request->validate([ 'image_path' => 'required|string', 'quantity' => 'required|integer|min:1', + 'gallery' => 'required|string|exists:galleries,slug', ]); + $gallery = Gallery::where('slug', $request->string('gallery'))->firstOrFail(); + + if ($gallery && ! $gallery->allow_print) { + return response()->json(['error' => 'Printing is disabled for this gallery.'], 403); + } + $imagePath = public_path(str_replace(url('/'), '', $request->input('image_path'))); + + if (! str_contains($imagePath, DIRECTORY_SEPARATOR.trim($gallery->images_path, '/').DIRECTORY_SEPARATOR)) { + Log::warning('PrintController: Image path does not belong to gallery.', [ + 'gallery' => $gallery->slug, + 'image_path' => $imagePath, + ]); + + return response()->json(['error' => 'Image file not found.'], 404); + } $quantity = $request->input('quantity'); $printerName = $this->settings->selected_printer === '__custom__' diff --git a/app/Http/Middleware/EnsureGalleryAccess.php b/app/Http/Middleware/EnsureGalleryAccess.php new file mode 100644 index 0000000..f4aea2a --- /dev/null +++ b/app/Http/Middleware/EnsureGalleryAccess.php @@ -0,0 +1,288 @@ +resolveGallery($request); + + if ($this->requestedGalleryMissing($request, $gallery)) { + return $this->missingGalleryResponse($request); + } + + if ($this->isGalleryExpired($gallery, $settings)) { + return $this->deny($request, __('api.gallery.expired'), $gallery); + } + + $hasValidAccess = $this->hasValidAccess($request, $gallery, $settings); + $hasExistingGrant = (bool) ($this->sessionGrantedAt($request, $gallery) ?? $this->cookieGrantedAt($request, $gallery)); + + if ($hasValidAccess) { + return $next($request); + } + + if ($this->accessDuration($gallery, $settings) && $hasExistingGrant) { + return $this->deny($request, __('api.gallery.expired'), $gallery); + } + + if (! $this->requiresPassword($gallery, $settings) || ! $this->passwordHash($gallery, $settings)) { + if ($this->accessDuration($gallery, $settings) && ! $hasExistingGrant) { + self::grantForGallery($request, $gallery, $settings); + } + + return $next($request); + } + + return $this->deny($request, __('api.gallery.password_required'), $gallery); + } + + public static function grantForGallery(Request $request, ?Gallery $gallery, GeneralSettings $settings, ?Carbon $grantedAt = null): void + { + $grantedAt = $grantedAt ?: Carbon::now(); + $suffix = self::keySuffix($gallery); + + if ($request->hasSession()) { + $request->session()->put(self::SESSION_GRANTED_BASE.'_'.$suffix, true); + $request->session()->put(self::SESSION_GRANTED_AT_BASE.'_'.$suffix, $grantedAt->toIso8601String()); + } + + $cookieMinutes = self::calculateCookieLifetime($gallery, $settings, $grantedAt); + + cookie()->queue( + cookie( + self::COOKIE_GRANTED_AT_BASE.'_'.$suffix, + $grantedAt->toIso8601String(), + $cookieMinutes, + path: '/', + secure: config('session.secure', false), + httpOnly: true, + raw: false, + sameSite: config('session.same_site', 'lax') + ) + ); + } + + private function hasValidAccess(Request $request, ?Gallery $gallery, GeneralSettings $settings): bool + { + if ($this->sessionAccessValid($request, $gallery, $settings)) { + return true; + } + + $grantedAt = $this->cookieGrantedAt($request, $gallery); + + if (! $grantedAt) { + return false; + } + + $duration = $this->accessDuration($gallery, $settings); + + if ($duration) { + return ! $grantedAt->copy()->addMinutes($duration)->isPast(); + } + + return (bool) $grantedAt; + } + + private function sessionAccessValid(Request $request, ?Gallery $gallery, GeneralSettings $settings): bool + { + if (! $request->hasSession()) { + return false; + } + + $suffix = self::keySuffix($gallery); + + if (! $request->session()->get(self::SESSION_GRANTED_BASE.'_'.$suffix, false)) { + return false; + } + + $grantedAt = $this->sessionGrantedAt($request, $gallery); + + if (! $grantedAt) { + return true; + } + + $duration = $this->accessDuration($gallery, $settings); + + if ($duration) { + $expiresAt = $grantedAt->copy()->addMinutes($duration); + + if ($expiresAt->isPast()) { + $request->session()->forget([ + self::SESSION_GRANTED_BASE.'_'.$suffix, + self::SESSION_GRANTED_AT_BASE.'_'.$suffix, + ]); + + return false; + } + } + + return true; + } + + private function sessionGrantedAt(Request $request, ?Gallery $gallery): ?Carbon + { + if (! $request->hasSession()) { + return null; + } + + $value = $request->session()->get(self::SESSION_GRANTED_AT_BASE.'_'.self::keySuffix($gallery)); + + if (! $value) { + return null; + } + + try { + return Carbon::parse($value); + } catch (\Exception) { + return null; + } + } + + private function cookieGrantedAt(Request $request, ?Gallery $gallery): ?Carbon + { + $value = $request->cookie(self::COOKIE_GRANTED_AT_BASE.'_'.self::keySuffix($gallery)); + + if (! $value) { + return null; + } + + try { + $value = Crypt::decryptString($value); + } catch (\Exception) { + // Cookie might already be decrypted by web middleware. + } + + try { + return Carbon::parse($value); + } catch (\Exception) { + return null; + } + } + + private function isGalleryExpired(?Gallery $gallery, GeneralSettings $settings): bool + { + $expiresAt = $this->expiresAt($gallery, $settings); + + return $expiresAt !== null && Carbon::now()->greaterThanOrEqualTo($expiresAt); + } + + private function deny(Request $request, string $message, ?Gallery $gallery = null): Response + { + if ($request->expectsJson()) { + return response()->json(['message' => $message], Response::HTTP_FORBIDDEN); + } + + $routeName = $gallery ? 'gallery.access.show' : 'gallery.access.default'; + + return redirect() + ->route($routeName, $gallery) + ->with('gallery_access_message', $message); + } + + private static function calculateCookieLifetime(?Gallery $gallery, GeneralSettings $settings, Carbon $grantedAt): int + { + $expiresAt = $gallery?->expires_at ?? $settings->gallery_expires_at; + + if ($expiresAt) { + $expiresAt = Carbon::parse($expiresAt); + + return max(1, $grantedAt->diffInMinutes($expiresAt)); + } + + $duration = $gallery?->access_duration_minutes ?? $settings->gallery_access_duration_minutes; + + if ($duration) { + return max(1, $duration); + } + + return 24 * 60; + } + + private function resolveGallery(Request $request): ?Gallery + { + $routeGallery = $request->route('gallery'); + + if ($routeGallery instanceof Gallery) { + return $routeGallery; + } + + $slug = is_string($routeGallery) ? $routeGallery : $request->query('gallery'); + + if (! $slug) { + return Gallery::first(); + } + + return Gallery::where('slug', $slug)->first(); + } + + private function requestedGalleryMissing(Request $request, ?Gallery $gallery): bool + { + if ($gallery) { + return false; + } + + return (bool) ($request->route('gallery') || $request->query('gallery')); + } + + private function missingGalleryResponse(Request $request): Response + { + if ($request->expectsJson()) { + return response()->json(['message' => 'Gallery not found.'], Response::HTTP_NOT_FOUND); + } + + abort(404, 'Gallery not found'); + } + + private static function keySuffix(?Gallery $gallery): string + { + return $gallery ? 'gallery_'.$gallery->id : 'global'; + } + + private function expiresAt(?Gallery $gallery, GeneralSettings $settings): ?Carbon + { + $value = $gallery?->expires_at ?? $settings->gallery_expires_at; + + if (! $value) { + return null; + } + + return Carbon::parse($value); + } + + private function accessDuration(?Gallery $gallery, GeneralSettings $settings): ?int + { + return $gallery?->access_duration_minutes ?? $settings->gallery_access_duration_minutes; + } + + private function requiresPassword(?Gallery $gallery, GeneralSettings $settings): bool + { + return (bool) ($gallery?->require_password ?? $settings->require_gallery_password); + } + + private function passwordHash(?Gallery $gallery, GeneralSettings $settings): ?string + { + return $gallery?->password_hash ?? $settings->gallery_password_hash; + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index c2224de..f82dc79 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -36,7 +36,9 @@ class HandleInertiaRequests extends Middleware 'user' => $request->user(), ], 'locale' => app()->getLocale(), - 'settings' => app(GeneralSettings::class)->toArray(), + 'settings' => tap(app(GeneralSettings::class)->toArray(), function (&$settings) { + unset($settings['gallery_password_hash']); + }), 'translations' => function () use ($request) { $currentLocale = app()->getLocale(); // Store current locale $requestedLocale = $request->input('locale', $currentLocale); diff --git a/app/Http/Requests/GalleryAccessRequest.php b/app/Http/Requests/GalleryAccessRequest.php new file mode 100644 index 0000000..f014e09 --- /dev/null +++ b/app/Http/Requests/GalleryAccessRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'password' => ['required', 'string', 'min:4'], + ]; + } +} diff --git a/app/Models/Gallery.php b/app/Models/Gallery.php new file mode 100644 index 0000000..a8967e4 --- /dev/null +++ b/app/Models/Gallery.php @@ -0,0 +1,63 @@ + */ + use HasFactory; + + protected $fillable = [ + 'name', + 'slug', + 'title', + 'images_path', + 'is_public', + 'allow_ai_styles', + 'allow_print', + 'require_password', + 'password_hash', + 'expires_at', + 'access_duration_minutes', + 'upload_enabled', + 'upload_token_hash', + 'upload_token_expires_at', + ]; + + protected function casts(): array + { + return [ + 'is_public' => 'bool', + 'allow_ai_styles' => 'bool', + 'allow_print' => 'bool', + 'require_password' => 'bool', + 'expires_at' => 'datetime', + 'access_duration_minutes' => 'int', + 'upload_enabled' => 'bool', + 'upload_token_expires_at' => 'datetime', + ]; + } + + public function images(): HasMany + { + return $this->hasMany(Image::class); + } + + public function setUploadToken(string $token): void + { + $this->upload_token_hash = \Illuminate\Support\Facades\Hash::make($token); + } + + public function regenerateUploadToken(): string + { + $token = \Illuminate\Support\Str::random(40); + $this->setUploadToken($token); + $this->save(); + + return $token; + } +} diff --git a/app/Models/Image.php b/app/Models/Image.php index 2b53d12..450b071 100644 --- a/app/Models/Image.php +++ b/app/Models/Image.php @@ -4,13 +4,14 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Concerns\HasUuids; +use Illuminate\Database\Eloquent\Relations\BelongsTo; class Image extends Model { use HasFactory; protected $fillable = [ + 'gallery_id', 'path', 'uuid', 'original_image_id', @@ -20,8 +21,21 @@ class Image extends Model 'comfyui_prompt_id', ]; + protected function casts(): array + { + return [ + 'is_temp' => 'bool', + 'is_public' => 'bool', + ]; + } + public function style() { return $this->belongsTo(Style::class); } + + public function gallery(): BelongsTo + { + return $this->belongsTo(Gallery::class); + } } diff --git a/app/Settings/GeneralSettings.php b/app/Settings/GeneralSettings.php index ced0bfc..9d3efd2 100644 --- a/app/Settings/GeneralSettings.php +++ b/app/Settings/GeneralSettings.php @@ -2,12 +2,21 @@ namespace App\Settings; +use DateTimeInterface; use Spatie\LaravelSettings\Settings; class GeneralSettings extends Settings { public string $gallery_heading = 'Style Gallery'; + public bool $require_gallery_password = false; + + public ?string $gallery_password_hash = null; + + public ?DateTimeInterface $gallery_expires_at = null; + + public ?int $gallery_access_duration_minutes = null; + public int $new_image_timespan_minutes = 60; public int $image_refresh_interval = 30_000; diff --git a/bootstrap/app.php b/bootstrap/app.php index 656e32d..7ea0804 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -17,6 +17,12 @@ return Application::configure(basePath: dirname(__DIR__)) \Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class, ]); + $middleware->api(prepend: [ + \App\Http\Middleware\EncryptCookies::class, + \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, + \Illuminate\Session\Middleware\StartSession::class, + ]); + $middleware->alias([ 'auth' => \App\Http\Middleware\Authenticate::class, 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, @@ -28,8 +34,9 @@ return Application::configure(basePath: dirname(__DIR__)) 'signed' => \App\Http\Middleware\ValidateSignature::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, + 'gallery.access' => \App\Http\Middleware\EnsureGalleryAccess::class, ]); }) ->withExceptions(function (Exceptions $exceptions) { // - })->create(); \ No newline at end of file + })->create(); diff --git a/composer.json b/composer.json index a5248b8..175c232 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ "laravel/sanctum": "^4.0", "laravel/tinker": "^2.8", "predis/predis": "^3.1", + "simplesoftwareio/simple-qrcode": "*", "spatie/laravel-settings": "*", "spatie/simple-excel": "^3.8", "tightenco/ziggy": "^2.0" diff --git a/composer.lock b/composer.lock index b564f9b..c773eb0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1a407b8d311c235ec776b18ee56e1223", + "content-hash": "3edef01c74be9931db5658ff80564372", "packages": [ { "name": "amphp/amp", @@ -1197,6 +1197,60 @@ }, "time": "2025-07-30T15:45:57+00:00" }, + { + "name": "bacon/bacon-qr-code", + "version": "2.0.8", + "source": { + "type": "git", + "url": "https://github.com/Bacon/BaconQrCode.git", + "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22", + "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22", + "shasum": "" + }, + "require": { + "dasprid/enum": "^1.0.3", + "ext-iconv": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phly/keep-a-changelog": "^2.1", + "phpunit/phpunit": "^7 | ^8 | ^9", + "spatie/phpunit-snapshot-assertions": "^4.2.9", + "squizlabs/php_codesniffer": "^3.4" + }, + "suggest": { + "ext-imagick": "to generate QR code images" + }, + "type": "library", + "autoload": { + "psr-4": { + "BaconQrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "BaconQrCode is a QR code generator for PHP.", + "homepage": "https://github.com/Bacon/BaconQrCode", + "support": { + "issues": "https://github.com/Bacon/BaconQrCode/issues", + "source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8" + }, + "time": "2022-12-07T17:46:57+00:00" + }, { "name": "blade-ui-kit/blade-heroicons", "version": "2.6.0", @@ -1740,6 +1794,56 @@ ], "time": "2025-02-21T08:52:11+00:00" }, + { + "name": "dasprid/enum", + "version": "1.0.7", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "shasum": "" + }, + "require": { + "php": ">=7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" + }, + "time": "2025-09-16T12:23:56+00:00" + }, { "name": "daverandom/libdns", "version": "v2.1.0", @@ -6896,6 +7000,74 @@ ], "time": "2022-12-17T21:53:22+00:00" }, + { + "name": "simplesoftwareio/simple-qrcode", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/SimpleSoftwareIO/simple-qrcode.git", + "reference": "916db7948ca6772d54bb617259c768c9cdc8d537" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SimpleSoftwareIO/simple-qrcode/zipball/916db7948ca6772d54bb617259c768c9cdc8d537", + "reference": "916db7948ca6772d54bb617259c768c9cdc8d537", + "shasum": "" + }, + "require": { + "bacon/bacon-qr-code": "^2.0", + "ext-gd": "*", + "php": ">=7.2|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1", + "phpunit/phpunit": "~9" + }, + "suggest": { + "ext-imagick": "Allows the generation of PNG QrCodes.", + "illuminate/support": "Allows for use within Laravel." + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "QrCode": "SimpleSoftwareIO\\QrCode\\Facades\\QrCode" + }, + "providers": [ + "SimpleSoftwareIO\\QrCode\\QrCodeServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "SimpleSoftwareIO\\QrCode\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Simple Software LLC", + "email": "support@simplesoftware.io" + } + ], + "description": "Simple QrCode is a QR code generator made for Laravel.", + "homepage": "https://www.simplesoftware.io/#/docs/simple-qrcode", + "keywords": [ + "Simple", + "generator", + "laravel", + "qrcode", + "wrapper" + ], + "support": { + "issues": "https://github.com/SimpleSoftwareIO/simple-qrcode/issues", + "source": "https://github.com/SimpleSoftwareIO/simple-qrcode/tree/4.2.0" + }, + "time": "2021-02-08T20:43:55+00:00" + }, { "name": "spatie/invade", "version": "2.1.0", diff --git a/database/factories/GalleryFactory.php b/database/factories/GalleryFactory.php new file mode 100644 index 0000000..e0bbaba --- /dev/null +++ b/database/factories/GalleryFactory.php @@ -0,0 +1,40 @@ + + */ +class GalleryFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $name = $this->faker->words(2, true); + $slug = Str::uuid()->toString(); + + return [ + 'name' => $name, + 'slug' => $slug, + 'title' => $name, + 'images_path' => 'uploads/'.$slug, + 'is_public' => true, + 'allow_ai_styles' => true, + 'allow_print' => true, + 'require_password' => false, + 'password_hash' => null, + 'expires_at' => null, + 'access_duration_minutes' => null, + 'upload_enabled' => false, + 'upload_token_hash' => null, + 'upload_token_expires_at' => null, + ]; + } +} diff --git a/database/migrations/2025_11_13_145542_upgrade_settings_table_for_spatie.php b/database/migrations/2025_11_13_145542_upgrade_settings_table_for_spatie.php index 03cff20..a741fdb 100644 --- a/database/migrations/2025_11_13_145542_upgrade_settings_table_for_spatie.php +++ b/database/migrations/2025_11_13_145542_upgrade_settings_table_for_spatie.php @@ -110,6 +110,10 @@ return new class extends Migration { $defaults = [ 'gallery_heading' => 'Style Gallery', + 'require_gallery_password' => false, + 'gallery_password_hash' => null, + 'gallery_expires_at' => null, + 'gallery_access_duration_minutes' => null, 'new_image_timespan_minutes' => 60, 'image_refresh_interval' => 30_000, 'max_number_of_copies' => 3, @@ -124,9 +128,10 @@ return new class extends Migration 'image_refresh_interval', 'max_number_of_copies', 'default_style_id', + 'gallery_access_duration_minutes', ]; - $boolKeys = ['show_print_button']; + $boolKeys = ['show_print_button', 'require_gallery_password']; $now = now(); diff --git a/database/migrations/2025_12_03_175747_create_galleries_table.php b/database/migrations/2025_12_03_175747_create_galleries_table.php new file mode 100644 index 0000000..1c1efee --- /dev/null +++ b/database/migrations/2025_12_03_175747_create_galleries_table.php @@ -0,0 +1,69 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('title'); + $table->string('images_path')->default('uploads'); + $table->boolean('is_public')->default(true); + $table->boolean('allow_ai_styles')->default(true); + $table->boolean('allow_print')->default(true); + $table->boolean('require_password')->default(false); + $table->string('password_hash')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->unsignedInteger('access_duration_minutes')->nullable(); + $table->timestamps(); + }); + + $defaultGalleryId = DB::table('galleries')->insertGetId([ + 'name' => 'Default Gallery', + 'slug' => Str::uuid()->toString(), + 'title' => 'Style Gallery', + 'images_path' => 'uploads', + 'is_public' => true, + 'allow_ai_styles' => true, + 'allow_print' => true, + 'require_password' => false, + 'password_hash' => null, + 'expires_at' => null, + 'access_duration_minutes' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + Schema::table('images', function (Blueprint $table) use ($defaultGalleryId): void { + $table->foreignId('gallery_id') + ->after('id') + ->default($defaultGalleryId) + ->constrained(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('images', function (Blueprint $table): void { + if (Schema::hasColumn('images', 'gallery_id')) { + $table->dropConstrainedForeignId('gallery_id'); + } + }); + + Schema::dropIfExists('galleries'); + } +}; diff --git a/database/migrations/2026_01_21_000001_add_upload_fields_to_galleries_table.php b/database/migrations/2026_01_21_000001_add_upload_fields_to_galleries_table.php new file mode 100644 index 0000000..87802ec --- /dev/null +++ b/database/migrations/2026_01_21_000001_add_upload_fields_to_galleries_table.php @@ -0,0 +1,34 @@ +boolean('upload_enabled')->default(false)->after('access_duration_minutes'); + $table->string('upload_token_hash')->nullable()->after('upload_enabled'); + $table->timestamp('upload_token_expires_at')->nullable()->after('upload_token_hash'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('galleries', function (Blueprint $table): void { + $table->dropColumn([ + 'upload_enabled', + 'upload_token_hash', + 'upload_token_expires_at', + ]); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index b1e44a2..1d3c559 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -20,6 +20,7 @@ class DatabaseSeeder extends Seeder AiModelSeeder::class, AiModelApiProviderSeeder::class, SettingSeeder::class, + GallerySeeder::class, StyleSeeder::class, ]); } diff --git a/database/seeders/GallerySeeder.php b/database/seeders/GallerySeeder.php new file mode 100644 index 0000000..10ea0c1 --- /dev/null +++ b/database/seeders/GallerySeeder.php @@ -0,0 +1,39 @@ + Str::uuid()->toString()], + [ + 'name' => 'Default Gallery', + 'title' => $settings->gallery_heading ?? 'Style Gallery', + 'images_path' => 'uploads', + 'is_public' => true, + 'allow_ai_styles' => true, + 'allow_print' => true, + 'require_password' => (bool) $settings->require_gallery_password, + 'password_hash' => $settings->gallery_password_hash, + 'expires_at' => $settings->gallery_expires_at, + 'access_duration_minutes' => $settings->gallery_access_duration_minutes, + 'upload_enabled' => false, + 'upload_token_hash' => null, + 'upload_token_expires_at' => null, + ] + ); + } +} diff --git a/database/seeders/SettingSeeder.php b/database/seeders/SettingSeeder.php index 71a934f..efaa91a 100644 --- a/database/seeders/SettingSeeder.php +++ b/database/seeders/SettingSeeder.php @@ -21,6 +21,10 @@ class SettingSeeder extends Seeder 'image_refresh_interval' => 30_000, 'max_number_of_copies' => 3, 'show_print_button' => true, + 'require_gallery_password' => false, + 'gallery_password_hash' => null, + 'gallery_expires_at' => null, + 'gallery_access_duration_minutes' => null, 'selected_printer' => null, 'custom_printer_address' => null, 'default_style_id' => null, diff --git a/resources/js/Components/ImageContextMenu.vue b/resources/js/Components/ImageContextMenu.vue index ea74553..fa806da 100644 --- a/resources/js/Components/ImageContextMenu.vue +++ b/resources/js/Components/ImageContextMenu.vue @@ -47,17 +47,17 @@
@@ -71,6 +71,10 @@