Skip to content

Commit 7bf3f26

Browse files
Merge pull request #35 from niteeshkanna-sh/claude/stoic-rubin-fnglq7
Make the panel survive a deploy, and put a turning snake in the header
2 parents 22bde85 + 26f7ba6 commit 7bf3f26

8 files changed

Lines changed: 350 additions & 67 deletions

File tree

‎.github/workflows/deploy-hostinger.yml‎

Lines changed: 34 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,27 @@ jobs:
103103
set -e
104104
105105
if [ "$rc" -ne 0 ]; then
106-
echo "::error::Could not list the FTP login directory, so the target is unverified."
106+
# curl exit 67 is specifically "the server rejected the login", and
107+
# saying so is worth the six lines: the generic message sent two
108+
# runs chasing the wrong thing, because "could not list the
109+
# directory" reads like a network or path problem when it is
110+
# actually a username and password the host no longer accepts.
111+
if [ "$rc" -eq 67 ]; then
112+
echo "::error::The FTP server rejected the username and password."
113+
echo ""
114+
echo "The three secrets are set, they are just no longer valid --"
115+
echo "an account renamed, deleted, or its password changed."
116+
echo ""
117+
echo " 1. hPanel -> Files -> FTP Accounts: note the hostname and"
118+
echo " username, and set a new password if you do not have it."
119+
echo " 2. Check the account's directory is the website root, the"
120+
echo " folder holding index.html. This deploy writes to"
121+
echo " wherever the account lands when it logs in."
122+
echo " 3. Update FTP_SERVER, FTP_USERNAME and FTP_PASSWORD under"
123+
echo " Settings -> Secrets and variables -> Actions."
124+
else
125+
echo "::error::Could not list the FTP login directory, so the target is unverified."
126+
fi
107127
sed 's/^/ /' "$cfg.err" | head -5
108128
exit 1
109129
fi
@@ -148,41 +168,6 @@ jobs:
148168
.ftp-deploy-sync-state.json
149169
photos/README.md
150170
151-
- name: Write the panel's config.php
152-
env:
153-
DB_HOST: ${{ secrets.DB_HOST }}
154-
DB_NAME: ${{ secrets.DB_NAME }}
155-
DB_USER: ${{ secrets.DB_USER }}
156-
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
157-
run: |
158-
# Placing this by hand was the last manual step, and it never
159-
# succeeded: two directories called admin/ look identical in a file
160-
# browser, and the copy kept landing in the one the server does not
161-
# serve. Generating it removes the step rather than documenting it
162-
# better.
163-
missing=""
164-
[ -n "$DB_NAME" ] || missing="$missing DB_NAME"
165-
[ -n "$DB_USER" ] || missing="$missing DB_USER"
166-
[ -n "$DB_PASSWORD" ] || missing="$missing DB_PASSWORD"
167-
if [ -n "$missing" ]; then
168-
echo "::error::Missing database secrets:$missing"
169-
echo ""
170-
echo "Add them under Settings -> Secrets and variables -> Actions."
171-
echo "The values are in the existing config.php on the server."
172-
exit 1
173-
fi
174-
175-
.github/scripts/write-admin-config.sh \
176-
admin/config.php
177-
178-
# Proves it parses before it is uploaded. A config.php with a syntax
179-
# error takes the whole panel down and says nothing about why.
180-
if command -v php >/dev/null 2>&1; then
181-
php -l admin/config.php
182-
else
183-
echo "No php on the runner; skipping the syntax check."
184-
fi
185-
186171
- name: Deploy the admin panel to /admin
187172
uses: SamKirkland/FTP-Deploy-Action@v4.3.5
188173
with:
@@ -212,11 +197,21 @@ jobs:
212197
# above would make each run think the other's files had vanished.
213198
state-name: .ftp-deploy-admin-state.json
214199

