'.$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.
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 '
'.$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.