vendor/autoload.php. A release ' .'zip ships with them already in place; a git clone does not, and installs ' .'them as its first step.', "Install the dependencies: composer install " .'On the development Docker stack: docker compose exec app composer install', ]; } /** * The frontend has never been built. * * `public/hot` counts as built: laravel-vite-plugin writes it while * `npm run dev` is running, and every asset is then served by that dev * server rather than out of public/build. Stopping a developer who has a dev * server running would make this guard worse than the exception it replaces. * * @return array{0: string, 1: string, 2: string}|null */ function projectsend_preflight_assets_failure(string $root): ?array { if (is_file($root.'/public/build/manifest.json') || is_file($root.'/public/hot')) { return null; } return [ 'ProjectSend is not built yet', 'The frontend has not been compiled — there is no ' .'public/build/manifest.json. A release zip ships it already built; a ' .'git clone does not. Without it every page fails while rendering, which ' .'is a stack trace rather than an answer.', "Build the frontend: npm ci npm run build", ]; } /** * Whether this installation has been configured at all — the original and * most common first-run failure, kept exactly as it was. * * @return array{0: string, 1: string, 2: string}|null */ function projectsend_preflight_configuration_failure(string $root): ?array { $appKey = projectsend_preflight_env('APP_KEY'); $envFile = $root.'/.env'; $hasEnvFile = is_file($envFile); // A .env that is there but unreadable looks exactly like one that was // never created — is_file() is false either way, since it has to follow // the link and stat the target — and the two need opposite advice. // Reported from the official Docker image, where .env is a symlink into // storage/: the image left /var/www/html world-writable and owned by a // uid that no longer existed, so the kernel's fs.protected_symlinks // refused to let the php-fpm worker follow it. Every request said "no // .env file was found" while `docker exec ... cat .env`, as root, printed // it back perfectly — which is the most misleading pair of facts this // guard could possibly hand somebody. $unreadableEnv = $hasEnvFile ? ! is_readable($envFile) : is_link($envFile); // Fall back to a cheap read of just APP_KEY from the file — we are not // going to boot Dotenv or parse the whole thing for one value. Skipped // when the file cannot be read, or file() emits a PHP warning into the // page this guard exists to keep clean. if ($appKey === null && $hasEnvFile && ! $unreadableEnv) { foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { if (preg_match('/^\s*(?:export\s+)?APP_KEY\s*=\s*(.*)$/', $line, $m) === 1) { // Strip the surrounding quotes Dotenv would also strip. $appKey = trim(trim($m[1]), "\"'"); break; } } } if ($appKey !== null && $appKey !== '') { return null; } if ($unreadableEnv) { return [ 'ProjectSend cannot read its configuration', 'A .env is in place, but the user PHP runs as cannot read it — or, if it ' .'is a symlink, cannot follow it. Note that root is exempt from both ' .'checks, so reading the file over docker exec or sudo ' .'proves nothing here.', "Compare who owns the file with who PHP runs as, and check the directory holding it:\n\n" ."ls -ln .env\nstat -c '%n %U %a' . .env", ]; } if (! $hasEnvFile) { return [ 'ProjectSend is not configured yet', 'No .env file was found, and no APP_KEY is set in this ' .'environment. ProjectSend needs an application key before it can start.', "Copy the example configuration, then generate the key:\n\n" ."cp .env.example .env\nphp artisan key:generate", ]; } return [ 'ProjectSend is not configured yet', 'A .env file is present, but its APP_KEY is empty. ProjectSend ' .'needs an application key before it can start.', "Generate the key:\n\nphp artisan key:generate", ]; } /** * Render the message and stop, or return so Laravel can take over. * * Called with no argument from public/index.php; the argument exists so * tests can point it at a fixture directory. */ function projectsend_preflight(?string $root = null): void { $failure = projectsend_preflight_failure($root ?? dirname(__DIR__)); if ($failure === null) { return; } [$title, $reason, $fix] = $failure; // 503, not 500: the install is temporarily unavailable pending setup, // not broken. Retry-After keeps a well-behaved proxy or uptime check // from hammering it while someone finishes the install. if (! headers_sent()) { http_response_code(503); header('Content-Type: text/html; charset=UTF-8'); header('Retry-After: 3600'); header('Cache-Control: no-store'); } $e = static fn (string $s): string => htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); echo '' .'' .''.$e($title).'
' .'

'.$e($title).'

' .'

'.$reason.'

' .'

'.$e(projectsend_preflight_intro($fix)).'

' .'
'.$e(projectsend_preflight_command($fix)).'
' .'

Run these as the user your web server runs as. ' .'The full procedure is in INSTALL.md.

' .'
'; exit; } /** * The sentence before the command block in a fix string. */ function projectsend_preflight_intro(string $fix): string { return trim(explode("\n\n", $fix, 2)[0]); } /** * The command block from a fix string. */ function projectsend_preflight_command(string $fix): string { $parts = explode("\n\n", $fix, 2); return trim($parts[1] ?? $parts[0]); } // Auto-run when included by the front controller; suppressed under test so // the pure helpers above can be exercised against fixtures. if (! defined('PROJECTSEND_PREFLIGHT_TEST')) { projectsend_preflight(); }