Skip to content

Root Folder Tidy

Ed Mozley edited this page Aug 16, 2026 · 3 revisions

Root folder tidy

The application root used to hold fifteen loose PHP files β€” login, logout, password pages, OAuth callbacks, background workers, a CSAT survey and a stray test script. In July 2026 they were moved into purposeful folders, leaving the root with just index.php, config.php and db_config.sample.php, and the login page gained a single canonical pretty URL: /login. This page documents every move, the URL contract that keeps old links working, and the rules to follow when adding new root-level pages.

The problem this solved

The same login page was reachable at three URLs, one of them broken:

URL What happened
/login.php worked
/login worked by accident β€” Apache's MultiViews content-negotiation silently mapped it onto login.php
/login/index.php half-broken β€” MultiViews served login.php with /index.php as PATH_INFO, so the browser resolved relative assets against a phantom /login/ folder and the logo 404'd

MultiViews came from the WAMP vhost (Options +MultiViews). The Docker image never had it β€” so /login 404'd in Docker until this change. Explicit rewrites now replace the accident in both environments, and MultiViews is switched off (Options -MultiViews in the root .htaccess).

The URL contract

/login is the one canonical login URL. Everything else funnels into it:

Request Result
/login βœ… 200 β€” the login page (canonical)
/login.php (GET) 301 β†’ /login (query string preserved)
/login.php (POST) served internally β€” a redirect would drop the form body
/login/, /login/index.php, /login/anything 301 β†’ /login
/auth/login.php (the file's real location) βœ… 200 β€” the same page, at the path the application itself uses

The same pattern applies to /forgot-password, /reset-password and /csat.

⚠️ Corrected in August 2026 β€” issue #68

This page previously said two things that turned out to be a bug, and they are struck out above and below. Both are recorded here rather than quietly edited away, because the reasoning was sound and the conclusion was still wrong.

1. /auth/login.php returns 404 so a third URL can never exist. It does return 200 now. Blocking the real path meant the application could not name its own login page β€” every internal redirect had to go through a URL that only existed if Apache read a rewrite rule.

2. The ~100 in-app references to login.php were deliberately not rewritten; they keep working through the 301. They have all been rewritten, to auth/login.php. "Keeps working through the 301" was only true on Apache with AllowOverride All. On nginx, which never reads .htaccess, a fresh install redirected every logged-out visitor to a 404 β€” the app was unusable, and the reporter of #68 quite reasonably concluded a file was missing from the repository.

The rule now: application code names real file paths. A redirect target must resolve with no rewriting of any kind. Pretty URLs are presentation, layered on top for humans, and nothing internal may depend on them.

The concern that motivated the old rule β€” relative assets resolving against /auth/ and breaking β€” was real, and is now fixed at source: the pages in auth/ use BASE_URL-absolute paths, so they render identically at either URL.

⚠️ That fix was itself incomplete for about ten minutes, and the way it failed is the most useful thing on this page. The asset paths were made absolute; the redirect targets were not. header('Location: index.php') had always been correct while the page was only served at /login, and resolved to /auth/index.php β€” a 404 β€” the moment the page became reachable at its real path as well. Signing in succeeded and then landed on nothing.

The rule to take away: if a page is reachable at more than one URL depth, every path it emits must be absolute β€” assets, links, PHP redirects and JavaScript redirects alike. A relative path silently encodes how deep the current URL is, and that assumption holds only until somebody adds a second route to the page.

Written up in full, with the idiom to use and the test that now guards it, in Running on nginx β†’ The trap this created.

See Running on nginx for the config nginx needs, including the nine .htaccess files that are security controls rather than conveniences.

What moved where

Key: πŸ” auth page Β· 🌐 external contract (URL must never change) Β· βš™οΈ CLI worker Β· πŸ§ͺ test Β· πŸ“Š module page

🎨 Old (root) New location URL behaviour
πŸ” login.php auth/login.php canonical /login; .php GET 301s, POST internally rewritten
πŸ” forgot-password.php auth/forgot-password.php canonical /forgot-password; .php 301s
πŸ” reset-password.php auth/reset-password.php canonical /reset-password; .php 301s β€” emailed reset links keep working (1-hour tokens, query preserved)
πŸ” force_password_change.php auth/force_password_change.php internal rewrite only, URL unchanged (POST-heavy interstitial; no pretty URL needed)
πŸ” analyst_logout.php auth/analyst_logout.php internal rewrite, URL unchanged; also aliased as /logout
🌐 oauth_callback.php auth/oauth_callback.php internal rewrite only β€” never redirected. This exact URL is registered in Azure app registrations and stored (encrypted) per mailbox
🌐 google_oauth_callback.php auth/google_oauth_callback.php same β€” registered in Google Cloud Console
πŸ“Š csat.php tickets/csat/survey.php canonical /csat; csat.php?token=… 301s with the token intact β€” survey links in already-sent emails keep working forever
βš™οΈ intune_worker.php scripts/intune_worker.php CLI only, spawned by intuneSpawnGenericWorker() in includes/intune.php (path updated) β€” never a URL
βš™οΈ intune_app_worker.php scripts/intune_app_worker.php same
πŸ§ͺ test_email_thread.php tests/test_email_thread.php dev script, zero references β€” never a URL
πŸ” logout.php auth/logout.php ⚠️ legacy corpse: it references a TOKEN_STORAGE_FILE constant that no longer exists, so it has fatalled on every call for a long time, and nothing references it (the real logout is analyst_logout.php). Moved rather than deleted only pending a deliberate decision β€” candidate for deletion

What deliberately stayed in root: index.php (the landing page β€” the web server's default document), config.php (its path is hardcoded in every require across the app) and db_config.sample.php (documented in the install guide as a root file to copy). Plus the non-PHP infrastructure: .htaccess, web.config, Dockerfile, docker-compose.yml, LICENSE, README.md.

How the compatibility layer works

Everything is driven by the root .htaccess (heavily commented in place):

  • Options -MultiViews -Indexes β€” kills the accidental URL mapping and directory listings.
  • Pretty URLs are internal rewrites (the address bar keeps the pretty form).
  • Legacy .php URLs: GET β†’ 301 to the pretty URL; POST β†’ internal rewrite (a 301 would discard the body). Redirect targets are built from a REQUEST_URI capture (%1), never a bare relative substitution β€” a relative substitution in an R= rule leaks the filesystem path into the Location header. This also makes the rules deployment-agnostic: they work at /freeitsm-app/ (WAMP) and / (Docker) unchanged.
  • Guard file in tickets/csat/ returns 404 on direct access: internal rewrites arrive with REDIRECT_STATUS set, direct requests don't. The same guard was in auth/ β€” removed in #68, because it blocked the path the application's own redirects now use. See the correction notice above.
  • All rewrite rules sit inside <IfModule mod_rewrite.c> (house style, matching api/v1/.htaccess) so a server without mod_rewrite serves the rest of the app instead of 500ing.
  • mod_rewrite is effectively required since this change. No longer true, and that was the bug. Every internal redirect now names a real file path, so the application works with no rewrite module, no .htaccess, and on a web server that has never heard of either. Rewrites are what make the URLs pretty, not what makes the app function. See Running on nginx.

Things that did not change

  • SSO/OIDC β€” its callback was already api/auth/oidc_callback.php; untouched.
  • The self-service portal β€” has its own self-service/login.php; untouched.
  • Mailbox OAuth registrations β€” nothing to re-register in Azure or Google; the callback URLs are byte-identical externally.
  • The mailbox save validation (api/tickets/save_mailbox.php) matches the callback filename with an any-prefix pattern ((^|/)google_oauth_callback\.php$), so it was already tolerant of the file's location.

Rules for future root-level pages

  1. Don't put new PHP pages in the root. Auth-flow pages go in auth/; module pages go in their module.
  2. Give a public page a pretty URL via a rewrite pair in the root .htaccess (internal rewrite for the canonical URL + guarded 301 for any legacy name), copying the existing REQUEST_URI %1 pattern for redirects.
  3. Never rely on MultiViews β€” it is off, deliberately.
  4. Never move or redirect an OAuth callback URL β€” it is an external contract with the IdP; if a callback file must move, add an internal rewrite so the URL survives.
  5. URLs embedded in sent emails (CSAT, password reset, verification links) must keep working indefinitely β€” 301 with the query string preserved is the minimum.

Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally