Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion LICENSE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2024 Freedomtech Hosting
Copyright (c) 2026 amazee.io

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
5 changes: 0 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
# Polydock Engine

> **⚠️ Warning: Experimental Project**
> Polydock Engine is currently in active development and has not yet reached a stable production release. This project should be considered experimental.
>
> If you are interested in using Polydock Engine in a production setting, please contact Bryan Gruneberg (bryan@workshoporange.co) from [Workshop Orange](https://www.workshoporange.co), one of the sponsoring organizations.

Polydock Engine is a Laravel-based application management and deployment platform that enables organizations to offer self-service trials and deployments of their applications on top of [Lagoon](https://www.lagoon.sh) platforms (such as [amazee.io](https://www.amazee.io)). While Lagoon empowers developers to use Kubernetes without deep technical knowledge, Polydock Engine focuses on enabling non-technical users to deploy and manage multiple instances of the same application through a user-friendly interface.

## Documentation
Expand Down
124 changes: 124 additions & 0 deletions app/Filament/Admin/Resources/PolydockHostedFormResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php

declare(strict_types=1);

namespace App\Filament\Admin\Resources;

use App\Filament\Admin\Resources\PolydockHostedFormResource\Pages;
use App\Models\PolydockHostedForm;
use App\Models\PolydockStoreApp;
use App\Services\HostedFormClassDiscovery;
use App\Support\HostedFormHtml;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;

class PolydockHostedFormResource extends Resource
{
protected static ?string $model = PolydockHostedForm::class;

protected static ?string $navigationIcon = 'heroicon-o-document-text';

protected static ?string $navigationGroup = 'Apps';

protected static ?string $navigationLabel = 'External Forms';

protected static ?int $navigationSort = 5200;

#[\Override]
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('title')
->helperText('Plain text — HTML is stripped')
->required()
->maxLength(255),
Forms\Components\TextInput::make('slug')
->helperText('The form is served at /f/{slug}')
->required()
->alphaDash()
->unique(ignoreRecord: true)
->maxLength(255),
Forms\Components\Select::make('form_class')
->label('Form type')
->helperText('Detected form implementations; "Generic Hosted Form" is fully driven by the fields below')
->options(app(HostedFormClassDiscovery::class)->getAvailableFormClasses())
->required(),
Forms\Components\Toggle::make('enabled')
->helperText('Disabled forms return 404')
->default(true),
Forms\Components\Select::make('storeApps')
->label('Allowed apps')
->helperText('Store apps this form may offer and provision. With none selected the form is locked.')
->relationship(
'storeApps',
'name',
fn ($query) => $query->with('store'),
)
->getOptionLabelFromRecordUsing(fn (PolydockStoreApp $record) => "{$record->store->name} — {$record->name}")
->multiple()
->preload()
->columnSpanFull(),
Forms\Components\Textarea::make('description')
->helperText('Optional text shown under the title (generic forms only). Allowed HTML tags: '.HostedFormHtml::ALLOWED_TAGS_HINT.' — everything else is stripped.')
->rows(3)
->columnSpanFull(),
Forms\Components\Textarea::make('notice')
->helperText('Optional text highlighted below the description (generic forms only). Same allowed HTML tags as the description.')
->rows(2)
->columnSpanFull(),
Forms\Components\Textarea::make('disclaimer')
->helperText('Optional text shown above the terms checkbox (generic forms only). Same allowed HTML tags as the description.')
->rows(3)
->columnSpanFull(),
Forms\Components\TextInput::make('seo_title')
->helperText('Falls back to the title')
->maxLength(255),
Forms\Components\TextInput::make('seo_description')
->maxLength(255),
]);
}

#[\Override]
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('title')
->searchable(),
Tables\Columns\TextColumn::make('slug')
->prefix('/f/')
->searchable(),
Tables\Columns\TextColumn::make('form_class')
->label('Form type')
->formatStateUsing(fn (string $state) => class_basename($state))
->badge(),
Tables\Columns\TextColumn::make('storeApps_count')
->label('Allowed apps')
->counts('storeApps'),
Tables\Columns\IconColumn::make('enabled')
->boolean(),
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}

