mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-05 06:41:02 +00:00
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:
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
+97
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user