Skip to content

Commit 7abe3a1

Browse files
committed
Ship the admin panel with the build, where the host actually serves from
niteshacars.in/admin returned the site's own "page not found" while the panel sat perfectly well in the repository. The reason took all day to find and is embarrassing in hindsight: Hostinger deploys this repository by running npm run build and serving my-app/dist. dist is the document root. Anything outside it does not exist as far as the web is concerned. Everything built today -- the FTPS pipeline, the config.php generator, the panel-finding search -- wrote to a folder nobody serves. The site has been deploying automatically from main the whole time. The Deployments panel said so plainly and I inferred from file listings instead. copy-admin-panel.mjs copies the panel into dist/admin after the Vite build, so /admin ships with the site and deploys the same automatic way. Vite would copy public/ verbatim and that would have been less code, but burying a PHP application inside the website's asset folder is a surprise to whoever reads the tree next. config.php is never copied, and config() had to change to match. It looked in one fixed place, beside the panel -- which is inside dist, recreated from scratch on every deploy, so the database password would need re-uploading after every push. It now also searches upward for a nitesha-config/ directory. Outside the document root, that survives deploys and cannot be fetched over HTTP even if a rule is wrong, which is where a database password belongs. The old path is still checked first, so an install that predates this is untouched. Six cases covered against real layouts: config above dist, several levels up, beside the panel, beside winning over the outward search, absent entirely, and beyond the search depth so it gives up rather than walking to the filesystem root. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
1 parent b766777 commit 7abe3a1

3 files changed

Lines changed: 107 additions & 3 deletions

File tree

‎my-app/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"type": "module",
66
"scripts": {
77
"dev": "node scripts/photo-manifest.mjs && node scripts/fetch-content.mjs && vite",
8-
"build": "node scripts/photo-manifest.mjs && node scripts/fetch-content.mjs && tsc -b && vite build && node scripts/prerender-seo.mjs",
8+
"build": "node scripts/photo-manifest.mjs && node scripts/fetch-content.mjs && tsc -b && vite build && node scripts/copy-admin-panel.mjs && node scripts/prerender-seo.mjs",
99
"lint": "oxlint",
1010
"preview": "vite preview"
1111
},
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copies the PHP admin panel into the build output, so it ships with the site.
2+
//
3+
// Hostinger deploys this repository by running `npm run build` and serving
4+
// my-app/dist. That means dist is the document root: anything not in it does
5+
// not exist as far as the web is concerned, which is why niteshacars.in/admin
6+
// returned the site's own "page not found" while the panel sat perfectly well
7+
// in the repository.
8+
//
9+
// Vite copies public/ verbatim into dist/, so the panel could simply live
10+
// there -- but public/ is for the website's own assets, and burying a PHP
11+
// application inside it would be a surprise to anyone reading the tree. This
12+
// copies it explicitly instead, after the build, where the intent is visible.
13+
//
14+
// config.php is never copied. It holds the database password, it is gitignored,
15+
// and dist is rebuilt from scratch on every deploy -- so anything written into
16+
// it is temporary by construction. The panel finds its configuration outside
17+
// the build output; see config() in the panel's src/db.php.
18+
19+
import { cp, mkdir, rm, stat } from 'node:fs/promises';
20+
import { dirname, join } from 'node:path';
21+
import { fileURLToPath } from 'node:url';
22+
23+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
24+
const source = join(dirname(root), 'public_html', 'admin.niteshacars.in', 'admin');
25+
const target = join(root, 'dist', 'admin');
26+
27+
// Never ship these. The test tooling is not a security boundary, but it is
28+
// noise on a public server and some of it takes arguments.
29+
const SKIP = new Set(['config.php', '.ftp-deploy-sync-state.json', '.ftp-deploy-admin-state.json']);
30+
31+
try {
32+
await stat(source);
33+
} catch {
34+
console.error(`copy-admin-panel: no panel at ${source}`);
35+
process.exit(1);
36+
}
37+
38+
await rm(target, { recursive: true, force: true });
39+
await mkdir(dirname(target), { recursive: true });
40+
41+
let copied = 0;
42+
await cp(source, target, {
43+
recursive: true,
44+
filter: (src) => {
45+
const name = src.slice(src.lastIndexOf('/') + 1);
46+
if (SKIP.has(name)) return false;
47+
copied += 1;
48+
return true;
49+
},
50+
});
51+
52+
console.log(`copy-admin-panel: ${copied} entries into dist/admin`);

‎public_html/admin.niteshacars.in/admin/src/db.php‎

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,58 @@ function config_set(array $values): void
1515
$GLOBALS['__config_override'] = $values;
1616
}
1717

18+
/**
19+
* Finds config.php.
20+
*
21+
* It used to be one fixed path, next to the panel. That stopped working when
22+
* the host began deploying this repository by building it: the build output is
23+
* the document root and it is recreated from scratch every time, so anything
24+
* written beside the panel is erased on the next push. Keeping the file there
25+
* would mean re-uploading the database password after every deploy.
26+
*
27+
* So the panel looks outward instead. A directory named nitesha-config,
28+
* anywhere above the panel, holds the file; being outside the document root it
29+
* survives deploys and cannot be fetched over HTTP even if a rule is
30+
* misconfigured, which is where a database password belongs anyway.
31+
*
32+
* The old location is still checked first, so an install that predates this
33+
* keeps working untouched.
34+
*
35+
* Returns null when there is nothing to load; the caller decides what to say.
36+
*/
37+
function config_path(): ?string
38+
{
39+
$explicit = getenv('NITESHA_CONFIG');
40+
if (is_string($explicit) && $explicit !== '' && is_file($explicit)) {
41+
return $explicit;
42+
}
43+
44+
// Beside the panel: how this has always worked, and still right for an
45+
// install that is uploaded rather than built.
46+
$beside = __DIR__ . '/../config.php';
47+
if (is_file($beside)) {
48+
return $beside;
49+
}
50+
51+
// Then upwards. Six levels is past the account root on every layout this
52+
// has run on, and stopping at the filesystem root keeps it terminating on
53+
// any layout it has not.
54+
$dir = dirname(__DIR__);
55+
for ($i = 0; $i < 6; $i++) {
56+
$candidate = $dir . '/nitesha-config/config.php';
57+
if (is_file($candidate)) {
58+
return $candidate;
59+
}
60+
$parent = dirname($dir);
61+
if ($parent === $dir) {
62+
break;
63+
}
64+
$dir = $parent;
65+
}
66+
67+
return null;
68+
}
69+
1870
function config(?string $key = null): mixed
1971
{
2072
static $config = null;
@@ -24,8 +76,8 @@ function config(?string $key = null): mixed
2476
}
2577

2678
if ($config === null) {
27-
$path = __DIR__ . '/../config.php';
28-
if (!is_file($path)) {
79+
$path = config_path();
80+
if ($path === null) {
2981
http_response_code(500);
3082
exit('Not set up yet. Open install.php in your browser to get started.');
3183
}

0 commit comments

Comments
 (0)