#[\Override]
public static function getPages(): array
{
return [
'index' => Pages\ListPolydockHostedForms::route('/'),
'create' => Pages\CreatePolydockHostedForm::route('/create'),
'edit' => Pages\EditPolydockHostedForm::route('/{record}/edit'),
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace App\Filament\Admin\Resources\PolydockHostedFormResource\Pages;

use App\Filament\Admin\Resources\PolydockHostedFormResource;
use Filament\Resources\Pages\CreateRecord;

class CreatePolydockHostedForm extends CreateRecord
{
protected static string $resource = PolydockHostedFormResource::class;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace App\Filament\Admin\Resources\PolydockHostedFormResource\Pages;

use App\Filament\Admin\Resources\PolydockHostedFormResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;

class EditPolydockHostedForm extends EditRecord
{
protected static string $resource = PolydockHostedFormResource::class;

#[\Override]
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace App\Filament\Admin\Resources\PolydockHostedFormResource\Pages;

use App\Filament\Admin\Resources\PolydockHostedFormResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;

class ListPolydockHostedForms extends ListRecords
{
protected static string $resource = PolydockHostedFormResource::class;

#[\Override]
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
87 changes: 85 additions & 2 deletions app/Forms/BaseHostedForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,76 @@

namespace App\Forms;

use App\Enums\PolydockStoreAppStatusEnum;
use App\Enums\PolydockStoreStatusEnum;
use App\Models\PolydockHostedForm;
use App\Rules\BannedEmail;
use Illuminate\Validation\Rule;

abstract class BaseHostedForm implements HostedFormInterface
{
public function __construct(protected PolydockHostedForm $hostedForm) {}

#[\Override]
public function getHostedForm(): PolydockHostedForm
{
return $this->hostedForm;
}

#[\Override]
public function getSlug(): string
{
return $this->hostedForm->slug;
}

#[\Override]
public function getTitle(): string
{
return $this->hostedForm->title;
}

#[\Override]
public function getSeoTitle(): string
{
return $this->getTitle().' | Polydock';
return $this->hostedForm->seo_title ?: $this->getTitle().' | Polydock';
}

#[\Override]
public function getSeoDescription(): string
{
return 'Provision and try a trial environment instantly with Polydock.';
return $this->hostedForm->seo_description
?: 'Provision and try a trial environment instantly with Polydock.';
}

/**
* Baseline rules shared by every hosted form: contact details and an
* allowlisted, publicly-available trial app. Concrete forms merge their
* extra fields on top via array_merge(parent::getValidationRules(), [...]).
*/
#[\Override]
public function getValidationRules(): array
{
return [
'first_name' => ['required', 'string', 'max:100'],
'last_name' => ['required', 'string', 'max:100'],
'email' => ['required', 'email', new BannedEmail],
'trial_app' => [
'required',
'uuid',
Rule::in($this->getAllowedTrialAppUuids()),
Rule::exists('polydock_store_apps', 'uuid')
->where('status', PolydockStoreAppStatusEnum::AVAILABLE->value)
->where('available_for_trials', true)
->where(function ($query) {
$query->whereExists(function ($subQuery) {
$subQuery->selectRaw(1)
->from('polydock_stores')
->whereColumn('polydock_stores.id', 'polydock_store_apps.polydock_store_id')
->where('polydock_stores.status', PolydockStoreStatusEnum::PUBLIC->value);
});
}),
],
];
}

#[\Override]
Expand All @@ -34,6 +92,17 @@ public function getAllowedEmbedDomains(): array
#[\Override]
public function getRecaptchaEnabled(): bool
{
// Non-production Lagoon environments (dev/PR) aren't registered
// domains for the reCAPTCHA site key, so the widget would only render
// "Invalid domain for site key" — skip it there entirely. Fail closed:
// only an EXPLICIT non-production type disables it; when the variable
// is absent, RECAPTCHA_ENABLED alone governs.
$lagoonEnvironmentType = config('services.recaptcha.lagoon_environment_type');

if ($lagoonEnvironmentType !== null && $lagoonEnvironmentType !== 'production') {
return false;
}

return (bool) config('services.recaptcha.enabled', true);
}

Expand All @@ -53,6 +122,17 @@ public function getAllowedEmbedOrigins(): array
return $origins;
}

/**
* Store app UUIDs this form may offer and provision, managed per form
* record in the admin panel. Empty means the form cannot provision
* anything, so new forms stay locked until apps are explicitly attached.
*/
#[\Override]
public function getAllowedTrialAppUuids(): array
{
return $this->hostedForm->storeApps()->pluck('uuid')->all();
}

/**
* Map form submission fields to the schema required by UserRemoteRegistration
*/
Expand All @@ -64,6 +144,9 @@ public function transformPayload(array $validatedData): array
'first_name' => $validatedData['first_name'] ?? '',
'last_name' => $validatedData['last_name'] ?? '',
'organization' => $validatedData['organization'] ?? '',
// ProcessUserRemoteRegistration stores the company on the
// allocated instance from this key, not 'organization'.
'company_name' => $validatedData['organization'] ?? '',
'job_title' => $validatedData['job_title'] ?? '',
'register_type' => 'REQUEST_TRIAL',
'aup_and_privacy_acceptance' => 1,
Expand Down
Loading