1717require_once __DIR__ . '/src/db.php ' ;
1818require_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+ }
2164const MIN_PHP = '8.1 ' ;
2265const MIN_PASSWORD = 10 ;
2366
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
170245function 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
183267function 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// ---------------------------------------------------------------------------
242336if ($ 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 — 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 — 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