diff --git a/app/Platform/Operations/Console/ResetApp.php b/app/Platform/Operations/Console/ResetApp.php new file mode 100644 index 00000000..3e688d9e --- /dev/null +++ b/app/Platform/Operations/Console/ResetApp.php @@ -0,0 +1,62 @@ +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); + } +} diff --git a/app/Platform/Operations/Console/UpdateCommand.php b/app/Platform/Operations/Console/UpdateCommand.php new file mode 100644 index 00000000..c77f4fb1 --- /dev/null +++ b/app/Platform/Operations/Console/UpdateCommand.php @@ -0,0 +1,261 @@ +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; + } +} diff --git a/app/Platform/Operations/Events/UpdateFinished.php b/app/Platform/Operations/Events/UpdateFinished.php new file mode 100644 index 00000000..6f5585b6 --- /dev/null +++ b/app/Platform/Operations/Events/UpdateFinished.php @@ -0,0 +1,24 @@ +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]); + } +} diff --git a/app/Platform/Operations/Http/Admin/UpdateController.php b/app/Platform/Operations/Http/Admin/UpdateController.php new file mode 100644 index 00000000..24d2d92b --- /dev/null +++ b/app/Platform/Operations/Http/Admin/UpdateController.php @@ -0,0 +1,144 @@ +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'))); + } +} diff --git a/app/Platform/Operations/Http/AppVersionController.php b/app/Platform/Operations/Http/AppVersionController.php new file mode 100644 index 00000000..7ae64334 --- /dev/null +++ b/app/Platform/Operations/Http/AppVersionController.php @@ -0,0 +1,47 @@ +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'; + } +} diff --git a/app/Platform/Operations/Http/Company/BootstrapController.php b/app/Platform/Operations/Http/Company/BootstrapController.php new file mode 100644 index 00000000..14d74d16 --- /dev/null +++ b/app/Platform/Operations/Http/Company/BootstrapController.php @@ -0,0 +1,215 @@ +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(); + } +} diff --git a/app/Platform/Operations/Http/Company/ConfigController.php b/app/Platform/Operations/Http/Company/ConfigController.php new file mode 100644 index 00000000..b4ec3285 --- /dev/null +++ b/app/Platform/Operations/Http/Company/ConfigController.php @@ -0,0 +1,50 @@ +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> + */ + 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(); + } +} diff --git a/app/Platform/Operations/Http/Concerns/GeneratesMenu.php b/app/Platform/Operations/Http/Concerns/GeneratesMenu.php new file mode 100644 index 00000000..8e8bfdc5 --- /dev/null +++ b/app/Platform/Operations/Http/Concerns/GeneratesMenu.php @@ -0,0 +1,52 @@ +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, + ]; + } +} diff --git a/app/Platform/Operations/Http/Middleware/CronJobMiddleware.php b/app/Platform/Operations/Http/Middleware/CronJobMiddleware.php new file mode 100644 index 00000000..609bfac2 --- /dev/null +++ b/app/Platform/Operations/Http/Middleware/CronJobMiddleware.php @@ -0,0 +1,47 @@ +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); + } +} diff --git a/app/Platform/Operations/Http/Requests/GetSettingRequest.php b/app/Platform/Operations/Http/Requests/GetSettingRequest.php new file mode 100644 index 00000000..84535bcf --- /dev/null +++ b/app/Platform/Operations/Http/Requests/GetSettingRequest.php @@ -0,0 +1,33 @@ + + */ + public function rules(): array + { + return [ + 'key' => 'required|string', + ]; + } +} diff --git a/app/Platform/Operations/Http/Requests/SettingRequest.php b/app/Platform/Operations/Http/Requests/SettingRequest.php new file mode 100644 index 00000000..5b2ad660 --- /dev/null +++ b/app/Platform/Operations/Http/Requests/SettingRequest.php @@ -0,0 +1,33 @@ + + */ + public function rules(): array + { + return [ + 'settings' => 'required', + ]; + } +} diff --git a/app/Platform/Operations/Http/Webhooks/CronJobController.php b/app/Platform/Operations/Http/Webhooks/CronJobController.php new file mode 100644 index 00000000..51e27b8f --- /dev/null +++ b/app/Platform/Operations/Http/Webhooks/CronJobController.php @@ -0,0 +1,26 @@ +json(['success' => true]); + } +} diff --git a/app/Platform/Operations/Installation/Application/EnvironmentManager.php b/app/Platform/Operations/Installation/Application/EnvironmentManager.php new file mode 100644 index 00000000..10254c8a --- /dev/null +++ b/app/Platform/Operations/Installation/Application/EnvironmentManager.php @@ -0,0 +1,365 @@ +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, + ]; + } +} diff --git a/app/Platform/Operations/Installation/Application/FilePermissionChecker.php b/app/Platform/Operations/Installation/Application/FilePermissionChecker.php new file mode 100644 index 00000000..2a4e976c --- /dev/null +++ b/app/Platform/Operations/Installation/Application/FilePermissionChecker.php @@ -0,0 +1,53 @@ +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); + } +} diff --git a/app/Platform/Operations/Installation/Application/RequirementsChecker.php b/app/Platform/Operations/Installation/Application/RequirementsChecker.php new file mode 100644 index 00000000..a5cf8089 --- /dev/null +++ b/app/Platform/Operations/Installation/Application/RequirementsChecker.php @@ -0,0 +1,181 @@ + $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 + */ + 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 + */ + 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]; + } +} diff --git a/app/Platform/Operations/Installation/Http/Controllers/AppDomainController.php b/app/Platform/Operations/Installation/Http/Controllers/AppDomainController.php new file mode 100644 index 00000000..15cc3a4d --- /dev/null +++ b/app/Platform/Operations/Installation/Http/Controllers/AppDomainController.php @@ -0,0 +1,35 @@ +saveDomainVariables($request); + + return response()->json([ + 'success' => true, + ]); + } +} diff --git a/app/Platform/Operations/Installation/Http/Controllers/DatabaseConfigurationController.php b/app/Platform/Operations/Installation/Http/Controllers/DatabaseConfigurationController.php new file mode 100644 index 00000000..b67e2081 --- /dev/null +++ b/app/Platform/Operations/Installation/Http/Controllers/DatabaseConfigurationController.php @@ -0,0 +1,97 @@ +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, + ]); + } +} diff --git a/app/Platform/Operations/Installation/Http/Controllers/FilePermissionsController.php b/app/Platform/Operations/Installation/Http/Controllers/FilePermissionsController.php new file mode 100644 index 00000000..23ea1e7b --- /dev/null +++ b/app/Platform/Operations/Installation/Http/Controllers/FilePermissionsController.php @@ -0,0 +1,30 @@ +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') + ), + ]); + } +} diff --git a/app/Platform/Operations/Installation/Http/Controllers/FinishController.php b/app/Platform/Operations/Installation/Http/Controllers/FinishController.php new file mode 100644 index 00000000..66444991 --- /dev/null +++ b/app/Platform/Operations/Installation/Http/Controllers/FinishController.php @@ -0,0 +1,20 @@ +json(['success' => true]); + } +} diff --git a/app/Platform/Operations/Installation/Http/Controllers/LoginController.php b/app/Platform/Operations/Installation/Http/Controllers/LoginController.php new file mode 100644 index 00000000..5dd4f13e --- /dev/null +++ b/app/Platform/Operations/Installation/Http/Controllers/LoginController.php @@ -0,0 +1,63 @@ +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, + ]); + } +} diff --git a/app/Platform/Operations/Installation/Http/Controllers/OnboardingWizardController.php b/app/Platform/Operations/Installation/Http/Controllers/OnboardingWizardController.php new file mode 100644 index 00000000..9fc55f5b --- /dev/null +++ b/app/Platform/Operations/Installation/Http/Controllers/OnboardingWizardController.php @@ -0,0 +1,68 @@ +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'), + ]); + } +} diff --git a/app/Platform/Operations/Installation/Http/Controllers/RequirementsController.php b/app/Platform/Operations/Installation/Http/Controllers/RequirementsController.php new file mode 100644 index 00000000..6d611965 --- /dev/null +++ b/app/Platform/Operations/Installation/Http/Controllers/RequirementsController.php @@ -0,0 +1,33 @@ +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') + ), + ]); + } +} diff --git a/app/Platform/Operations/Installation/Http/Middleware/EnsureInstalled.php b/app/Platform/Operations/Installation/Http/Middleware/EnsureInstalled.php new file mode 100644 index 00000000..7d3161df --- /dev/null +++ b/app/Platform/Operations/Installation/Http/Middleware/EnsureInstalled.php @@ -0,0 +1,41 @@ +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; + } + } +} diff --git a/app/Platform/Operations/Installation/Http/Middleware/RedirectIfInstalled.php b/app/Platform/Operations/Installation/Http/Middleware/RedirectIfInstalled.php new file mode 100644 index 00000000..6ba5b6bb --- /dev/null +++ b/app/Platform/Operations/Installation/Http/Middleware/RedirectIfInstalled.php @@ -0,0 +1,43 @@ +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; + } + } +} diff --git a/app/Platform/Operations/Installation/Http/Requests/DatabaseEnvironmentRequest.php b/app/Platform/Operations/Installation/Http/Requests/DatabaseEnvironmentRequest.php new file mode 100644 index 00000000..f5521b6c --- /dev/null +++ b/app/Platform/Operations/Installation/Http/Requests/DatabaseEnvironmentRequest.php @@ -0,0 +1,92 @@ +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', + ], + ]; + } +} diff --git a/app/Platform/Operations/Installation/Http/Requests/DomainEnvironmentRequest.php b/app/Platform/Operations/Installation/Http/Requests/DomainEnvironmentRequest.php new file mode 100644 index 00000000..03887556 --- /dev/null +++ b/app/Platform/Operations/Installation/Http/Requests/DomainEnvironmentRequest.php @@ -0,0 +1,26 @@ + [ + 'required', + ], + ]; + } +} diff --git a/app/Platform/Operations/Models/Setting.php b/app/Platform/Operations/Models/Setting.php new file mode 100644 index 00000000..1dc8d12b --- /dev/null +++ b/app/Platform/Operations/Models/Setting.php @@ -0,0 +1,78 @@ + $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 $settings + * @return Collection + */ + 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]); + } +} diff --git a/app/Platform/Operations/Update/CallsReleaseServer.php b/app/Platform/Operations/Update/CallsReleaseServer.php new file mode 100644 index 00000000..b5582377 --- /dev/null +++ b/app/Platform/Operations/Update/CallsReleaseServer.php @@ -0,0 +1,53 @@ + '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; + } + } +} diff --git a/app/Platform/Operations/Update/Updater.php b/app/Platform/Operations/Update/Updater.php new file mode 100644 index 00000000..e8abfcd7 --- /dev/null +++ b/app/Platform/Operations/Update/Updater.php @@ -0,0 +1,301 @@ + 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; + } +} diff --git a/app/Platform/Operations/helpers.php b/app/Platform/Operations/helpers.php new file mode 100644 index 00000000..4cb64b97 --- /dev/null +++ b/app/Platform/Operations/helpers.php @@ -0,0 +1,250 @@ + 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__