diff --git a/Modules/Blog/Models/Blog.php b/Modules/Blog/Models/Blog.php index b202da7..4b08934 100644 --- a/Modules/Blog/Models/Blog.php +++ b/Modules/Blog/Models/Blog.php @@ -1,4 +1,5 @@ 'boolean','published_at'=>'datetime','expires_at'=>'datetime']; } public function author(): BelongsTo { return $this->belongsTo(Author::class); } public function category(): BelongsTo { return $this->belongsTo(Category::class); } public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class, 'blog_post_tag'); } public function scopePublished(Builder $query): Builder { return $query->where('status','published')->where(fn(Builder $q)=>$q->whereNull('published_at')->orWhere('published_at','<=',now()))->where(fn(Builder $q)=>$q->whereNull('expires_at')->orWhere('expires_at','>',now())); } } +use Modules\Sites\Concerns\BelongsToSites; +final class Blog extends Model { use BelongsToSites, HasTranslations, HasWorkflow; protected $table='blog_posts'; protected $fillable=['author_id','category_id','title','slug','excerpt','content','featured_image','status','is_featured','published_at','expires_at','meta_title','meta_description']; protected function casts(): array { return ['is_featured'=>'boolean','published_at'=>'datetime','expires_at'=>'datetime']; } public function author(): BelongsTo { return $this->belongsTo(Author::class); } public function category(): BelongsTo { return $this->belongsTo(Category::class); } public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class, 'blog_post_tag'); } public function scopePublished(Builder $query): Builder { return $query->where('status','published')->where(fn(Builder $q)=>$q->whereNull('published_at')->orWhere('published_at','<=',now()))->where(fn(Builder $q)=>$q->whereNull('expires_at')->orWhere('expires_at','>',now())); } } diff --git a/Modules/News/Models/News.php b/Modules/News/Models/News.php index 2b369b3..c481083 100644 --- a/Modules/News/Models/News.php +++ b/Modules/News/Models/News.php @@ -1,4 +1,5 @@ 'boolean','published_at'=>'datetime','expires_at'=>'datetime']; } public function author(): BelongsTo { return $this->belongsTo(Author::class); } public function category(): BelongsTo { return $this->belongsTo(Category::class); } public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class, 'news_article_tag'); } public function scopePublished(Builder $query): Builder { return $query->where('status','published')->where(fn(Builder $q)=>$q->whereNull('published_at')->orWhere('published_at','<=',now()))->where(fn(Builder $q)=>$q->whereNull('expires_at')->orWhere('expires_at','>',now())); } } +use Modules\Sites\Concerns\BelongsToSites; +final class News extends Model { use BelongsToSites, HasTranslations, HasWorkflow; protected $table='news_articles'; protected $fillable=['author_id','category_id','title','slug','excerpt','content','featured_image','status','is_featured','published_at','expires_at','meta_title','meta_description']; protected function casts(): array { return ['is_featured'=>'boolean','published_at'=>'datetime','expires_at'=>'datetime']; } public function author(): BelongsTo { return $this->belongsTo(Author::class); } public function category(): BelongsTo { return $this->belongsTo(Category::class); } public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class, 'news_article_tag'); } public function scopePublished(Builder $query): Builder { return $query->where('status','published')->where(fn(Builder $q)=>$q->whereNull('published_at')->orWhere('published_at','<=',now()))->where(fn(Builder $q)=>$q->whereNull('expires_at')->orWhere('expires_at','>',now())); } } diff --git a/Modules/Pages/Models/Page.php b/Modules/Pages/Models/Page.php index db9afca..28a2084 100644 --- a/Modules/Pages/Models/Page.php +++ b/Modules/Pages/Models/Page.php @@ -7,10 +7,11 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; use Modules\Workflow\Concerns\HasWorkflow; use Modules\Localization\Concerns\HasTranslations; +use Modules\Sites\Concerns\BelongsToSites; final class Page extends Model { - use HasFactory, HasTranslations, HasWorkflow, SoftDeletes; + use BelongsToSites, HasFactory, HasTranslations, HasWorkflow, SoftDeletes; protected $table = 'pages'; protected $fillable = ['title', 'slug', 'summary', 'content', 'status', 'published_at', 'is_home']; diff --git a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php index f5a29f8..0e0e0ef 100644 --- a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php +++ b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php @@ -55,6 +55,9 @@ final class RolePermissionSeeder extends Seeder 'workflow.review', 'localization.manage', 'localization.translate', + 'sites.view', + 'sites.manage', + 'sites.assign', ]; foreach ($permissions as $permission) { diff --git a/Modules/Sites/Concerns/BelongsToSites.php b/Modules/Sites/Concerns/BelongsToSites.php new file mode 100644 index 0000000..f58afa5 --- /dev/null +++ b/Modules/Sites/Concerns/BelongsToSites.php @@ -0,0 +1,24 @@ +morphToMany(Site::class, 'content', 'site_content', 'content_id', 'site_id')->withPivot('is_primary')->withTimestamps(); + } + public function scopeForCurrentSite(Builder $query): Builder + { + $site = app(SiteManager::class)->current(); + return $site ? $query->whereHas('sites', fn (Builder $sites) => $sites->whereKey($site->id)) : $query; + } + public function assignToSite(Site|int $site, bool $primary = false): void + { + $id = $site instanceof Site ? $site->getKey() : $site; + if ($primary) { $this->sites()->updateExistingPivot($this->sites()->pluck('sites.id'), ['is_primary' => false]); } + $this->sites()->syncWithoutDetaching([$id => ['is_primary' => $primary]]); + } +} diff --git a/Modules/Sites/Config/config.php b/Modules/Sites/Config/config.php new file mode 100644 index 0000000..1b7cee1 --- /dev/null +++ b/Modules/Sites/Config/config.php @@ -0,0 +1,11 @@ + true, + 'session_key' => 'platform_site_id', + 'central_domains' => array_values(array_filter(explode(',', (string) env('CENTRAL_DOMAINS', '')))), + 'content_types' => [ + 'pages' => Modules\Pages\Models\Page::class, + 'blog' => Modules\Blog\Models\Blog::class, + 'news' => Modules\News\Models\News::class, + ], +]; diff --git a/Modules/Sites/Contracts/SiteAwareModule.php b/Modules/Sites/Contracts/SiteAwareModule.php new file mode 100644 index 0000000..3566063 --- /dev/null +++ b/Modules/Sites/Contracts/SiteAwareModule.php @@ -0,0 +1,7 @@ +id(); $table->string('name'); $table->string('key')->unique(); $table->string('status')->default('active'); + $table->string('theme')->nullable(); $table->string('default_locale', 20)->default('en'); $table->string('timezone')->default('UTC'); + $table->boolean('is_default')->default(false); $table->timestamps(); + }); + Schema::create('site_domains', function (Blueprint $table): void { + $table->id(); $table->foreignId('site_id')->constrained()->cascadeOnDelete(); $table->string('domain')->unique(); + $table->boolean('is_primary')->default(false); $table->boolean('redirect_to_primary')->default(false); $table->boolean('force_https')->default(true); $table->boolean('is_active')->default(true); $table->timestamps(); + }); + Schema::create('site_settings', function (Blueprint $table): void { + $table->id(); $table->foreignId('site_id')->constrained()->cascadeOnDelete(); $table->string('group')->default('general'); $table->string('key'); $table->longText('value')->nullable(); $table->string('type')->default('string'); $table->timestamps(); $table->unique(['site_id','key']); + }); + Schema::create('site_content', function (Blueprint $table): void { + $table->id(); $table->foreignId('site_id')->constrained()->cascadeOnDelete(); $table->string('content_type'); $table->unsignedBigInteger('content_id'); $table->boolean('is_primary')->default(false); $table->timestamps(); + $table->unique(['site_id','content_type','content_id']); $table->index(['content_type','content_id']); + }); + Schema::create('site_user', function (Blueprint $table): void { + $table->foreignId('site_id')->constrained()->cascadeOnDelete(); $table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->string('role')->default('editor'); $table->timestamps(); $table->primary(['site_id','user_id']); + }); + } + public function down(): void + { + Schema::dropIfExists('site_user'); Schema::dropIfExists('site_content'); Schema::dropIfExists('site_settings'); Schema::dropIfExists('site_domains'); Schema::dropIfExists('sites'); + } +}; diff --git a/Modules/Sites/Database/Seeders/SiteSeeder.php b/Modules/Sites/Database/Seeders/SiteSeeder.php new file mode 100644 index 0000000..1908004 --- /dev/null +++ b/Modules/Sites/Database/Seeders/SiteSeeder.php @@ -0,0 +1,13 @@ +updateOrCreate(['key' => 'default'], ['name'=>'Default Site','status'=>'active','default_locale'=>config('app.locale','en'),'timezone'=>config('app.timezone','UTC'),'is_default'=>true]); + $site->domains()->updateOrCreate(['domain'=>$host], ['is_primary'=>true,'redirect_to_primary'=>false,'force_https'=>str_starts_with((string) config('app.url'), 'https://'),'is_active'=>true]); + } +} diff --git a/Modules/Sites/Http/Controllers/DomainController.php b/Modules/Sites/Http/Controllers/DomainController.php new file mode 100644 index 0000000..155a886 --- /dev/null +++ b/Modules/Sites/Http/Controllers/DomainController.php @@ -0,0 +1,19 @@ +validate(['domain'=>['required','string','max:255','regex:/^[a-z0-9.-]+$/i',Rule::unique('site_domains','domain')],'is_primary'=>['sometimes','boolean'],'redirect_to_primary'=>['sometimes','boolean'],'force_https'=>['sometimes','boolean'],'is_active'=>['sometimes','boolean']]); + if ($request->boolean('is_primary')) { $site->domains()->update(['is_primary'=>false]); } + $site->domains()->create($data + ['is_primary'=>$request->boolean('is_primary'),'redirect_to_primary'=>$request->boolean('redirect_to_primary'),'force_https'=>$request->boolean('force_https'),'is_active'=>$request->boolean('is_active', true)]); + return back()->with('success','Domain added.'); + } + public function destroy(Site $site, SiteDomain $domain): RedirectResponse { abort_unless($domain->site_id===$site->id,404); abort_if($domain->is_primary && $site->domains()->count()===1,422,'A site must retain a domain.'); $domain->delete(); return back()->with('success','Domain removed.'); } +} diff --git a/Modules/Sites/Http/Controllers/SiteController.php b/Modules/Sites/Http/Controllers/SiteController.php new file mode 100644 index 0000000..7dc537c --- /dev/null +++ b/Modules/Sites/Http/Controllers/SiteController.php @@ -0,0 +1,27 @@ +Site::query()->with('domains')->orderByDesc('is_default')->orderBy('name')->paginate(20)]); } + public function create(): View { return view('sites::admin.sites.form', ['site'=>new Site]); } + public function store(Request $request): RedirectResponse { $site = Site::query()->create($this->validated($request)); return redirect()->route('administration.sites.edit', $site)->with('success','Site created.'); } + public function edit(Site $site): View { return view('sites::admin.sites.form', ['site'=>$site->load(['domains','settings'])]); } + public function update(Request $request, Site $site): RedirectResponse + { + DB::transaction(function () use ($request,$site): void { $data=$this->validated($request,$site); if ($data['is_default']) { Site::query()->whereKeyNot($site->id)->update(['is_default'=>false]); } $site->update($data); }); + return back()->with('success','Site updated.'); + } + public function destroy(Site $site): RedirectResponse { abort_if($site->is_default, 422, 'The default site cannot be deleted.'); $site->delete(); return redirect()->route('administration.sites.index')->with('success','Site deleted.'); } + public function switch(Request $request, Site $site): RedirectResponse { $request->session()->put(config('sites.session_key'), $site->id); return back()->with('success', "Site context changed to {$site->name}."); } + private function validated(Request $request, ?Site $site=null): array + { + return $request->validate(['name'=>['required','string','max:150'],'key'=>['required','alpha_dash','max:100',Rule::unique('sites','key')->ignore($site)],'status'=>['required',Rule::in(['active','inactive','maintenance'])],'theme'=>['nullable','string','max:100'],'default_locale'=>['required','string','max:20'],'timezone'=>['required','timezone'],'is_default'=>['sometimes','boolean']]) + ['is_default'=>$request->boolean('is_default')]; + } +} diff --git a/Modules/Sites/Http/Middleware/ResolveSite.php b/Modules/Sites/Http/Middleware/ResolveSite.php new file mode 100644 index 0000000..780ae24 --- /dev/null +++ b/Modules/Sites/Http/Middleware/ResolveSite.php @@ -0,0 +1,19 @@ +sites->resolve($request); + if ($site) { + config(['app.url' => $request->getSchemeAndHttpHost(), 'app.timezone' => $site->timezone ?: config('app.timezone')]); + date_default_timezone_set((string) config('app.timezone')); + } + return $next($request); + } +} diff --git a/Modules/Sites/Models/Site.php b/Modules/Sites/Models/Site.php new file mode 100644 index 0000000..d9bedd2 --- /dev/null +++ b/Modules/Sites/Models/Site.php @@ -0,0 +1,19 @@ + 'boolean']; } + public function domains(): HasMany { return $this->hasMany(SiteDomain::class); } + public function settings(): HasMany { return $this->hasMany(SiteSetting::class); } + public function assignments(): HasMany { return $this->hasMany(SiteContent::class); } + public function scopeActive(Builder $query): Builder { return $query->where('status', 'active'); } + public function setting(string $key, mixed $default = null): mixed + { + $setting = $this->relationLoaded('settings') ? $this->settings->firstWhere('key', $key) : $this->settings()->where('key', $key)->first(); + return $setting?->typedValue() ?? $default; + } +} diff --git a/Modules/Sites/Models/SiteContent.php b/Modules/Sites/Models/SiteContent.php new file mode 100644 index 0000000..e142b2b --- /dev/null +++ b/Modules/Sites/Models/SiteContent.php @@ -0,0 +1,13 @@ +'boolean']; } + public function site(): BelongsTo { return $this->belongsTo(Site::class); } + public function content(): MorphTo { return $this->morphTo(__FUNCTION__, 'content_type', 'content_id'); } +} diff --git a/Modules/Sites/Models/SiteDomain.php b/Modules/Sites/Models/SiteDomain.php new file mode 100644 index 0000000..1011ea4 --- /dev/null +++ b/Modules/Sites/Models/SiteDomain.php @@ -0,0 +1,10 @@ +'boolean','redirect_to_primary'=>'boolean','force_https'=>'boolean','is_active'=>'boolean']; } + public function site(): BelongsTo { return $this->belongsTo(Site::class); } +} diff --git a/Modules/Sites/Models/SiteSetting.php b/Modules/Sites/Models/SiteSetting.php new file mode 100644 index 0000000..bf1b0b4 --- /dev/null +++ b/Modules/Sites/Models/SiteSetting.php @@ -0,0 +1,18 @@ +belongsTo(Site::class); } + public function typedValue(): mixed + { + return match ($this->type) { + 'boolean' => filter_var($this->value, FILTER_VALIDATE_BOOLEAN), + 'integer' => (int) $this->value, + 'json' => json_decode((string) $this->value, true), + default => $this->value, + }; + } +} diff --git a/Modules/Sites/Providers/RouteServiceProvider.php b/Modules/Sites/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..2b6e293 --- /dev/null +++ b/Modules/Sites/Providers/RouteServiceProvider.php @@ -0,0 +1,8 @@ +group(module_path('Sites', 'Routes/web.php')); } +} diff --git a/Modules/Sites/Providers/SitesServiceProvider.php b/Modules/Sites/Providers/SitesServiceProvider.php new file mode 100644 index 0000000..5153092 --- /dev/null +++ b/Modules/Sites/Providers/SitesServiceProvider.php @@ -0,0 +1,18 @@ +mergeConfigFrom(module_path('Sites', 'Config/config.php'), 'sites'); + $this->app->singleton(SiteManager::class); + $this->app->register(RouteServiceProvider::class); + } + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Sites', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Sites', 'Resources/views'), 'sites'); + } +} diff --git a/Modules/Sites/Resources/views/admin/sites/form.blade.php b/Modules/Sites/Resources/views/admin/sites/form.blade.php new file mode 100644 index 0000000..987ee09 --- /dev/null +++ b/Modules/Sites/Resources/views/admin/sites/form.blade.php @@ -0,0 +1 @@ +{{ $site->exists ? 'Edit site' : 'New site' }}Configure the site identity, theme and regional defaults.@csrf @if($site->exists) @method('PUT') @endifstatus)==='active')>Activestatus)==='inactive')>Inactivestatus)==='maintenance')>MaintenanceDefault siteSave siteCancel@if($site->exists)Domains@foreach($site->domains as $domain){{ $domain->domain }} @if($domain->is_primary)Primary@endif@csrf @method('DELETE')Remove@endforeach@csrfPrimaryForce HTTPSActiveAdd domain@endif diff --git a/Modules/Sites/Resources/views/admin/sites/index.blade.php b/Modules/Sites/Resources/views/admin/sites/index.blade.php new file mode 100644 index 0000000..dc7414e --- /dev/null +++ b/Modules/Sites/Resources/views/admin/sites/index.blade.php @@ -0,0 +1 @@ +SitesManage websites, domains, themes and regional defaults.New siteSiteDomainsThemeStatus@foreach($sites as $site){{ $site->name }} @if($site->is_default)Default@endif{{ $site->domains->pluck('domain')->join(', ') ?: 'No domains' }}{{ $site->theme ?: 'Inherited' }}{{ ucfirst($site->status) }}Edit@endforeach{{ $sites->links() }} diff --git a/Modules/Sites/Routes/web.php b/Modules/Sites/Routes/web.php new file mode 100644 index 0000000..d01b09f --- /dev/null +++ b/Modules/Sites/Routes/web.php @@ -0,0 +1,10 @@ +prefix('administration')->name('administration.')->group(function (): void { + Route::post('sites/{site}/switch', [SiteController::class,'switch'])->name('sites.switch'); + Route::resource('sites', SiteController::class)->except('show'); + Route::post('sites/{site}/domains', [DomainController::class,'store'])->name('sites.domains.store'); + Route::delete('sites/{site}/domains/{domain}', [DomainController::class,'destroy'])->name('sites.domains.destroy'); +}); diff --git a/Modules/Sites/Services/SiteManager.php b/Modules/Sites/Services/SiteManager.php new file mode 100644 index 0000000..1bd2a99 --- /dev/null +++ b/Modules/Sites/Services/SiteManager.php @@ -0,0 +1,20 @@ +current; } + public function setCurrent(?Site $site): void { $this->current = $site; if ($site) { app()->instance(Site::class, $site); } } + public function all(): Collection { return Site::query()->active()->orderByDesc('is_default')->orderBy('name')->get(); } + public function resolve(Request $request): ?Site + { + $host = strtolower(preg_replace('/:\d+$/', '', $request->getHost())); + $site = Site::query()->with(['domains','settings'])->whereHas('domains', fn ($q) => $q->where('domain', $host)->where('is_active', true))->first(); + if (! $site && config('sites.fallback_to_default')) { $site = Site::query()->with(['domains','settings'])->active()->where('is_default', true)->first(); } + $this->setCurrent($site); + return $site; + } +} diff --git a/Modules/Sites/Tests/Feature/SitesModuleBootTest.php b/Modules/Sites/Tests/Feature/SitesModuleBootTest.php new file mode 100644 index 0000000..d31be8a --- /dev/null +++ b/Modules/Sites/Tests/Feature/SitesModuleBootTest.php @@ -0,0 +1,11 @@ +assertTrue(app('router')->has('administration.sites.index')); + $this->assertTrue(app('router')->has('administration.sites.domains.store')); + } +} diff --git a/Modules/Sites/module.json b/Modules/Sites/module.json new file mode 100644 index 0000000..a8886dd --- /dev/null +++ b/Modules/Sites/module.json @@ -0,0 +1,9 @@ +{ + "name": "Sites", + "alias": "sites", + "description": "Multi-site domains, context, settings, content assignments and administration.", + "keywords": ["multisite", "sites", "domains", "tenancy"], + "priority": 35, + "providers": ["Modules\\Sites\\Providers\\SitesServiceProvider"], + "files": [] +} diff --git a/bootstrap/app.php b/bootstrap/app.php index a26229d..d00a034 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,7 +12,7 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - $middleware->web(append: [\Modules\Localization\Http\Middleware\SetLocale::class]); + $middleware->web(append: [\Modules\Sites\Http\Middleware\ResolveSite::class, \Modules\Localization\Http\Middleware\SetLocale::class]); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( diff --git a/modules_statuses.json b/modules_statuses.json index 7bd5ce5..d30050b 100644 --- a/modules_statuses.json +++ b/modules_statuses.json @@ -32,5 +32,6 @@ "Careers": true, "Locations": true, "Workflow": true, - "Localization": true + "Localization": true, + "Sites": true } diff --git a/resources/themes/common/views/components/layouts/app/sidebar.blade.php b/resources/themes/common/views/components/layouts/app/sidebar.blade.php index 1b26aa4..883cdff 100644 --- a/resources/themes/common/views/components/layouts/app/sidebar.blade.php +++ b/resources/themes/common/views/components/layouts/app/sidebar.blade.php @@ -31,7 +31,7 @@ {{ __('Team') }} {{ __('Testimonials') }} -{{ __('Dashboard') }}{{ __('Administration') }} +{{ __('Sites') }}{{ __('Dashboard') }}{{ __('Administration') }} {{ __('Review Queue') }} {{ __('Users') }}{{ __('Roles & Permissions') }}{{ __('Media') }}{{ __('Search') }} {{ __('Localisation') }}{{ __('SEO') }}{{ __('Settings') }}{{ __('Modules') }}{{ __('Audit Log') }}