DirectoryIndex index.php
Options -Indexes

RewriteEngine On

# Force HTTPS in production (skip local *.test)
RewriteCond %{HTTPS} off
RewriteCond %{HTTP_HOST} !\.test$ [NC]
RewriteCond %{HTTP_HOST} !^localhost [NC]
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]

# Legacy co.uk → .com
RewriteCond %{HTTP_HOST} ^bennettphillips\.co\.uk$ [OR]
RewriteCond %{HTTP_HOST} ^www\.bennettphillips\.co\.uk$
RewriteRule ^(.*)$ https://www.bennettphillips.com/$1 [R=301,L]

# Admin needs a trailing slash so relative assets resolve.
RewriteRule ^admin$ /admin/ [R=301,L]
RewriteRule ^bennetcms/?$ /admin/ [R=301,L]
RewriteRule ^bennetcms/(property|property_list|featured)(\.php)?/?$ /admin/properties [R=301,L]
RewriteRule ^bennetcms/(applicants|applicants_list|applicant_details|applicant_edit|applicant_addurl|callback_list|call_details|reqcallback)(\.php)?/?$ /admin/applicants [R=301,L]
RewriteRule ^bennetcms/(news|news_list)(\.php)?/?$ /admin/collection/news [R=301,L]
RewriteRule ^bennetcms/(services|service_list)(\.php)?/?$ /admin/collection/services [R=301,L]
RewriteRule ^bennetcms/(cmss|cmss_list)(\.php)?/?$ /admin/pages-overview [R=301,L]
RewriteRule ^bennetcms/(enquiry_list|enquiry_details)(\.php)?/?$ /admin/enquiries [R=301,L]
RewriteRule ^bennetcms/password(\.php)?/?$ /admin/change-password [R=301,L]
RewriteRule ^bennetcms/logout(\.php)?/?$ /admin/logout [R=301,L]
RewriteRule ^bennetcms/(getimage|getimage_listing|getmeta)(\.php)?/?$ /admin/media [R=301,L]
RewriteRule ^bennetcms/.*$ /admin/ [R=301,L]

# Asset aliases: /css|/js|/img|/fonts → /assets/...
# Templates now link assets/ directly; these cover links already in the wild.
RewriteRule ^(css|js|img|fonts)/(.*)$ assets/$1/$2 [L]

# Upload aliases: CMS content moved under /uploads/. Paths stored in the
# database still use the old directory names, so keep these permanently.
RewriteRule ^property_images/(.*)$ uploads/properties/$1 [L]
RewriteRule ^propertyfiles/(.*)$ uploads/propertyfiles/$1 [L]
RewriteRule ^news_images/(.*)$ uploads/news/$1 [L]
RewriteRule ^footerimages/(.*)$ uploads/services/$1 [L]

# Root certificate docs moved into pdf/ and assets/img/
RewriteRule ^Client\ Money\ Handling\ Procedure\.pdf$ assets/pdf/Client\ Money\ Handling\ Procedure.pdf [L]
RewriteRule ^Client\ Money\ Handling\ Procedure\.jpg$ assets/img/Client\ Money\ Handling\ Procedure.jpg [L]
RewriteRule ^BPL\ The\ RICS\ Client\ Money\ Protection\ Scheme\.pdf$ assets/pdf/BPL\ The\ RICS\ Client\ Money\ Protection\ Scheme.pdf [L]
RewriteRule ^The\ RICS\ Client\ Money\ Protection\ Scheme\.jpg$ assets/img/The\ RICS\ Client\ Money\ Protection\ Scheme.jpg [L]

# Sitemap is generated from the database; crawlers still see a .xml URL.
RewriteRule ^sitemap\.xml$ sitemap.php [L]

# Fragments: included by other pages, never documents in their own right.
# Served directly they emit headless markup or leak PHP errors, so block them
# before the extensionless rule below can reach them. Mirrors the FRAGMENTS
# list in LocalValetDriver.php.
RewriteRule ^(news|googlemap|property_video)(\.php)?/?$ - [R=404,L]

