Selaa lähdekoodia

Documentation: setup/deployment, admin guide, architecture

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Medowar 1 kuukausi sitten
vanhempi
sitoutus
feafb77714
3 muutettua tiedostoa jossa 311 lisäystä ja 0 poistoa
  1. 61 0
      docs/ADMIN-GUIDE.md
  2. 131 0
      docs/ARCHITECTURE.md
  3. 119 0
      docs/SETUP.md

+ 61 - 0
docs/ADMIN-GUIDE.md

@@ -0,0 +1,61 @@
+# Admin Guide (Backoffice)
+
+Open `https://your-domain.com/admin/` and log in. All content management
+happens here; visitors never see this area.
+
+## Front page
+
+*Front page* edits the landing page: your name/title, the introduction text
+(line breaks are kept), and the full-page hero image. Uploading a new hero
+image replaces the old one. Images are stored in full resolution, unmodified.
+
+## Showreel
+
+*Showreel* manages the portfolio shown at `/showreel.php` — each image fills
+the whole screen, in the order listed. Upload images (a few at a time — these
+go through the webhost, which may have upload size limits), reorder them with
+the arrow buttons, remove them with ✕. Showreel images are stored locally on
+the webhost.
+
+## Galleries
+
+*Galleries* is for delivering whole events to clients.
+
+**Create** a gallery with a title, and optionally:
+
+- **Password** — visitors must enter it before seeing the gallery. You can
+  set, change, or remove it later.
+- **Expiry date** — after this day the gallery link shows "not available".
+  Nothing is deleted; clearing the date brings it back.
+
+Each gallery gets an unguessable link like
+`/gallery.php?g=wedding-mueller-x7Kf3q` — copy the *Share link* from the
+gallery editor and send it to your client.
+
+**Upload images** by dropping them onto the upload area in the gallery editor.
+Files travel directly from your browser to the S3 storage in **full
+resolution, byte-for-byte unmodified** — the webhost never touches them, so
+there is no server upload limit. Keep the browser tab open until every file
+shows *done*; failed files offer a *retry* link. A small preview thumbnail is
+generated by your browser for the gallery grid; files the browser cannot
+decode (e.g. RAW) are uploaded anyway, just without a preview.
+
+**Delete** an image (✕ on its thumbnail) or a whole gallery — this also
+removes the files from S3 permanently.
+
+## Settings
+
+Change the admin password here (minimum 8 characters). The change takes
+effect immediately. The username and the S3 settings can only be changed by
+editing the files in `config/` on the server.
+
+After 5 failed login attempts the login is locked for 15 minutes.
+
+## Good to know
+
+- 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/`
+  (showreel/hero images). Backing up = copying those two folders plus
+  `config/`.

+ 131 - 0
docs/ARCHITECTURE.md

@@ -0,0 +1,131 @@
+# Architecture
+
+Plain PHP 8, no framework, no database, no Composer. Designed for shared
+webhosting where the only deployment tool is FTP.
+
+## Layout
+
+```
+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
+  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
+```
+
+## Flat-file storage
+
+- `data/site.json` — front page text, hero filename, ordered showreel list.
+- `data/galleries/<slug>.json` — one file per gallery:
+
+  ```json
+  {
+    "slug": "wedding-mueller-x7Kf3q",
+    "title": "Wedding Müller",
+    "created_at": "2026-07-05 12:00:00",
+    "password_hash": "$2y$...",        // or null
+    "expires_at": "2026-12-31",         // or null
+    "images": [
+      { "key":   "galleries/<slug>/originals/a1b2c3-DSC_0001.jpg",
+        "thumb": "galleries/<slug>/thumbs/a1b2c3-DSC_0001.jpg.jpg",
+        "name":  "DSC_0001.jpg", "size": 18349201 }
+    ]
+  }
+  ```
+
+Writes go through `json_write()`: serialize to a temp file, then `rename()` —
+atomic on the same filesystem, so a crashed request can't corrupt data.
+Reads take a shared lock. The slug embeds a random token, making gallery URLs
+unguessable; the slug is also validated (`gallery_file()`) before being used
+in a filesystem path.
+
+## Image storage split
+
+| What | Where | Why |
+| --- | --- | --- |
+| Hero + showreel | `public/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
+re-encode, no EXIF stripping.
+
+## Presigned URLs (app/s3.php)
+
+AWS Signature v4 implemented directly (~100 lines, `hash_hmac` only), verified
+against the official AWS example vectors. Three uses:
+
+1. **Presigned GET** — `gallery.php` embeds signed image URLs
+   (`s3.url_ttl`, default 1 h). The browser fetches from S3 directly; the
+   webhost serves only HTML.
+2. **Presigned PUT** — `admin/api.php` hands the uploader short-lived upload
+   URLs. Only the `Host` header is signed, so the browser may send its own
+   `Content-Type`.
+3. **Signed DELETE** — server-side via curl when images or galleries are
+   deleted.
+
+Because the bucket is private, access control is entirely on the PHP side:
+no unlock → no signed URL → no image. Once a gallery expires or is deleted,
+outstanding URLs die within the TTL.
+
+## Upload flow (admin browser → S3)
+
+```
+admin.js                    api.php                    Hetzner S3
+   │  action=presign  ───────▶ │
+   │ ◀─── key, thumb, 2 PUT URLs
+   │  PUT original (unmodified, full res) ───────────────▶
+   │  canvas → JPEG thumb; PUT thumb ────────────────────▶
+   │  action=register ──────▶ │  appends to gallery JSON
+```
+
+Files are uploaded sequentially with progress; failures get a per-file retry.
+The thumbnail is drawn client-side (`createImageBitmap` +
+`imageOrientation: 'from-image'` for EXIF rotation). Undecodable files (RAW,
+video) upload without a thumbnail; the grid then falls back to the original
+key. Random 6-char key prefixes prevent same-filename collisions. Requires a
+CORS rule on the bucket (see SETUP.md).
+
+This route exists because shared hosting typically limits `post_max_size` and
+request time — a 2 GB wedding shoot can't pass through the webhost, but it
+can go straight to S3.
+
+## Security model
+
+- **Admin auth**: credentials in `config/credentials.php`
+  (`password_hash`/`password_verify`); session flag; 5 failed logins → 15 min
+  lock (flat file). Online password change rewrites the credentials file
+  atomically and invalidates the opcache entry.
+- **CSRF**: session token required on every admin POST (form field) and API
+  call (`X-CSRF-Token` header), and on gallery password submissions.
+- **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.
+- **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()`.
+
+## Known trade-offs
+
+- One admin account, one shared session store — fine for a single
+  photographer, not a multi-user CMS.
+- Gallery JSON writes are last-writer-wins; the uploader registers files
+  sequentially, so this only matters if two admin tabs edit the same gallery
+  simultaneously.
+- Presigned URLs mean gallery pages must be re-rendered after `s3.url_ttl`;
+  a visitor who keeps a tab open >1 h reloads to see images again.

+ 119 - 0
docs/SETUP.md

@@ -0,0 +1,119 @@
+# Setup & Deployment
+
+## 1. Requirements
+
+- Shared webhosting with PHP **8.1 or newer**, `curl` and `openssl` extensions
+  (both are standard), and Apache `.htaccess` support.
+- A **Hetzner Object Storage** bucket (any S3-compatible storage works).
+- No database, no Composer, no build step.
+
+## 2. Hetzner Object Storage
+
+1. In the [Hetzner Cloud Console](https://console.hetzner.cloud/) create a
+   Bucket (e.g. location `fsn1`). Set visibility to **private** — visitors get
+   access only through short-lived presigned URLs.
+2. Create S3 credentials (Security → S3 credentials) and note the
+   *access key* and *secret key*.
+3. Configure **CORS** on the bucket so the admin browser may upload directly
+   to S3 and so gallery images load on your domain. Hetzner supports the
+   standard S3 CORS API; using `s3cmd`, `aws s3api`, or any S3 GUI client
+   (e.g. Cyberduck), apply:
+
+   ```json
+   {
+     "CORSRules": [
+       {
+         "AllowedOrigins": ["https://www.your-domain.com"],
+         "AllowedMethods": ["GET", "PUT"],
+         "AllowedHeaders": ["*"],
+         "MaxAgeSeconds": 3600
+       }
+     ]
+   }
+   ```
+
+   With `aws` CLI:
+
+   ```bash
+   aws s3api put-bucket-cors \
+     --endpoint-url https://fsn1.your-objectstorage.com \
+     --bucket my-photo-galleries \
+     --cors-configuration file://cors.json
+   ```
+
+   Replace the origin with your real domain (and add `http://localhost:8080`
+   temporarily if you want to test uploads locally).
+
+## 3. Configuration
+
+```bash
+cp config/config.sample.php config/config.php
+cp config/credentials.sample.php config/credentials.php
+```
+
+Edit `config/config.php`:
+
+| Key | Meaning |
+| --- | --- |
+| `site.name` | Fallback site title |
+| `site.base_url` | Public base URL, used for gallery share links |
+| `site.timezone` | Timezone for expiry checks, e.g. `Europe/Berlin` |
+| `s3.endpoint` | `https://<location>.your-objectstorage.com` |
+| `s3.region` | The location, e.g. `fsn1` |
+| `s3.bucket` | Bucket name |
+| `s3.access_key` / `s3.secret_key` | S3 credentials |
+| `s3.url_ttl` | Lifetime of presigned view URLs in seconds |
+| `uploads.thumb_size` | Longest edge of grid thumbnails (browser-generated) |
+
+The default admin login is `admin` / `changeme` — **change it in the admin
+Settings page immediately after the first login.**
+
+## 4. Uploading to the webhost
+
+Upload the whole project via FTP/SFTP. Two layouts work:
+
+**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.
+
+**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*.
+
+### Writable directories
+
+The PHP process must be able to write to:
+
+- `data/` (and `data/galleries/`) — flat-file content
+- `public/media/` — hero + showreel images
+- `config/` — only for the online password change
+
+On typical shared hosting (suEXEC/FPM running as your user) this already
+works; otherwise `chmod 755` the directories (or `775`/`777` as a last resort).
+
+## 5. First-deploy smoke test
+
+1. Open `/admin/`, log in, change the password (Settings).
+2. Front page: set your name and intro text, upload a hero image → check the
+   landing page.
+3. Showreel: upload 2–3 images → check `/showreel.php`, scroll behavior, and
+   that navigation hides when scrolling down.
+4. Galleries: create a test gallery **with password and an expiry date of
+   today**, upload a handful of images. Then:
+   - the upload list shows *done* for each file (S3 + CORS working),
+   - the gallery page asks for the password and then shows images
+     (presigned GETs working),
+   - images load from `your-objectstorage.com`, not from your domain,
+   - tomorrow the gallery shows "not available" (expiry working).
+5. Delete the test gallery — the S3 objects are removed as well.
+
+## 6. Local development
+
+```bash
+php -S localhost:8080 -t public
+```
+
+Everything except real S3 traffic works without credentials; gallery pages
+render presigned URLs that simply won't resolve until real keys are configured.