Selaa lähdekoodia

Flatten layout: document root is the project folder, harden .htaccess

The site's index.php now lives at the top of the uploaded folder, so the
document root can point straight at it (upload contents into public_html/).
Application internals (app/, config/, data/, docs/) now sit inside the web
root and are protected instead of hidden by location:

- root .htaccess blocks those dirs via mod_rewrite and denies dotfiles,
  *.json, *.md and *.sample.php via FilesMatch; per-dir deny-all .htaccess
  remain as a fallback
- router.php reproduces the same rules for the PHP built-in server so local
  dev matches production
- include paths, MEDIA_DIR, .gitignore and docs updated for the new layout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Medowar 1 kuukausi sitten
vanhempi
sitoutus
75ecfa8c4f

+ 2 - 2
.gitignore

@@ -5,8 +5,8 @@
 # Runtime data
 /data/*
 !/data/.htaccess
-/public/media/*
-!/public/media/.htaccess
+/media/*
+!/media/.htaccess
 
 # OS / editor noise
 .DS_Store

+ 25 - 10
.htaccess

@@ -1,10 +1,25 @@
-# Repository root .htaccess.
-#
-# Preferred setup: point the document root of your hosting account at public/.
-# If your shared host cannot do that and serves this directory instead,
-# this file routes visitors into public/ and the per-directory .htaccess
-# files in app/, config/ and data/ block direct access to internals.
-
-RewriteEngine On
-RewriteCond %{REQUEST_URI} !^/public/
-RewriteRule ^(.*)$ public/$1 [L]
+# This folder is the document root: index.php here is the site's home page.
+# Everything below protects the application internals that now live inside it.
+
+DirectoryIndex index.php
+
+# Primary protection: block the internal directories outright. This works even
+# on hosts that ignore the per-directory .htaccess files in app/, config/, data/.
+<IfModule mod_rewrite.c>
+    RewriteEngine On
+    RewriteRule ^(app|config|data|docs)/ - [F,L]
+</IfModule>
+
+# Never serve dotfiles (.htaccess, .git*, …), flat-file data, docs, config
+# templates or lock files as plain text — regardless of directory.
+<FilesMatch "(^\.ht|^\.git|\.(?:json|md|lock)$|\.sample\.php$)">
+    Require all denied
+</FilesMatch>
+
+# Belt-and-suspenders for older Apache that lacks the FilesMatch above.
+<IfModule !mod_authz_core.c>
+    <FilesMatch "(^\.ht|^\.git|\.(?:json|md|lock)$|\.sample\.php$)">
+        Order allow,deny
+        Deny from all
+    </FilesMatch>
+</IfModule>

+ 14 - 6
README.md

@@ -24,7 +24,7 @@ No database, no framework, no build step — upload via FTP/SFTP and it runs.
 
 - PHP 8.1+ with `curl` and `openssl` (standard on shared hosting)
 - Apache with `.htaccess` support (or any server with the document root
-  pointed at `public/`)
+  pointed at this folder)
 - A private Hetzner Object Storage bucket (for client galleries)
 
 ## Quick start
@@ -32,7 +32,7 @@ No database, no framework, no build step — upload via FTP/SFTP and it runs.
 ```bash
 cp config/config.sample.php config/config.php          # fill in S3 + site settings
 cp config/credentials.sample.php config/credentials.php # default login: admin / changeme
-php -S localhost:8080 -t public                         # local preview
+php -S localhost:8080 router.php                        # local preview
 ```
 
 Then open http://localhost:8080/admin/, log in, and **change the password
@@ -46,10 +46,18 @@ first** (Settings).
 
 ## Layout
 
+Upload the contents of this folder straight into your document root —
+`index.php` is the home page.
+
 ```
-public/    web root (point your domain here)
-app/       PHP library code (not web-accessible)
-config/    static configuration + admin credentials
-data/      flat-file content storage (JSON)
+index.php  landing page              ← document root
+showreel.php, gallery.php
+admin/     backoffice
+assets/    css + js
+media/     local images (hero + showreel)
+app/       PHP library code    ┐
+config/    config + credentials├─ inside the docroot but blocked by .htaccess
+data/      flat-file storage   ┘
 docs/      documentation
+router.php local dev only (php -S)
 ```

+ 1 - 1
public/admin/api.php → admin/api.php

@@ -11,7 +11,7 @@
  *   register { slug, key, thumb, name, size }
  *     → append an uploaded image to the gallery's JSON file.
  */