# The structured-CMS templates are only ever reached through /{slug} and
# the /structured|/menu segment routes; they are not standalone pages.
RewriteRule ^(services|menudetails)/?$ - [R=404,L]

# HTML aliases (legacy)
# No news.html alias: news.php is a fragment included by nine pages, not a
# standalone document. Routing to it emitted a bare <section> with no <html>.
RewriteRule ^index\.html$ index.php [L,QSA]
RewriteRule ^thanks\.html$ thankyou.php [L,QSA]
RewriteRule ^register\.html$ register-interest.php [L,QSA]
RewriteRule ^contact\.html$ contact-us.php [L,QSA]
RewriteRule ^search\.html$ search.php [L,QSA]
RewriteRule ^thankyou\.html$ thankyou.php [L,QSA]

# The category grid was merged into the single properties listing.
# Both old URLs keep their inbound links alive with a permanent redirect.
RewriteRule ^category\.html$ /search [R=301,L,QSA]
RewriteRule ^property-search/?$ /search [R=301,L,QSA]
RewriteRule ^property-list/?$ /search [R=301,L,QSA]

# Pretty segment routes.
# News articles live at /news/{slug}. Keep /details/{slug}/{id} as a 301.
RewriteRule ^news/?$ /news-list [R=301,L]
RewriteRule ^news/([^/]+)/?$ news-details.php?news_slug=$1 [L,QSA]
RewriteRule ^details/([^/]+)/([0-9]+)$ /news/$1 [R=301,L]
RewriteRule ^structured/([^/]+)/([0-9]+)$ services.php?structured_id=$2 [L,QSA]
RewriteRule ^menu/([^/]+)/([0-9]+)$ menudetails.php?structured_id=$2 [L,QSA]
RewriteRule ^property/([^/]+)/([0-9]+)$ search.php?type_id=$2 [L,QSA]
RewriteRule ^propertydetails/([^/]+)/([0-9]+)$ property-details.php?property_id=$2 [L,QSA]
# Generated brochure for a property. Mirrors the detail route so the slug stays
# readable in the printed URL; the uploaded PDF is unrelated and served by
# download.php as before.
RewriteRule ^brochure/([^/]+)/([0-9]+)$ brochure.php?property_id=$2 [L,QSA]
RewriteRule ^offer-template/([^/]+)/([0-9]+)$ offer-template.php?property_id=$2 [L,QSA]

# Retired service pages keep their inbound links.
RewriteRule ^service/(commercial-lettings|commercial-sales)/?$ /service/commercial-agency [R=301,L]
RewriteRule ^(commercial-lettings|commercial-sales)/?$ /service/commercial-agency [R=301,L]
RewriteRule ^service/managing-agents/?$ /service/property-management [R=301,L]
RewriteRule ^managing-agents/?$ /service/property-management [R=301,L]
RewriteRule ^cms/(commercial-lettings|commercial-sales)/?$ /service/commercial-agency [R=301,L]
RewriteRule ^cms/managing-agents/?$ /service/property-management [R=301,L]

# Service pages at /service/{slug}. Keep this slug list in sync with
# service_page_slugs() in partials/property-helpers.php.
RewriteRule ^service/?$ /our-services [R=301,L]
RewriteRule ^service/([^/]+)/?$ cms.php?slug=$1 [L,QSA]
RewriteRule ^(commercial-agency|property-management|property-acquisition|property-valuation|property-investment|rent-reviews-lease-renewals|surveyors)/?$ /service/$1 [R=301,L]

