move into sonardesk

This commit is contained in:
Mark Gregory
2026-10-12 14:50:13 +01:00
parent 5f0d2ceb72
commit 38b4a7406f
879 changed files with 24021 additions and 1 deletions
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace Modules\Sites\Concerns;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Modules\Sites\Models\Site;
use Modules\Sites\Services\SiteManager;
trait BelongsToSites
{
public function sites(): MorphToMany
{
return $this->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]]);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
use Modules\Blog\Models\Blog;
use Modules\News\Models\News;
use Modules\Pages\Models\Page;
return [
'fallback_to_default' => true,
'session_key' => 'platform_site_id',
'central_domains' => array_values(array_filter(explode(',', (string) env('CENTRAL_DOMAINS', '')))),
'content_types' => [
'pages' => Page::class,
'blog' => Blog::class,
'news' => News::class,
],
];
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace Modules\Sites\Contracts;
interface SiteAwareModule
{
public function supportsMultisite(): bool;
public function sharedAcrossSites(): bool;
}
@@ -0,0 +1,69 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('sites', function (Blueprint $table): void {
$table->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');
}
};
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Modules\Sites\Database\Seeders;
use Illuminate\Database\Seeder;
use Modules\Sites\Models\Site;
final class SiteSeeder extends Seeder
{
public function run(): void
{
$host = parse_url((string) config('app.url'), PHP_URL_HOST) ?: 'localhost';
$site = Site::query()->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]);
}
}
@@ -0,0 +1,33 @@
<?php
namespace Modules\Sites\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Validation\Rule;
use Modules\Sites\Models\Site;
use Modules\Sites\Models\SiteDomain;
final class DomainController extends Controller
{
public function store(Request $request, Site $site): RedirectResponse
{
$data = $request->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.');
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace Modules\Sites\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
use Modules\Sites\Models\Site;
final class SiteController extends Controller
{
public function index(): View
{
return view('sites::admin.sites.index', ['sites' => 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')];
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Modules\Sites\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Modules\Sites\Services\SiteManager;
use Symfony\Component\HttpFoundation\Response;
final class ResolveSite
{
public function __construct(private readonly SiteManager $sites) {}
public function handle(Request $request, Closure $next): Response
{
$site = $this->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);
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace Modules\Sites\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class Site extends Model
{
protected $fillable = ['name', 'key', 'status', 'theme', 'default_locale', 'timezone', 'is_default'];
protected function casts(): array
{
return ['is_default' => '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;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Modules\Sites\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
final class SiteContent extends Model
{
protected $table = 'site_content';
protected $fillable = ['site_id', 'content_type', 'content_id', 'is_primary'];
protected function casts(): array
{
return ['is_primary' => 'boolean'];
}
public function site(): BelongsTo
{
return $this->belongsTo(Site::class);
}
public function content(): MorphTo
{
return $this->morphTo(__FUNCTION__, 'content_type', 'content_id');
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace Modules\Sites\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class SiteDomain extends Model
{
protected $fillable = ['site_id', 'domain', 'is_primary', 'redirect_to_primary', 'force_https', 'is_active'];
protected function casts(): array
{
return ['is_primary' => 'boolean', 'redirect_to_primary' => 'boolean', 'force_https' => 'boolean', 'is_active' => 'boolean'];
}
public function site(): BelongsTo
{
return $this->belongsTo(Site::class);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Modules\Sites\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class SiteSetting extends Model
{
protected $fillable = ['site_id', 'group', 'key', 'value', 'type'];
public function site(): BelongsTo
{
return $this->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,
};
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace Modules\Sites\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
parent::boot();
Route::middleware('web')->group(module_path('Sites', 'Routes/web.php'));
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Modules\Sites\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\Sites\Services\SiteManager;
final class SitesServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->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');
}
}
@@ -0,0 +1 @@
<x-layouts.app :title="$site->exists ? __('Edit site') : __('New site')"><div class="max-w-5xl space-y-6"><div><flux:heading size="xl">{{ $site->exists ? 'Edit site' : 'New site' }}</flux:heading><flux:text>Configure the site identity, theme and regional defaults.</flux:text></div><form method="POST" action="{{ $site->exists ? route('administration.sites.update',$site) : route('administration.sites.store') }}" class="space-y-5 rounded-xl border border-zinc-200 p-6 dark:border-zinc-700">@csrf @if($site->exists) @method('PUT') @endif<div class="grid gap-4 md:grid-cols-2"><flux:input name="name" label="Name" value="{{ old('name',$site->name) }}" required/><flux:input name="key" label="Key" value="{{ old('key',$site->key) }}" required/><flux:select name="status" label="Status"><option value="active" @selected(old('status',$site->status)==='active')>Active</option><option value="inactive" @selected(old('status',$site->status)==='inactive')>Inactive</option><option value="maintenance" @selected(old('status',$site->status)==='maintenance')>Maintenance</option></flux:select><flux:input name="theme" label="Theme" value="{{ old('theme',$site->theme) }}"/><flux:input name="default_locale" label="Default locale" value="{{ old('default_locale',$site->default_locale ?: 'en') }}" required/><flux:input name="timezone" label="Timezone" value="{{ old('timezone',$site->timezone ?: config('app.timezone')) }}" required/></div><flux:checkbox name="is_default" value="1" :checked="old('is_default',$site->is_default)">Default site</flux:checkbox><div class="flex gap-2"><flux:button type="submit" variant="primary">Save site</flux:button><flux:button :href="route('administration.sites.index')" wire:navigate>Cancel</flux:button></div></form>@if($site->exists)<div class="rounded-xl border border-zinc-200 p-6 dark:border-zinc-700"><flux:heading size="lg">Domains</flux:heading><div class="mt-4 space-y-3">@foreach($site->domains as $domain)<div class="flex items-center justify-between rounded-lg border border-zinc-200 p-3 dark:border-zinc-700"><div><span class="font-medium">{{ $domain->domain }}</span> @if($domain->is_primary)<flux:badge size="sm">Primary</flux:badge>@endif</div><form method="POST" action="{{ route('administration.sites.domains.destroy',[$site,$domain]) }}">@csrf @method('DELETE')<flux:button type="submit" size="sm" variant="danger">Remove</flux:button></form></div>@endforeach</div><form method="POST" action="{{ route('administration.sites.domains.store',$site) }}" class="mt-5 grid gap-3 md:grid-cols-2">@csrf<flux:input name="domain" label="Domain" placeholder="example.com" required/><div class="flex flex-wrap items-end gap-4"><flux:checkbox name="is_primary" value="1">Primary</flux:checkbox><flux:checkbox name="force_https" value="1" checked>Force HTTPS</flux:checkbox><flux:checkbox name="is_active" value="1" checked>Active</flux:checkbox></div><div><flux:button type="submit">Add domain</flux:button></div></form></div>@endif</div></x-layouts.app>
@@ -0,0 +1 @@
<x-layouts.app :title="__('Sites')"><div class="flex h-full w-full flex-1 flex-col gap-6 rounded-xl"><div class="flex items-center justify-between"><div><flux:heading size="xl">Sites</flux:heading><flux:text>Manage websites, domains, themes and regional defaults.</flux:text></div><flux:button variant="primary" icon="plus" :href="route('administration.sites.create')" wire:navigate>New site</flux:button></div><div class="overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-700"><table class="w-full text-sm"><thead class="bg-zinc-50 dark:bg-zinc-900"><tr><th class="p-3 text-left">Site</th><th class="p-3 text-left">Domains</th><th class="p-3 text-left">Theme</th><th class="p-3 text-left">Status</th><th class="p-3"></th></tr></thead><tbody>@foreach($sites as $site)<tr class="border-t border-zinc-200 dark:border-zinc-700"><td class="p-3 font-medium">{{ $site->name }} @if($site->is_default)<flux:badge size="sm">Default</flux:badge>@endif</td><td class="p-3">{{ $site->domains->pluck('domain')->join(', ') ?: 'No domains' }}</td><td class="p-3">{{ $site->theme ?: 'Inherited' }}</td><td class="p-3"><flux:badge :color="$site->status === 'active' ? 'green' : 'zinc'">{{ ucfirst($site->status) }}</flux:badge></td><td class="p-3 text-right"><flux:button size="sm" :href="route('administration.sites.edit',$site)" wire:navigate>Edit</flux:button></td></tr>@endforeach</tbody></table></div>{{ $sites->links() }}</div></x-layouts.app>
+12
View File
@@ -0,0 +1,12 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Sites\Http\Controllers\DomainController;
use Modules\Sites\Http\Controllers\SiteController;
Route::middleware(['auth', 'verified'])->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');
});
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace Modules\Sites\Services;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Modules\Sites\Models\Site;
final class SiteManager
{
private ?Site $current = null;
public function current(): ?Site
{
return $this->current;
}
public function setCurrent(?Site $site): void
{
$this->current = $site;
if ($site) {
app()->instance(Site::class, $site);
app()->instance('currentSite', $site);
}
}
public function all(): Collection
{
if (! $this->tablesExist()) {
return collect();
}
return Site::query()
->active()
->orderByDesc('is_default')
->orderBy('name')
->get();
}
public function resolve(Request $request): ?Site
{
if (! $this->tablesExist()) {
$this->setCurrent(null);
return null;
}
$host = strtolower(preg_replace('/:\d+$/', '', $request->getHost()));
$site = Site::query()
->with(['domains', 'settings'])
->whereHas('domains', fn ($query) => $query
->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;
}
private function tablesExist(): bool
{
return Schema::hasTable('sites') && Schema::hasTable('site_domains');
}
}
@@ -0,0 +1,14 @@
<?php
namespace Modules\Sites\Tests\Feature;
use Tests\TestCase;
final class SitesModuleBootTest extends TestCase
{
public function test_sites_module_routes_are_registered(): void
{
$this->assertTrue(app('router')->has('administration.sites.index'));
$this->assertTrue(app('router')->has('administration.sites.domains.store'));
}
}
+9
View File
@@ -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": []
}