# Routing and deployment verification Read this before changing URLs, redirects, `.htaccess`, `router.php`, `route.php`, login destinations, or the directory layout of request handlers. Apply it to site migrations and core upgrades as well as routing fixes. ## Why local success is insufficient Production routing has multiple layers: the hosting proxy, Apache's rewrite and directory handling, and Raiju's PHP front controller. The development server executes `router.php` and does not interpret `.htaccess`. Passing local PHP tests therefore does not prove that the uploaded installation routes correctly. Verify affected routes under Apache and on the deployed host. The concrete regression found in September 2026 was a filesystem collision: `admin/dashboard.php` is the request handler, while `admin/dashboard/scripts/` holds implementation fragments. Login redirected to `dashboard`. Apache redirected `/admin/dashboard` to `/admin/dashboard/` with HTTP 301; that URL then fell through to `route.php` and returned 404. `/admin/` continued to work because it has an actual `index.php`. The PHP development server served the slashless dashboard successfully and concealed the broken login flow. The fix sends login directly to `dashboard.php` and redirects both clean URL aliases to it. Do not change the destination back to `dashboard` without reworking and testing the complete routing contract below. Site-specific news/event detail routes can have a similar ordering problem when physical listing templates compete with nested detail URLs. Preserve the site's explicit detail rules when merging updated core rewrite rules. ## Routing contract - Use explicit PHP URLs for internal admin endpoints that share their name with implementation directories. New fragment directories must not silently change existing public URLs. Check for collisions before adding them. - Keep `/admin/` as a directory URL. The current dashboard destination is `/admin/dashboard.php`; `/admin/dashboard` and `/admin/dashboard/` are compatibility aliases that return HTTP 308 with query parameters preserved. - `/admin` redirects to `/admin/`; `/login` and `/login/` are aliases for `/admin/`. Login aliases must not be accepted as post-login return targets. - Physical directories with an `index.php` get their missing slash. If a PHP handler shares its name with a directory, both clean aliases resolve to the explicit `.php` handler. A PHP handler with an extra trailing slash also resolves to its explicit filename. These HTTP 308 redirects retain POST method/body and query parameters. They do not change JSON-backed page slugs. - `includes/routing-helper.php` implements the shared PHP fallback used by `router.php` and `route.php`; `.htaccess` applies the equivalent rules before Apache's generic file/directory handling. Update these together and include the helper in upgrades. - Canonicalization is route-specific. Do not add a blanket rule to append or remove slashes across the site, or disable directory handling globally to conceal one collision. A trailing slash changes how browsers resolve relative assets, forms, API requests and login redirects. - Preserve existing security rules before routing shortcuts. Put explicit collision/detail rules before generic file/directory handling. Retain `Options -MultiViews` and the existing front-controller rewrite termination; changing either requires Apache verification. - Keep Apache and `router.php` behavior aligned for paths they both serve. `route.php` handles dynamic content; it should not interpret admin fragment directory names as public page slugs. - Consider cached permanent redirects. Do not introduce an inverse redirect that loops with an older cached 301. The explicit `dashboard.php` target avoids the previous `/admin/dashboard` to `/admin/dashboard/` redirect. - Merge `.htaccess` deliberately during upgrades and include hidden files in upload checks. Preserve customer security, redirects and news/event rules. Uploading only PHP files can leave obsolete production routing in place. - The bundled Apache substitutions use root-absolute paths. Do not claim subdirectory hosting works based on a root installation test; verify rewrite targets, cookies, assets and redirects under the actual deployment prefix. ## Hosting proxies and internal folders `.htaccess` protects `content/`, `backups/`, `cli/`, `tests/` and the media trash folders only for requests that reach Apache. Some hosting panels put nginx in front of Apache and let it deliver static files such as `.json`, `.txt` and `.zip` directly from disk. Those requests never reach Apache, so `content/users.json` (password hashes), `content/settings.json` (SMTP password, web cron token), `content/mails.json` (form submissions) and backup ZIPs become public. This happened on a HestiaCP staging server in September 2026: a test JSON file in `content/` returned HTTP 200 from nginx, while a PHP file in the same folder returned 403 from Apache. Block the folders in nginx as well. `^~` makes these prefix locations win over the panel's regular-expression location for static files: ```nginx location ^~ /content/ { deny all; } location ^~ /backups/ { deny all; } location ^~ /cli/ { deny all; } location ^~ /tests/ { deny all; } location ^~ /assets/images-trash/ { deny all; } location ^~ /assets/audio-trash/ { deny all; } location ^~ /assets/videos-trash/ { deny all; } location ^~ /assets/documents-trash/ { deny all; } ``` - **HestiaCP:** save the lines as `/home//conf/web//nginx.ssl.conf_raiju` (for plain HTTP also as `nginx.conf_raiju`); HestiaCP includes these files in the domain's server block. Then run `nginx -t && systemctl reload nginx` as root. - **Plesk:** the default "Smart static files processing" asks Apache first, so `.htaccess` applies. With "Serve static files directly by nginx" switched on, requests for the listed extensions never reach Apache. Keep the option switched off, or add the lines under "Apache & nginx Settings" → "Additional nginx directives". - For an installation in a subdirectory, prefix each path with it. Check this after the first upload and after hosting changes. Dashboard → System status → Security requests short-lived probe files through the site's own address and shows these rules for the current installation. A missing file is no test: nginx passes unknown files to Apache, which answers 403. To check by hand, request an existing file, for example `curl -I https://example.com/content/settings.json`, and expect 403 or 404. `python3 tests/security-check-smoke.py --apache` reproduces the proxy locally in front of Apache and PHP-FPM, with and without these rules. ## Required verification For routing or request-handler directory changes, run the focused regression: ```sh python3 tests/system-smoke.py python3 tests/routing-smoke.py python3 tests/routing-smoke.py --apache ``` These suites use disposable accounts and content, never live credentials. The default routing suite runs on the PHP development server; `--apache` runs the same contract against Apache and PHP-FPM on temporary loopback ports using the candidate `.htaccess`. It does not alter the machine's web-server services. Set `HTTPD`, `PHP_FPM` and `HTTPD_MODULE_DIR` if their paths are not detected. The separate Apache CI job runs this fixture too. Do not describe PHP-only results as Apache coverage. Repeat the relevant checks on the deployed host after an authorized upload; local Apache cannot verify hosting proxy rules. | Check | Expected result | | --- | --- | | Guest opens `/admin/` | Login form, HTTP 200 | | `/admin`, `/login`, `/login/` | HTTP 308 to `/admin/`; final login form and CSS load | | Successful login through the form | Redirect reaches the real dashboard, HTTP 200; no intermediate 404 or loop | | `/admin/dashboard`, `/admin/dashboard/` | HTTP 308 to `/admin/dashboard.php`; query string preserved | | Dashboard request without a session | Redirect to login; no dashboard data exposed | | Dashboard request with a valid session | Dashboard HTML, HTTP 200 | | Actual admin CSS, JS, form/API URLs after navigation | Correct paths and expected content types; authenticated API still works | | Form POST to directory URL without slash | HTTP 308; followed request retains method/body and query | | Add fragment directory beside an existing PHP endpoint | Both clean aliases resolve to `.php`; HTML and relative CSS still work | | Existing language homepage and nested page, with/without slash | Established site behavior, correct assets and links | | Existing news/event listing and detail URLs in each supported language | Established site behavior; detail request reaches its intended handler | | Unknown public and admin URLs | HTTP 404, not a successful empty page | | Config, content and implementation-only resources | Existing access protections remain effective | | Existing internal files through the public address, behind any hosting proxy (`/content/settings.json`, a backup ZIP) | HTTP 403 or 404; System status → Security reports no public folders | Inspect the first response **and** the followed redirect chain. A final HTTP 200 alone may be a login or error page, so check the returned content. Use GET requests, preserve session cookies during login tests, include a query string such as `?tab=content&probe=a%2Fb`, and check both a fresh browser session and previously used aliases. Verify relative assets from the final browser URL. Record the tested code revision or file hashes, server/PHP versions, relevant hosting prefix/proxy, request paths, statuses, Location headers and final page. Keep test credentials and session cookies out of the evidence. If Apache or post-upload checks have not run, state that explicitly as outstanding verification rather than reporting a production fix as verified. ## Local verification — 2026-09-15 The candidate working tree passed all 19 smoke suites with PHP 8.5.4 and the additional routing suite under Apache 2.4.67 + PHP-FPM 8.5.4 on macOS. The installation was at the document root, on temporary loopback ports, without a hosting proxy. The executable assertions in `tests/routing-smoke.py` record the request paths, expected first-response statuses/Location values, followed login/API content, CSS types, and POST payload retention. No live host or subdirectory installation was tested. The new GitHub Apache job is configured; a remote CI run has not been triggered from this local check. Tested SHA-256 values: ```text .htaccess 1e8a86df0fb6304f7c6cc0189ad2813e0e657c4a49ba4694750d8241a5840d42 includes/routing-helper.php c2d7aa3ff89fbf7ae89d06208e8c94801948d98ea1abfd0f008a7ef1e97b3fc0 router.php 831613fc9e35f2c252d7ebe52a56c8d01e2b8af427ae0c16470de766a6f64e6f route.php 9709439626c7cbf7716154180f9e8244894a9580815c120fd6c9a799d3312033 ```