# Hide /cms — service slugs land on /service/{slug}; other CMS pages on /{slug}.
RewriteRule ^cms/?$ - [R=404,L]
RewriteRule ^cms/(commercial-agency|property-management|property-acquisition|property-valuation|property-investment|rent-reviews-lease-renewals|commercial-lettings|commercial-sales|surveyors|managing-agents)/?$ /service/$1 [R=301,L]
RewriteRule ^cms/([^/]+)/?$ /$1 [R=301,L,QSA]

# 301 .php → extensionless for public pages only.
# sitemap.php is excluded: it emits application/xml and is advertised in
# robots.txt as /sitemap.xml, so it must not acquire a second HTML-looking URL.
RewriteCond %{THE_REQUEST} \s/+(.+?)\.php[\s?] [NC]
RewriteCond %{REQUEST_URI} !^/admin/ [NC]
RewriteCond %{REQUEST_URI} !^/(download|rss|validates|googlemap|property_video|sitemap)\.php [NC]
RewriteCond %{REQUEST_URI} !^/assets/ [NC]
RewriteCond %{REQUEST_URI} !index\.php [NC]
RewriteRule ^ /%1 [R=301,L,QSA]

# /sitemap has no meaning of its own; send it to the advertised .xml URL.
RewriteRule ^sitemap/?$ /sitemap.xml [R=301,L]

# Extensionless → .php when file exists
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} !^/admin/ [NC]
RewriteCond %{REQUEST_URI} !^/(assets|property_images|propertyfiles|footerimages|news_images)/ [NC]
RewriteCond %{REQUEST_FILENAME}.php -f
RewriteRule ^(.+?)/?$ $1.php [L,QSA]

# CMS pages at /{slug} when no matching .php file exists.
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} !^/admin/ [NC]
RewriteCond %{REQUEST_URI} !^/(assets|app|config|layouts|partials|resources|uploads|vendor)/ [NC]
RewriteRule ^([^/]+)/?$ cms.php?slug=$1 [L,QSA]

ErrorDocument 404 /404.php

# ---------------------------------------------------------------------------
# Performance: compression, caching, and keep-alive.
#
# Every directive is wrapped in <IfModule> because the shared host may not load
# all of them; an unguarded directive there is a 500, not a missing feature.
# ---------------------------------------------------------------------------

# --- Compression -----------------------------------------------------------
# Text assets only. Images, video, PDFs and web fonts (woff/woff2) are already
# compressed; re-compressing them burns CPU for a fraction of a percent.
<IfModule mod_deflate.c>
  AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css
  AddOutputFilterByType DEFLATE application/javascript application/x-javascript text/javascript
  AddOutputFilterByType DEFLATE application/json application/ld+json
  AddOutputFilterByType DEFLATE application/xml application/rss+xml application/atom+xml
  AddOutputFilterByType DEFLATE image/svg+xml
  AddOutputFilterByType DEFLATE application/manifest+json

  # Netscape 4 and early IE workarounds that some hosts still ship; harmless.
  BrowserMatch ^Mozilla/4 gzip-only-text/html
  BrowserMatch \bMSIE\s(7|8) !no-gzip !gzip-only-text/html
</IfModule>

# Brotli where the host offers it -- roughly 15-20% smaller than gzip on CSS/JS.
# Listed after mod_deflate so it simply takes precedence when present.
<IfModule mod_brotli.c>
  AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/xml text/css
  AddOutputFilterByType BROTLI_COMPRESS application/javascript application/x-javascript text/javascript
  AddOutputFilterByType BROTLI_COMPRESS application/json application/ld+json
  AddOutputFilterByType BROTLI_COMPRESS application/xml application/rss+xml application/atom+xml
  AddOutputFilterByType BROTLI_COMPRESS image/svg+xml
</IfModule>