-require dirname(__DIR__, 2) . '/app/bootstrap.php';
+require dirname(__DIR__) . '/app/bootstrap.php';
 
 if (!auth_check()) {
     json_response(['error' => 'Not authenticated'], 401);

+ 1 - 1
public/admin/frontpage.php → admin/frontpage.php

@@ -2,7 +2,7 @@
 /**
  * Front page editor: artist intro title/text + full-page hero image.
  */
-require dirname(__DIR__, 2) . '/app/bootstrap.php';
+require dirname(__DIR__) . '/app/bootstrap.php';
 auth_require();
 
 $site = site_get();

+ 1 - 1
public/admin/galleries.php → admin/galleries.php

@@ -2,7 +2,7 @@
 /**
  * Gallery overview: create new galleries, list and delete existing ones.
  */
-require dirname(__DIR__, 2) . '/app/bootstrap.php';
+require dirname(__DIR__) . '/app/bootstrap.php';
 auth_require();
 
 if ($_SERVER['REQUEST_METHOD'] === 'POST') {

+ 1 - 1
public/admin/gallery-edit.php → admin/gallery-edit.php

@@ -3,7 +3,7 @@
  * Per-gallery editor: settings, share link, direct-to-S3 bulk uploader,
  * and image removal.
  */
-require dirname(__DIR__, 2) . '/app/bootstrap.php';
+require dirname(__DIR__) . '/app/bootstrap.php';
 auth_require();
 
 $gallery = gallery_load((string)($_GET['g'] ?? ''));

+ 1 - 1
public/admin/index.php → admin/index.php

@@ -1,5 +1,5 @@
 <?php
-require dirname(__DIR__, 2) . '/app/bootstrap.php';
+require dirname(__DIR__) . '/app/bootstrap.php';
 auth_require();
 
 $site = site_get();

+ 1 - 1
public/admin/login.php → admin/login.php

@@ -1,5 +1,5 @@
 <?php
-require dirname(__DIR__, 2) . '/app/bootstrap.php';
+require dirname(__DIR__) . '/app/bootstrap.php';
 
 session_boot();
 if (auth_check()) {

+ 4 - 0
admin/logout.php

@@ -0,0 +1,4 @@
+<?php
+require dirname(__DIR__) . '/app/bootstrap.php';
+auth_logout();
+redirect('login.php');

+ 1 - 1
public/admin/settings.php → admin/settings.php

@@ -2,7 +2,7 @@
 /**
  * Settings: online admin password change (rewrites config/credentials.php).
  */
-require dirname(__DIR__, 2) . '/app/bootstrap.php';
+require dirname(__DIR__) . '/app/bootstrap.php';
 auth_require();
 
 if ($_SERVER['REQUEST_METHOD'] === 'POST') {

+ 2 - 2
public/admin/showreel.php → admin/showreel.php

@@ -1,9 +1,9 @@
 <?php
 /**
  * Showreel manager: upload, reorder and remove the portfolio images.
- * These are stored locally in public/media/ in full resolution.
+ * These are stored locally in media/ in full resolution.
  */
-require dirname(__DIR__, 2) . '/app/bootstrap.php';
+require dirname(__DIR__) . '/app/bootstrap.php';
 auth_require();
 
 $site = site_get();

+ 2 - 2
app/bootstrap.php

@@ -1,13 +1,13 @@
 <?php
 /**
- * Application bootstrap. Every entry script in public/ includes this first.
+ * Application bootstrap. Every public entry script includes this first.
  */
 
 declare(strict_types=1);
 
 define('APP_ROOT', dirname(__DIR__));
 define('DATA_DIR', APP_ROOT . '/data');
-define('MEDIA_DIR', APP_ROOT . '/public/media');
+define('MEDIA_DIR', APP_ROOT . '/media');
 define('CONFIG_DIR', APP_ROOT . '/config');
 
 if (!is_file(CONFIG_DIR . '/config.php')) {

+ 2 - 2
app/partials.php

@@ -1,8 +1,8 @@
 <?php
 /**
  * Shared HTML fragments for the public site and the admin backoffice.
- * All pages sit directly in public/ or public/admin/, so asset paths are
- * passed as a relative $base ('' for public pages, '../' for admin pages).
+ * Public pages sit in the document root and admin pages in admin/, so asset
+ * paths differ: public pages use 'assets/...', admin pages use '../assets/...'.
  */
 
 declare(strict_types=1);

+ 2 - 2
app/storage.php

@@ -67,13 +67,13 @@ function slugify(string $title): string
 }
 
 // ---------------------------------------------------------------------------
-// Local media (hero + showreel images in public/media/)
+// Local media (hero + showreel images in media/)
 // ---------------------------------------------------------------------------
 
 const MEDIA_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif'];
 
 /**
- * Store one uploaded image in public/media/, full resolution, unmodified.
+ * Store one uploaded image in media/, full resolution, unmodified.
  * Returns the stored filename, or null if the upload is invalid.
  */
 function media_store_upload(array $file): ?string

+ 0 - 0
public/assets/admin.js → assets/admin.js


+ 0 - 0
public/assets/site.css → assets/site.css


+ 0 - 0
public/assets/site.js → assets/site.js


+ 1 - 1
docs/ADMIN-GUIDE.md

@@ -56,6 +56,6 @@ After 5 failed login attempts the login is locked for 15 minutes.
 - Visitors load gallery photos straight from Hetzner S3 through signed URLs
   that expire after ~1 hour; reloading the page issues fresh ones. Nobody can
   use a shared image URL indefinitely, and expired galleries really do go dark.
-- All content lives in flat files: `data/` (JSON) and `public/media/`
+- All content lives in flat files: `data/` (JSON) and `media/`
   (showreel/hero images). Backing up = copying those two folders plus
   `config/`.

+ 23 - 16
docs/ARCHITECTURE.md

@@ -5,24 +5,28 @@ webhosting where the only deployment tool is FTP.
 
 ## Layout
 
+The document root is the project folder itself — `index.php` is the home page.
+Application internals sit in the same tree but are blocked from the web by
+`.htaccess`.
+
 ```
-public/            web root — the only web-accessible directory
-  index.php        landing page (hero + intro)
-  showreel.php     fullscreen portfolio, scroll-snap
-  gallery.php      client gallery: password gate, expiry, grid + lightbox
-  admin/           backoffice (session-protected)
-    api.php        JSON API for the uploader (presign / register)
-  assets/          site.css, site.js (nav + lightbox), admin.js (uploader)
-  media/           local images: hero + showreel (full resolution)
-app/               library code, not web-accessible
+index.php          landing page (hero + intro)            ← document root
+showreel.php       fullscreen portfolio, scroll-snap
+gallery.php        client gallery: password gate, expiry, grid + lightbox
+admin/             backoffice (session-protected)
+  api.php          JSON API for the uploader (presign / register)
+assets/            site.css, site.js (nav + lightbox), admin.js (uploader)
+media/             local images: hero + showreel (full resolution)
+app/               library code — blocked by .htaccess
   bootstrap.php    config loading, session, helpers
   storage.php      JSON flat-file store, slugs, local media handling
   auth.php         login, throttling, online password change
   s3.php           AWS Signature v4 (presign GET/PUT, signed DELETE)
   csrf.php         CSRF tokens
   partials.php     shared HTML header/footer for public + admin pages
-config/            static config (S3, site) + admin credentials
-data/              flat-file content: site.json, galleries/<slug>.json
+config/            static config (S3, site) + admin credentials — blocked
+data/              flat-file content: site.json, galleries/<slug>.json — blocked
+router.php         local dev only: applies the .htaccess rules under php -S
 ```
 
 ## Flat-file storage
@@ -55,7 +59,7 @@ in a filesystem path.
 
 | What | Where | Why |
 | --- | --- | --- |
-| Hero + showreel | `public/media/` on the webhost | Few images, served directly, no S3 round-trip for the portfolio |
+| Hero + showreel | `media/` on the webhost | Few images, served directly, no S3 round-trip for the portfolio |
 | Gallery images | Hetzner S3, **private** bucket | Hundreds of full-res files per event; webspace stays small; traffic goes to S3 |
 
 Originals are **never modified** anywhere in the pipeline — no resize, no
@@ -112,10 +116,13 @@ can go straight to S3.
 - **Gallery access**: bcrypt-hashed gallery passwords; unlock state is
   per-gallery in the session. Expiry is a pure server-side date check —
   expired and nonexistent galleries return the identical 404 page.
-- **Web exposure**: only `public/` is served. If the docroot can't be moved,
-  the root `.htaccess` rewrites into `public/` and deny-all `.htaccess` files
-  protect `app/`, `config/`, `data/`. `public/media/.htaccess` serves images
-  only and disables PHP execution.
+- **Web exposure**: the document root is the project folder. The root
+  `.htaccess` blocks `app/`, `config/`, `data/`, `docs/` (via `mod_rewrite`)
+  and denies dotfiles, `*.json`, `*.md` and config templates (via
+  `FilesMatch`); each of `app/`, `config/`, `data/` also carries a deny-all
+  `.htaccess` as a fallback for hosts without `mod_rewrite`. `media/.htaccess`
+  serves images only and disables PHP execution. `router.php` reproduces these
+  rules for the PHP built-in server during local development.
 - **Input hygiene**: slugs validated by regex before touching the filesystem;
   upload filenames sanitized; `register` keys must lie under the gallery's own
   S3 prefix; all output HTML-escaped via `e()`.

+ 21 - 11
docs/SETUP.md

@@ -70,24 +70,31 @@ Settings page immediately after the first login.**
 
 ## 4. Uploading to the webhost
 
-Upload the whole project via FTP/SFTP. Two layouts work:
+Upload the **contents of this folder** into your account's document root
+(usually `public_html/`, `htdocs/` or `www/`) via FTP/SFTP. The site's home
+page, `index.php`, sits directly in the document root — there is no separate
+web-root subfolder to configure.
 
-**Preferred — document root points at `public/`:**
-`app/`, `config/` and `data/` are outside the web root and unreachable by
-design. Most shared hosters let you set the document root per domain.
+The application internals (`app/`, `config/`, `data/`, `docs/`) live inside
+the document root but are blocked from the web by the root `.htaccess` (plus a
+deny-all `.htaccess` inside each of `app/`, `config/`, `data/` as a fallback).
 
-**Fallback — document root is the project root:**
-The included root `.htaccess` rewrites all requests into `public/`, and
-`app/.htaccess`, `config/.htaccess`, `data/.htaccess` each deny direct access.
-Verify after deploying: `https://your-domain.com/config/config.php` must
-return *403 Forbidden*.
+Verify after deploying — each of these must return **403 Forbidden**, never
+their contents:
+
+- `https://your-domain.com/config/config.php`
+- `https://your-domain.com/config/credentials.php`
+- `https://your-domain.com/data/site.json`
+
+If they don't, your host ignores `.htaccess` — move `app/`, `config/` and
+`data/` above the document root and adjust the paths, or contact support.
 
 ### Writable directories
 
 The PHP process must be able to write to:
 
 - `data/` (and `data/galleries/`) — flat-file content
-- `public/media/` — hero + showreel images
+- `media/` — hero + showreel images
 - `config/` — only for the online password change
 
 On typical shared hosting (suEXEC/FPM running as your user) this already
@@ -112,8 +119,11 @@ works; otherwise `chmod 755` the directories (or `775`/`777` as a last resort).
 ## 6. Local development
 
 ```bash
-php -S localhost:8080 -t public
+php -S localhost:8080 router.php
 ```
 
+`router.php` reproduces the `.htaccess` protection for the PHP built-in server
+(which does not read `.htaccess`); it is only used locally.
+
 Everything except real S3 traffic works without credentials; gallery pages
 render presigned URLs that simply won't resolve until real keys are configured.

+ 1 - 1
public/gallery.php → gallery.php

@@ -8,7 +8,7 @@
  * - Thumbnails and full-res originals are loaded by the browser directly
  *   from S3 via short-lived presigned URLs.
  */
-require dirname(__DIR__) . '/app/bootstrap.php';
+require __DIR__ . '/app/bootstrap.php';
 
 session_boot();
 

+ 1 - 1
public/index.php → index.php

@@ -2,7 +2,7 @@
 /**
  * Landing page: full-screen hero image with the artist introduction.
  */
-require dirname(__DIR__) . '/app/bootstrap.php';
+require __DIR__ . '/app/bootstrap.php';
 
 $site = site_get();
 $hero = $site['hero_image'] ?? null;

+ 0 - 0
public/media/.htaccess → media/.htaccess


+ 0 - 4
public/admin/logout.php

@@ -1,4 +0,0 @@
-<?php
-require dirname(__DIR__, 2) . '/app/bootstrap.php';
-auth_logout();
-redirect('login.php');

+ 26 - 0
router.php

@@ -0,0 +1,26 @@
+<?php
+/**
+ * Router for the PHP built-in server used in local development:
+ *
+ *     php -S localhost:8080 router.php
+ *
+ * The built-in server does not read .htaccess, so this mirrors the production
+ * protection rules — otherwise data/*.json (which contains gallery password
+ * hashes) and other internals would be readable while testing locally.
+ * Not used in production; Apache ignores it.
+ */
+$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?? '/';
+
+$blocked = preg_match('#^/(app|config|data|docs)/#', $path)   // internals
+    || preg_match('#(^|/)\.[^/]#', $path)                     // dotfiles/dirs
+    || preg_match('#\.(json|md|lock)$#', $path)               // data/docs
+    || str_ends_with($path, '.sample.php');                   // config templates
+
+if ($blocked) {
+    http_response_code(403);
+    echo 'Forbidden';
+    return true;
+}
+
+// Let the built-in server serve the requested file (or its own 404) as usual.
+return false;

+ 2 - 2
public/showreel.php → showreel.php

@@ -1,10 +1,10 @@
 <?php
 /**
  * Showreel: selected portfolio images, one per viewport, scroll-snap.
- * Images are local files in public/media/, shown in the order configured
+ * Images are local files in media/, shown in the order configured
  * in the admin backoffice.
  */
-require dirname(__DIR__) . '/app/bootstrap.php';
+require __DIR__ . '/app/bootstrap.php';
 
 $site = site_get();
 $images = $site['showreel'] ?? [];