feat(operations): fresh platform-operations implementation

Full rewrite of the installer (incl. the finish step), self-update pipeline,
settings store, cron webhook, dashboard/bootstrap surface, environment
manager, wizard login, config endpoint and shared helpers. Byte-compatible
public interfaces and observable behavior, including documented legacy
quirks; verified by the pilot behavioral suite and the full test suite.
This commit is contained in:
Darko Gjorgjijoski
2026-08-20 18:30:12 +02:00
parent a1828fac52
commit 7db2a8d094
31 changed files with 2869 additions and 0 deletions
@@ -0,0 +1,62 @@
<?php
namespace App\Platform\Operations\Console;
use Illuminate\Console\Command;
use Illuminate\Console\ConfirmableTrait;
use Illuminate\Support\Facades\Artisan;
use function Laravel\Prompts\confirm;
/**
* Development helper that throws the instance away and rebuilds it with demo
* data. Everything in the database goes; there is no undo.
*
* The app is taken down for the duration so nobody can talk to a half-migrated
* schema, and brought back up as the final step.
*/
class ResetApp extends Command
{
use ConfirmableTrait;
protected $signature = 'reset:app {--force}';
protected $description = 'Clean database and public/storage folder';
public function handle(): void
{
if (! $this->cleared()) {
$this->components->error('Reset cancelled');
return;
}
$this->step('Activating maintenance mode...', 'down');
$this->step('Running migrate:fresh', 'migrate:fresh --seed --force');
$this->step('Seeding database', 'db:seed', ['--class' => 'DemoSeeder', '--force' => true]);
$this->step('Clearing cache...', 'optimize:clear');
$this->step('Deactivating maintenance mode...', 'up');
$this->info('App reset completed successfully!');
}
/**
* Whether the operator has agreed to lose the database — either up front
* with --force, or by answering the prompt.
*/
private function cleared(): bool
{
return (bool) $this->option('force')
|| confirm('Are you sure you want to reset the application?');
}
/**
* Announce a stage, then hand it to Artisan.
*/
private function step(string $announcement, string $command, array $arguments = []): void
{
$this->info($announcement);
Artisan::call($command, $arguments);
}
}
@@ -0,0 +1,261 @@
<?php
namespace App\Platform\Operations\Console;
use App\Platform\Operations\Update\Updater;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
/**
* Applies a release from the command line.
*
* Walks the same pipeline the admin UI drives, announcing every stage and
* bailing out on the first one that fails. Containerized installs are told to
* pull a new image instead.
*/
class UpdateCommand extends Command
{
/**
* Version this instance runs right now.
*/
public $installed;
/**
* Version being installed, or false when there is nothing to do.
*/
public $version;
/**
* Whatever the release-server check handed back.
*/
public $response;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'core:update';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Automatically update your InvoiceShelf Core App';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): void
{
set_time_limit(3600); // 1 hour
if (config('invoiceshelf.containerized')) {
$this->error('The in-app updater is disabled in containerized installs. Upgrade with `docker compose pull`.');
return;
}
$this->installed = $this->getInstalledVersion();
$this->response = $this->getLatestVersionResponse();
$this->version = ($this->response) ? $this->response->version : false;
if ($this->response == 'extension_required') {
$this->info('Sorry! Your system does not meet the minimum requirements for this update.');
$this->info('Please retry after installing the required version/extensions.');
return;
}
if (! $this->version) {
$this->info('No Update Available! You are already on the latest version.');
return;
}
if (! $this->confirm("Do you wish to update to {$this->version}?")) {
return;
}
$archive = $this->download();
if ($archive === false) {
return;
}
$extracted = $this->unzip($archive);
if ($extracted === false) {
return;
}
if (! $this->copyFiles($extracted)) {
return;
}
$removals = $this->response->deleted_files ?? null;
if (! empty($removals) && ! $this->deleteFiles($removals)) {
return;
}
if (! $this->migrateUpdate() || ! $this->finish()) {
return;
}
$this->info('Successfully updated to '.$this->version);
}
/**
* Read the running version off the file shipped with the release.
*/
public function getInstalledVersion()
{
return preg_replace('~[\r\n]+~', '', File::get(base_path('version.md')));
}
/**
* Ask the release server what is available and grade the requirements.
*
* @return object|string|false the release, 'extension_required' when this
* machine falls short, false when there is
* nothing newer or the check failed
*/
public function getLatestVersionResponse()
{
$this->info('Your currently installed version is '.$this->installed);
$this->line('');
$this->info('Checking for update...');
try {
$response = Updater::checkForUpdate($this->installed);
if ($response->success) {
$extensions = $response->version->extensions;
$is_required = false;
foreach ($extensions as $key => $extension) {
if (! $extension) {
$is_required = true;
$this->info('❌ '.$key);
}
$this->info('✅ '.$key);
}
if ($is_required) {
return 'extension_required';
}
return $response->version;
}
return false;
} catch (\Exception $e) {
$this->error($e->getMessage());
return false;
}
}
/**
* Fetch the release archive.
*/
public function download()
{
return $this->runStep(
'Downloading update...',
fn () => Updater::download($this->version, 1),
'Download exception'
);
}
/**
* Expand the archive that was just fetched.
*/
public function unzip($path)
{
return $this->runStep(
'Unzipping update package...',
fn () => Updater::unzip($path),
'Unzipping exception'
);
}
/**
* Lay the extracted release over the installation.
*/
public function copyFiles($path)
{
return $this->runStep('Copying update files...', fn () => Updater::copyFiles($path));
}
/**
* Remove the files the release server flagged as gone.
*/
public function deleteFiles($files)
{
return $this->runStep('Deleting unused old files...', fn () => Updater::deleteFiles($files));
}
/**
* Bring the database schema in line with the new code.
*/
public function migrateUpdate()
{
return $this->runStep('Running Migrations...', fn () => Updater::migrateUpdate());
}
/**
* Stamp the new version and fire the completion event.
*/
public function finish()
{
return $this->runStep('Finishing update...', fn () => Updater::finishUpdate($this->installed, $this->version));
}
/**
* Announce a stage, run it, and report what came back.
*
* Without $pathExpected the stage is judged on whether it threw; with it,
* the stage must hand back a path and that path becomes the return value.
*
* @return string|bool
*/
private function runStep(string $announcement, callable $stage, ?string $pathExpected = null)
{
$this->info($announcement);
try {
$outcome = $stage();
} catch (\Exception $e) {
$this->error($e->getMessage());
return false;
}
if ($pathExpected === null) {
return true;
}
if (! is_string($outcome)) {
$this->error($pathExpected);
return false;
}
return $outcome;
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Platform\Operations\Events;
use Illuminate\Foundation\Events\Dispatchable;
/**
* Raised once an update has been applied and the version setting re-stamped.
*
* Carries both sides of the jump so listeners can tell what changed.
*/
class UpdateFinished
{
use Dispatchable;
/**
* @param string $old the version that was running before the update
* @param string $new the version that is now installed
*/
public function __construct(
public $old,
public $new,
) {}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Platform\Operations\Http\Admin;
use App\Platform\Http\Controller;
use App\Platform\Operations\Http\Requests\GetSettingRequest;
use App\Platform\Operations\Http\Requests\SettingRequest;
use App\Platform\Operations\Models\Setting;
use Illuminate\Http\JsonResponse;
/**
* Read and write access to the instance-wide settings store.
*/
class SettingsController extends Controller
{
/**
* Return a single option, keyed by the option name that was asked for.
* An option that has never been written answers with null, not a 404.
*/
public function show(GetSettingRequest $request): JsonResponse
{
$this->authorize('manage settings');
$key = $request->input('key');
return response()->json([$key => Setting::getSetting($key)]);
}
/**
* Upsert every submitted option.
*
* The echoed payload sits at numeric index 0 rather than under a named
* property, so the body serialises as {"success": true, "0": {...}}.
* Clients only read `success`; the shape is kept as it is.
*/
public function update(SettingRequest $request): JsonResponse
{
$this->authorize('manage settings');
$settings = $request->input('settings');
Setting::setSettings($settings);
return response()->json(['success' => true, $settings]);
}
}
@@ -0,0 +1,144 @@
<?php
namespace App\Platform\Operations\Http\Admin;
use App\Platform\Http\Controller;
use App\Platform\Operations\Update\Updater;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
/**
* Drives the in-app upgrade one hop at a time.
*
* The browser walks the pipeline itself — check, download, unzip, copy, clean,
* migrate, finish — so no single request has to survive a whole release swap.
* Refusing the pipeline on containerized installs is the route group's job
* ("not-containerized" middleware), not this controller's.
*/
class UpdateController extends Controller
{
/**
* Head room, in seconds, for the release-server round trip.
*/
private const CHECK_TIME_BUDGET = 600;
public function checkVersion(Request $request): JsonResponse
{
$this->authorizeUpdates();
set_time_limit(self::CHECK_TIME_BUDGET);
$channel = $request->get('channel', 'stable');
return response()->json(
Updater::checkForUpdate($this->versionOnDisk(), $channel)
);
}
public function download(Request $request): JsonResponse
{
$this->authorizeUpdates();
$request->validate(['version' => 'required']);
return $this->completed(Updater::download($request->input('version')));
}
public function unzip(Request $request): JsonResponse
{
$this->authorizeUpdates();
$request->validate(['path' => 'required']);
try {
return $this->completed(Updater::unzip($request->input('path')));
} catch (Exception $failure) {
return response()->json([
'success' => false,
'error' => $failure->getMessage(),
], 500);
}
}
public function copy(Request $request): JsonResponse
{
$this->authorizeUpdates();
$request->validate(['path' => 'required']);
// The copy step answers with a boolean, which has always travelled back
// to the client under the "path" key. Left alone on purpose.
return $this->completed(Updater::copyFiles($request->input('path')));
}
public function delete(Request $request): JsonResponse
{
return $this->clean($request);
}
public function clean(Request $request): JsonResponse
{
$this->authorizeUpdates();
$legacyList = $request->input('deleted_files');
// Releases from before the manifest era shipped an explicit removal
// list instead of a manifest; honour it only while no manifest exists.
if (! empty($legacyList) && ! File::exists(base_path('manifest.json'))) {
Updater::deleteFiles($legacyList);
return response()->json(['success' => true, 'cleaned' => 0]);
}
return response()->json(Updater::cleanStaleFiles());
}
public function migrate(Request $request): JsonResponse
{
$this->authorizeUpdates();
Updater::migrateUpdate();
return response()->json(['success' => true]);
}
public function finish(Request $request): JsonResponse
{
$this->authorizeUpdates();
$request->validate([
'installed' => 'required',
'version' => 'required',
]);
return response()->json(
Updater::finishUpdate($request->input('installed'), $request->input('version'))
);
}
/**
* Only the platform administrator may touch any part of the pipeline.
*/
private function authorizeUpdates(): void
{
$this->authorize('manage update app');
}
/**
* The shared "this step worked, here is what it produced" envelope.
*/
private function completed(mixed $outcome): JsonResponse
{
return response()->json([
'success' => true,
'path' => $outcome,
]);
}
private function versionOnDisk(): string
{
return preg_replace('~[\r\n]+~', '', File::get(base_path('version.md')));
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Platform\Operations\Http;
use App\Platform\Http\Controller;
use App\Platform\Operations\Models\Setting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
/**
* Public probe describing the running build.
*
* Three facts, no authentication: what is on disk, which release channel the
* updater follows, and whether the in-app updater is available at all.
*/
class AppVersionController extends Controller
{
/**
* @return JsonResponse
*/
public function __invoke(Request $request)
{
return response()->json([
'version' => preg_replace('~[\r\n]+~', '', File::get(base_path('version.md'))),
'channel' => $this->releaseChannel(),
'containerized' => (bool) config('invoiceshelf.containerized'),
]);
}
/**
* The stored channel, self-healing: the first caller to find nothing stored
* gets "stable" and leaves that default behind for everyone after them.
*/
private function releaseChannel(): mixed
{
$stored = Setting::getSetting('updater_channel');
if (! is_null($stored)) {
return $stored;
}
Setting::setSetting('updater_channel', 'stable');
return 'stable';
}
}
@@ -0,0 +1,215 @@
<?php
namespace App\Platform\Operations\Http\Company;
use App\Domains\Accounts\Http\Resources\CompanyInvitationResource;
use App\Domains\Accounts\Http\Resources\CompanyResource;
use App\Domains\Accounts\Http\Resources\UserResource;
use App\Domains\Accounts\Models\Company;
use App\Domains\Accounts\Models\CompanyInvitation;
use App\Domains\Accounts\Models\CompanySetting;
use App\Domains\Money\Models\Currency;
use App\Platform\Http\Controller;
use App\Platform\Modules\Models\Module;
use App\Platform\Operations\Http\Concerns\GeneratesMenu;
use App\Platform\Operations\Models\Setting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use InvoiceShelf\Modules\Registry as ModuleRegistry;
use Silber\Bouncer\BouncerFacade;
/**
* The single round trip that hydrates the admin SPA on load: who is signed in,
* which workspace they are looking at, and what navigation they may see.
*
* Three shapes come out of here -- platform administration, a user who belongs
* to no company yet, and the ordinary company view -- built from one common
* envelope so the shared keys cannot drift apart.
*/
class BootstrapController extends Controller
{
use GeneratesMenu;
/**
* Instance-wide settings the shell needs before it can paint anything.
*
* Deliberately an allow-list rather than a dump of the settings table:
* credentials and tokens stored alongside these must not reach a browser.
*/
private const SHELL_SETTINGS = [
'admin_portal_theme',
'admin_portal_logo',
'login_page_logo',
'login_page_heading',
'login_page_description',
'admin_page_title',
'copyright_text',
'save_pdf_to_disk',
'show_sidebar_group_labels',
];
/**
* Handle the incoming request.
*
* @return JsonResponse
*/
public function __invoke(Request $request)
{
$user = $request->user();
$memberships = $user->companies;
if ($user->isSuperAdmin() && $request->has('admin_mode')) {
return response()->json($this->administrationView($user, $memberships));
}
if ($memberships->isEmpty()) {
return response()->json($this->envelope($user, Currency::first()));
}
return response()->json($this->companyView($request, $user, $memberships));
}
/**
* Re-read the workspace named by the company header. No membership check
* here; the SPA calls it right after it has switched companies.
*/
public function currentCompany(Request $request)
{
return new CompanyResource(Company::find($request->header('company')));
}
/**
* Everything every variant carries, with the company-scoped slots empty.
* Each variant fills in the ones that apply to it.
*/
private function envelope($user, ?Currency $currency): array
{
return [
'current_user' => new UserResource($user),
'current_user_settings' => $user->getAllSettings(),
'current_user_abilities' => [],
'companies' => [],
'current_company' => null,
'current_company_settings' => [],
'current_company_currency' => $currency,
'config' => config('invoiceshelf'),
'global_settings' => Setting::getSettings(self::SHELL_SETTINGS),
'main_menu' => [],
'setting_menu' => [],
'modules' => [],
'pending_invitations' => CompanyInvitationResource::collection(
$this->openInvitations($user)
),
];
}
/**
* Platform administration: no workspace is selected, so the payload swaps
* in the admin navigation and lists every company the admin belongs to.
*/
private function administrationView($user, $memberships): array
{
return array_merge($this->envelope($user, Currency::first()), [
'companies' => CompanyResource::collection($memberships),
'main_menu' => $this->generateMenu('admin_menu', $user),
'admin_mode' => true,
]);
}
/**
* The ordinary view, scoped to one company.
*/
private function companyView(Request $request, $user, $memberships): array
{
// Both menus are resolved against the abilities Bouncer has cached so
// far, i.e. before the refresh further down. Keep that order.
$mainMenu = $this->mainMenuWithModules($user);
$settingMenu = $this->generateMenu('setting_menu', $user);
$company = $this->activeCompany($request, $user);
$companySettings = CompanySetting::getAllSettings($company->id);
$currency = $companySettings->has('currency')
? Currency::find($companySettings->get('currency'))
: Currency::first();
BouncerFacade::refreshFor($user);
return array_merge($this->envelope($user, $currency), [
'current_user_abilities' => $user->getAbilities(),
'companies' => CompanyResource::collection($memberships),
'current_company' => new CompanyResource($company),
'current_company_settings' => $companySettings,
'main_menu' => $mainMenu,
'setting_menu' => $settingMenu,
'modules' => Module::where('enabled', true)->pluck('name'),
'user_menu' => $this->moduleUserMenu(),
]);
}
/**
* The workspace this request talks about: the one named by the company
* header when the user is actually a member of it, their first otherwise.
*/
private function activeCompany(Request $request, $user): ?Company
{
$requested = Company::find($request->header('company'));
if ($requested && $user->hasCompany($requested->id)) {
return $requested;
}
return $user->companies()->first();
}
/**
* Main navigation with module-registered entries appended. They join the
* same list so the frontend orders core and module items in one pass.
*/
private function mainMenuWithModules($user): array
{
$menu = $this->generateMenu('main_menu', $user);
foreach (ModuleRegistry::allMenu() as $slug => $entry) {
$menu[] = [
'title' => __($entry['title']),
'link' => $entry['link'],
'icon' => $entry['icon'],
'name' => 'module-'.$slug,
'group' => $entry['group'] ?? 'modules',
'group_label' => $entry['group_label'] ?? 'navigation.modules',
'priority' => $entry['priority'] ?? 100,
];
}
return $menu;
}
/**
* Module entries for the account dropdown, lightest priority first.
*/
private function moduleUserMenu(): array
{
return collect(ModuleRegistry::allUserMenu())
->map(fn (array $entry, string $slug): array => [
...$entry,
'title' => __($entry['title']),
'name' => 'module-'.$slug,
])
->sortBy('priority')
->values()
->all();
}
/**
* Invitations still awaiting this user's answer, with everything the
* frontend needs to describe them eager-loaded.
*/
private function openInvitations($user)
{
return CompanyInvitation::forUser($user)
->pending()
->with(['company', 'role', 'invitedBy'])
->get();
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Platform\Operations\Http\Company;
use App\Platform\Http\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use InvoiceShelf\Modules\Registry;
class ConfigController extends Controller
{
/**
* Hand the SPA one value out of the application configuration.
*
* Exchange-rate drivers are assembled at runtime rather than read from a
* config file, so that key takes its own path.
*/
public function __invoke(Request $request): JsonResponse
{
$key = $request->key;
if ($key === 'exchange_rate_drivers') {
return response()->json(['exchange_rate_drivers' => $this->exchangeRateDrivers()]);
}
return response()->json([$key => config('invoiceshelf.'.$key)]);
}
/**
* Build the exchange rate driver list from the module Registry.
*
* Returns enriched objects (with label, website, and config_fields) so the
* frontend can render driver-specific configuration forms without hardcoding
* any per-driver UI.
*
* @return array<int, array<string, mixed>>
*/
protected function exchangeRateDrivers(): array
{
return collect(Registry::allDrivers('exchange_rate'))
->map(fn (array $meta, string $name) => [
'value' => $name,
'label' => $meta['label'] ?? $name,
'website' => $meta['website'] ?? '',
'config_fields' => $meta['config_fields'] ?? [],
])
->values()
->all();
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Platform\Operations\Http\Concerns;
/**
* Flattens a registered navigation tree into the plain arrays the SPA renders.
*/
trait GeneratesMenu
{
/**
* Read one registered menu and drop every entry this user may not see.
*
* Visibility itself is decided by the user model; this only asks.
*/
public function generateMenu($key, $user)
{
$navigation = \Menu::get($key);
if (! $navigation) {
return [];
}
$visible = [];
foreach ($navigation->items->toArray() as $entry) {
if ($user->checkAccess($entry)) {
$visible[] = $this->describeMenuEntry($entry);
}
}
return $visible;
}
/**
* One navigation entry in wire shape. Grouping label and ordering weight
* are optional in the menu definition, so they fall back here.
*/
private function describeMenuEntry(object $entry): array
{
$meta = $entry->data;
return [
'title' => $entry->title,
'link' => $entry->link->path['url'],
'icon' => $meta['icon'],
'name' => $meta['name'],
'group' => $meta['group'],
'group_label' => $meta['group_label'] ?? '',
'priority' => $meta['priority'] ?? 100,
];
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Platform\Operations\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Gate for the externally callable cron webhook: the caller proves itself
* with a shared token carried in a request header.
*/
class CronJobMiddleware
{
/**
* Name of the header the external scheduler is expected to send.
*/
private const TOKEN_HEADER = 'x-authorization-token';
/**
* Forward the request only when the presented token matches the one in
* the configuration; anything else is refused outright.
*/
public function handle(Request $request, Closure $next): Response
{
$presented = $request->header(self::TOKEN_HEADER);
// An empty (or literally "0") header is treated as no token at all,
// so it can never match, whatever the configured token happens to be.
if (! $presented) {
return $this->refuse();
}
return $presented == config('services.cron_job.auth_token')
? $next($request)
: $this->refuse();
}
/**
* The refusal body is a bare JSON array, not an object — callers of the
* webhook match on the status code, so the shape stays as it is.
*/
private function refuse(): Response
{
return response()->json(['unauthorized'], Response::HTTP_UNAUTHORIZED);
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Platform\Operations\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* Query parameters for a single-option read from the settings store.
*/
class GetSettingRequest extends FormRequest
{
/**
* Access is decided by the `manage settings` ability in the controller,
* so the request itself lets everything through.
*/
public function authorize(): bool
{
return true;
}
/**
* The option name to look up. It is echoed back as the response key, so
* it has to be a scalar string.
*
* @return array<string, string>
*/
public function rules(): array
{
return [
'key' => 'required|string',
];
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Platform\Operations\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* Payload for a bulk write to the settings store.
*/
class SettingRequest extends FormRequest
{
/**
* Access is decided by the `manage settings` ability in the controller,
* so the request itself lets everything through.
*/
public function authorize(): bool
{
return true;
}
/**
* The map of options to write must be present; individual option names
* are free-form, so nothing below `settings` is constrained here.
*
* @return array<string, string>
*/
public function rules(): array
{
return [
'settings' => 'required',
];
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Platform\Operations\Http\Webhooks;
use App\Platform\Http\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
/**
* Webhook that lets an external scheduler drive Laravel's own scheduler on
* installs that cannot register a system cron entry.
*/
class CronJobController extends Controller
{
/**
* Run the due scheduled tasks. The shared-token check has already been
* made by the route middleware, so nothing is read off the request.
*/
public function __invoke(Request $request): JsonResponse
{
Artisan::call('schedule:run');
return response()->json(['success' => true]);
}
}
@@ -0,0 +1,365 @@
<?php
namespace App\Platform\Operations\Installation\Application;
use App\Platform\Operations\Installation\Http\Requests\DatabaseEnvironmentRequest;
use App\Platform\Operations\Installation\Http\Requests\DomainEnvironmentRequest;
use Exception;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Owns every write the installation wizard makes to the environment file: the
* database step, the domain step, and the line-wise editing rules both of them
* go through.
*/
class EnvironmentManager
{
private string $envPath;
/**
* @var string
*/
private $delimiter = "\n";
/**
* The argument is vestigial: the only file the wizard ever edits is the
* one sitting at the application root.
*/
public function __construct($path = null)
{
$this->envPath = base_path('.env');
}
/**
* Apply a map of variable name => value to the environment file. Names the
* file already declares are rewritten where they stand; the rest are
* appended. Returns false when there is nothing to write, or no file to
* write it into.
*
* @return bool
*/
public function updateEnv(array $data)
{
if ($data === [] || ! is_file($this->envPath)) {
return false;
}
$lines = explode($this->delimiter, (string) file_get_contents($this->envPath));
foreach ($data as $name => $value) {
$lines = $this->applyDeclaration($lines, (string) $name, $value);
}
file_put_contents($this->envPath, implode($this->delimiter, $lines));
return true;
}
/**
* Rewrite every line declaring $name, or append one when none does. A
* line's name is whatever sits in front of its first "=".
*/
private function applyDeclaration(array $lines, string $name, $value): array
{
$declaration = $name.'='.$this->encode($value);
$found = false;
foreach ($lines as $index => $line) {
if (explode('=', $line, 2)[0] === $name) {
$lines[$index] = $declaration;
$found = true;
}
}
if (! $found) {
$lines[] = $declaration;
}
return $lines;
}
/**
* Encodes value for .env
*
* @return mixed|string
*/
private function encode($str)
{
// Convert to string if not already
$str = (string) $str;
// If the value is already properly quoted, return as is
if (strlen($str) >= 2 && $str[0] === '"' && $str[strlen($str) - 1] === '"') {
return $str;
}
// Check if the value contains characters that need quoting
// Using a character class regex to properly match special characters
$specialChars = '\^\'£$%&*()}{@#~?><,|=\-_+¬!';
$needsQuoting = (
strpos($str, ' ') !== false ||
preg_match('/['.preg_quote($specialChars, '/').']/', $str)
);
if ($needsQuoting) {
// Escape any existing double quotes in the string
$str = str_replace('"', '\\"', $str);
$str = '"'.$str.'"';
}
return $str;
}
/**
* The database step. Nothing is written until the submitted credentials
* have been proven to open a connection and the target database has been
* shown to be free of a previous installation.
*
* @return array
*/
public function saveDatabaseVariables(DatabaseEnvironmentRequest $request)
{
$appUrl = $request->get('app_url');
if ($appUrl !== config('app.url')) {
config(['app.url' => $appUrl]);
}
$driver = $request->get('database_connection');
// Derived against the URL just adopted above, from the host the wizard
// itself is being served on.
[$statefulDomains, $sessionDomain] = $this->getDomains($request->getHttpHost());
$variables = [
'APP_URL' => $appUrl,
'APP_LOCALE' => $request->get('app_locale'),
'DB_CONNECTION' => $driver,
'SESSION_DOMAIN' => $sessionDomain,
];
if ($statefulDomains !== null) {
$variables['SANCTUM_STATEFUL_DOMAINS'] = $statefulDomains;
}
if ($driver === 'sqlite') {
$unsupported = $this->sqliteSupportFailure();
if ($unsupported !== null) {
return [
'error_message' => $unsupported,
];
}
$variables['DB_DATABASE'] = $request->get('database_name');
$this->createSqliteDatabase(
$this->resolveSqliteDatabasePath($variables['DB_DATABASE'])
);
} elseif ($request->has('database_username') && $request->has('database_password')) {
// Server credentials are only recorded once both halves are on the
// request. The password is not a validation requirement, so a
// passwordless account submits it as an empty string.
$variables['DB_HOST'] = $request->get('database_hostname');
$variables['DB_PORT'] = $request->get('database_port');
$variables['DB_DATABASE'] = $request->get('database_name');
$variables['DB_USERNAME'] = $request->get('database_username');
$variables['DB_PASSWORD'] = $request->get('database_password');
}
try {
$this->openSubmittedConnection($request);
if ($request->get('database_overwrite')) {
Artisan::call('db:wipe --force');
}
// Checked before the environment file is touched: refusing here
// leaves the instance exactly as it was found.
if (Schema::hasTable('users')) {
return [
'error' => 'database_should_be_empty',
];
}
} catch (Exception $e) {
return [
'error_message' => $e->getMessage(),
];
}
try {
$this->updateEnv($variables);
} catch (Exception $e) {
return [
'error' => 'database_variables_save_error',
];
}
return [
'success' => 'database_variables_save_successfully',
];
}
/**
* Laravel's SQLite grammar needs 3.35 or newer. Returns the sentence to
* hand back to the wizard, or null when the extension is fit for use.
*/
private function sqliteSupportFailure(): ?string
{
$minimum = '3.35.0';
if (! extension_loaded('sqlite3') || ! class_exists('\SQLite3') || ! method_exists('\SQLite3', 'version')) {
return sprintf('SQLite3 is not present. Please install SQLite >=%s and retry.', $minimum);
}
$found = \SQLite3::version()['versionString'] ?? '';
if ($found !== '' && version_compare($found, $minimum, '<')) {
return sprintf('The minimum SQLite version is %s. Your current SQLite version is %s which is not supported. Please upgrade SQLite and retry.', $minimum, $found);
}
return null;
}
/**
* A fresh SQLite install points at a file that does not exist yet: lay down
* the bundled empty database, digging out the directory the user asked for
* on the way, so an absolute path outside the project still works.
*/
private function createSqliteDatabase(string $path): void
{
if (file_exists($path)) {
return;
}
$directory = dirname($path);
if (! is_dir($directory)) {
mkdir($directory, 0755, true);
}
copy(database_path('stubs/sqlite.empty.db'), $path);
}
/**
* Narrow the runtime database configuration down to the single connection
* described by the form and open it. Bad credentials, an unreachable
* server or a missing database all surface as a driver exception.
*
* @return \PDO
*/
private function openSubmittedConnection(DatabaseEnvironmentRequest $request)
{
$driver = $request->get('database_connection');
$database = $request->get('database_name');
$settings = array_merge(config("database.connections.{$driver}"), [
'driver' => $driver,
'database' => $driver === 'sqlite'
? $this->resolveSqliteDatabasePath($database)
: $database,
]);
if ($driver !== 'sqlite' && $request->has('database_username') && $request->has('database_password')) {
$settings['username'] = $request->get('database_username');
$settings['password'] = $request->get('database_password');
$settings['host'] = $request->get('database_hostname');
$settings['port'] = $request->get('database_port');
}
config([
'database' => [
'migrations' => 'migrations',
'default' => $driver,
'connections' => [$driver => $settings],
],
]);
DB::purge($driver);
return DB::connection($driver)->getPdo();
}
private function resolveSqliteDatabasePath(?string $databasePath): string
{
$databasePath = trim((string) $databasePath);
if ($databasePath === '') {
return storage_path('app/database.sqlite');
}
if ($this->isAbsolutePath($databasePath)) {
return $databasePath;
}
return base_path($databasePath);
}
/**
* Absolute means a leading separator, or a Windows drive prefix.
*/
private function isAbsolutePath(string $path): bool
{
if (str_starts_with($path, DIRECTORY_SEPARATOR)) {
return true;
}
return preg_match('~^[A-Za-z]:[\\\\/]~', $path) === 1;
}
/**
* The domain step: rewrite the session domain, and the stateful-domain
* list unless writing it would be a no-op.
*
* @return array
*/
public function saveDomainVariables(DomainEnvironmentRequest $request)
{
try {
[$statefulDomains, $sessionDomain] = $this->getDomains(
$request->get('app_domain')
);
$variables = [
'SESSION_DOMAIN' => $sessionDomain,
];
if ($statefulDomains !== null) {
$variables['SANCTUM_STATEFUL_DOMAINS'] = $statefulDomains;
}
$this->updateEnv($variables);
} catch (Exception $e) {
return [
'error' => 'domain_verification_failed',
];
}
return [
'success' => 'domain_variable_save_successfully',
];
}
private function getDomains(string $requestDomain): array
{
$appUrl = config('app.url');
$port = parse_url($appUrl, PHP_URL_PORT);
$currentDomain = parse_url($appUrl, PHP_URL_HOST).(
$port ? ':'.$port : ''
);
$requestHost = parse_url($requestDomain, PHP_URL_HOST) ?: $requestDomain;
$isSame = $currentDomain === $requestDomain;
return [
$isSame && env('SANCTUM_STATEFUL_DOMAINS', false) === false ?
null : $requestDomain,
$isSame && env('SESSION_DOMAIN', false) === null ?
null : $requestHost,
];
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Platform\Operations\Installation\Application;
/**
* Reports whether the writable directories the application relies on are at
* least as permissive as the installer requires.
*/
class FilePermissionChecker
{
protected array $results = [];
public function __construct()
{
$this->results = [
'permissions' => [],
'errors' => null,
];
}
/**
* Walk a map of relative folder path => required octal mode. Entries are
* reported in the order given; "errors" stays null until something fails.
*/
public function check(array $folders): array
{
foreach ($folders as $folder => $required) {
$granted = $this->modeOf($folder) >= $required;
$this->results['permissions'][] = [
'folder' => $folder,
'permission' => $required,
'isSet' => $granted,
];
if (! $granted) {
$this->results['errors'] = true;
}
}
return $this->results;
}
/**
* The last four octal digits of the folder's mode, e.g. "0775". Both this
* and the requirement are numeric strings, so the caller's comparison is
* made on their numeric value.
*/
private function modeOf(string $folder): string
{
return substr(sprintf('%o', fileperms(base_path($folder))), -4);
}
}
@@ -0,0 +1,181 @@
<?php
namespace App\Platform\Operations\Installation\Application;
use Illuminate\Support\Str;
use PDO;
use SQLite3;
/**
* Probes the runtime for the pieces the installer refuses to continue without:
* PHP extensions, web server modules and the version of whichever database
* engine the operator picked on the wizard's database step.
*/
class RequirementsChecker
{
/**
* Floor used only when a caller supplies none of its own. The real floor
* ships in config/installer.php.
*/
private const FALLBACK_MIN_PHP_VERSION = '7.0.0';
/**
* Evaluate a grouped requirement list. Only the "php" and "apache" groups
* carry meaning; anything else is dropped, as is a group that produced no
* verdicts at all. The top level "errors" flag appears only once something
* has actually failed.
*/
public function check(array $requirements): array
{
$report = [];
$missing = false;
foreach ($requirements as $group => $names) {
$verdicts = match ($group) {
'php' => $this->probeExtensions($names),
'apache' => $this->probeApacheModules($names),
default => [],
};
if ($verdicts === []) {
continue;
}
$report['requirements'][$group] = $verdicts;
if (in_array(false, $verdicts, true)) {
$missing = true;
}
}
if ($missing) {
$report['errors'] = true;
}
return $report;
}
/**
* Compare the running interpreter against a floor, reporting both the raw
* version string and its leading numeric part.
*/
public function checkPHPVersion(?string $minPhpVersion = null): array
{
$floor = ($minPhpVersion !== null && $minPhpVersion !== '')
? $minPhpVersion
: $this->getMinPhpVersion();
$running = $this->numericPhpVersion();
return [
'full' => PHP_VERSION,
'current' => $running,
'minimum' => $floor,
'supported' => version_compare($running, $floor) >= 0,
];
}
/**
* Compare a live MySQL/MariaDB connection against the floor configured for
* whichever of the two the server banner reports.
*/
public function checkMysqlVersion($conn): array
{
$banner = $conn->getAttribute(PDO::ATTR_SERVER_VERSION);
$floor = Str::contains($banner, 'MariaDB')
? config('invoiceshelf.min_mariadb_version')
: config('invoiceshelf.min_mysql_version');
return $this->versionVerdict($this->queryMysqlVersion($conn), $floor);
}
/**
* Compare the bundled SQLite library against a floor.
*/
public function checkSqliteVersion(?string $minSqliteVersion = null): array
{
return $this->versionVerdict(SQLite3::version()['versionString'], $minSqliteVersion);
}
/**
* Compare a live PostgreSQL connection against a floor.
*/
public function checkPgsqlVersion($conn, ?string $minPgsqlVersion = null): array
{
return $this->versionVerdict(pg_version($conn)['server'], $minPgsqlVersion);
}
/**
* Default PHP floor for callers that pass none.
*/
protected function getMinPhpVersion(): string
{
return self::FALLBACK_MIN_PHP_VERSION;
}
/**
* @return array<string, bool>
*/
private function probeExtensions(array $extensions): array
{
$verdicts = [];
foreach ($extensions as $extension) {
$verdicts[$extension] = extension_loaded($extension);
}
return $verdicts;
}
/**
* Module introspection is only available under mod_php; without it there
* is nothing to report, so the group is skipped rather than failed.
*
* @return array<string, bool>
*/
private function probeApacheModules(array $modules): array
{
if (! function_exists('apache_get_modules')) {
return [];
}
$enabled = apache_get_modules();
$verdicts = [];
foreach ($modules as $module) {
$verdicts[$module] = in_array($module, $enabled);
}
return $verdicts;
}
/**
* The shared shape of every database version report.
*/
private function versionVerdict(?string $current, ?string $minimum): array
{
return [
'current' => $current,
'minimum' => $minimum,
'supported' => version_compare($current, $minimum) >= 0,
];
}
/**
* PHP_VERSION with any suffix such as "-1+ubuntu" trimmed away.
*/
private function numericPhpVersion(): string
{
preg_match("#^\d+(\.\d+)*#", PHP_VERSION, $leading);
return $leading[0];
}
private function queryMysqlVersion($pdo): string
{
preg_match("/^[0-9\.]+/", $pdo->query('select version()')->fetchColumn(), $leading);
return $leading[0];
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Platform\Operations\Installation\Http\Controllers;
use App\Platform\Http\Controller;
use App\Platform\Operations\Installation\Application\EnvironmentManager;
use App\Platform\Operations\Installation\Http\Requests\DomainEnvironmentRequest;
use Illuminate\Support\Facades\Artisan;
/**
* The wizard's domain step: records the host this instance will be served
* from, so sessions and stateful API calls are scoped to it.
*/
class AppDomainController extends Controller
{
/**
* Compiled configuration is dropped first, otherwise the manager would
* compare the submitted domain against a cached application URL.
*
* The step answers success unconditionally — a failed write has never been
* reported to the client here, and the wizard's later steps rewrite the
* same two variables anyway. Surfacing an error at this point would change
* the contract the wizard front end is built against.
*/
public function __invoke(DomainEnvironmentRequest $request)
{
Artisan::call('optimize:clear');
(new EnvironmentManager)->saveDomainVariables($request);
return response()->json([
'success' => true,
]);
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Platform\Operations\Installation\Http\Controllers;
use App\Platform\Http\Controller;
use App\Platform\Operations\Installation\Application\EnvironmentManager;
use App\Platform\Operations\Installation\Application\InstallationState;
use App\Platform\Operations\Installation\Http\Requests\DatabaseEnvironmentRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
/**
* The wizard's database step, in both directions: what the form should be
* prefilled with, and what happens once it comes back.
*/
class DatabaseConfigurationController extends Controller
{
/**
* @var EnvironmentManager
*/
protected $EnvironmentManager;
public function __construct(EnvironmentManager $environmentManager)
{
$this->EnvironmentManager = $environmentManager;
}
/**
* Caches go first so the manager works against the environment as it sits
* on disk. A clean write is followed by building the installation out in
* place: public storage symlink, the whole migration chain with seeders,
* and the version stamp.
*
* The application key is deliberately not regenerated here. Rotating it
* mid-wizard invalidates the session the browser is holding, which surfaces
* as a token mismatch on the very next step; an instance that needs a fresh
* key generates it before the wizard runs.
*/
public function saveDatabaseEnvironment(DatabaseEnvironmentRequest $request)
{
Artisan::call('config:clear');
Artisan::call('cache:clear');
$results = $this->EnvironmentManager->saveDatabaseVariables($request);
if (array_key_exists('success', $results)) {
Artisan::call('optimize:clear');
Artisan::call('config:clear');
Artisan::call('cache:clear');
Artisan::call('storage:link');
Artisan::call('migrate --seed --force');
InstallationState::setCurrentVersion();
}
return response()->json($results);
}
/**
* Defaults for the database form. The driver comes from the query string,
* falling back to whatever the runtime is configured with.
*
* A driver with no arm of its own is echoed back with the server defaults
* rather than an empty config: the wizard chooses which form to render from
* database_connection, so answering with nothing left the step blank and
* the install stuck (as a DB_CONNECTION=mariadb compose file once did).
*
* The prefill key is database_host while the form posts back
* database_hostname. The mismatch is what the front end expects.
*/
public function getDatabaseEnvironment(Request $request)
{
$connection = $request->connection ?? config('database.default');
$databaseData = match ($connection) {
'sqlite' => [
'database_connection' => 'sqlite',
'database_name' => config('database.connections.sqlite.database') ?: 'storage/app/database.sqlite',
],
'pgsql' => [
'database_connection' => 'pgsql',
'database_host' => '127.0.0.1',
'database_port' => 5432,
],
default => [
'database_connection' => $connection,
'database_host' => '127.0.0.1',
'database_port' => 3306,
],
};
return response()->json([
'config' => $databaseData,
'success' => true,
]);
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Platform\Operations\Installation\Http\Controllers;
use App\Platform\Http\Controller;
use App\Platform\Operations\Installation\Application\FilePermissionChecker;
use Illuminate\Http\JsonResponse;
class FilePermissionsController extends Controller
{
protected FilePermissionChecker $permissions;
public function __construct(FilePermissionChecker $checker)
{
$this->permissions = $checker;
}
/**
* Second wizard gate: the writability of the folders listed in
* config/installer.php.
*/
public function permissions(): JsonResponse
{
return response()->json([
'permissions' => $this->permissions->check(
config('installer.permissions')
),
]);
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Platform\Operations\Installation\Http\Controllers;
use App\Platform\Http\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* The wizard's closing step. Nothing is persisted here — completion state is
* tracked through the wizard-step endpoint — so acknowledging is all there is
* to do.
*/
class FinishController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
return response()->json(['success' => true]);
}
}
@@ -0,0 +1,63 @@
<?php
namespace App\Platform\Operations\Installation\Http\Controllers;
use App\Domains\Accounts\Models\User;
use App\Platform\Http\Controller;
use App\Platform\Operations\Installation\Authentication\InstallWizardAuth;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* Signs the wizard in as the administrator the seeders just created, so the
* remaining steps can call the authenticated API.
*/
class LoginController extends Controller
{
/**
* There is exactly one candidate at this point in the install: the first
* platform administrator, and the first company attached to them. Any
* browser session that got this far is thrown away — the wizard carries on
* with a bearer token limited to the wizard ability, and only one such
* token is ever live at a time.
*/
public function __invoke(Request $request): JsonResponse
{
$user = User::where('role', 'super admin')->first();
if ($user === null) {
return response()->json([
'message' => 'Super admin user not found.',
], 404);
}
$company = $user->companies()->first();
if ($company === null) {
return response()->json([
'message' => 'Super admin company not found.',
], 422);
}
Auth::guard('web')->logout();
if ($request->hasSession()) {
$request->session()->invalidate();
$request->session()->regenerateToken();
}
$user->tokens()->where('name', InstallWizardAuth::TOKEN_NAME)->delete();
return response()->json([
'success' => true,
'type' => 'Bearer',
'token' => $user->createToken(
InstallWizardAuth::TOKEN_NAME,
[InstallWizardAuth::TOKEN_ABILITY],
)->plainTextToken,
'user' => $user,
'company' => $company,
]);
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Platform\Operations\Installation\Http\Controllers;
use App\Platform\Http\Controller;
use App\Platform\Operations\Installation\Application\InstallationState;
use App\Platform\Operations\Models\Setting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Tracks how far through the setup wizard the operator has walked, so a
* reloaded browser resumes on the step it left off.
*/
class OnboardingWizardController extends Controller
{
/**
* Terminal value of the profile_complete setting.
*/
private const FINISHED = 'COMPLETED';
/**
* Until the schema exists there is nowhere to read progress from, so the
* very first step and the default language are answered from thin air.
*/
public function getStep(Request $request): JsonResponse
{
if (! InstallationState::isDbCreated()) {
return response()->json([
'profile_complete' => 0,
'profile_language' => 'en',
]);
}
return response()->json([
'profile_complete' => Setting::getSetting('profile_complete'),
'profile_language' => Setting::getSetting('profile_language'),
]);
}
/**
* Record progress, unless the wizard has already run to completion — a
* finished install must not be walked backwards into the setup flow.
*/
public function updateStep(Request $request): JsonResponse
{
$step = Setting::getSetting('profile_complete');
if ($step !== self::FINISHED) {
Setting::setSetting('profile_complete', $request->input('profile_complete'));
$step = Setting::getSetting('profile_complete');
}
return response()->json([
'profile_complete' => $step,
]);
}
public function saveLanguage(Request $request): JsonResponse
{
Setting::setSetting('profile_language', $request->input('profile_language'));
return response()->json([
'profile_language' => Setting::getSetting('profile_language'),
]);
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Platform\Operations\Installation\Http\Controllers;
use App\Platform\Http\Controller;
use App\Platform\Operations\Installation\Application\RequirementsChecker;
use Illuminate\Http\JsonResponse;
class RequirementsController extends Controller
{
protected RequirementsChecker $requirements;
public function __construct(RequirementsChecker $checker)
{
$this->requirements = $checker;
}
/**
* First wizard gate: the interpreter version block plus the per-extension
* verdicts drawn from config/installer.php.
*/
public function requirements(): JsonResponse
{
return response()->json([
'phpSupportInfo' => $this->requirements->checkPHPVersion(
config('installer.core.minPhpVersion')
),
'requirements' => $this->requirements->check(
config('installer.requirements')
),
]);
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Platform\Operations\Installation\Http\Middleware;
use App\Platform\Operations\Installation\Application\InstallationState;
use App\Platform\Operations\Models\Setting;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Keeps the application proper out of reach until the setup wizard is done.
*
* The question "is this instance installed?" is answered against a database
* that may not exist yet, so every failure mode -- no connection, no schema,
* no settings row -- is read as "not installed" and sends the visitor to the
* wizard rather than to a stack trace.
*/
class EnsureInstalled
{
public function handle(Request $request, Closure $next): Response
{
return $this->isInstalled()
? $next($request)
: redirect('/installation');
}
/**
* A finished install means the schema is in place and the wizard wrote its
* completion marker. The marker is only looked up once the schema exists.
*/
private function isInstalled(): bool
{
try {
return InstallationState::isDbCreated()
&& Setting::getSetting('profile_complete') === 'COMPLETED';
} catch (\Exception) {
return false;
}
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Platform\Operations\Installation\Http\Middleware;
use App\Platform\Operations\Installation\Application\InstallationState;
use App\Platform\Operations\Models\Setting;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* The mirror image of the installed gate, guarding the wizard itself: once the
* instance is live nobody gets to walk through setup a second time.
*/
class RedirectIfInstalled
{
public function handle(Request $request, Closure $next): Response
{
if ($this->wizardAlreadyFinished()) {
return redirect('login');
}
return $next($request);
}
/**
* Reading the completion marker needs the settings table, which the wizard
* itself creates -- so a failure here just means setup is still running and
* the request is allowed through.
*/
private function wizardAlreadyFinished(): bool
{
if (! InstallationState::isDbCreated()) {
return false;
}
try {
return Setting::getSetting('profile_complete') === 'COMPLETED';
} catch (\Exception) {
return false;
}
}
}
@@ -0,0 +1,92 @@
<?php
namespace App\Platform\Operations\Installation\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* Validates the wizard's database step. The shape of the form depends on the
* chosen driver: a file-backed SQLite database needs nothing but a path,
* whereas a server-backed one needs somewhere to connect to.
*
* The password is intentionally absent from both rule sets — server setups
* with a passwordless local account must remain installable.
*/
class DatabaseEnvironmentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
if ($this->get('database_connection') == 'sqlite') {
return $this->fileDatabaseRules();
}
return $this->serverDatabaseRules();
}
/**
* SQLite: database_name carries the path to the database file.
*/
private function fileDatabaseRules(): array
{
return [
'app_url' => [
'required',
'url',
],
'database_connection' => [
'required',
'string',
],
'database_name' => [
'required',
'string',
],
'database_overwrite' => [
'nullable',
'boolean',
],
];
}
/**
* MySQL, MariaDB, PostgreSQL and anything else driver-shaped.
*/
private function serverDatabaseRules(): array
{
return [
'app_url' => [
'required',
'url',
],
'database_connection' => [
'required',
'string',
],
'database_hostname' => [
'required',
'string',
],
'database_port' => [
'required',
'numeric',
],
'database_name' => [
'required',
'string',
],
'database_username' => [
'required',
'string',
],
'database_overwrite' => [
'nullable',
'boolean',
],
];
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Platform\Operations\Installation\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* Validates the wizard's domain step, which only ever carries the host the
* instance will be served from.
*/
class DomainEnvironmentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'app_domain' => [
'required',
],
];
}
}
@@ -0,0 +1,78 @@
<?php
namespace App\Platform\Operations\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* One row of the instance-wide configuration table.
*
* The interesting surface is static: the installer, the updater, seeders,
* migrations and the platform services all treat this class as "the global
* settings store" rather than as an entity they hydrate and pass around.
*/
class Setting extends Model
{
use HasFactory;
protected $table = 'settings';
protected $fillable = ['option', 'value'];
/**
* Store one option. An option that already has a row is overwritten.
*/
public static function setSetting(string $key, mixed $setting): void
{
static::writeOption($key, $setting);
}
/**
* Store a batch of options, each one following the single-key write rule.
*
* @param array<string, mixed> $settings
*/
public static function setSettings(array $settings): void
{
foreach ($settings as $key => $value) {
static::writeOption($key, $value);
}
}
/**
* Read one option. Keys that were never stored read as null.
*/
public static function getSetting(string $key): mixed
{
return static::query()
->where('option', $key)
->value('value');
}
/**
* Read several options at once, as an option => value map.
*
* Keys without a row are left out of the map entirely: callers get a
* shorter map, never a null placeholder.
*
* @param array<int, string> $settings
* @return Collection<string, mixed>
*/
public static function getSettings(array $settings): Collection
{
return static::query()
->whereIn('option', $settings)
->pluck('value', 'option');
}
/**
* The shared write rule behind both public writers: update in place when
* the option is already known, insert it otherwise.
*/
private static function writeOption(string $key, mixed $value): void
{
static::query()->updateOrCreate(['option' => $key], ['value' => $value]);
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Platform\Operations\Update;
use App\Platform\Operations\Models\Setting;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Psr\Http\Message\ResponseInterface;
/**
* Talks to the release server that publishes InvoiceShelf builds.
*
* Every call is a plain TLS-verified GET against the configured base URL. The
* server identifies the caller through a product header carrying the version
* this instance currently runs, so requests must keep going out over a Guzzle
* client rather than the framework HTTP facade.
*/
trait CallsReleaseServer
{
/**
* Fetch a release-server resource, or null when the request never landed.
*
* @param string $url path relative to the release-server base URL
* @param array $data extra Guzzle request options (timeouts, redirects, ...)
* @param string|null $token optional bearer credential
* @return ResponseInterface|null
*/
protected static function getRemote($url, $data = [], $token = null)
{
$options = $data;
// Error statuses are part of the answer here, not an exception.
$options['http_errors'] = false;
$options['headers'] = [
'Accept' => 'application/json',
'Referer' => url('/'),
'Authorization' => "Bearer {$token}",
'invoiceshelf' => Setting::getSetting('version'),
];
$client = new Client([
'verify' => true,
'base_uri' => config('invoiceshelf.base_url').'/',
]);
try {
return $client->get($url, $options);
} catch (GuzzleException $e) {
return null;
}
}
}
+301
View File
@@ -0,0 +1,301 @@
<?php
namespace App\Platform\Operations\Update;
use App\Platform\Operations\Events\UpdateFinished;
use App\Platform\Operations\Models\Setting;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use ZipArchive;
/**
* The self-update pipeline.
*
* Each stage is a separate static entry point so that the HTTP controller and
* the console command can drive them one at a time and report progress in
* between: check -> download -> unzip -> copy -> clean -> migrate -> finish.
*/
class Updater
{
use CallsReleaseServer;
/**
* Guzzle options shared by every release-server call.
*/
private const REQUEST_OPTIONS = ['timeout' => 100, 'track_redirects' => true];
/**
* Ask the release server whether a newer build exists on the given channel.
*
* The answer is handed back as the release server phrased it, except that a
* list of required extensions is graded against this machine first.
*/
public static function checkForUpdate($installed_version, $updater_channel = 'stable')
{
$response = static::getRemote(
sprintf('releases/update-check/%s?channel=%s', $installed_version, $updater_channel),
self::REQUEST_OPTIONS
);
$answer = (object) ['success' => false, 'release' => null];
if ($response && ($response->getStatusCode() == 200)) {
$answer = json_decode($response->getBody()->getContents());
}
if ($answer->success && $answer->release && property_exists($answer->release, 'extensions')) {
$answer->release->extensions = static::gradeRequirements(
$answer->release->extensions,
$answer->release->min_php_version
);
}
return $answer;
}
/**
* Pull the release archive into a private temporary directory.
*
* @return string|array|false the archive path, or a falsy/failure payload
*/
public static function download($new_version, $is_cmd = 0)
{
$response = static::getRemote('releases/download/'.$new_version.'.zip', self::REQUEST_OPTIONS);
if ($response instanceof RequestException) {
return [
'success' => false,
'error' => 'Download Exception',
'data' => [
'path' => null,
],
];
}
$archive = null;
if ($response && ($response->getStatusCode() == 200)) {
$archive = $response->getBody()->getContents();
}
$target = static::makeTempDirectory('temp-').'/upload.zip';
if (! is_int(file_put_contents($target, $archive))) {
return false;
}
return $target;
}
/**
* Expand the archive into a second temporary directory and drop the zip.
*
* @return string the directory holding the extracted release
*
* @throws \Exception when the archive is gone
*/
public static function unzip($zip_file_path)
{
if (! file_exists($zip_file_path)) {
throw new \Exception('Zip file not found');
}
$destination = static::makeTempDirectory('temp2-');
$archive = new ZipArchive;
if ($archive->open($zip_file_path)) {
$archive->extractTo($destination);
}
$archive->close();
File::delete($zip_file_path);
return $destination;
}
/**
* Overlay the extracted release on top of this installation.
*/
public static function copyFiles($temp_extract_dir)
{
if (! File::copyDirectory($temp_extract_dir.'/InvoiceShelf', base_path())) {
return false;
}
File::deleteDirectory($temp_extract_dir);
return true;
}
/**
* Legacy clean-up: remove exactly the paths the release server listed.
*
* @param string $json JSON array of installation-relative paths
*/
public static function deleteFiles($json)
{
foreach (json_decode($json) as $relative) {
File::delete(base_path($relative));
}
return true;
}
/**
* Manifest clean-up: drop everything the shipped manifest does not list.
*
* Protected prefixes (local state, dependencies, VCS data, mobile apps)
* are never touched. Directories left empty by the sweep are removed in a
* second walk, so a pruned subtree disappears entirely.
*/
public static function cleanStaleFiles(): array
{
$manifestPath = base_path('manifest.json');
if (! File::exists($manifestPath)) {
return ['success' => true, 'cleaned' => 0];
}
$manifest = json_decode(File::get($manifestPath), true);
if (! is_array($manifest)) {
return ['success' => false, 'error' => 'Invalid manifest'];
}
$shipped = array_flip($manifest);
$keep = config('invoiceshelf.update_protected_paths', []);
$cleaned = 0;
foreach (static::walkInstallation() as $entry) {
$relative = static::relativeToInstallation($entry->getPathname());
if (static::isKeptPath($relative, $keep)) {
continue;
}
if ($entry->isFile() && ! isset($shipped[$relative])) {
File::delete($entry->getPathname());
$cleaned++;
}
}
foreach (static::walkInstallation() as $entry) {
if (! $entry->isDir()) {
continue;
}
$relative = static::relativeToInstallation($entry->getPathname());
if (static::isKeptPath($relative, $keep)) {
continue;
}
if (static::hasNoEntries($entry->getPathname())) {
@rmdir($entry->getPathname());
}
}
return ['success' => true, 'cleaned' => $cleaned];
}
/**
* Bring the schema up to date with the freshly copied code.
*/
public static function migrateUpdate()
{
Artisan::call('migrate --force');
return true;
}
/**
* Record the new version and announce the finished update.
*/
public static function finishUpdate($installed, $version)
{
Setting::setSetting('version', $version);
event(new UpdateFinished($installed, $version));
return [
'success' => true,
'error' => false,
'data' => [],
];
}
/**
* Turn the release's requirement list into a name => satisfied map, with a
* synthetic entry for the minimum interpreter version.
*/
private static function gradeRequirements($required, $minimumPhpVersion): array
{
$graded = [];
foreach ($required as $extension) {
$graded[$extension] = phpversion($extension) !== false;
}
$graded[sprintf('php(%s)', $minimumPhpVersion)] = version_compare(phpversion(), $minimumPhpVersion, '>=');
return $graded;
}
/**
* Create an empty, randomly named working directory in private storage.
*/
private static function makeTempDirectory(string $prefix): string
{
$directory = storage_path('app/'.$prefix.md5(mt_rand()));
if (! File::isDirectory($directory)) {
File::makeDirectory($directory);
}
return $directory;
}
/**
* Depth-first walk over the whole installation, children before parents.
*/
private static function walkInstallation(): \RecursiveIteratorIterator
{
return new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator(base_path(), \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
}
/**
* Does this directory hold nothing at all (dot entries aside)?
*/
private static function hasNoEntries(string $directory): bool
{
return ! (new \FilesystemIterator($directory))->valid();
}
/**
* Strip the installation root from an absolute path.
*/
private static function relativeToInstallation(string $absolutePath): string
{
return substr($absolutePath, strlen(base_path()) + 1);
}
/**
* Is this path itself protected, or does it live under a protected one?
*/
private static function isKeptPath(string $relativePath, array $protectedPaths): bool
{
foreach ($protectedPaths as $protected) {
if ($relativePath === $protected || str_starts_with($relativePath, $protected.'/')) {
return true;
}
}
return false;
}
}
+250
View File
@@ -0,0 +1,250 @@
<?php
use App\Domains\Accounts\Models\CompanySetting;
use App\Domains\Metadata\Models\CustomField;
use App\Domains\Money\Models\Currency;
use App\Platform\Operations\Installation\Application\InstallationState;
use App\Platform\Operations\Models\Setting;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response as HttpResponse;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
/*
|--------------------------------------------------------------------------
| Settings
|--------------------------------------------------------------------------
|
| These three run during boot — view composers, the blade shell, the PDF
| layouts — which means they can be reached before the installer has created
| a single table. Each therefore probes the installation first and answers
| null rather than letting a "no such table" error escape.
|
*/
/**
* Read one company-scoped setting, or null when there is no database yet.
*
* @param string $key
* @param mixed $company_id
* @return mixed
*/
function get_company_setting($key, $company_id)
{
return InstallationState::isDbCreated()
? CompanySetting::getSetting($key, $company_id)
: null;
}
/**
* Read one instance-wide setting, or null when there is no database yet.
*
* @param string $key
* @return mixed
*/
function get_app_setting($key)
{
return InstallationState::isDbCreated()
? Setting::getSetting($key)
: null;
}
/**
* Resolve the <title> for the SPA shell.
*
* The customer portal gets its own per-company title; everything else gets the
* instance-wide admin title. An empty or missing setting falls through to the
* product name, and an uninstalled instance has no title at all.
*
* @param mixed $company_id
* @return string|null
*/
function get_page_title($company_id)
{
if (! InstallationState::isDbCreated()) {
return null;
}
$configured = Route::currentRouteName() === 'customer.dashboard'
? CompanySetting::getSetting('customer_portal_page_title', $company_id)
: Setting::getSetting('admin_page_title');
return $configured ?: 'InvoiceShelf - Self Hosted Invoicing Platform';
}
/*
|--------------------------------------------------------------------------
| Request path matching
|--------------------------------------------------------------------------
*/
/**
* Does the current request path match one of the given patterns?
*
* @param string|array $path One pattern, or a list of them.
* @return bool
*/
function is_url($path)
{
return Request::is(...(array) $path);
}
/**
* Blade sugar: emit the marker class when the current path matches.
*
* @param string|array $path
* @param string $active Returned on a match.
* @return string
*/
function set_active($path, $active = 'active')
{
return is_url($path) ? $active : '';
}
/*
|--------------------------------------------------------------------------
| Custom fields
|--------------------------------------------------------------------------
*/
/**
* Which custom_field_values column stores an answer of this field type.
*
* Every value column is typed, so the field's declared type decides where the
* answer is written and read. Phone numbers are kept alongside numbers, and an
* unrecognised type is treated as free text so an unknown field never loses
* its answer.
*
* @return string
*/
function getCustomFieldValueKey(string $type)
{
return match ($type) {
'Number', 'Phone' => 'number_answer',
'Switch' => 'boolean_answer',
'Date' => 'date_answer',
'Time' => 'time_answer',
'DateTime' => 'date_time_answer',
// 'Input', 'TextArea', 'Url', 'Dropdown' — and anything unmapped.
default => 'string_answer',
};
}
/**
* Every existing slug that could collide with $slug for this model type.
*
* Prefix-matched in one query so the caller can test the base slug and all of
* its numbered variants without going back to the database. The field being
* renamed is excluded, otherwise it would collide with itself.
*
* @param string $type Model type the field is attached to.
* @param string $slug Base slug to match as a prefix.
* @param int $id Custom field to leave out of the comparison.
* @return Collection
*/
function getRelatedSlugs($type, $slug, $id = 0)
{
return CustomField::query()
->select('slug')
->where('model_type', $type)
->where('slug', 'like', $slug.'%')
->where('id', '!=', $id)
->get();
}
/**
* Build the unique storage slug for a custom field.
*
* The shape is CUSTOM_<MODEL>_<LABEL>, upper-cased with underscores. When that
* is taken, _1 .. _10 are tried in turn; an eleventh collision is a caller
* problem, not something to paper over with a random suffix.
*
* @param string $model Model type the field is attached to.
* @param string $title Human label to slugify.
* @param int $id Custom field being renamed, if any.
* @return string
*
* @throws Exception When every candidate is already in use.
*/
function clean_slug($model, $title, $id = 0)
{
$base = Str::upper('CUSTOM_'.$model.'_'.Str::slug($title, '_'));
$taken = getRelatedSlugs($model, $base, $id)->pluck('slug')->all();
$candidates = [$base, ...array_map(fn ($n) => $base.'_'.$n, range(1, 10))];
foreach ($candidates as $candidate) {
if (! in_array($candidate, $taken)) {
return $candidate;
}
}
throw new Exception('Can not create a unique slug');
}
/*
|--------------------------------------------------------------------------
| Output
|--------------------------------------------------------------------------
*/
/**
* Render a minor-unit amount as currency markup for a PDF template.
*
* Two things set this apart from the ordinary money formatter. The symbol is
* wrapped in a DejaVu Sans span, because the template fonts carry no glyph for
* most currency symbols. And the sign is prefixed to the finished string
* instead of being formatted into it, so a negative amount reads "-$24,738.00"
* with the symbol still against the digits in either symbol position.
*
* The sign is decided on the formatted digits rather than on the input: an
* amount that rounds away at the currency's precision prints as zero, and
* "-$0.00" is not a number anyone owes.
*
* @param int|float|string|null $money Amount in minor units (cents).
* @param Currency|null $currency
* @return string
*/
function format_money_pdf($money, $currency = null)
{
$amount = $money / 100;
// Quirk, deliberately preserved: with no currency in hand this falls back
// to company 1's setting rather than to the amount's own company.
$currency = $currency ?: Currency::findOrFail(CompanySetting::getSetting('currency', 1));
$digits = number_format(
abs($amount),
$currency->precision,
$currency->decimal_separator,
$currency->thousand_separator
);
$symbol = '<span style="font-family: DejaVu Sans;">'.$currency->symbol.'</span>';
$rendered = $currency->swap_currency_symbol
? $digits.$symbol
: $symbol.$digits;
$signed = $amount < 0 && preg_match('/[1-9]/', $digits) === 1;
return $signed ? '-'.$rendered : $rendered;
}
/**
* The validation-shaped rejection controllers use for domain conflicts.
*
* @param string $error Machine-readable key the SPA switches on.
* @param string $message Human-readable explanation.
* @return JsonResponse
*/
function respondJson($error, $message)
{
return response()->json([
'error' => $error,
'message' => $message,
], HttpResponse::HTTP_UNPROCESSABLE_ENTITY);
}