215-
# config.php is no longer excluded: it is generated from secrets a
216-
# step above, so the deploy is now the only thing that writes it.
217-
# Editing it on the server will not survive the next run.
200+
# config.php is excluded, and this deploy no longer generates one.
201+
#
202+
# It used to: the file was written from DB_* secrets and uploaded
203+
# beside the panel. That was reasonable when the panel's config could
204+
# only live there, and it is now actively harmful. The panel reads
205+
# nitesha-config/config.php from above the document root precisely so
206+
# that a deploy cannot reach it -- but config_path() checks beside
207+
# the panel FIRST, so an uploaded copy silently wins over the real
208+
# one. A stale secret would then take the admin offline with
209+
# "Database unavailable" on a deploy that only meant to change the
210+
# website, and the working config would still be sitting there
211+
# untouched and ignored.
218212
exclude: |
219213
**/.git*
220214
**/.git*/**
221215
**/.DS_Store
216+
config.php
222217
.ftp-deploy-admin-state.json

‎admin/admin.css‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,10 @@ button { font-family: inherit; }
492492
}
493493
.install-checks li.ok::before { content: '✓'; color: #2F6B4F; }
494494
.install-checks li.no::before { content: '✕'; color: #B3261E; }
495+
/* Not a failure -- the install worked -- but the result needs acting on
496+
before the next deploy, so it cannot look like the passing rows. */
497+
.install-checks li.warn::before { content: '!'; color: #8A5A00; font-weight: 800; }
498+
.install-checks li.warn { color: var(--ink); }
495499
.install-checks li span { display: block; color: var(--ink-dim); font-size: 0.8rem; }
496500
.install-errors {
497501
background: #FDECEA; border: 1px solid #F3C4BF; border-radius: 10px;

‎admin/install.php‎

Lines changed: 129 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,50 @@
1717
require_once __DIR__ . '/src/db.php';
1818
require_once __DIR__ . '/src/migrate.php';
1919

20-
const CONFIG_PATH = __DIR__ . '/config.php';
20+
/**
21+
* Where a new config.php should be written.
22+
*
23+
* It used to be __DIR__ . '/config.php', beside the panel. That is inside the
24+
* document root, and the document root is rebuilt from scratch on every
25+
* deploy -- so the installer's own output was erased by the next publish, and
26+
* the panel came back reporting it had never been set up. It happened, and it
27+
* took the admin down.
28+
*
29+
* config() looks for nitesha-config/config.php in each directory above the
30+
* panel, so a file one level above the document root is found and is out of
31+
* reach of anything that rewrites the site. That is where this writes.
32+
*
33+
* Beside the panel stays as the fallback for hosting where the parent
34+
* directory cannot be written, because a panel that works until the next
35+
* deploy beats a panel that never starts. install_config_path() says which
36+
* one was used so the last screen can be honest about it.
37+
*/
38+
function install_config_dir_preferred(): string
39+
{
40+
// __DIR__ is <docroot>/admin, so this is the directory holding the
41+
// document root -- above everything a deploy replaces.
42+
return dirname(__DIR__, 2) . '/nitesha-config';
43+
}
44+
45+
function install_config_path(): string
46+
{
47+
// An existing config wins wherever it is: rewriting a working install's
48+
// settings into a second file would leave two, and config() would pick
49+
// whichever it reached first.
50+
$existing = config_path();
51+
if ($existing !== null) {
52+
return $existing;
53+
}
54+
55+
$preferred = install_config_dir_preferred();
56+
if (is_dir($preferred) || @mkdir($preferred, 0750, true) || is_dir($preferred)) {
57+
if (is_writable($preferred)) {
58+
return $preferred . '/config.php';
59+
}
60+
}
61+
62+
return __DIR__ . '/config.php';
63+
}
2164
const MIN_PHP = '8.1';
2265
const MIN_PASSWORD = 10;
2366

@@ -39,9 +82,10 @@
3982
// The presence of config.php is not enough — a half-finished attempt leaves
4083
// one behind. What settles it is whether anyone can sign in.
4184
// ---------------------------------------------------------------------------
42-
if (is_file(CONFIG_PATH)) {
85+
$existingConfig = config_path();
86+
if ($existingConfig !== null) {
4387
try {
44-
$existing = require CONFIG_PATH;
88+
$existing = require $existingConfig;
4589
config_set(is_array($existing) ? $existing : []);
4690
$n = (int) (fetch_one('SELECT COUNT(*) AS n FROM users')['n'] ?? 0);
4791
if ($n > 0) {
@@ -126,17 +170,46 @@ function install(array $form): array
126170
return ['Setting up the tables failed: ' . $e->getMessage()];
127171
}
128172

173+
// The config is written before the account, and that order matters.
174+
//
175+
// It used to be the other way round, which broke the one case this file is
176+
// now most often opened for: a database that still holds everything, and a
177+
// config.php that a deploy erased. create_user() then failed on the
178+
// duplicate email, returned early, and write_config() never ran -- so the
179+
// panel stayed dead and the installer sent you round the same loop with no
180+
// way out. Writing the config first means a problem creating an account is
181+
// a problem creating an account, not a panel that will not start.
182+
if (!write_config($config)) {
183+
return ['The database details work, but config.php could not be written to '
184+
. h(dirname(install_config_path())) . '. Create it there by hand '
185+
. 'from config.sample.php, using the details above.'];
186+
}
187+
188+
// An account already in this database is the signal that the database is
189+
// not new -- so this is a reconnection, not an installation, and making a
190+
// second Super Admin would be wrong.
191+
$existingUsers = 0;
192+
try {
193+
$existingUsers = (int) (fetch_one('SELECT COUNT(*) AS n FROM users')['n'] ?? 0);
194+
} catch (Throwable $e) {
195+
// A fresh database that has only just been migrated; treat it as empty.
196+
}
197+
198+
if ($existingUsers > 0) {
199+
$GLOBALS['__applied'] = $applied;
200+
$GLOBALS['__reconnect'] = true;
201+
return [];
202+
}
203+
129204
try {
130205
require_once __DIR__ . '/src/audit.php';
131206
require_once __DIR__ . '/src/auth.php';
132207
create_user($form['admin_name'], $form['admin_email'], $password, 'super_admin');
133208
} catch (Throwable $e) {
134-
return ['Creating your account failed: ' . $e->getMessage()];
135-
}
136-
137-
if (!write_config($config)) {
138-
return ['Everything else worked, but config.php could not be written. '
139-
. 'Create it by hand from config.sample.php, using the details above.'];
209+
return ['The settings were saved, but creating your account failed: '
210+
. $e->getMessage() . ' If you already have an account in this database, '
211+
. 'reload this page -- the panel is configured now and should let you '
212+
. 'sign in with it.'];
140213
}
141214

142215
$GLOBALS['__applied'] = $applied;
@@ -158,7 +231,9 @@ function build_config(array $form): array
158231
'password' => (string) $form['db_pass'],
159232
'charset' => 'utf8mb4',
160233
],
161-
'storage_path' => dirname(__DIR__) . '/nitesha-storage',
234+
// Above the document root for the same reason config.php is: anything
235+
// inside it is temporary, because a deploy rebuilds it.
236+
'storage_path' => dirname(__DIR__, 2) . '/nitesha-storage',
162237
'public_site_origin' => $origins,
163238
// Hostinger serves these domains over HTTPS, and the session cookie
164239
// carries the sign-in, so it should never travel in the clear.
@@ -169,20 +244,36 @@ function build_config(array $form): array
169244

170245
function write_config(array $config): bool
171246
{
247+
$target = install_config_path();
248+
172249
$php = "<?php\n\n// Written by install.php. Holds the database password —\n"
173250
. "// keep it out of version control and off any public URL.\n\n"
174251
. 'return ' . var_export($config, true) . ";\n";
175252

176-
if (@file_put_contents(CONFIG_PATH, $php) === false) {
253+
$dir = dirname($target);
254+
if (!is_dir($dir) && !@mkdir($dir, 0750, true) && !is_dir($dir)) {
177255
return false;
178256
}
179-
@chmod(CONFIG_PATH, 0640);
257+
258+
if (@file_put_contents($target, $php) === false) {
259+
return false;
260+
}
261+
@chmod($target, 0640);
262+
263+
$GLOBALS['__config_written_to'] = $target;
180264
return true;
181265
}
182266

183267
function environment_checks(): array
184268
{
185-
$writable = is_writable(__DIR__);
269+
// The directory that matters is the one write_config() will use, which is
270+
// normally above the document root -- not this one. Checking __DIR__ told
271+
// people the install would work when the file was going somewhere else
272+
// entirely.
273+
$target = install_config_path();
274+
$dir = dirname($target);
275+
$writable = is_dir($dir) ? is_writable($dir) : is_writable(dirname($dir));
276+
$aboveRoot = $dir !== __DIR__;
186277
return [
187278
['label' => 'PHP ' . MIN_PHP . ' or newer',
188279
'ok' => version_compare(PHP_VERSION, MIN_PHP, '>='),
@@ -192,10 +283,13 @@ function environment_checks(): array
192283
'note' => extension_loaded('pdo_mysql') ? 'Available' : 'Enable it in hPanel → PHP Configuration'],
193284
['label' => 'JSON support',
194285
'ok' => extension_loaded('json'), 'note' => ''],
195-
['label' => 'This folder is writable',
286+
['label' => 'Somewhere to keep config.php',
196287
'ok' => $writable,
197-
'note' => $writable ? 'config.php can be written for you'
198-
: 'You will need to create config.php by hand'],
288+
'note' => !$writable
289+
? 'You will need to create ' . $dir . '/config.php by hand'
290+
: ($aboveRoot
291+
? 'Will be written to ' . $dir . ', above the website folder, where a deploy cannot erase it'
292+
: 'Will be written beside the panel. A deploy that rebuilds the website folder will erase it')],
199293
['label' => 'sql/ files present',
200294
'ok' => (glob(__DIR__ . '/sql/*.sql') ?: []) !== [],
201295
'note' => 'The table definitions'],
@@ -241,13 +335,27 @@ function page(string $title, string $body): void
241335
// ---------------------------------------------------------------------------
242336
if ($done) {
243337
$applied = $GLOBALS['__applied'] ?? [];
244-
page('Ready', '
245-
<p class="install-note">The panel is set up. Sign in with the account you
246-
just made.</p>
338+
page('Ready',
339+
'<p class="install-note">The panel is set up. '
340+
. (empty($GLOBALS['__reconnect'])
341+
? 'Sign in with the account you just made.'
342+
: 'Your existing data and accounts were already there and were left alone.')
343+
. '</p>
247344
<ul class="install-checks">'
248345
. ($applied === [] ? '' : '<li class="ok">Tables created (' . h(implode(', ', $applied)) . ')</li>')
249-
. '<li class="ok">Your Super Admin account was created</li>
250-
<li class="ok">config.php was written</li>
346+
. (empty($GLOBALS['__reconnect'])
347+
? '<li class="ok">Your Super Admin account was created</li>'
348+
: '<li class="ok">This database already had accounts in it, so no new one was
349+
made &mdash; sign in with the one you already use</li>')
350+
. '<li class="ok">config.php was written to <code>'
351+
. h((string) ($GLOBALS['__config_written_to'] ?? 'the panel folder')) . '</code></li>'
352+
. (str_contains((string) ($GLOBALS['__config_written_to'] ?? ''), 'nitesha-config')
353+
? ''
354+
: '<li class="warn">That is inside the website folder. A deploy that rebuilds the
355+
site will erase it and the panel will report it has never been set up. Move it
356+
to a folder named <code>nitesha-config</code> above the website folder when you
357+
can &mdash; the panel looks there first.</li>')
358+
. '
251359
</ul>
252360
<p style="margin-top:18px"><a class="btn btn-primary btn-block" href="index.php">Sign in</a></p>
253361
<p class="install-note" style="margin-top:18px"><strong>One last step:</strong>

0 commit comments

Comments
 (0)