diff --git a/.env.example b/.env.example index b44d2eec0..bbfd24b95 100644 --- a/.env.example +++ b/.env.example @@ -34,7 +34,10 @@ SESSION_PATH=/ SESSION_DOMAIN=null BROADCAST_CONNECTION=log -FILESYSTEM_DISK=local +FILESYSTEM_DISK=s3 +MEDIA_DISK=s3 +LOCAL_MEDIA_DISK=public +CLOUD_MEDIA_DISK=s3 QUEUE_CONNECTION=database CACHE_STORE=database @@ -53,9 +56,21 @@ MAIL_PASSWORD=null MAIL_FROM_ADDRESS="hello@openclassify.com" MAIL_FROM_NAME="${APP_NAME}" +AWS_ACCESS_KEY_ID=2OR5YLAPW0UN0O45A20L +AWS_SECRET_ACCESS_KEY=bceTprhHHl3GZFMq9ii80hjvN6JRppQzA4JsFCCr +AWS_DEFAULT_REGION=hel1 +AWS_BUCKET= +AWS_URL= +AWS_ENDPOINT=https://hel1.your-objectstorage.com +AWS_USE_PATH_STYLE_ENDPOINT=false + VITE_APP_NAME="${APP_NAME}" OPENAI_API_KEY= GEMINI_API_KEY= QUICK_LISTING_AI_PROVIDER=openai QUICK_LISTING_AI_MODEL=gpt-5.2 + + +DEMO=0 +DEMO_TTL_MINUTES=360 \ No newline at end of file diff --git a/Modules/Admin/Filament/Pages/ManageGeneralSettings.php b/Modules/Admin/Filament/Pages/ManageGeneralSettings.php index f478f3d37..85acc0d70 100644 --- a/Modules/Admin/Filament/Pages/ManageGeneralSettings.php +++ b/Modules/Admin/Filament/Pages/ManageGeneralSettings.php @@ -2,19 +2,23 @@ namespace Modules\Admin\Filament\Pages; -use App\Support\HomeSlideDefaults; -use App\Support\CountryCodeManager; use App\Settings\GeneralSettings; +use App\Support\CountryCodeManager; +use App\Support\HomeSlideDefaults; use BackedEnum; use Filament\Forms\Components\FileUpload; +use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Select; use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Toggle; use Filament\Pages\SettingsPage; +use Filament\Schemas\Components\Utilities\Get; +use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; use Modules\Admin\Support\HomeSlideFormSchema; +use Modules\S3\Support\MediaStorage; use Tapp\FilamentCountryCodeField\Forms\Components\CountryCodeSelect; use UnitEnum; use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput; @@ -40,8 +44,15 @@ class ManageGeneralSettings extends SettingsPage return [ 'site_name' => filled($data['site_name'] ?? null) ? $data['site_name'] : $defaults['site_name'], 'site_description' => filled($data['site_description'] ?? null) ? $data['site_description'] : $defaults['site_description'], - 'home_slides' => $this->normalizeHomeSlides($data['home_slides'] ?? $defaults['home_slides']), + 'media_disk' => MediaStorage::normalizeDriver($data['media_disk'] ?? $defaults['media_disk']), + 'home_slides' => $this->normalizeHomeSlides( + $data['home_slides'] ?? $defaults['home_slides'], + MediaStorage::storedDisk('public'), + ), 'site_logo' => $data['site_logo'] ?? null, + 'site_logo_disk' => filled($data['site_logo'] ?? null) + ? MediaStorage::storedDisk($data['site_logo_disk'] ?? 'public') + : null, 'sender_name' => filled($data['sender_name'] ?? null) ? $data['sender_name'] : $defaults['sender_name'], 'sender_email' => filled($data['sender_email'] ?? null) ? $data['sender_email'] : $defaults['sender_email'], 'default_language' => filled($data['default_language'] ?? null) ? $data['default_language'] : $defaults['default_language'], @@ -64,6 +75,21 @@ class ManageGeneralSettings extends SettingsPage ]; } + protected function mutateFormDataBeforeSave(array $data): array + { + $mediaDriver = MediaStorage::normalizeDriver($data['media_disk'] ?? null); + $mediaDisk = MediaStorage::diskFromDriver($mediaDriver); + + $data['media_disk'] = $mediaDriver; + $data['home_slides'] = $this->normalizeHomeSlides($data['home_slides'] ?? [], $mediaDisk); + $data['site_logo_disk'] = MediaStorage::managesPath($data['site_logo'] ?? null) + ? MediaStorage::storedDisk($data['site_logo_disk'] ?? null, $mediaDriver) + : null; + $data['currencies'] = $this->normalizeCurrencies($data['currencies'] ?? []); + + return $data; + } + public function form(Schema $schema): Schema { $defaults = $this->defaultFormData(); @@ -80,16 +106,32 @@ class ManageGeneralSettings extends SettingsPage ->default($defaults['site_description']) ->rows(3) ->maxLength(500), + Select::make('media_disk') + ->label('Medya Depolama') + ->options(MediaStorage::options()) + ->default($defaults['media_disk']) + ->required() + ->native(false) + ->helperText('İlan resimleri, videolar, logo ve slide görselleri için kullanılacak depolama sürücüsü.'), HomeSlideFormSchema::make( $defaults['home_slides'], - fn ($state): array => $this->normalizeHomeSlides($state), + fn ($state): array => $this->normalizeHomeSlides($state, MediaStorage::activeDisk()), ), + Hidden::make('site_logo_disk'), FileUpload::make('site_logo') ->label('Site Logosu') ->image() - ->disk('public') + ->disk(fn (Get $get): string => MediaStorage::storedDisk($get('site_logo_disk'), $get('media_disk'))) ->directory('settings') - ->visibility('public'), + ->visibility('public') + ->afterStateUpdated(function (Get $get, Set $set, mixed $state): void { + $set( + 'site_logo_disk', + MediaStorage::managesPath($state) + ? MediaStorage::diskFromDriver($get('media_disk')) + : null, + ); + }), TextInput::make('sender_name') ->label('Gönderici Adı') ->default($defaults['sender_name']) @@ -200,7 +242,9 @@ class ManageGeneralSettings extends SettingsPage return [ 'site_name' => $siteName, 'site_description' => 'Alim satim icin hizli ve guvenli ilan platformu.', + 'media_disk' => MediaStorage::defaultDriver(), 'home_slides' => $this->defaultHomeSlides(), + 'site_logo_disk' => null, 'sender_name' => $siteName, 'sender_email' => (string) config('mail.from.address', 'info@' . $siteHost), 'default_language' => in_array(config('app.locale'), array_keys($this->localeOptions()), true) ? (string) config('app.locale') : 'tr', @@ -248,8 +292,8 @@ class ManageGeneralSettings extends SettingsPage return HomeSlideDefaults::defaults(); } - private function normalizeHomeSlides(mixed $state): array + private function normalizeHomeSlides(mixed $state, ?string $defaultDisk = null): array { - return HomeSlideDefaults::normalize($state); + return HomeSlideDefaults::normalize($state, $defaultDisk); } } diff --git a/Modules/Admin/Filament/Pages/ManageHomeSlides.php b/Modules/Admin/Filament/Pages/ManageHomeSlides.php index 5290ce379..c311e6728 100644 --- a/Modules/Admin/Filament/Pages/ManageHomeSlides.php +++ b/Modules/Admin/Filament/Pages/ManageHomeSlides.php @@ -9,6 +9,7 @@ use Filament\Pages\SettingsPage; use Filament\Schemas\Schema; use Filament\Support\Enums\Width; use Modules\Admin\Support\HomeSlideFormSchema; +use Modules\S3\Support\MediaStorage; use UnitEnum; class ManageHomeSlides extends SettingsPage @@ -30,17 +31,27 @@ class ManageHomeSlides extends SettingsPage protected function mutateFormDataBeforeFill(array $data): array { return [ - 'home_slides' => $this->normalizeHomeSlides($data['home_slides'] ?? $this->defaultHomeSlides()), + 'home_slides' => $this->normalizeHomeSlides( + $data['home_slides'] ?? $this->defaultHomeSlides(), + MediaStorage::storedDisk('public'), + ), ]; } + protected function mutateFormDataBeforeSave(array $data): array + { + $data['home_slides'] = $this->normalizeHomeSlides($data['home_slides'] ?? [], MediaStorage::activeDisk()); + + return $data; + } + public function form(Schema $schema): Schema { return $schema ->components([ HomeSlideFormSchema::make( $this->defaultHomeSlides(), - fn ($state): array => $this->normalizeHomeSlides($state), + fn ($state): array => $this->normalizeHomeSlides($state, MediaStorage::activeDisk()), ), ]); } @@ -50,8 +61,8 @@ class ManageHomeSlides extends SettingsPage return HomeSlideDefaults::defaults(); } - private function normalizeHomeSlides(mixed $state): array + private function normalizeHomeSlides(mixed $state, ?string $defaultDisk = null): array { - return HomeSlideDefaults::normalize($state); + return HomeSlideDefaults::normalize($state, $defaultDisk); } } diff --git a/Modules/Admin/Providers/AdminPanelProvider.php b/Modules/Admin/Providers/AdminPanelProvider.php index 8f66bb457..d86417485 100644 --- a/Modules/Admin/Providers/AdminPanelProvider.php +++ b/Modules/Admin/Providers/AdminPanelProvider.php @@ -1,6 +1,7 @@ label('Homepage Slides') ->helperText('Use 1 to 5 slides. Upload a wide image for each slide to improve the hero area.') ->schema([ + Hidden::make('disk'), FileUpload::make('image_path') ->label('Slide Image') ->image() - ->disk('public') + ->disk(fn (Get $get): string => MediaStorage::storedDisk($get('disk'), self::mediaDriver($get))) ->directory('home-slides') ->visibility('public') ->imageEditor() ->imagePreviewHeight('200') ->helperText('Recommended: 1600x1000 or wider.') + ->afterStateUpdated(function (Get $get, Set $set, mixed $state): void { + $set( + 'disk', + MediaStorage::managesPath($state) + ? MediaStorage::diskFromDriver(self::mediaDriver($get)) + : null, + ); + }) ->columnSpanFull(), TextInput::make('badge') ->label('Badge') @@ -59,4 +72,13 @@ final class HomeSlideFormSchema ->itemLabel(fn (array $state): string => filled($state['title'] ?? null) ? (string) $state['title'] : 'New Slide') ->dehydrateStateUsing(fn ($state) => $normalizeSlides($state)); } + + private static function mediaDriver(Get $get): string + { + $driver = $get('../../media_disk'); + + return is_string($driver) && trim($driver) !== '' + ? MediaStorage::normalizeDriver($driver) + : MediaStorage::activeDriver(); + } } diff --git a/Modules/Demo/App/Console/CleanupDemoCommand.php b/Modules/Demo/App/Console/CleanupDemoCommand.php new file mode 100644 index 000000000..97286a397 --- /dev/null +++ b/Modules/Demo/App/Console/CleanupDemoCommand.php @@ -0,0 +1,29 @@ +cleanupExpired(); + $this->info("Expired demos removed: {$deletedCount}"); + + return self::SUCCESS; + } catch (Throwable $exception) { + report($exception); + $this->error($exception->getMessage()); + + return self::FAILURE; + } + } +} diff --git a/Modules/Demo/App/Console/PrepareDemoCommand.php b/Modules/Demo/App/Console/PrepareDemoCommand.php new file mode 100644 index 000000000..ef4b5f4ee --- /dev/null +++ b/Modules/Demo/App/Console/PrepareDemoCommand.php @@ -0,0 +1,33 @@ +prepare($this->argument('uuid')); + + $this->info('Demo prepared.'); + $this->line('UUID: '.$instance->uuid); + $this->line('Schema: '.$instance->schema_name); + $this->line('Expires: '.$instance->expires_at?->toDateTimeString()); + + return self::SUCCESS; + } catch (Throwable $exception) { + report($exception); + $this->error($exception->getMessage()); + + return self::FAILURE; + } + } +} diff --git a/Modules/Demo/App/Http/Controllers/DemoController.php b/Modules/Demo/App/Http/Controllers/DemoController.php new file mode 100644 index 000000000..bf6d4ac57 --- /dev/null +++ b/Modules/Demo/App/Http/Controllers/DemoController.php @@ -0,0 +1,86 @@ +sanitizeRedirectTarget($request->input('redirect_to')) + ?? route('home'); + + try { + $instance = $demoSchemaManager->prepare($request->cookie($cookieName)); + $user = $demoSchemaManager->resolveLoginUser(); + + Auth::guard('web')->login($user); + $request->session()->regenerate(); + $request->session()->put([ + 'demo_uuid' => $instance->uuid, + 'is_demo_session' => true, + 'demo_expires_at' => $instance->expires_at?->toIso8601String(), + ]); + + Cookie::queue(cookie( + $cookieName, + $instance->uuid, + (int) config('demo.ttl_minutes', 360), + )); + + return redirect()->to($redirectTo)->with('success', 'Your private demo is ready.'); + } catch (Throwable $exception) { + report($exception); + + Auth::guard('web')->logout(); + $request->session()->forget([ + 'demo_uuid', + 'is_demo_session', + ]); + Cookie::queue(Cookie::forget($cookieName)); + $demoSchemaManager->activatePublic(); + + return redirect()->to($redirectTo)->with('error', 'Demo could not be prepared right now.'); + } + } + + private function sanitizeRedirectTarget(?string $target): ?string + { + $target = trim((string) $target); + + if ($target === '' || str_starts_with($target, '//')) { + return null; + } + + if (str_starts_with($target, '/')) { + return $target; + } + + if (! filter_var($target, FILTER_VALIDATE_URL)) { + return null; + } + + $applicationUrl = parse_url(url('/')); + $targetUrl = parse_url($target); + + if (($applicationUrl['host'] ?? null) !== ($targetUrl['host'] ?? null)) { + return null; + } + + $path = $targetUrl['path'] ?? '/'; + $query = isset($targetUrl['query']) ? '?'.$targetUrl['query'] : ''; + $fragment = isset($targetUrl['fragment']) ? '#'.$targetUrl['fragment'] : ''; + + return $path.$query.$fragment; + } +} diff --git a/Modules/Demo/App/Http/Middleware/ResolveDemoRequest.php b/Modules/Demo/App/Http/Middleware/ResolveDemoRequest.php new file mode 100644 index 000000000..a0e8ce536 --- /dev/null +++ b/Modules/Demo/App/Http/Middleware/ResolveDemoRequest.php @@ -0,0 +1,77 @@ +demoSchemaManager->enabled()) { + return $next($request); + } + + $cookieName = (string) config('demo.cookie_name', 'oc2_demo'); + $demoUuid = $request->cookie($cookieName); + $instance = $this->demoSchemaManager->findActiveInstance($demoUuid); + $shouldForgetCookie = filled($demoUuid) && ! $instance; + + if (! $instance) { + $this->resetDemoSession($request); + $this->demoSchemaManager->activatePublic(); + + $response = $next($request); + + if ($shouldForgetCookie) { + Cookie::queue(Cookie::forget($cookieName)); + } + + return $response; + } + + if (! (bool) $request->session()->get('is_demo_session') && $this->hasAuthSession($request)) { + Auth::guard('web')->logout(); + } + + $this->demoSchemaManager->activateDemo($instance); + + $request->session()->put([ + 'demo_uuid' => $instance->uuid, + 'is_demo_session' => true, + 'demo_expires_at' => $instance->expires_at?->toIso8601String(), + ]); + + return $next($request); + } + + private function resetDemoSession(Request $request): void + { + if (! $request->session()->has('demo_uuid') && ! (bool) $request->session()->get('is_demo_session')) { + return; + } + + if ($this->hasAuthSession($request)) { + Auth::guard('web')->logout(); + } + + $request->session()->forget([ + 'demo_uuid', + 'is_demo_session', + 'demo_expires_at', + ]); + } + + private function hasAuthSession(Request $request): bool + { + return filled($request->session()->get(Auth::guard('web')->getName())); + } +} diff --git a/Modules/Demo/App/Models/DemoInstance.php b/Modules/Demo/App/Models/DemoInstance.php new file mode 100644 index 000000000..d5e481ee6 --- /dev/null +++ b/Modules/Demo/App/Models/DemoInstance.php @@ -0,0 +1,33 @@ + 'datetime', + 'expires_at' => 'datetime', + ]; + } + + public function scopeActiveUuid(Builder $query, string $uuid): Builder + { + return $query + ->where('uuid', $uuid) + ->where('expires_at', '>', now()); + } +} diff --git a/Modules/Demo/App/Providers/DemoServiceProvider.php b/Modules/Demo/App/Providers/DemoServiceProvider.php new file mode 100644 index 000000000..114470827 --- /dev/null +++ b/Modules/Demo/App/Providers/DemoServiceProvider.php @@ -0,0 +1,42 @@ +app->singleton(DemoSchemaManager::class); + + if ($this->app->runningInConsole()) { + $this->commands([ + PrepareDemoCommand::class, + CleanupDemoCommand::class, + ]); + } + } + + public function boot(): void + { + $this->guardConfiguration(); + $this->loadMigrationsFrom(module_path('Demo', 'database/migrations')); + $this->loadRoutesFrom(module_path('Demo', 'routes/web.php')); + } + + private function guardConfiguration(): void + { + if (! config('demo.enabled')) { + return; + } + + if (config('database.default') !== 'pgsql') { + throw new RuntimeException('Demo mode requires DB_CONNECTION=pgsql.'); + } + } +} diff --git a/Modules/Demo/App/Support/DemoSchemaManager.php b/Modules/Demo/App/Support/DemoSchemaManager.php new file mode 100644 index 000000000..85aabbcb5 --- /dev/null +++ b/Modules/Demo/App/Support/DemoSchemaManager.php @@ -0,0 +1,276 @@ +defaultConnection = (string) config('database.default', 'pgsql'); + $this->publicConnection = 'pgsql_public'; + $this->baseCachePrefix = (string) config('cache.prefix', ''); + $this->basePermissionCacheKey = (string) config('permission.cache.key', 'spatie.permission.cache'); + } + + public function enabled(): bool + { + return (bool) config('demo.enabled'); + } + + public function prepare(?string $uuid = null): DemoInstance + { + $this->ensureEnabled(); + $this->cleanupExpired(); + + $uuid = $this->normalizeUuid($uuid); + + if ($uuid) { + $instance = $this->findActiveInstance($uuid); + + if ($instance) { + return $this->reuse($instance); + } + } + + return $this->createFresh($uuid ?? (string) str()->uuid()); + } + + public function findActiveInstance(?string $uuid): ?DemoInstance + { + $uuid = $this->normalizeUuid($uuid); + + if (! $uuid) { + return null; + } + + return DemoInstance::query()->activeUuid($uuid)->first(); + } + + public function activateDemo(DemoInstance $instance): void + { + $this->activateSchema($instance->schema_name, $instance->uuid); + $this->ensureLoginUserExists(); + } + + public function activatePublic(): void + { + if (! $this->enabled()) { + return; + } + + $this->activateSchema((string) config('demo.public_schema', 'public')); + } + + public function cleanupExpired(): int + { + if (! $this->enabled()) { + return 0; + } + + $expired = DemoInstance::query() + ->where('expires_at', '<=', now()) + ->get(); + + foreach ($expired as $instance) { + $this->dropSchema($instance->schema_name); + $instance->delete(); + } + + return $expired->count(); + } + + public function resolveLoginUser(): User + { + $user = User::query() + ->where('email', (string) config('demo.login_email', 'a@a.com')) + ->first(); + + if (! $user) { + throw new \RuntimeException('The seeded demo login user could not be found.'); + } + + return $user; + } + + public function clearDemoArtifacts(?string $uuid): void + { + $uuid = $this->normalizeUuid($uuid); + + if (! $uuid) { + return; + } + + $instance = DemoInstance::query()->where('uuid', $uuid)->first(); + + if ($instance) { + $this->dropSchema($instance->schema_name); + $instance->delete(); + + return; + } + + $this->dropSchema($this->schemaNameFor($uuid)); + } + + private function reuse(DemoInstance $instance): DemoInstance + { + $instance->forceFill([ + 'expires_at' => now()->addMinutes((int) config('demo.ttl_minutes', 360)), + ])->save(); + + $this->activateDemo($instance); + + return $instance->fresh() ?? $instance; + } + + private function createFresh(string $uuid): DemoInstance + { + $schema = $this->schemaNameFor($uuid); + + try { + $this->createSchema($schema); + $this->activateSchema($schema, $uuid); + $this->runProvisioningCommands(); + $this->ensureLoginUserExists(); + + return DemoInstance::query()->updateOrCreate( + ['uuid' => $uuid], + [ + 'schema_name' => $schema, + 'prepared_at' => now(), + 'expires_at' => now()->addMinutes((int) config('demo.ttl_minutes', 360)), + ], + ); + } catch (Throwable $exception) { + $this->dropSchema($schema); + DemoInstance::query()->where('uuid', $uuid)->delete(); + $this->activatePublic(); + + throw $exception; + } + } + + private function runProvisioningCommands(): void + { + config(['demo.provisioning' => true]); + + try { + Artisan::call('migrate', [ + '--database' => $this->defaultConnection, + '--force' => true, + ]); + + Artisan::call('db:seed', [ + '--class' => \Database\Seeders\DatabaseSeeder::class, + '--database' => $this->defaultConnection, + '--force' => true, + ]); + } finally { + config(['demo.provisioning' => false]); + } + } + + private function createSchema(string $schema): void + { + DB::connection($this->publicConnection)->statement( + sprintf('CREATE SCHEMA IF NOT EXISTS %s', $this->quoteIdentifier($schema)) + ); + } + + private function dropSchema(string $schema): void + { + DB::connection($this->publicConnection)->statement( + sprintf('DROP SCHEMA IF EXISTS %s CASCADE', $this->quoteIdentifier($schema)) + ); + } + + private function ensureEnabled(): void + { + if (! $this->enabled()) { + throw new \RuntimeException('Demo mode is disabled.'); + } + } + + private function activateSchema(string $schema, ?string $uuid = null): void + { + config([ + "database.connections.{$this->defaultConnection}.search_path" => $schema, + 'cache.prefix' => $uuid + ? $this->baseCachePrefix.'demo-'.$uuid.'-' + : $this->baseCachePrefix, + 'permission.cache.key' => $uuid + ? $this->basePermissionCacheKey.'.'.$uuid + : $this->basePermissionCacheKey, + ]); + + DB::purge($this->defaultConnection); + DB::reconnect($this->defaultConnection); + + if ($this->app->resolved(GeneralSettings::class)) { + $this->app->forgetInstance(GeneralSettings::class); + } + + if ($this->app->resolved('cache') && method_exists($this->app['cache'], 'forgetDriver')) { + $this->app['cache']->forgetDriver(config('cache.default')); + } + + if ($this->app->resolved(PermissionRegistrar::class)) { + $permissionRegistrar = $this->app->make(PermissionRegistrar::class); + $permissionRegistrar->initializeCache(); + $permissionRegistrar->clearPermissionsCollection(); + } + } + + private function ensureLoginUserExists(): void + { + $this->resolveLoginUser(); + } + + private function normalizeUuid(?string $uuid): ?string + { + $uuid = trim((string) $uuid); + + if ($uuid === '' || ! preg_match('/^[a-f0-9-]{36}$/i', $uuid)) { + return null; + } + + return strtolower($uuid); + } + + private function schemaNameFor(string $uuid): string + { + $prefix = strtolower((string) config('demo.schema_prefix', 'demo_')); + $prefix = preg_replace('/[^a-z0-9_]+/', '_', $prefix) ?? 'demo_'; + $prefix = trim($prefix, '_'); + $prefix = $prefix !== '' ? $prefix.'_' : 'demo_'; + $suffix = str_replace('-', '', strtolower($uuid)); + + return substr($prefix.$suffix, 0, 63); + } + + private function quoteIdentifier(string $identifier): string + { + if (! preg_match('/^[a-z0-9_]+$/', $identifier)) { + throw new \RuntimeException('Invalid demo schema identifier.'); + } + + return '"'.$identifier.'"'; + } +} diff --git a/Modules/Demo/database/Seeders/DemoContentSeeder.php b/Modules/Demo/database/Seeders/DemoContentSeeder.php new file mode 100644 index 000000000..c59543cbe --- /dev/null +++ b/Modules/Demo/database/Seeders/DemoContentSeeder.php @@ -0,0 +1,47 @@ +updateOrCreate( + ['email' => 'a@a.com'], + [ + 'name' => 'Admin', + 'password' => Hash::make('236330'), + 'status' => 'active', + ], + ); + + $partner = User::query()->updateOrCreate( + ['email' => 'b@b.com'], + [ + 'name' => 'Partner', + 'password' => Hash::make('36330'), + 'status' => 'active', + ], + ); + + if (class_exists(Role::class)) { + $adminRole = Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']); + $partnerRole = Role::firstOrCreate(['name' => 'partner', 'guard_name' => 'web']); + + $admin->syncRoles([$adminRole->name]); + $partner->syncRoles([$partnerRole->name]); + } + + $this->call([ + \Modules\Listing\Database\Seeders\ListingSeeder::class, + \Modules\Listing\Database\Seeders\ListingPanelDemoSeeder::class, + \Modules\Favorite\Database\Seeders\FavoriteDemoSeeder::class, + \Modules\Conversation\Database\Seeders\ConversationDemoSeeder::class, + ]); + } +} diff --git a/Modules/Demo/database/migrations/2026_03_07_000000_create_demo_instances_table.php b/Modules/Demo/database/migrations/2026_03_07_000000_create_demo_instances_table.php new file mode 100644 index 000000000..bee2c384a --- /dev/null +++ b/Modules/Demo/database/migrations/2026_03_07_000000_create_demo_instances_table.php @@ -0,0 +1,25 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('schema_name', 63)->unique(); + $table->timestamp('prepared_at'); + $table->timestamp('expires_at')->index(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('demo_instances'); + } +}; diff --git a/Modules/Demo/module.json b/Modules/Demo/module.json new file mode 100644 index 000000000..3cb17b408 --- /dev/null +++ b/Modules/Demo/module.json @@ -0,0 +1,11 @@ +{ + "name": "Demo", + "alias": "demo", + "description": "Temporary per-visitor demo schemas", + "keywords": [], + "priority": 0, + "providers": [ + "Modules\\Demo\\App\\Providers\\DemoServiceProvider" + ], + "files": [] +} diff --git a/Modules/Demo/routes/web.php b/Modules/Demo/routes/web.php new file mode 100644 index 000000000..01630ac1c --- /dev/null +++ b/Modules/Demo/routes/web.php @@ -0,0 +1,8 @@ +group(function () { + Route::post('/demo/prepare', [DemoController::class, 'prepare'])->name('demo.prepare'); +}); diff --git a/Modules/Partner/Providers/PartnerPanelProvider.php b/Modules/Partner/Providers/PartnerPanelProvider.php index 636d0d585..50621d9b2 100644 --- a/Modules/Partner/Providers/PartnerPanelProvider.php +++ b/Modules/Partner/Providers/PartnerPanelProvider.php @@ -1,6 +1,7 @@ mergeConfigFrom(module_path('S3', 'config/s3.php'), 'media_storage'); + } +} diff --git a/Modules/S3/Support/MediaStorage.php b/Modules/S3/Support/MediaStorage.php new file mode 100644 index 000000000..2b08cd954 --- /dev/null +++ b/Modules/S3/Support/MediaStorage.php @@ -0,0 +1,147 @@ + 'S3 Object Storage', + self::DRIVER_LOCAL => 'Local Storage', + ]; + } + + public static function defaultDriver(): string + { + return self::coerceDriver(config('media_storage.default_driver')) + ?? self::coerceDriver(env('MEDIA_DISK')) + ?? self::coerceDriver(env('FILESYSTEM_DISK')) + ?? self::DRIVER_S3; + } + + public static function activeDriver(): string + { + if (! self::hasSettingsTable()) { + return self::defaultDriver(); + } + + try { + return self::normalizeDriver(app(GeneralSettings::class)->media_disk ?? null); + } catch (Throwable) { + return self::defaultDriver(); + } + } + + public static function normalizeDriver(mixed $driver): string + { + return self::coerceDriver($driver) ?? self::defaultDriver(); + } + + public static function diskFromDriver(mixed $driver = null): string + { + return self::normalizeDriver($driver) === self::DRIVER_LOCAL + ? (string) config('media_storage.local_disk', 'public') + : (string) config('media_storage.cloud_disk', 's3'); + } + + public static function activeDisk(): string + { + return self::diskFromDriver(self::activeDriver()); + } + + public static function storedDisk(mixed $disk = null, mixed $driver = null): string + { + if (is_string($disk) && trim($disk) !== '') { + return self::diskFromDriver(trim($disk) === 'public' ? self::DRIVER_LOCAL : trim($disk)); + } + + return self::diskFromDriver($driver); + } + + public static function managesPath(mixed $path): bool + { + $path = is_string($path) ? trim($path) : ''; + + if ($path === '') { + return false; + } + + return ! self::isExternalUrl($path) && ! self::isAssetPath($path); + } + + public static function url(mixed $path, mixed $disk = null): ?string + { + $path = is_string($path) ? trim($path) : ''; + + if ($path === '') { + return null; + } + + if (self::isExternalUrl($path)) { + return $path; + } + + if (self::isAssetPath($path)) { + return asset($path); + } + + return Storage::disk(self::storedDisk($disk))->url($path); + } + + public static function applyRuntimeConfig(): void + { + $disk = self::activeDisk(); + + config([ + 'filesystems.default' => $disk, + 'filemanager.disk' => env('FILEMANAGER_DISK', $disk), + 'filament.default_filesystem_disk' => $disk, + 'media-library.disk_name' => $disk, + 'video.disk' => $disk, + ]); + } + + private static function coerceDriver(mixed $driver): ?string + { + if (! is_string($driver)) { + return null; + } + + return match (strtolower(trim($driver))) { + self::DRIVER_LOCAL, 'public' => self::DRIVER_LOCAL, + self::DRIVER_S3 => self::DRIVER_S3, + default => null, + }; + } + + private static function hasSettingsTable(): bool + { + try { + return Schema::hasTable('settings'); + } catch (Throwable) { + return false; + } + } + + private static function isAssetPath(string $path): bool + { + return str_starts_with($path, 'images/'); + } + + private static function isExternalUrl(string $path): bool + { + return str_starts_with($path, 'http://') + || str_starts_with($path, 'https://') + || str_starts_with($path, '//'); + } +} diff --git a/Modules/S3/config/s3.php b/Modules/S3/config/s3.php new file mode 100644 index 000000000..c1be9e993 --- /dev/null +++ b/Modules/S3/config/s3.php @@ -0,0 +1,7 @@ + env('MEDIA_DISK', env('FILESYSTEM_DISK', 's3')), + 'local_disk' => env('LOCAL_MEDIA_DISK', 'public'), + 'cloud_disk' => env('CLOUD_MEDIA_DISK', 's3'), +]; diff --git a/Modules/S3/module.json b/Modules/S3/module.json new file mode 100644 index 000000000..7008b0870 --- /dev/null +++ b/Modules/S3/module.json @@ -0,0 +1,12 @@ +{ + "name": "S3", + "alias": "s3", + "description": "Media storage selection and S3 object storage integration", + "keywords": [], + "priority": 0, + "providers": [ + "Modules\\S3\\Providers\\S3ServiceProvider" + ], + "aliases": {}, + "files": [] +} diff --git a/Modules/User/App/Models/User.php b/Modules/User/App/Models/User.php index c5d785f7b..a5e8159af 100644 --- a/Modules/User/App/Models/User.php +++ b/Modules/User/App/Models/User.php @@ -19,6 +19,7 @@ use Modules\Conversation\App\Models\Conversation; use Modules\Conversation\App\Models\ConversationMessage; use Modules\Favorite\App\Models\FavoriteSearch; use Modules\Listing\Models\Listing; +use Modules\S3\Support\MediaStorage; use Modules\User\App\States\UserStatus; use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\Traits\LogsActivity; @@ -133,7 +134,27 @@ class User extends Authenticatable implements FilamentUser, HasTenants, HasAvata public function getFilamentAvatarUrl(): ?string { - return filled($this->avatar_url) ? Storage::disk('public')->url($this->avatar_url) : null; + if (! filled($this->avatar_url)) { + return null; + } + + $path = trim((string) $this->avatar_url); + + if (! MediaStorage::managesPath($path)) { + return MediaStorage::url($path); + } + + $activeDisk = MediaStorage::activeDisk(); + + if (Storage::disk($activeDisk)->exists($path)) { + return Storage::disk($activeDisk)->url($path); + } + + if ($activeDisk !== 'public' && Storage::disk('public')->exists($path)) { + return Storage::disk('public')->url($path); + } + + return MediaStorage::url($path, $activeDisk); } public function getDisplayName(): string diff --git a/Modules/Video/Models/Video.php b/Modules/Video/Models/Video.php index c7ca83dac..490d2be0f 100644 --- a/Modules/Video/Models/Video.php +++ b/Modules/Video/Models/Video.php @@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use Modules\Listing\Models\Listing; +use Modules\S3\Support\MediaStorage; use Modules\User\App\Models\User; use Modules\Video\Enums\VideoStatus; use Modules\Video\Jobs\ProcessVideo; @@ -87,7 +88,7 @@ class Video extends Model public static function createFromTemporaryUpload(Listing $listing, TemporaryUploadedFile $file, array $attributes = []): self { - $disk = (string) config('video.disk', 'public'); + $disk = (string) config('video.disk', MediaStorage::activeDisk()); $path = $file->storeAs( trim((string) config('video.upload_directory', 'videos/uploads').'/'.$listing->getKey(), '/'), Str::ulid().'.'.($file->getClientOriginalExtension() ?: $file->guessExtension() ?: 'mp4'), @@ -133,9 +134,9 @@ class Video extends Model $uploadPath = $this->upload_path; $this->forceFill([ - 'disk' => $attributes['disk'] ?? (string) config('video.disk', 'public'), + 'disk' => $attributes['disk'] ?? (string) config('video.disk', MediaStorage::activeDisk()), 'path' => $attributes['path'] ?? null, - 'upload_disk' => (string) config('video.disk', 'public'), + 'upload_disk' => (string) config('video.disk', MediaStorage::activeDisk()), 'upload_path' => null, 'mime_type' => $attributes['mime_type'] ?? 'video/mp4', 'size' => $attributes['size'] ?? null, @@ -184,14 +185,14 @@ class Video extends Model $status = $this->currentStatus(); if (($status !== VideoStatus::Ready) && filled($this->upload_path)) { - return (string) ($this->upload_disk ?: config('video.disk', 'public')); + return (string) ($this->upload_disk ?: config('video.disk', MediaStorage::activeDisk())); } if (filled($this->path)) { - return (string) ($this->disk ?: config('video.disk', 'public')); + return (string) ($this->disk ?: config('video.disk', MediaStorage::activeDisk())); } - return (string) ($this->upload_disk ?: config('video.disk', 'public')); + return (string) ($this->upload_disk ?: config('video.disk', MediaStorage::activeDisk())); } public function playableUrl(): ?string @@ -293,7 +294,7 @@ class Video extends Model $this->previousUploadDisk = filled($this->getOriginal('upload_disk')) ? (string) $this->getOriginal('upload_disk') - : (string) config('video.disk', 'public'); + : (string) config('video.disk', MediaStorage::activeDisk()); $this->previousUploadPath = filled($this->getOriginal('upload_path')) ? (string) $this->getOriginal('upload_path') @@ -318,11 +319,11 @@ class Video extends Model protected function normalizeStatus(): void { if (blank($this->disk)) { - $this->disk = (string) config('video.disk', 'public'); + $this->disk = (string) config('video.disk', MediaStorage::activeDisk()); } if (blank($this->upload_disk)) { - $this->upload_disk = (string) config('video.disk', 'public'); + $this->upload_disk = (string) config('video.disk', MediaStorage::activeDisk()); } if (! $this->isDirty('upload_path')) { diff --git a/Modules/Video/Support/Filament/VideoFormSchema.php b/Modules/Video/Support/Filament/VideoFormSchema.php index 4ae2269e2..b527c7177 100644 --- a/Modules/Video/Support/Filament/VideoFormSchema.php +++ b/Modules/Video/Support/Filament/VideoFormSchema.php @@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\HtmlString; use Illuminate\Support\Str; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; +use Modules\S3\Support\MediaStorage; use Modules\Video\Models\Video; class VideoFormSchema @@ -126,7 +127,7 @@ class VideoFormSchema return FileUpload::make('upload_path') ->label('Source video') - ->disk((string) config('video.disk', 'public')) + ->disk(fn (?Video $record): string => MediaStorage::storedDisk($record?->upload_disk)) ->directory(trim((string) config('video.upload_directory', 'videos/uploads'), '/')) ->visibility('public') ->acceptedFileTypes([ @@ -191,7 +192,7 @@ class VideoFormSchema protected static function normalizeData(array $data): array { - $data['upload_disk'] = (string) config('video.disk', 'public'); + $data['upload_disk'] = (string) config('video.disk', MediaStorage::activeDisk()); if (blank($data['title'] ?? null) && filled($data['upload_path'] ?? null)) { $data['title'] = str(pathinfo((string) $data['upload_path'], PATHINFO_FILENAME)) diff --git a/Modules/Video/Support/VideoTranscoder.php b/Modules/Video/Support/VideoTranscoder.php index 6d907133f..3d9096e2b 100644 --- a/Modules/Video/Support/VideoTranscoder.php +++ b/Modules/Video/Support/VideoTranscoder.php @@ -3,6 +3,8 @@ namespace Modules\Video\Support; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; +use Modules\S3\Support\MediaStorage; use Modules\Video\Models\Video; use RuntimeException; use Symfony\Component\Process\Process; @@ -11,87 +13,137 @@ class VideoTranscoder { public function transcode(Video $video): array { - $disk = (string) config('video.disk', 'public'); + $disk = (string) config('video.disk', MediaStorage::activeDisk()); $inputDisk = Storage::disk((string) ($video->upload_disk ?: $disk)); $outputDisk = Storage::disk($disk); - $inputPath = $inputDisk->path((string) $video->upload_path); + $workspace = storage_path('app/private/video-processing/'.Str::uuid()); + $inputExtension = pathinfo((string) $video->upload_path, PATHINFO_EXTENSION) ?: 'mp4'; + $inputPath = $workspace.'/input.'.$inputExtension; + $outputPath = $workspace.'/output.mp4'; $outputRelativePath = $video->mobileOutputPath(); - $outputPath = $outputDisk->path($outputRelativePath); - $outputDirectory = dirname($outputPath); + $inputStream = null; + $outputStream = null; - if (! is_dir($outputDirectory)) { - mkdir($outputDirectory, 0775, true); + if (! is_dir($workspace) && ! mkdir($workspace, 0775, true) && ! is_dir($workspace)) { + throw new RuntimeException('Video processing workspace could not be created.'); } - $process = new Process([ - (string) config('video.ffmpeg', 'ffmpeg'), - '-y', - '-i', - $inputPath, - '-map', - '0:v:0', - '-map', - '0:a:0?', - '-vf', - 'scale=min('.(int) config('video.mobile_width', 854).'\\,iw):-2', - '-c:v', - 'libx264', - '-preset', - 'veryfast', - '-crf', - (string) config('video.mobile_crf', 31), - '-maxrate', - (string) config('video.mobile_video_bitrate', '900k'), - '-bufsize', - '1800k', - '-movflags', - '+faststart', - '-pix_fmt', - 'yuv420p', - '-c:a', - 'aac', - '-b:a', - (string) config('video.mobile_audio_bitrate', '96k'), - '-ac', - '2', - $outputPath, - ]); + try { + $inputStream = $inputDisk->readStream((string) $video->upload_path); - $process->setTimeout((int) config('video.timeout', 1800)); - $process->run(); + if (! is_resource($inputStream)) { + throw new RuntimeException('Source video could not be read.'); + } - if (! $process->isSuccessful()) { - throw new RuntimeException(trim($process->getErrorOutput()) ?: 'Video transcoding failed.'); + $localInputStream = fopen($inputPath, 'wb'); + + if (! is_resource($localInputStream)) { + throw new RuntimeException('Temporary input video could not be created.'); + } + + stream_copy_to_stream($inputStream, $localInputStream); + fclose($localInputStream); + + $process = new Process([ + (string) config('video.ffmpeg', 'ffmpeg'), + '-y', + '-i', + $inputPath, + '-map', + '0:v:0', + '-map', + '0:a:0?', + '-vf', + 'scale=min('.(int) config('video.mobile_width', 854).'\\,iw):-2', + '-c:v', + 'libx264', + '-preset', + 'veryfast', + '-crf', + (string) config('video.mobile_crf', 31), + '-maxrate', + (string) config('video.mobile_video_bitrate', '900k'), + '-bufsize', + '1800k', + '-movflags', + '+faststart', + '-pix_fmt', + 'yuv420p', + '-c:a', + 'aac', + '-b:a', + (string) config('video.mobile_audio_bitrate', '96k'), + '-ac', + '2', + $outputPath, + ]); + + $process->setTimeout((int) config('video.timeout', 1800)); + $process->run(); + + if (! $process->isSuccessful()) { + throw new RuntimeException(trim($process->getErrorOutput()) ?: 'Video transcoding failed.'); + } + + $probe = new Process([ + (string) config('video.ffprobe', 'ffprobe'), + '-v', + 'error', + '-select_streams', + 'v:0', + '-show_entries', + 'stream=width,height:format=duration', + '-of', + 'json', + $outputPath, + ]); + + $probe->setTimeout(30); + $probe->run(); + + $outputStream = fopen($outputPath, 'rb'); + + if (! is_resource($outputStream)) { + throw new RuntimeException('Processed video could not be opened.'); + } + + if (! $outputDisk->put($outputRelativePath, $outputStream, ['visibility' => 'public'])) { + throw new RuntimeException('Processed video could not be stored.'); + } + + $metadata = json_decode($probe->getOutput(), true); + $stream = $metadata['streams'][0] ?? []; + $format = $metadata['format'] ?? []; + + return [ + 'disk' => $disk, + 'path' => $outputRelativePath, + 'mime_type' => $outputDisk->mimeType($outputRelativePath) ?: 'video/mp4', + 'size' => $outputDisk->size($outputRelativePath), + 'width' => isset($stream['width']) ? (int) $stream['width'] : null, + 'height' => isset($stream['height']) ? (int) $stream['height'] : null, + 'duration_seconds' => isset($format['duration']) ? (int) round((float) $format['duration']) : null, + ]; + } finally { + if (is_resource($inputStream)) { + fclose($inputStream); + } + + if (is_resource($outputStream)) { + fclose($outputStream); + } + + if (is_file($inputPath)) { + unlink($inputPath); + } + + if (is_file($outputPath)) { + unlink($outputPath); + } + + if (is_dir($workspace)) { + rmdir($workspace); + } } - - $probe = new Process([ - (string) config('video.ffprobe', 'ffprobe'), - '-v', - 'error', - '-select_streams', - 'v:0', - '-show_entries', - 'stream=width,height:format=duration', - '-of', - 'json', - $outputPath, - ]); - - $probe->setTimeout(30); - $probe->run(); - - $metadata = json_decode($probe->getOutput(), true); - $stream = $metadata['streams'][0] ?? []; - $format = $metadata['format'] ?? []; - - return [ - 'disk' => $disk, - 'path' => $outputRelativePath, - 'mime_type' => $outputDisk->mimeType($outputRelativePath) ?: 'video/mp4', - 'size' => $outputDisk->size($outputRelativePath), - 'width' => isset($stream['width']) ? (int) $stream['width'] : null, - 'height' => isset($stream['height']) ? (int) $stream['height'] : null, - 'duration_seconds' => isset($format['duration']) ? (int) round((float) $format['duration']) : null, - ]; } } diff --git a/Modules/Video/config/video.php b/Modules/Video/config/video.php index 4cdbf9bdb..321eb68e8 100644 --- a/Modules/Video/config/video.php +++ b/Modules/Video/config/video.php @@ -1,7 +1,7 @@ env('VIDEO_DISK', 'public'), + 'disk' => env('VIDEO_DISK', env('MEDIA_DISK', env('FILESYSTEM_DISK', 's3'))), 'upload_directory' => env('VIDEO_UPLOAD_DIRECTORY', 'videos/uploads'), 'processed_directory' => env('VIDEO_PROCESSED_DIRECTORY', 'videos/mobile'), 'queue' => env('VIDEO_QUEUE', 'videos'), diff --git a/README.md b/README.md index 0192e54eb..8889243ef 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ A modern classified ads platform built with Laravel 12, FilamentPHP v5, and Lara - 👤 **User Profiles** — Manage your listings and account - 🔐 **Admin Panel** — Full control via FilamentPHP v5 at `/admin` - 🤝 **Partner Panel** — Users manage their own listings at `/partner/{id}` (tenant isolation) +- 🧪 **Demo Mode** — Per-visitor PostgreSQL schema provisioning with seeded data and automatic cleanup - 🌍 **10 Languages** — English, Turkish, Arabic, German, French, Spanish, Portuguese, Russian, Chinese, Japanese - 🐳 **Docker Ready** — One-command production and development setup - ☁️ **GitHub Codespaces** — Zero-config cloud development @@ -31,8 +32,8 @@ Project-level custom instruction set files are available at: | Modules | nWidart/laravel-modules v11 | | Auth/Roles | Spatie Laravel Permission | | Frontend | Blade + TailwindCSS + Vite | -| Database | MySQL / SQLite | -| Cache/Queue | Redis | +| Database | PostgreSQL (required for demo mode), SQLite for minimal local dev | +| Cache/Queue | Database or Redis | ## Quick Start (Docker) @@ -50,12 +51,14 @@ docker compose up -d # The application will be available at http://localhost:8000 ``` -### Default Credentials +### Demo Credentials (`DEMO=1` only) | Role | Email | Password | |------|-------|----------| -| Admin | admin@openclassify.com | password | -| Partner | partner@openclassify.com | password | +| Admin | a@a.com | 236330 | +| Partner | b@b.com | 36330 | + +Demo preparation auto-logs the visitor into the schema-local admin account, so manual login is usually not required. **Admin Panel:** http://localhost:8000/admin **Partner Panel:** http://localhost:8000/partner @@ -82,7 +85,7 @@ docker compose -f docker-compose.dev.yml logs -f app ### Option 3: Local (PHP + Node) -**Requirements:** PHP 8.2+, Composer, Node 18+, SQLite or MySQL +**Requirements:** PHP 8.2+, Composer, Node 18+, PostgreSQL for demo mode ```bash # Install dependencies @@ -102,6 +105,53 @@ php artisan db:seed composer run dev ``` +## Demo Mode + +Demo mode is designed for isolated visitor sessions. When enabled, each visitor can provision a private temporary marketplace backed by its own PostgreSQL schema. + +### Requirements + +- `DB_CONNECTION=pgsql` +- `DEMO=1` +- database-backed session / cache / queue drivers are supported and will stay on the public schema via `pgsql_public` + +If `DEMO=1` is set while the app is not using PostgreSQL, the application fails fast during boot. + +### Runtime Behavior + +- On the first guest homepage visit, the primary visible CTA is a single large `Prepare Demo` button. +- The homepage shows how long the temporary demo will live before automatic deletion. +- Clicking `Prepare Demo` provisions a visitor-specific schema, runs `migrate` and `db:seed`, and logs the visitor into the seeded admin account. +- The same browser reuses its active demo instead of creating duplicate schemas. +- Demo lifetime defaults to `360` minutes from explicit prepare / reopen time. +- Expired demos are removed by `demo:cleanup`, which is scheduled hourly. + +### Environment + +```env +DB_CONNECTION=pgsql +DEMO=1 +DEMO_TTL_MINUTES=360 +DEMO_SCHEMA_PREFIX=demo_ +DEMO_COOKIE_NAME=oc2_demo +DEMO_LOGIN_EMAIL=a@a.com +DEMO_PUBLIC_SCHEMA=public +``` + +### Commands + +```bash +php artisan migrate --force +php artisan db:seed --force +php artisan demo:prepare +php artisan demo:cleanup +``` + +### Notes + +- `php artisan db:seed` only injects demo-heavy listings, favorites, inbox threads, and demo users when demo mode is enabled. +- Public infrastructure tables such as sessions, cache, jobs, and failed jobs remain on the public schema even while visitor requests are switched into demo schemas. + --- ## Architecture diff --git a/app/Http/Middleware/BootstrapAppData.php b/app/Http/Middleware/BootstrapAppData.php new file mode 100644 index 000000000..52ee86f99 --- /dev/null +++ b/app/Http/Middleware/BootstrapAppData.php @@ -0,0 +1,21 @@ +requestAppData->bootstrap(); + + return $next($request); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8143bb957..078a5088c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,26 +2,17 @@ namespace App\Providers; -use App\Support\CountryCodeManager; -use App\Support\HomeSlideDefaults; -use App\Settings\GeneralSettings; use BezhanSalleh\LanguageSwitch\LanguageSwitch; -use Illuminate\Support\ServiceProvider; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Gate; -use Illuminate\Support\Facades\Schema; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\View; -use Modules\Category\Models\Category; -use Modules\Location\Models\Country; +use Illuminate\Support\ServiceProvider; use SocialiteProviders\Manager\SocialiteWasCalled; -use Throwable; class AppServiceProvider extends ServiceProvider { public function register(): void { - // } public function boot(): void @@ -36,141 +27,6 @@ class AppServiceProvider extends ServiceProvider View::addNamespace('app', resource_path('views')); - $fallbackName = config('app.name', 'OpenClassify'); - $fallbackLocale = config('app.locale', 'tr'); - $fallbackCurrencies = $this->normalizeCurrencies(config('app.currencies', ['USD'])); - $fallbackDescription = 'Alım satım için hızlı ve güvenli ilan platformu.'; - $fallbackHomeSlides = $this->defaultHomeSlides(); - $fallbackGoogleMapsApiKey = env('GOOGLE_MAPS_API_KEY'); - $fallbackGoogleClientId = env('GOOGLE_CLIENT_ID'); - $fallbackGoogleClientSecret = env('GOOGLE_CLIENT_SECRET'); - $fallbackFacebookClientId = env('FACEBOOK_CLIENT_ID'); - $fallbackFacebookClientSecret = env('FACEBOOK_CLIENT_SECRET'); - $fallbackAppleClientId = env('APPLE_CLIENT_ID'); - $fallbackAppleClientSecret = env('APPLE_CLIENT_SECRET'); - $fallbackDefaultCountryCode = '+90'; - - $generalSettings = [ - 'site_name' => $fallbackName, - 'site_description' => $fallbackDescription, - 'home_slides' => $fallbackHomeSlides, - 'site_logo_url' => null, - 'default_language' => $fallbackLocale, - 'default_country_code' => $fallbackDefaultCountryCode, - 'currencies' => $fallbackCurrencies, - 'sender_email' => config('mail.from.address', 'hello@example.com'), - 'sender_name' => config('mail.from.name', $fallbackName), - 'linkedin_url' => null, - 'instagram_url' => null, - 'whatsapp' => null, - 'google_maps_enabled' => false, - 'google_maps_api_key' => $fallbackGoogleMapsApiKey, - 'google_login_enabled' => (bool) env('ENABLE_GOOGLE_LOGIN', false), - 'google_client_id' => $fallbackGoogleClientId, - 'google_client_secret' => $fallbackGoogleClientSecret, - 'facebook_login_enabled' => (bool) env('ENABLE_FACEBOOK_LOGIN', false), - 'facebook_client_id' => $fallbackFacebookClientId, - 'facebook_client_secret' => $fallbackFacebookClientSecret, - 'apple_login_enabled' => (bool) env('ENABLE_APPLE_LOGIN', false), - 'apple_client_id' => $fallbackAppleClientId, - 'apple_client_secret' => $fallbackAppleClientSecret, - ]; - - $hasSettingsTable = false; - - try { - $hasSettingsTable = Schema::hasTable('settings'); - } catch (Throwable) { - $hasSettingsTable = false; - } - - if ($hasSettingsTable) { - try { - $settings = app(GeneralSettings::class); - $currencies = $this->normalizeCurrencies($settings->currencies ?? $fallbackCurrencies); - $availableLocales = config('app.available_locales', ['en']); - $defaultLanguage = in_array($settings->default_language, $availableLocales, true) - ? $settings->default_language - : $fallbackLocale; - $googleMapsApiKey = trim((string) ($settings->google_maps_api_key ?: $fallbackGoogleMapsApiKey)); - $googleMapsApiKey = $googleMapsApiKey !== '' ? $googleMapsApiKey : null; - $googleClientId = trim((string) ($settings->google_client_id ?: $fallbackGoogleClientId)); - $googleClientSecret = trim((string) ($settings->google_client_secret ?: $fallbackGoogleClientSecret)); - $facebookClientId = trim((string) ($settings->facebook_client_id ?: $fallbackFacebookClientId)); - $facebookClientSecret = trim((string) ($settings->facebook_client_secret ?: $fallbackFacebookClientSecret)); - $appleClientId = trim((string) ($settings->apple_client_id ?: $fallbackAppleClientId)); - $appleClientSecret = trim((string) ($settings->apple_client_secret ?: $fallbackAppleClientSecret)); - $defaultCountryCode = CountryCodeManager::normalizeCountryCode($settings->default_country_code ?? $fallbackDefaultCountryCode); - $homeSlides = $this->normalizeHomeSlides($settings->home_slides ?? []); - - $generalSettings = [ - 'site_name' => trim((string) ($settings->site_name ?: $fallbackName)), - 'site_description' => trim((string) ($settings->site_description ?: $fallbackDescription)), - 'home_slides' => $homeSlides, - 'site_logo_url' => filled($settings->site_logo) - ? Storage::disk('public')->url($settings->site_logo) - : null, - 'default_language' => $defaultLanguage, - 'default_country_code' => $defaultCountryCode, - 'currencies' => $currencies, - 'sender_email' => trim((string) ($settings->sender_email ?: config('mail.from.address'))), - 'sender_name' => trim((string) ($settings->sender_name ?: $fallbackName)), - 'linkedin_url' => $settings->linkedin_url ?: null, - 'instagram_url' => $settings->instagram_url ?: null, - 'whatsapp' => $settings->whatsapp ?: null, - 'google_maps_enabled' => (bool) ($settings->enable_google_maps ?? false), - 'google_maps_api_key' => $googleMapsApiKey, - 'google_login_enabled' => (bool) ($settings->enable_google_login ?? false), - 'google_client_id' => $googleClientId !== '' ? $googleClientId : null, - 'google_client_secret' => $googleClientSecret !== '' ? $googleClientSecret : null, - 'facebook_login_enabled' => (bool) ($settings->enable_facebook_login ?? false), - 'facebook_client_id' => $facebookClientId !== '' ? $facebookClientId : null, - 'facebook_client_secret' => $facebookClientSecret !== '' ? $facebookClientSecret : null, - 'apple_login_enabled' => (bool) ($settings->enable_apple_login ?? false), - 'apple_client_id' => $appleClientId !== '' ? $appleClientId : null, - 'apple_client_secret' => $appleClientSecret !== '' ? $appleClientSecret : null, - ]; - - config([ - 'app.name' => $generalSettings['site_name'], - 'app.locale' => $generalSettings['default_language'], - 'app.currencies' => $generalSettings['currencies'], - 'mail.from.address' => $generalSettings['sender_email'], - 'mail.from.name' => $generalSettings['sender_name'], - ]); - } catch (Throwable) { - config(['app.currencies' => $fallbackCurrencies]); - } - } else { - config(['app.currencies' => $fallbackCurrencies]); - } - - $mapsKey = $generalSettings['google_maps_enabled'] - ? $generalSettings['google_maps_api_key'] - : null; - - config([ - 'filament-google-maps.key' => $mapsKey, - 'filament-google-maps.keys.web_key' => $mapsKey, - 'filament-google-maps.keys.server_key' => $mapsKey, - 'services.google.client_id' => $generalSettings['google_client_id'], - 'services.google.client_secret' => $generalSettings['google_client_secret'], - 'services.google.redirect' => url('/oauth/callback/google'), - 'services.google.enabled' => (bool) $generalSettings['google_login_enabled'], - 'services.facebook.client_id' => $generalSettings['facebook_client_id'], - 'services.facebook.client_secret' => $generalSettings['facebook_client_secret'], - 'services.facebook.redirect' => url('/oauth/callback/facebook'), - 'services.facebook.enabled' => (bool) $generalSettings['facebook_login_enabled'], - 'services.apple.client_id' => $generalSettings['apple_client_id'], - 'services.apple.client_secret' => $generalSettings['apple_client_secret'], - 'services.apple.redirect' => url('/oauth/callback/apple'), - 'services.apple.stateless' => true, - 'services.apple.enabled' => (bool) $generalSettings['apple_login_enabled'], - 'money.defaults.currency' => $generalSettings['currencies'][0] ?? 'USD', - 'app.default_country_code' => $generalSettings['default_country_code'] ?? $fallbackDefaultCountryCode, - 'app.default_country_iso2' => CountryCodeManager::iso2FromCountryCode($generalSettings['default_country_code'] ?? $fallbackDefaultCountryCode) ?? 'TR', - ]); - Event::listen(function (SocialiteWasCalled $event): void { $event->extendSocialite('apple', \SocialiteProviders\Apple\Provider::class); }); @@ -189,75 +45,5 @@ class AppServiceProvider extends ServiceProvider )->all()) ->visible(insidePanels: count($availableLocales) > 1, outsidePanels: false); }); - - $headerLocationCountries = []; - $headerNavCategories = []; - - try { - if (Schema::hasTable('countries') && Schema::hasTable('cities')) { - $headerLocationCountries = Country::query() - ->where('is_active', true) - ->orderBy('name') - ->get(['id', 'name', 'code']) - ->map(function (Country $country): array { - return [ - 'id' => (int) $country->id, - 'name' => (string) $country->name, - 'code' => strtoupper((string) $country->code), - ]; - }) - ->values() - ->all(); - } - } catch (Throwable) { - $headerLocationCountries = []; - } - - try { - if (Schema::hasTable('categories')) { - $headerNavCategories = Category::query() - ->where('is_active', true) - ->whereNull('parent_id') - ->orderBy('sort_order') - ->orderBy('name') - ->limit(8) - ->get(['id', 'name']) - ->map(fn (Category $category): array => [ - 'id' => (int) $category->id, - 'name' => (string) $category->name, - ]) - ->values() - ->all(); - } - } catch (Throwable) { - $headerNavCategories = []; - } - - View::share('generalSettings', $generalSettings); - View::share('headerLocationCountries', $headerLocationCountries); - View::share('headerNavCategories', $headerNavCategories); - } - - private function normalizeCurrencies(array $currencies): array - { - $normalized = collect($currencies) - ->filter(fn ($currency) => is_string($currency) && trim($currency) !== '') - ->map(fn (string $currency) => strtoupper(substr(trim($currency), 0, 3))) - ->filter(fn (string $currency) => strlen($currency) === 3) - ->unique() - ->values() - ->all(); - - return $normalized !== [] ? $normalized : ['USD']; - } - - private function defaultHomeSlides(): array - { - return HomeSlideDefaults::defaults(); - } - - private function normalizeHomeSlides(mixed $slides): array - { - return HomeSlideDefaults::normalize($slides); } } diff --git a/app/Settings/GeneralSettings.php b/app/Settings/GeneralSettings.php index 6ca21f49c..6151ebf8e 100644 --- a/app/Settings/GeneralSettings.php +++ b/app/Settings/GeneralSettings.php @@ -10,8 +10,12 @@ class GeneralSettings extends Settings public string $site_description; + public string $media_disk; + public ?string $site_logo; + public ?string $site_logo_disk; + public string $default_language; public string $default_country_code; diff --git a/app/Support/HomeSlideDefaults.php b/app/Support/HomeSlideDefaults.php index a2f0c6b0b..ceb21556a 100644 --- a/app/Support/HomeSlideDefaults.php +++ b/app/Support/HomeSlideDefaults.php @@ -3,12 +3,10 @@ namespace App\Support; use Illuminate\Support\Arr; +use Modules\S3\Support\MediaStorage; final class HomeSlideDefaults { - /** - * @return array - */ public static function defaults(): array { return [ @@ -19,6 +17,7 @@ final class HomeSlideDefaults 'primary_button_text' => 'Browse Listings', 'secondary_button_text' => 'Post Listing', 'image_path' => 'images/home-slides/slide-marketplace.svg', + 'disk' => null, ], [ 'badge' => 'Fresh Categories', @@ -27,6 +26,7 @@ final class HomeSlideDefaults 'primary_button_text' => 'See Categories', 'secondary_button_text' => 'Start Now', 'image_path' => 'images/home-slides/slide-categories.svg', + 'disk' => null, ], [ 'badge' => 'Local Shopping', @@ -35,14 +35,12 @@ final class HomeSlideDefaults 'primary_button_text' => 'Nearby Deals', 'secondary_button_text' => 'Sell for Free', 'image_path' => 'images/home-slides/slide-local.svg', + 'disk' => null, ], ]; } - /** - * @return array - */ - public static function normalize(mixed $slides): array + public static function normalize(mixed $slides, ?string $defaultDisk = null): array { $defaults = self::defaults(); $source = is_array($slides) ? $slides : []; @@ -58,6 +56,9 @@ final class HomeSlideDefaults $primaryButtonText = trim((string) ($slide['primary_button_text'] ?? '')); $secondaryButtonText = trim((string) ($slide['secondary_button_text'] ?? '')); $imagePath = self::normalizeImagePath($slide['image_path'] ?? null); + $disk = MediaStorage::managesPath($imagePath) + ? MediaStorage::storedDisk($slide['disk'] ?? null, $defaultDisk) + : null; if ($title === '') { return null; @@ -70,6 +71,7 @@ final class HomeSlideDefaults 'primary_button_text' => $primaryButtonText !== '' ? $primaryButtonText : $fallback['primary_button_text'], 'secondary_button_text' => $secondaryButtonText !== '' ? $secondaryButtonText : $fallback['secondary_button_text'], 'image_path' => $imagePath !== '' ? $imagePath : ($fallback['image_path'] ?? null), + 'disk' => $imagePath !== '' ? $disk : ($fallback['disk'] ?? null), ]; }) ->filter(fn ($slide): bool => is_array($slide)) diff --git a/app/Support/RequestAppData.php b/app/Support/RequestAppData.php new file mode 100644 index 000000000..e0bfedc8c --- /dev/null +++ b/app/Support/RequestAppData.php @@ -0,0 +1,228 @@ +resolveGeneralSettings(); + + $this->applyRuntimeConfig($generalSettings); + + View::share('generalSettings', $generalSettings); + View::share('headerLocationCountries', $this->resolveHeaderLocationCountries()); + View::share('headerNavCategories', $this->resolveHeaderNavCategories()); + } + + private function resolveGeneralSettings(): array + { + $fallbackName = config('app.name', 'OpenClassify'); + $fallbackLocale = config('app.locale', 'en'); + $fallbackCurrencies = $this->normalizeCurrencies(config('app.currencies', ['USD'])); + $fallbackDescription = 'Buy and sell everything in your area.'; + $fallbackHomeSlides = HomeSlideDefaults::defaults(); + $fallbackGoogleMapsApiKey = env('GOOGLE_MAPS_API_KEY'); + $fallbackGoogleClientId = env('GOOGLE_CLIENT_ID'); + $fallbackGoogleClientSecret = env('GOOGLE_CLIENT_SECRET'); + $fallbackFacebookClientId = env('FACEBOOK_CLIENT_ID'); + $fallbackFacebookClientSecret = env('FACEBOOK_CLIENT_SECRET'); + $fallbackAppleClientId = env('APPLE_CLIENT_ID'); + $fallbackAppleClientSecret = env('APPLE_CLIENT_SECRET'); + $fallbackDefaultCountryCode = '+90'; + $fallbackMediaDriver = MediaStorage::defaultDriver(); + + $generalSettings = [ + 'site_name' => $fallbackName, + 'site_description' => $fallbackDescription, + 'media_disk' => $fallbackMediaDriver, + 'home_slides' => $fallbackHomeSlides, + 'site_logo_url' => null, + 'default_language' => $fallbackLocale, + 'default_country_code' => $fallbackDefaultCountryCode, + 'currencies' => $fallbackCurrencies, + 'sender_email' => config('mail.from.address', 'hello@example.com'), + 'sender_name' => config('mail.from.name', $fallbackName), + 'linkedin_url' => null, + 'instagram_url' => null, + 'whatsapp' => null, + 'google_maps_enabled' => false, + 'google_maps_api_key' => $fallbackGoogleMapsApiKey, + 'google_login_enabled' => (bool) env('ENABLE_GOOGLE_LOGIN', false), + 'google_client_id' => $fallbackGoogleClientId, + 'google_client_secret' => $fallbackGoogleClientSecret, + 'facebook_login_enabled' => (bool) env('ENABLE_FACEBOOK_LOGIN', false), + 'facebook_client_id' => $fallbackFacebookClientId, + 'facebook_client_secret' => $fallbackFacebookClientSecret, + 'apple_login_enabled' => (bool) env('ENABLE_APPLE_LOGIN', false), + 'apple_client_id' => $fallbackAppleClientId, + 'apple_client_secret' => $fallbackAppleClientSecret, + ]; + + try { + if (! Schema::hasTable('settings')) { + return $generalSettings; + } + } catch (Throwable) { + return $generalSettings; + } + + try { + $settings = app(GeneralSettings::class); + $currencies = $this->normalizeCurrencies($settings->currencies ?? $fallbackCurrencies); + $availableLocales = config('app.available_locales', ['en']); + $defaultLanguage = in_array($settings->default_language, $availableLocales, true) + ? $settings->default_language + : $fallbackLocale; + $googleMapsApiKey = trim((string) ($settings->google_maps_api_key ?: $fallbackGoogleMapsApiKey)); + $googleClientId = trim((string) ($settings->google_client_id ?: $fallbackGoogleClientId)); + $googleClientSecret = trim((string) ($settings->google_client_secret ?: $fallbackGoogleClientSecret)); + $facebookClientId = trim((string) ($settings->facebook_client_id ?: $fallbackFacebookClientId)); + $facebookClientSecret = trim((string) ($settings->facebook_client_secret ?: $fallbackFacebookClientSecret)); + $appleClientId = trim((string) ($settings->apple_client_id ?: $fallbackAppleClientId)); + $appleClientSecret = trim((string) ($settings->apple_client_secret ?: $fallbackAppleClientSecret)); + $defaultCountryCode = CountryCodeManager::normalizeCountryCode($settings->default_country_code ?? $fallbackDefaultCountryCode); + $mediaDriver = MediaStorage::normalizeDriver($settings->media_disk ?? null); + + return [ + 'site_name' => trim((string) ($settings->site_name ?: $fallbackName)), + 'site_description' => trim((string) ($settings->site_description ?: $fallbackDescription)), + 'media_disk' => $mediaDriver, + 'home_slides' => HomeSlideDefaults::normalize( + $settings->home_slides ?? [], + MediaStorage::diskFromDriver($mediaDriver), + ), + 'site_logo_url' => filled($settings->site_logo) + ? MediaStorage::url($settings->site_logo, $settings->site_logo_disk ?? null) + : null, + 'default_language' => $defaultLanguage, + 'default_country_code' => $defaultCountryCode, + 'currencies' => $currencies, + 'sender_email' => trim((string) ($settings->sender_email ?: config('mail.from.address'))), + 'sender_name' => trim((string) ($settings->sender_name ?: $fallbackName)), + 'linkedin_url' => $settings->linkedin_url ?: null, + 'instagram_url' => $settings->instagram_url ?: null, + 'whatsapp' => $settings->whatsapp ?: null, + 'google_maps_enabled' => (bool) ($settings->enable_google_maps ?? false), + 'google_maps_api_key' => $googleMapsApiKey !== '' ? $googleMapsApiKey : null, + 'google_login_enabled' => (bool) ($settings->enable_google_login ?? false), + 'google_client_id' => $googleClientId !== '' ? $googleClientId : null, + 'google_client_secret' => $googleClientSecret !== '' ? $googleClientSecret : null, + 'facebook_login_enabled' => (bool) ($settings->enable_facebook_login ?? false), + 'facebook_client_id' => $facebookClientId !== '' ? $facebookClientId : null, + 'facebook_client_secret' => $facebookClientSecret !== '' ? $facebookClientSecret : null, + 'apple_login_enabled' => (bool) ($settings->enable_apple_login ?? false), + 'apple_client_id' => $appleClientId !== '' ? $appleClientId : null, + 'apple_client_secret' => $appleClientSecret !== '' ? $appleClientSecret : null, + ]; + } catch (Throwable) { + return $generalSettings; + } + } + + private function applyRuntimeConfig(array $generalSettings): void + { + $mapsKey = $generalSettings['google_maps_enabled'] + ? $generalSettings['google_maps_api_key'] + : null; + + Config::set([ + 'app.name' => $generalSettings['site_name'], + 'app.locale' => $generalSettings['default_language'], + 'app.currencies' => $generalSettings['currencies'], + 'mail.from.address' => $generalSettings['sender_email'], + 'mail.from.name' => $generalSettings['sender_name'], + 'filament-google-maps.key' => $mapsKey, + 'filament-google-maps.keys.web_key' => $mapsKey, + 'filament-google-maps.keys.server_key' => $mapsKey, + 'services.google.client_id' => $generalSettings['google_client_id'], + 'services.google.client_secret' => $generalSettings['google_client_secret'], + 'services.google.redirect' => url('/oauth/callback/google'), + 'services.google.enabled' => (bool) $generalSettings['google_login_enabled'], + 'services.facebook.client_id' => $generalSettings['facebook_client_id'], + 'services.facebook.client_secret' => $generalSettings['facebook_client_secret'], + 'services.facebook.redirect' => url('/oauth/callback/facebook'), + 'services.facebook.enabled' => (bool) $generalSettings['facebook_login_enabled'], + 'services.apple.client_id' => $generalSettings['apple_client_id'], + 'services.apple.client_secret' => $generalSettings['apple_client_secret'], + 'services.apple.redirect' => url('/oauth/callback/apple'), + 'services.apple.stateless' => true, + 'services.apple.enabled' => (bool) $generalSettings['apple_login_enabled'], + 'money.defaults.currency' => $generalSettings['currencies'][0] ?? 'USD', + 'app.default_country_code' => $generalSettings['default_country_code'] ?? '+90', + 'app.default_country_iso2' => CountryCodeManager::iso2FromCountryCode($generalSettings['default_country_code'] ?? '+90') ?? 'TR', + ]); + + MediaStorage::applyRuntimeConfig(); + } + + private function resolveHeaderLocationCountries(): array + { + try { + if (! Schema::hasTable('countries') || ! Schema::hasTable('cities')) { + return []; + } + + return Country::query() + ->where('is_active', true) + ->orderBy('name') + ->get(['id', 'name', 'code']) + ->map(fn (Country $country): array => [ + 'id' => (int) $country->id, + 'name' => (string) $country->name, + 'code' => strtoupper((string) $country->code), + ]) + ->values() + ->all(); + } catch (Throwable) { + return []; + } + } + + private function resolveHeaderNavCategories(): array + { + try { + if (! Schema::hasTable('categories')) { + return []; + } + + return Category::query() + ->where('is_active', true) + ->whereNull('parent_id') + ->orderBy('sort_order') + ->orderBy('name') + ->limit(8) + ->get(['id', 'name']) + ->map(fn (Category $category): array => [ + 'id' => (int) $category->id, + 'name' => (string) $category->name, + ]) + ->values() + ->all(); + } catch (Throwable) { + return []; + } + } + + private function normalizeCurrencies(array $currencies): array + { + $normalized = collect($currencies) + ->filter(fn ($currency) => is_string($currency) && trim($currency) !== '') + ->map(fn (string $currency) => strtoupper(substr(trim($currency), 0, 3))) + ->filter(fn (string $currency) => strlen($currency) === 3) + ->unique() + ->values() + ->all(); + + return $normalized !== [] ? $normalized : ['USD']; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index ca1d20c81..843a3d107 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,9 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +use Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests; +use Illuminate\Session\Middleware\StartSession; +use Modules\Demo\App\Http\Middleware\ResolveDemoRequest; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -12,8 +15,15 @@ return Application::configure(basePath: dirname(__DIR__)) ) ->withMiddleware(function (Middleware $middleware): void { $middleware->web(append: [ + ResolveDemoRequest::class, + \App\Http\Middleware\BootstrapAppData::class, \App\Http\Middleware\SetLocale::class, ]); + + $middleware->appendToPriorityList(StartSession::class, ResolveDemoRequest::class); + $middleware->appendToPriorityList(ResolveDemoRequest::class, \App\Http\Middleware\BootstrapAppData::class); + $middleware->appendToPriorityList(\App\Http\Middleware\BootstrapAppData::class, \App\Http\Middleware\SetLocale::class); + $middleware->prependToPriorityList(AuthenticatesRequests::class, ResolveDemoRequest::class); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/composer.json b/composer.json index 3898d959e..56a7b7767 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ "php": "^8.2", "a909m/filament-statefusion": "^2.3", "ariaieboy/filament-currency": "^3.0", + "aws/aws-sdk-php": "^3.322", "bezhansalleh/filament-language-switch": "^4.1", "cheesegrits/filament-google-maps": "^5.0", "dutchcodingcompany/filament-developer-logins": "^2.1", @@ -21,6 +22,7 @@ "laravel/framework": "^12.0", "laravel/sanctum": "^4.3", "laravel/tinker": "^2.10.1", + "league/flysystem-aws-s3-v3": "^3.25", "mwguerra/filemanager": "^2.0", "nwidart/laravel-modules": "^11.0", "pxlrbt/filament-activity-log": "^2.1", diff --git a/config/cache.php b/config/cache.php index b32aead25..efb264893 100644 --- a/config/cache.php +++ b/config/cache.php @@ -41,9 +41,9 @@ return [ 'database' => [ 'driver' => 'database', - 'connection' => env('DB_CACHE_CONNECTION'), + 'connection' => env('DB_CACHE_CONNECTION', env('DEMO', false) ? 'pgsql_public' : null), 'table' => env('DB_CACHE_TABLE', 'cache'), - 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), + 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION', env('DEMO', false) ? 'pgsql_public' : null), 'lock_table' => env('DB_CACHE_LOCK_TABLE'), ], diff --git a/config/database.php b/config/database.php index df933e7f1..0331dd9af 100644 --- a/config/database.php +++ b/config/database.php @@ -98,6 +98,21 @@ return [ 'sslmode' => env('DB_SSLMODE', 'prefer'), ], + 'pgsql_public' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => env('DEMO_PUBLIC_SCHEMA', 'public'), + 'sslmode' => env('DB_SSLMODE', 'prefer'), + ], + 'sqlsrv' => [ 'driver' => 'sqlsrv', 'url' => env('DB_URL'), diff --git a/config/demo.php b/config/demo.php new file mode 100644 index 000000000..63d696342 --- /dev/null +++ b/config/demo.php @@ -0,0 +1,11 @@ + (bool) env('DEMO', false), + 'provisioning' => false, + 'ttl_minutes' => (int) env('DEMO_TTL_MINUTES', 360), + 'schema_prefix' => env('DEMO_SCHEMA_PREFIX', 'demo_'), + 'cookie_name' => env('DEMO_COOKIE_NAME', 'oc2_demo'), + 'login_email' => env('DEMO_LOGIN_EMAIL', 'a@a.com'), + 'public_schema' => env('DEMO_PUBLIC_SCHEMA', 'public'), +]; diff --git a/config/filesystems.php b/config/filesystems.php index 40ab58625..157e0ffac 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -13,7 +13,7 @@ return [ | */ - 'default' => env('FILESYSTEM_DISK', 'local'), + 'default' => env('FILESYSTEM_DISK', env('MEDIA_DISK', 's3')), /* |-------------------------------------------------------------------------- @@ -49,13 +49,14 @@ return [ 's3' => [ 'driver' => 's3', - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'region' => env('AWS_DEFAULT_REGION'), - 'bucket' => env('AWS_BUCKET'), - 'url' => env('AWS_URL'), - 'endpoint' => env('AWS_ENDPOINT'), + 'key' => env('AWS_ACCESS_KEY_ID', env('OBJECT_STORAGE_ACCESS_KEY_ID')), + 'secret' => env('AWS_SECRET_ACCESS_KEY', env('OBJECT_STORAGE_SECRET_ACCESS_KEY')), + 'region' => env('AWS_DEFAULT_REGION', env('OBJECT_STORAGE_REGION', 'hel1')), + 'bucket' => env('AWS_BUCKET', env('OBJECT_STORAGE_BUCKET')), + 'url' => env('AWS_URL', env('OBJECT_STORAGE_URL')), + 'endpoint' => env('AWS_ENDPOINT', env('OBJECT_STORAGE_ENDPOINT')), 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'visibility' => 'public', 'throw' => false, 'report' => false, ], diff --git a/config/queue.php b/config/queue.php index 79c2c0a23..00734f85a 100644 --- a/config/queue.php +++ b/config/queue.php @@ -37,7 +37,7 @@ return [ 'database' => [ 'driver' => 'database', - 'connection' => env('DB_QUEUE_CONNECTION'), + 'connection' => env('DB_QUEUE_CONNECTION', env('DEMO', false) ? 'pgsql_public' : null), 'table' => env('DB_QUEUE_TABLE', 'jobs'), 'queue' => env('DB_QUEUE', 'default'), 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), @@ -103,7 +103,7 @@ return [ */ 'batching' => [ - 'database' => env('DB_CONNECTION', 'sqlite'), + 'database' => env('DB_BATCHING_CONNECTION', env('DEMO', false) ? 'pgsql_public' : env('DB_CONNECTION', 'sqlite')), 'table' => 'job_batches', ], @@ -122,7 +122,7 @@ return [ 'failed' => [ 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), - 'database' => env('DB_CONNECTION', 'sqlite'), + 'database' => env('DB_FAILED_CONNECTION', env('DEMO', false) ? 'pgsql_public' : env('DB_CONNECTION', 'sqlite')), 'table' => 'failed_jobs', ], diff --git a/config/session.php b/config/session.php index 5b541b755..075cb6280 100644 --- a/config/session.php +++ b/config/session.php @@ -73,7 +73,7 @@ return [ | */ - 'connection' => env('SESSION_CONNECTION'), + 'connection' => env('SESSION_CONNECTION', env('DEMO', false) ? 'pgsql_public' : null), /* |-------------------------------------------------------------------------- diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index e2285bdf1..0710ec4d1 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -1,42 +1,24 @@ 'a@a.com'], - ['name' => 'Admin', 'password' => Hash::make('236330'), 'status' => 'active'] - ); - - $partner = User::updateOrCreate( - ['email' => 'b@b.com'], - ['name' => 'Partner', 'password' => Hash::make('36330'), 'status' => 'active'] - ); - - if (class_exists(Role::class)) { - $adminRole = Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']); - $partnerRole = Role::firstOrCreate(['name' => 'partner', 'guard_name' => 'web']); - - $admin->syncRoles([$adminRole->name]); - $partner->syncRoles([$partnerRole->name]); - } - $this->call([ HomeSliderSettingsSeeder::class, \Modules\Location\Database\Seeders\LocationSeeder::class, \Modules\Category\Database\Seeders\CategorySeeder::class, \Modules\Listing\Database\Seeders\ListingCustomFieldSeeder::class, - \Modules\Listing\Database\Seeders\ListingSeeder::class, - \Modules\Listing\Database\Seeders\ListingPanelDemoSeeder::class, - \Modules\Favorite\Database\Seeders\FavoriteDemoSeeder::class, - \Modules\Conversation\Database\Seeders\ConversationDemoSeeder::class, ]); + + if ((bool) config('demo.enabled') || (bool) config('demo.provisioning')) { + $this->call([ + \Modules\Demo\Database\Seeders\DemoContentSeeder::class, + ]); + } } } diff --git a/database/settings/2026_03_07_120000_add_media_storage_to_general_settings.php b/database/settings/2026_03_07_120000_add_media_storage_to_general_settings.php new file mode 100644 index 000000000..0df0542f1 --- /dev/null +++ b/database/settings/2026_03_07_120000_add_media_storage_to_general_settings.php @@ -0,0 +1,83 @@ +migrator->exists('general.media_disk')) { + $this->migrator->add('general.media_disk', 's3'); + } else { + $this->migrator->update('general.media_disk', fn ($value) => in_array($value, ['local', 's3'], true) ? $value : 's3'); + } + + if (! $this->migrator->exists('general.site_logo_disk')) { + $this->migrator->add('general.site_logo_disk', $this->legacyDiskForPath($this->settingValue('site_logo'))); + } else { + $this->migrator->update('general.site_logo_disk', fn ($value) => is_string($value) && trim($value) !== '' ? trim($value) : $this->legacyDiskForPath($this->settingValue('site_logo'))); + } + + if (! $this->migrator->exists('general.home_slides')) { + return; + } + + $this->migrator->update('general.home_slides', function ($slides) { + if (! is_array($slides)) { + return $slides; + } + + return collect($slides) + ->map(function ($slide) { + if (! is_array($slide)) { + return $slide; + } + + $imagePath = is_string($slide['image_path'] ?? null) ? trim($slide['image_path']) : ''; + $slide['disk'] = is_string($slide['disk'] ?? null) && trim($slide['disk']) !== '' + ? trim($slide['disk']) + : $this->legacyDiskForPath($imagePath); + + return $slide; + }) + ->all(); + }); + } + + private function legacyDiskForPath(mixed $path): ?string + { + if (! is_string($path) || trim($path) === '') { + return null; + } + + $path = trim($path); + + if ( + str_starts_with($path, 'http://') + || str_starts_with($path, 'https://') + || str_starts_with($path, '//') + || str_starts_with($path, 'images/') + ) { + return null; + } + + return 'public'; + } + + private function settingValue(string $name): mixed + { + $payload = DB::table('settings') + ->where('group', 'general') + ->where('name', $name) + ->value('payload'); + + if (! is_string($payload)) { + return $payload; + } + + $decoded = json_decode($payload, true); + + return json_last_error() === JSON_ERROR_NONE ? $decoded : $payload; + } +}; diff --git a/modules_statuses.json b/modules_statuses.json index 3d7af52c5..d8ed418d4 100644 --- a/modules_statuses.json +++ b/modules_statuses.json @@ -8,5 +8,7 @@ "Conversation": true, "Favorite": true, "User": true, - "Video": true + "Video": true, + "S3": true, + "Demo": true } diff --git a/resources/views/home.blade.php b/resources/views/home.blade.php index 4572f4b66..60fc4e0cf 100644 --- a/resources/views/home.blade.php +++ b/resources/views/home.blade.php @@ -5,6 +5,25 @@ $heroListing = $featuredListings->first() ?? $recentListings->first(); $heroImage = $heroListing?->getFirstMediaUrl('listing-images'); $listingCards = $recentListings->take(6); + $demoEnabled = (bool) config('demo.enabled'); + $prepareDemoRoute = $demoEnabled ? route('demo.prepare') : null; + $prepareDemoRedirect = url()->full(); + $hasDemoSession = (bool) session('is_demo_session') || filled(session('demo_uuid')); + $demoLandingMode = $demoEnabled && !auth()->check() && !$hasDemoSession; + $demoTtlMinutes = (int) config('demo.ttl_minutes', 360); + $demoTtlHours = intdiv($demoTtlMinutes, 60); + $demoTtlRemainderMinutes = $demoTtlMinutes % 60; + $demoTtlLabelParts = []; + + if ($demoTtlHours > 0) { + $demoTtlLabelParts[] = $demoTtlHours.' '.\Illuminate\Support\Str::plural('hour', $demoTtlHours); + } + + if ($demoTtlRemainderMinutes > 0) { + $demoTtlLabelParts[] = $demoTtlRemainderMinutes.' '.\Illuminate\Support\Str::plural('minute', $demoTtlRemainderMinutes); + } + + $demoTtlLabel = $demoTtlLabelParts !== [] ? implode(' ', $demoTtlLabelParts) : '0 minutes'; $homeSlides = collect($generalSettings['home_slides'] ?? []) ->filter(fn ($slide): bool => is_array($slide)) ->map(function (array $slide): array { @@ -21,13 +40,7 @@ 'subtitle' => $subtitle !== '' ? $subtitle : 'Buy and sell everything in your area', 'primary_button_text' => $primaryButtonText !== '' ? $primaryButtonText : 'Browse Listings', 'secondary_button_text' => $secondaryButtonText !== '' ? $secondaryButtonText : 'Post Listing', - 'image_url' => $imagePath !== '' - ? (str_starts_with($imagePath, 'http://') || str_starts_with($imagePath, 'https://') - ? $imagePath - : (str_starts_with($imagePath, 'images/') - ? asset($imagePath) - : \Illuminate\Support\Facades\Storage::disk('public')->url($imagePath))) - : null, + 'image_url' => \Modules\S3\Support\MediaStorage::url($imagePath, $slide['disk'] ?? null), ]; }) ->values(); @@ -67,6 +80,24 @@ ]; @endphp +@if($demoLandingMode && $prepareDemoRoute) +
+
+ @csrf + +

Prepare Demo

+

+ Launch a private seeded marketplace for this browser. Listings, favorites, inbox data, and admin access are prepared automatically. +

+

+ This demo is deleted automatically after {{ $demoTtlLabel }}. +

+ +
+
+@else
@@ -292,15 +323,17 @@ @endphp @empty @@ -364,6 +391,7 @@
+@endif