# Bond - Apache config for https://ashrafhallal.com/fitness
# Deploy target: the /fitness directory on Bluehost.

RewriteEngine On
RewriteBase /fitness/

# ── CHARACTER ENCODING ──────────────────────────────────────
# The server was serving i18n.js as "Content-Type: application/javascript"
# with NO charset. The Arabic catalog then decodes correctly only because both
# shells declare <meta charset="utf-8"> and a classic <script src> falls back to
# the document's encoding. That is a coincidence to depend on, not a guarantee:
# the same file fetched with fetch() or loaded as a module resolves its encoding
# by a different rule, so the Arabic would render as mojibake in one path and
# fine in another. Declare it once, here, and the question stops existing.
AddDefaultCharset UTF-8
AddCharset UTF-8 .js .css .html .json .svg
AddType application/manifest+json .webmanifest

# ── COMPRESSION (gzip) ──────────────────────────────────────
# The console shell (~197 KB) + core.js + i18n.js + countries.js are all text.
# Uncompressed, a flaky/throttled mobile link can stall mid-transfer, leaving a
# painted header over a blank body - the boot script simply never arrives (desktop
# on stable Wi-Fi gets the whole file, so it looked "mobile-only"). gzip shrinks
# these ~5x (197 KB -> ~40 KB) so the transfer completes. The <IfModule> guard
# makes this a safe no-op if mod_deflate is unavailable.
<IfModule mod_deflate.c>
  AddOutputFilterByType DEFLATE text/html text/plain text/css text/xml application/javascript application/x-javascript application/json image/svg+xml
</IfModule>

# ── FORCE HTTPS ─────────────────────────────────────────────
# Non-negotiable: this app carries personal health data.
RewriteCond %{HTTPS} !=on
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]

# ── PROTECT SECRETS AND TOOLING ─────────────────────────────
# PHP is executed rather than served, so config.php is not exposed by
# default - this is defence in depth. Better still: move config.php above
# the web root and require it by absolute path (see config.php header).
# NOTE: core.js and i18n.js must stay reachable - both shells load them. Dev
# scripts are named explicitly rather than matched by extension, precisely so a
# broad "*.js" rule cannot take the front end down.
#
# console.html and portal.html are denied too. Both are READ FROM DISK by
# index.php / portal.php (file_get_contents(__DIR__ . ...)), and nothing in the
# app loads either by URL, so the direct fetch serves no one but a stranger.
# console.html is 1.2 MB carrying the entire admin nav registry, every section
# name and the whole feature vocabulary - handing that to an unauthenticated
# reader tells an attacker exactly what exists and what to aim at. That is the
# information-disclosure control S2e keeps after the branding rule was retired.
# design.html is deliberately NOT denied: the console iframes it BY URL.
# test-* and diag-* are denied as defence in depth. build-dist.cmd never stages
# them, so they should not be present at all - but "should not be there" is not
# a control. test-scope.php would EXECUTE if requested, and diag-rollup.js
# prompts for a token; neither belongs on a public host under any circumstance.
# devplan.php is an INCLUDE, not an entry point. Reaching it directly returns an
# empty 200 (it only defines functions), so nothing leaks - but it holds the
# development roadmap, and an include has no business being addressable at all.
# sqlite-schema.json is READ BY THE SERVER (bond_sqlite_schema reads it off
# disk); the browser never fetches it. It was being served 200 to anyone, which
# handed an anonymous visitor the entire data model - every table and every
# column, including Users.PasswordHash and InviteTokenHash. Nothing in it is a
# credential, but it is a map of exactly what exists and what to aim at, which
# is the same disclosure the client-bundle rule exists to prevent.
<FilesMatch "^(config\.php|config\.local\.php|config\.local\.php\.example|registry\.php|messages-ar\.php|devplan\.php|selftest\.php|wipe-verify\.php|setup-schema\.js|sqlite-schema\.json|console\.html|portal\.html|seed-test\.js|seed-.*\.php|grant-.*\.php|migrate-.*\.php|setup-.*\.php|.*\.sqlite|.*\.sqlite-wal|.*\.sqlite-shm|.*\.sqlite-journal|.*\.db|verify-.*\.js|test-.*\.(js|php)|diag-.*\.js|fixture-credential\.js|create-admin\.js|.*\.sh|.*\.cmd|.*\.md|.*\.example)$">
  <IfModule mod_authz_core.c>
    Require all denied
  </IfModule>
  <IfModule !mod_authz_core.c>
    Order allow,deny
    Deny from all
  </IfModule>
