Add site redirects (#552)

* feat(redirects): add redirects to sites

* chore(style): fixed coding style issues

* style: fix php-stan docblocks

* style: pint cleanup

* tests: fixed redirect test suite

* feat: vhosts include additional configs

* fix: use exact location matching

* - add enums
- use queues
- use vhost rather than separate conf files
- vhost formatter
- cleanup

* generate docs

---------

Co-authored-by: Saeed Vaziry <mr.saeedvaziry@gmail.com>
This commit is contained in:
Jamie Wood
2025-03-31 16:30:57 +01:00
committed by GitHub
parent 7882d2022c
commit f483f7fdca
53 changed files with 6944 additions and 4495 deletions

View File

@ -0,0 +1,75 @@
<?php
namespace App\Actions\Redirect;
use App\Enums\RedirectStatus;
use App\Models\Redirect;
use App\Models\Service;
use App\Models\Site;
use App\SSH\Services\Webserver\Webserver;
use Illuminate\Validation\Rule;
class CreateRedirect
{
/**
* @param array<string, mixed> $input
*/
public function create(Site $site, array $input): Redirect
{
$redirect = new Redirect;
$redirect->site_id = $site->id;
$redirect->from = $input['from'];
$redirect->to = $input['to'];
$redirect->mode = $input['mode'];
$redirect->status = RedirectStatus::CREATING;
$redirect->save();
dispatch(function () use ($site, $redirect): void {
/** @var Service $service */
$service = $site->server->webserver();
/** @var Webserver $webserver */
$webserver = $service->handler();
$webserver->updateVHost($site);
$redirect->status = RedirectStatus::READY;
$redirect->save();
})
->catch(function () use ($redirect): void {
$redirect->status = RedirectStatus::FAILED;
$redirect->save();
})
->onConnection('ssh');
return $redirect->refresh();
}
/**
* @return array<string, array<string>>
*/
public static function rules(Site $site): array
{
return [
'from' => [
'required',
'string',
'max:255',
'not_regex:/^http(s)?:\/\//',
Rule::unique('redirects', 'from')->where('site_id', $site->id),
],
'to' => [
'required',
'url:http,https',
],
'mode' => [
'required',
'integer',
Rule::in([
301,
302,
307,
308,
]),
],
];
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Actions\Redirect;
use App\Enums\RedirectStatus;
use App\Models\Redirect;
use App\Models\Service;
use App\Models\Site;
use App\SSH\Services\Webserver\Webserver;
class DeleteRedirect
{
public function delete(Site $site, Redirect $redirect): void
{
$redirect->status = RedirectStatus::DELETING;
$redirect->save();
dispatch(function () use ($site, $redirect): void {
/** @var Service $service */
$service = $site->server->webserver();
/** @var Webserver $webserver */
$webserver = $service->handler();
$webserver->updateVHost($site);
$redirect->delete();
})->catch(function () use ($redirect): void {
$redirect->status = RedirectStatus::FAILED;
$redirect->save();
})->onConnection('ssh');
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Enums;
final class RedirectStatus
{
const CREATING = 'creating';
const READY = 'ready';
const DELETING = 'deleting';
const FAILED = 'failed';
}

View File

@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers\API;
use App\Actions\Redirect\CreateRedirect;
use App\Actions\Redirect\DeleteRedirect;
use App\Http\Controllers\Controller;
use App\Http\Resources\RedirectResource;
use App\Models\Project;
use App\Models\Redirect;
use App\Models\Server;
use App\Models\Site;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Http\Response as HttpResponse;
use Knuckles\Scribe\Attributes\BodyParam;
use Knuckles\Scribe\Attributes\Endpoint;
use Knuckles\Scribe\Attributes\Group;
use Knuckles\Scribe\Attributes\Response;
use Knuckles\Scribe\Attributes\ResponseFromApiResource;
use Spatie\RouteAttributes\Attributes\Delete;
use Spatie\RouteAttributes\Attributes\Get;
use Spatie\RouteAttributes\Attributes\Middleware;
use Spatie\RouteAttributes\Attributes\Post;
use Spatie\RouteAttributes\Attributes\Prefix;
#[Prefix('api/projects/{project}/servers/{server}/sites/{site}/redirects')]
#[Middleware(['auth:sanctum', 'can-see-project'])]
#[Group(name: 'redirects')]
class RedirectController extends Controller
{
#[Get('/', name: 'api.projects.servers.sites.redirects.index', middleware: 'ability:read')]
#[Endpoint(title: 'index', description: 'Get all redirects.')]
#[ResponseFromApiResource(RedirectResource::class, Redirect::class, collection: true, paginate: 25)]
public function index(Project $project, Server $server, Site $site): ResourceCollection
{
$this->authorize('view', [Redirect::class, $site, $server]);
$this->validateRoute($project, $server, $site);
return RedirectResource::collection($site->redirects()->simplePaginate(25));
}
#[Post('/', name: 'api.projects.servers.sites.redirects.create', middleware: 'ability:write')]
#[Endpoint(title: 'create', description: 'Create a new redirect.')]
#[BodyParam(name: 'from', required: true)]
#[BodyParam(name: 'to', required: true)]
#[BodyParam(name: 'mode', required: true, enum: [301, 302, 307, 308])]
#[Response(status: 200)]
public function create(Request $request, Project $project, Server $server, Site $site): RedirectResource
{
$this->authorize('create', [Redirect::class, $site, $server]);
$this->validateRoute($project, $server, $site);
$this->validate($request, CreateRedirect::rules($site));
$redirect = app(CreateRedirect::class)->create($site, $request->all());
return new RedirectResource($redirect);
}
#[Delete('/{redirect}', name: 'api.projects.servers.sites.redirects.delete', middleware: 'ability:write')]
#[Endpoint(title: 'delete', description: 'Delete a redirect.')]
#[Response(status: 204)]
public function delete(Project $project, Server $server, Site $site, Redirect $redirect): HttpResponse
{
$this->authorize('delete', [Redirect::class, $site, $server]);
$this->validateRoute($project, $server, $site);
app(DeleteRedirect::class)->delete($site, $redirect);
return response()->noContent();
}
private function validateRoute(Project $project, Server $server, Site $site): void
{
if ($project->id !== $server->project_id) {
abort(404, 'Server not found in project');
}
if ($site->server_id !== $server->id) {
abort(404, 'Site not found in server');
}
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Resources;
use App\Models\Redirect;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Redirect */
class RedirectResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'site_id' => $this->site_id,
'mode' => $this->mode,
'from' => $this->from,
'to' => $this->to,
'status' => $this->status,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

49
app/Models/Redirect.php Normal file
View File

@ -0,0 +1,49 @@
<?php
namespace App\Models;
use App\Enums\RedirectStatus;
use Database\Factories\RedirectFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property int $site_id
* @property string $from
* @property string $to
* @property string $mode
* @property string $status
* @property Site $site
*/
class Redirect extends AbstractModel
{
/** @use HasFactory<RedirectFactory> */
use HasFactory;
protected $fillable = [
'site_id',
'from',
'to',
'mode',
'status',
];
/**
* @var array<string, string>
*/
public static array $statusColors = [
RedirectStatus::CREATING => 'warning',
RedirectStatus::READY => 'success',
RedirectStatus::DELETING => 'warning',
RedirectStatus::FAILED => 'danger',
];
/**
* @return BelongsTo<Site, covariant $this>
*/
public function site(): BelongsTo
{
return $this->belongsTo(Site::class);
}
}

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\RedirectStatus;
use App\Enums\SiteStatus;
use App\Enums\SslStatus;
use App\Exceptions\FailedToDestroyGitHook;
@ -52,6 +53,8 @@
* @property ?SourceControl $sourceControl
* @property Collection<int, LoadBalancerServer> $loadBalancerServers
* @property Project $project
* @property Collection<int, Redirect> $redirects
* @property Collection<int, Redirect> $activeRedirects
*/
class Site extends AbstractModel
{
@ -419,4 +422,20 @@ public function getSshUsers(): array
return $users;
}
/**
* @return HasMany<Redirect, covariant $this>
*/
public function redirects(): HasMany
{
return $this->hasMany(Redirect::class);
}
/**
* @return HasMany<Redirect, covariant $this>
*/
public function activeRedirects(): HasMany
{
return $this->redirects()->whereIn('status', [RedirectStatus::CREATING, RedirectStatus::READY]);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Policies;
use App\Models\Server;
use App\Models\Site;
use App\Models\User;
class RedirectPolicy
{
public function view(User $user, Site $site, Server $server): bool
{
if ($user->isAdmin()) {
return true;
}
return $site->server->project->users->contains($user);
}
public function create(User $user, Site $site, Server $server): bool
{
return ($user->isAdmin() || $site->server->project->users->contains($user))
&& $site->server_id === $server->id
&& $site->server->isReady()
&& $site->server->webserver();
}
public function delete(User $user, Site $site, Server $server): bool
{
return ($user->isAdmin() || $site->server->project->users->contains($user))
&& $site->server_id === $server->id
&& $site->server->isReady()
&& $site->server->webserver();
}
}

View File

@ -80,18 +80,14 @@ public function createVHost(Site $site): void
$this->service->server->ssh()->write(
'/etc/nginx/sites-available/'.$site->domain,
view('ssh.services.webserver.nginx.vhost', [
'site' => $site,
]),
$this->generateVhost($site),
'root'
);
$this->service->server->ssh()->exec(
view('ssh.services.webserver.nginx.create-vhost', [
'domain' => $site->domain,
'vhost' => view('ssh.services.webserver.nginx.vhost', [
'site' => $site,
]),
'vhost' => $this->generateVhost($site),
]),
'create-vhost',
$site->id
@ -105,9 +101,7 @@ public function updateVHost(Site $site, ?string $vhost = null): void
{
$this->service->server->ssh()->write(
'/etc/nginx/sites-available/'.$site->domain,
$vhost ?? view('ssh.services.webserver.nginx.vhost', [
'site' => $site,
]),
$vhost ?? $this->generateVhost($site),
'root'
);
@ -209,4 +203,13 @@ public function removeSSL(Ssl $ssl): void
$this->updateVHost($ssl->site);
}
private function generateVhost(Site $site): string
{
$vhost = view('ssh.services.webserver.nginx.vhost', [
'site' => $site,
]);
return format_nginx_config($vhost);
}
}

View File

@ -218,3 +218,36 @@ function home_path(string $user): string
return '/home/'.$user;
}
function format_nginx_config(string $config): string
{
$lines = explode("\n", trim($config));
$indent = 0;
$formattedLines = [];
foreach ($lines as $line) {
$trimmed = trim($line);
// Preserve empty lines exactly as they are
if ($trimmed === '') {
$formattedLines[] = '';
continue;
}
// If line is a closing brace, decrease indentation first
if ($trimmed === '}') {
$indent--;
}
// Apply indentation
$formattedLines[] = str_repeat(' ', max(0, $indent)).$trimmed;
// If line contains an opening brace, increase indentation
if (str_ends_with($trimmed, '{')) {
$indent++;
}
}
return implode("\n", $formattedLines)."\n";
}

View File

@ -2,6 +2,7 @@
namespace App\Web\Pages\Servers\Sites;
use App\Models\Redirect;
use App\Models\ServerLog;
use App\Models\Site;
use App\Models\Ssl;
@ -75,6 +76,16 @@ public function getSecondSubNavigation(): array
]));
}
if ($user->can('view', [Redirect::class, $this->site, $this->server])) {
$items[] = NavigationItem::make(Pages\Redirects\Index::getNavigationLabel())
->icon('heroicon-o-arrows-right-left')
->isActiveWhen(fn () => request()->routeIs(Pages\Redirects\Index::getRouteName()))
->url(Pages\Redirects\Index::getUrl(parameters: [
'server' => $this->server,
'site' => $this->site,
]));
}
return [
NavigationGroup::make()
->items($items),

View File

@ -0,0 +1,43 @@
<?php
namespace App\Web\Pages\Servers\Sites\Pages\Redirects\Actions;
use App\Actions\Redirect\CreateRedirect;
use App\Models\Site;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Livewire\Component;
class Create
{
/**
* @return array<int, mixed>
*/
public static function form(Site $site): array
{
return [
TextInput::make('from')
->rules(CreateRedirect::rules($site)['from']),
TextInput::make('to')
->rules(CreateRedirect::rules($site)['to']),
Select::make('mode')
->rules(CreateRedirect::rules($site)['mode'])
->options([
'301' => '301 - Moved Permanently',
'302' => '302 - Found',
'307' => '307 - Temporary Redirect',
'308' => '308 - Permanent Redirect',
]),
];
}
/**
* @param array<string, mixed> $data
*/
public static function action(Component $component, array $data, Site $site): void
{
app(CreateRedirect::class)->create($site, $data);
$component->dispatch('$refresh');
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Web\Pages\Servers\Sites\Pages\Redirects;
use App\Models\Redirect;
use App\Web\Pages\Servers\Sites\Page;
use App\Web\Pages\Servers\Sites\Pages\Redirects\Actions\Create;
use App\Web\Pages\Servers\Sites\Pages\Redirects\Widgets\RedirectsList;
use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Filament\Support\Enums\MaxWidth;
class Index extends Page
{
protected static ?string $slug = 'servers/{server}/sites/{site}/redirects';
protected static ?string $title = 'Redirects';
public function mount(): void
{
$this->authorize('view', [Redirect::class, $this->site, $this->server]);
}
public function getWidgets(): array
{
return [
[
RedirectsList::class, [
'site' => $this->site,
],
],
];
}
protected function getHeaderActions(): array
{
return [
Action::make('read-the-docs')
->label('Read the Docs')
->icon('heroicon-o-document-text')
->color('gray')
->url('https://vitodeploy.com/docs/sites/redirects')
->openUrlInNewTab(),
CreateAction::make('create')
->icon('heroicon-o-plus')
->createAnother(false)
->modalWidth(MaxWidth::ExtraLarge)
->label('New Redirect')
->form(Create::form($this->site))
->using(fn (array $data) => run_action($this, function () use ($data): void {
Create::action($this, $data, $this->site);
})),
];
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace App\Web\Pages\Servers\Sites\Pages\Redirects\Widgets;
use App\Actions\Redirect\DeleteRedirect;
use App\Models\Redirect;
use App\Models\Site;
use App\Models\User;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget as Widget;
use Illuminate\Database\Eloquent\Builder;
class RedirectsList extends Widget
{
public Site $site;
/**
* @var array<string>
*/
protected $listeners = ['$refresh'];
/**
* @return Builder<Redirect>
*/
protected function getTableQuery(): Builder
{
return Redirect::query()->where('site_id', $this->site->id);
}
protected function getTableColumns(): array
{
auth()->user();
return [
TextColumn::make('from')
->limit(40)
->tooltip(fn (Redirect $redirect) => $redirect->from)
->searchable()
->copyable(),
TextColumn::make('to')
->limit(40)
->tooltip(fn (Redirect $redirect) => $redirect->to)
->searchable()
->copyable(),
TextColumn::make('mode')
->searchable()
->sortable(),
TextColumn::make('status')
->label('Status')
->badge()
->color(fn (Redirect $redirect) => Redirect::$statusColors[$redirect->status])
->searchable()
->sortable(),
TextColumn::make('created_at')
->formatStateUsing(fn (Redirect $record) => $record->created_at)
->sortable(),
];
}
public function table(Table $table): Table
{
/** @var User $user */
$user = auth()->user();
return $table
->heading(null)
->query($this->getTableQuery())
->columns($this->getTableColumns())
->actions([
DeleteAction::make('delete')
->hiddenLabel()
->tooltip('Delete')
->icon('heroicon-o-trash')
->authorize(fn (Redirect $record) => $user->can('delete', [$this->site, $this->site->server]))
->using(function (Redirect $record): void {
run_action($this, function () use ($record): void {
app(DeleteRedirect::class)->delete($this->site, $record);
$this->dispatch('$refresh');
});
}),
]);
}
}