# --- Cache lifetimes -------------------------------------------------------
# Static assets are versioned by content: the generated derivatives carry a
# content hash in the filename, and asset_url() (config/helpers.php) appends a
# mtime query to CSS/JS. Both change identity when the bytes change, so a long
# immutable TTL is safe and is what "Use efficient cache lifetimes" asks for.
#
# HTML is deliberately NOT cached: listings change whenever an agent edits a
# property, and a stale page is worse than a revalidation round-trip.
<IfModule mod_expires.c>
  ExpiresActive On
  ExpiresDefault                              "access plus 1 month"

  ExpiresByType text/html                     "access plus 0 seconds"
  ExpiresByType application/xml               "access plus 1 hour"
  ExpiresByType application/rss+xml           "access plus 1 hour"
  ExpiresByType application/manifest+json     "access plus 1 week"

  ExpiresByType text/css                      "access plus 1 year"
  ExpiresByType text/javascript               "access plus 1 year"
  ExpiresByType application/javascript        "access plus 1 year"

  ExpiresByType image/jpeg                    "access plus 1 year"
  ExpiresByType image/png                     "access plus 1 year"
  ExpiresByType image/gif                     "access plus 1 year"
  ExpiresByType image/webp                    "access plus 1 year"
  ExpiresByType image/avif                    "access plus 1 year"
  ExpiresByType image/svg+xml                 "access plus 1 year"
  ExpiresByType image/x-icon                  "access plus 1 year"
  ExpiresByType image/vnd.microsoft.icon      "access plus 1 year"

  ExpiresByType font/woff2                    "access plus 1 year"
  ExpiresByType font/woff                     "access plus 1 year"
  ExpiresByType font/ttf                      "access plus 1 year"
  ExpiresByType application/font-woff2        "access plus 1 year"

  ExpiresByType video/mp4                     "access plus 1 year"
  ExpiresByType application/pdf               "access plus 1 month"
</IfModule>

<IfModule mod_headers.c>
  # immutable tells the browser not to revalidate even on a hard reload, which
  # is only honest for URLs whose content cannot change. Scoped to the hashed
  # derivative cache and the versioned static assets.
  <FilesMatch "\.(?i:css|js|jpe?g|png|gif|webp|avif|svg|ico|woff2?|ttf|eot|mp4|webm)$">
    Header set Cache-Control "public, max-age=31536000, immutable"
  </FilesMatch>

  # HTML must always revalidate -- see the note above.
  <FilesMatch "\.(?i:php|html?)$">
    Header set Cache-Control "no-cache, must-revalidate"
  </FilesMatch>

  # Strip the ETag: with Expires+Cache-Control already set, a second validator
  # only adds a header to every response, and Apache's default inode-based
  # ETag differs between load-balanced servers, defeating caching entirely.
  Header unset ETag

  # --- Security headers (Lighthouse "Best Practices") ---
  # X-Content-Type-Options and Referrer-Policy are also emitted per-page in
  # layouts/global-styles.php; setting them here covers static assets too.
  Header always set X-Content-Type-Options "nosniff"
  Header always set Referrer-Policy "strict-origin-when-cross-origin"
  Header always set X-Frame-Options "SAMEORIGIN"
  Header always set Cross-Origin-Opener-Policy "same-origin"
  Header always set Permissions-Policy "geolocation=(), microphone=(), camera=(), interest-cohort=()"

  # HSTS, production only -- never send it over plain HTTP or from *.test.
  <If "%{HTTPS} == 'on' && %{HTTP_HOST} !~ /\.test$/">
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
  </If>
</IfModule>

FileETag None

<IfModule mod_mime.c>
  # Some shared hosts still lack these, which makes the browser download the
  # file instead of rendering it and skips compression entirely.
  AddType image/webp                  .webp
  AddType image/avif                  .avif
  AddType font/woff2                  .woff2
  AddType application/manifest+json   .webmanifest
  AddType image/svg+xml               .svg
</IfModule>

# php -- BEGIN cPanel-generated handler, do not edit
<IfModule mime_module>
  AddHandler application/x-httpd-ea-php81 .php .php8 .phtml
</IfModule>
# php -- END cPanel-generated handler, do not edit