</FilesMatch>

# Health media is served ONLY through media.php, which re-runs the
# ownership check. The raw directory is never browsable.
RedirectMatch 404 ^/fitness/uploads/

# ── BRANDED PORTAL LOGIN ────────────────────────────────────
# /fitness/p/<slug>  ->  portal-login.php?t=<slug>   (the dietitian's own link)
# The slug is a BRANDING HINT FOR THE UNAUTHENTICATED PAGE ONLY. Once a
# client is authenticated the tenant comes from Clients.DietitianKey and the
# slug is ignored - otherwise a transferred client who bookmarked an old
# slug would be shown their previous dietitian's brand.
# /fitness/p/join  ->  the solo (no-provider) self-signup page. MUST come BEFORE
# the generic slug rule below, or "join" would be read as a provider slug and
# route to a neutral login. 'join' is therefore a RESERVED slug, like 'app' (the
# platform brand) - no provider can own it.
RewriteRule ^p/join/?$ portal-signup.php [QSA,L]
RewriteRule ^p/([A-Za-z0-9._-]{1,64})/?$ portal-login.php?t=$1 [QSA,L]
# Bare /fitness/p/ -> neutral, unbranded login
RewriteRule ^p/?$ portal-login.php [QSA,L]

# ── PROXY PATH ROUTING ──────────────────────────────────────
# api-proxy.php/<baseId>/<table>[/<recId>] - the trailing segments arrive
# as PATH_INFO, which api-proxy.php parses out of REQUEST_URI.
AcceptPathInfo On

<IfModule mod_headers.c>
  # ── NO-CACHE THE APP SHELLS ───────────────────────────────
  # A deploy must be live immediately; stale shells were a recurring
  # pain point on the MIS build.
  <FilesMatch "\.(html|js)$">
    Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
    Header set Pragma "no-cache"
    Header set Expires "0"
  </FilesMatch>

  # pdf.js is a vendored, never-changing library (~1.3 MB). It must NOT inherit
  # the app-shell no-cache rule above, or every disclosure view re-downloads it.
  # Cache it hard; a version change means a new filename, so this is safe.
  <FilesMatch "^(pdf\.min\.js|pdf\.worker\.min\.js)$">
    Header set Cache-Control "public, max-age=31536000, immutable"
    Header unset Pragma
    Header unset Expires
  </FilesMatch>

  # ── SECURITY HEADERS ──────────────────────────────────────
  Header always set X-Content-Type-Options "nosniff"
  Header always set X-Frame-Options "SAMEORIGIN"
  # no-referrer keeps record ids out of third-party referer logs
  Header always set Referrer-Policy "no-referrer"
  Header always set X-XSS-Protection "0"
  Header always set Strict-Transport-Security "max-age=31536000"

  # ── CONTENT SECURITY POLICY ──────────────────────────────────────────────
  # Bond fetches NOTHING from another origin - no CDN, no webfont host, no
  # analytics - so this costs the product nothing and buys the one control that
  # matters most for health data: connect-src 'self' means an injected script
  # cannot POST a client's record to an outside collector, and img-src stops the
  # same thing being done with a beacon.
  #
  # 'unsafe-inline' is required and is NOT a shrug: the console carries 472
  # inline handlers and the portal 148, plus inline <style>. Removing them is a
  # rewrite, not a review. What the policy still blocks with it in place is
  # every EXTERNAL script, style, font, frame and connection - which is the
  # exfiltration path, not the injection path.
  #
  # frame-ancestors supersedes X-Frame-Options above (kept for old browsers).
  # The only frames Bond opens are its own design.html / requirements.php /
  # appflow.php, and the telehealth link is a NAVIGATION, not a subresource, so
  # it is unaffected. (No frame-src is set: it falls back through child-src to
  # default-src 'self', so a same-origin page frames and an external one cannot.)
  # blob: is needed for photo previews (URL.createObjectURL).
  Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'"
</IfModule>

# Directory listings off, everywhere.
Options -Indexes
