Update SOPs: consolidate index, clean client data, set Imagen as default

- README: rewrite index to reflect actual files (STACK/CONTENT/OPTIMIZATION);
  remove 15 dead links to old numbered SOPs; add subdirectory table; update
  image gen to Google Imagen as default
- STACK: fix wp-divi-pipeline script paths; genericize vibrantyou/domain
  examples; strip pre-existing em dashes throughout
- CONTENT: update image generation default to Google Imagen API with allotted quota
- image-gen-workflow: remove client-specific cobhamtech data; generalize
  brand palette step; update date
- wp-divi-pipeline-to-am-stack: remove vibrantyou.yoga client data block;
  fix Related SOPs links to current files
This commit is contained in:
2026-06-09 18:54:57 +02:00
parent 94f7a1f72a
commit 5eb4426d30
7 changed files with 190 additions and 251 deletions
+95 -95
View File
@@ -1,4 +1,4 @@
# STACK Architecture, Deployment, and Build Pipeline
# STACK: Architecture, Deployment, and Build Pipeline
Author: Andre Cobham / Arising Media
Updated: 2026-06-09
@@ -6,19 +6,19 @@ Updated: 2026-06-09
Two primary stacks. Pick based on page count and update frequency.
### Stack A PHP Router + SQLite (50+ pages, standard as of 2026-05-21)
### Stack A: PHP Router + SQLite (50+ pages, standard as of 2026-05-21)
- **PHP Router** `router.php` dispatches every content URL to the correct PHP template. Edit one template = entire page class updates on next request. No find-and-replace. No file edits.
- **SQLite** single-file content DB. `pages.sqlite` holds all page content (title, meta, sections JSON, schema). 10,000 rows = 5MB. Sub-millisecond reads. No server process.
- **Vanilla JavaScript** no frameworks. `fetch`, `IntersectionObserver`, `querySelector`
- **Plain CSS** `tokens.css` (design tokens) + `main.css` (components). No Sass, no Tailwind
- **Docker + nginx** nginx routes `/assets/*` directly; all content URLs → PHP-FPM → router.php
- **Resend** transactional email via `/api/contact.php`
- **Reference:** `arisingmedia.us` 10,000+ pages
- **PHP Router**: `router.php` dispatches every content URL to the correct PHP template. Edit one template = entire page class updates on next request. No find-and-replace. No file edits.
- **SQLite**: single-file content DB. `pages.sqlite` holds all page content (title, meta, sections JSON, schema). 10,000 rows = 5MB. Sub-millisecond reads. No server process.
- **Vanilla JavaScript**: no frameworks. `fetch`, `IntersectionObserver`, `querySelector`
- **Plain CSS**: `tokens.css` (design tokens) + `main.css` (components). No Sass, no Tailwind
- **Docker + nginx**: nginx routes `/assets/*` directly; all content URLs → PHP-FPM → router.php
- **Resend**: transactional email via `/api/contact.php`
- **Reference:** `arisingmedia.us`: 10,000+ pages
### Stack B Static HTML (fewer than 50 pages)
### Stack B: Static HTML (fewer than 50 pages)
- **Static HTML** every page is a `.html` file on disk
- **Static HTML**: every page is a `.html` file on disk
- Same JS, CSS, Docker, nginx, Resend as Stack A
- Python 3 stdlib for build scripts (no pip)
- **Reference:** `lahrcarpetcleaning.com`
@@ -36,11 +36,11 @@ Two primary stacks. Pick based on page count and update frequency.
### Why This Stack
1. **Performance** a static HTML page with vanilla JS loads in <100ms with no parse cost from frameworks
2. **Longevity** no dependency rot. A site we build today still works in 10 years with no maintenance
3. **Security** no `npm audit` warnings, no supply-chain attack vectors, no transitive deps to patch
4. **Auditability** every line on the site is something we wrote and can read in plain text
5. **Hosting** a static folder + tiny Python container fits in the smallest VM tier any provider sells
1. **Performance**: a static HTML page with vanilla JS loads in <100ms with no parse cost from frameworks
2. **Longevity**: no dependency rot. A site we build today still works in 10 years with no maintenance
3. **Security**: no `npm audit` warnings, no supply-chain attack vectors, no transitive deps to patch
4. **Auditability**: every line on the site is something we wrote and can read in plain text
5. **Hosting**: a static folder + tiny Python container fits in the smallest VM tier any provider sells
### When to Add a Server-Side Service
@@ -121,7 +121,7 @@ Contains ONLY what's needed to run `docker compose up`.
├── api/ # form-submit Python service (if used)
│ ├── server.py
│ ├── Dockerfile
│ ├── .env # gitignored Resend key, etc.
│ ├── .env # gitignored: Resend key, etc.
│ └── .env.example
├── Dockerfile # nginx web container
├── nginx.conf
@@ -145,7 +145,7 @@ notes, raw assets). This is the dev/maintenance copy. NOT what gets deployed.
Build scripts, JSON data, and notes go into `.planning/` to keep root clean and
prevent accidental web exposure.
### URL Structure Two Valid Patterns
### URL Structure: Two Valid Patterns
#### Pattern A: Flat HTML (default for Docker/nginx projects)
@@ -156,7 +156,7 @@ Why flat:
- One file = one page, no `/index.html` confusion
- Easier sitemap generation
- `<a href>` links are unambiguous
- Crawl budget benefit Google indexes one URL per page, not two
- Crawl budget benefit: Google indexes one URL per page, not two
#### Pattern B: Directory-style (default for cPanel/Apache projects)
@@ -242,9 +242,9 @@ directly. Build scripts are for repetition, not for everything.
Three files per template family:
1. **`data/{thing}.json`** array of objects, one per page
2. **`{thing}/_template.html`** HTML with `{{placeholder}}` markers
3. **`build_{thing}.py`** stdlib Python, stamps template with data
1. **`data/{thing}.json`**: array of objects, one per page
2. **`{thing}/_template.html`**: HTML with `{{placeholder}}` markers
3. **`build_{thing}.py`**: stdlib Python, stamps template with data
#### Example: locations.json
@@ -327,7 +327,7 @@ if __name__ == "__main__":
### Rules
1. **Source of truth is JSON, not HTML.** When content needs to change, edit the
JSON and re-run the build script. Never hand-edit a generated `.html` file
JSON and re-run the build script. Never hand-edit a generated `.html` file :
the next build will overwrite your changes.
2. **Generated files land in the same folder as their template.** Do not nest
@@ -345,7 +345,7 @@ if __name__ == "__main__":
5. **Build is idempotent.** Running it twice produces identical files.
### Stamping Rules Escaping
### Stamping Rules: Escaping
When a JSON value gets stamped into an HTML attribute or `<title>`, special
characters can break the page. Use these rules:
@@ -376,20 +376,20 @@ After build, sync the rendered files to deployment.
The playbook for migrating a WordPress (Divi, Elementor, classic, whatever) site
to vanilla static HTML.
### Phase 1 Capture Source
### Phase 1: Capture Source
Before touching anything, capture the current site so nothing is lost.
1. **Database dump** `wp db export ${domain}.sql --add-drop-table`
2. **Wp-content snapshot** tar the entire `wp-content/` (themes, plugins, uploads)
3. **Crawl the live site** use `wget --mirror --convert-links --adjust-extension --page-requisites --no-parent https://{domain}` to capture rendered HTML + all assets
4. **Inventory pages** list every URL returning 200 (use the sitemap if it has one)
5. **Inventory forms** note every Gravity Form / Contact Form 7 / etc. field-by-field
6. **Inventory dynamic features** search, comments, members, anything truly dynamic
1. **Database dump**: `wp db export ${domain}.sql --add-drop-table`
2. **Wp-content snapshot**: tar the entire `wp-content/` (themes, plugins, uploads)
3. **Crawl the live site**: use `wget --mirror --convert-links --adjust-extension --page-requisites --no-parent https://{domain}` to capture rendered HTML + all assets
4. **Inventory pages**: list every URL returning 200 (use the sitemap if it has one)
5. **Inventory forms**: note every Gravity Form / Contact Form 7 / etc. field-by-field
6. **Inventory dynamic features**: search, comments, members, anything truly dynamic
Save all of this in the project's `.planning/` folder.
### Phase 2 Decide What to Keep
### Phase 2: Decide What to Keep
Re-design pass. Most WP sites have:
- Bloated copy → cut by 30-50%
@@ -400,7 +400,7 @@ Re-design pass. Most WP sites have:
Show the client a wireframe of the simplified structure before building anything.
### Phase 3 Information Architecture
### Phase 3: Information Architecture
Standard structure for a small business:
@@ -419,19 +419,19 @@ Standard structure for a small business:
For each location and each service: one flat `.html` page generated from JSON +
template.
### Phase 4 Build
### Phase 4: Build
1. Set up source folder per `01-project-structure.md`
1. Set up source folder per the Project Structure section in STACK.md
2. Write `assets/css/main.css` (variables, reset, typography, layout)
3. Write `assets/css/components.css` (header, footer, hero, cards, forms)
4. Write `components/header.html` and `components/footer.html`
5. Write `assets/js/components.js` (fetch + inject header/footer)
6. Write `assets/js/main.js` (scroll animations, anything page-wide)
7. Build `index.html` first this is the design system in working form
7. Build `index.html` first: this is the design system in working form
8. Generate location and service detail pages from JSON
9. Build remaining pages: about, contact, reviews, blog index
### Phase 5 Forms
### Phase 5: Forms
If the WP site had Gravity Forms or similar, build a vanilla replacement:
- HTML form in `contact/index.html` (and inline on service/location pages if needed)
@@ -439,7 +439,7 @@ If the WP site had Gravity Forms or similar, build a vanilla replacement:
- POST to `/api/estimate` (or similar) handled by Python stdlib service
- Server-side validation, reCAPTCHA verification, send via Resend
### Phase 6 SEO Parity
### Phase 6: SEO Parity
Before launch, every old URL must either:
- Have a matching new URL with the same or better content, OR
@@ -461,7 +461,7 @@ Per-page parity checklist:
- Image alt text preserved or improved
- Schema.org JSON-LD added (`LocalBusiness`, `Service`, `BreadcrumbList`)
### Phase 7 Switch DNS / Cutover
### Phase 7: Switch DNS / Cutover
1. Deploy the static site to a separate URL first (`new.{domain}`) for client review
2. Once approved, point production DNS to the new container
@@ -469,16 +469,16 @@ Per-page parity checklist:
4. Submit new sitemap to Google Search Console
5. Use Search Console URL inspection on 5-10 key pages to confirm indexing
### Phase 8 Post-Launch
### Phase 8: Post-Launch
- Monitor Search Console for crawl errors / 404s, fix in nginx as redirects
- Monitor form submissions first real lead through the new form is the
- Monitor form submissions: first real lead through the new form is the
ultimate "it works" check
- Decommission WP only after 30 days of clean operation
### What NOT to Do
- Do not run a "headless WordPress" or "WordPress as API" that defeats the
- Do not run a "headless WordPress" or "WordPress as API": that defeats the
whole point. Static means static.
- Do not use a static-site-generator tool (Hugo, 11ty, Jekyll, Astro, Next.js
static export). We hand-write HTML and use small Python build scripts only
@@ -506,7 +506,7 @@ Takes a single `.wpress` archive (All-in-One WP Migration backup) and produces:
The goal is NOT a 1:1 copy. The goal is:
1. Preserve all content, SEO equity, and brand identity
2. ENHANCE the design cleaner, faster, more modern
2. ENHANCE the design: cleaner, faster, more modern
3. Remove all WordPress / Divi bloat (plugin CSS, shortcode residue, 300KB JS bundles)
4. Produce a site that loads in <2s on mobile and scores 95+ on Lighthouse
@@ -551,14 +551,14 @@ All scripts live in `.am-webdesign-sops/wp-divi-pipeline/scripts/`.
| `extract_design.py` | 4 | Pull Divi theme options → design-system.json |
| `extract_media.py` | 5 | Catalog uploads/, emit media-manifest.json |
| `convert_images.py` | 5 | Batch convert images → WebP |
| `run_pipeline.sh` | 0-7 | Master script runs all phases in order |
| `run_pipeline.sh` | 0-7 | Master script: runs all phases in order |
### Per-Project Working Directory
```
{domain}/
└── .planning/
├── vibrantyou-yoga-YYYYMMDD-*.wpress ← source archive (never modify)
├── {domain}-YYYYMMDD-*.wpress ← source archive (never modify)
├── wpress-extract/ ← Phase 1 output (gitignored)
│ ├── package.json ← archive metadata
│ ├── database.sql ← MySQL dump
@@ -593,7 +593,7 @@ The archive ends when a header of all null bytes is encountered, or EOF.
Extraction script:
```bash
python3 /home/sirdrez/arisingmedia-websites/.am-webdesign-sops/wp-divi-pipeline/scripts/extract_wpress.py \
python3 /home/sirdrez/arisingmedia-websites/.am-webdesign-sops/wp-divi-pipeline-to-am-stack/scripts/extract_wpress.py \
/home/sirdrez/arisingmedia-websites/{domain}/.planning/{file}.wpress \
/home/sirdrez/arisingmedia-websites/{domain}/.planning/wpress-extract/
```
@@ -610,9 +610,9 @@ python3 /home/sirdrez/arisingmedia-websites/.am-webdesign-sops/wp-divi-pipeline/
```
Outputs three files into `.planning/data/`:
- `pages.json` all published pages/posts with content and SEO meta
- `design-system.json` colors, fonts, Divi settings
- `site-info.json` domain, plugin list, WP version, Divi version
- `pages.json`: all published pages/posts with content and SEO meta
- `design-system.json`: colors, fonts, Divi settings
- `site-info.json`: domain, plugin list, WP version, Divi version
### Divi 5 Content Extraction
@@ -654,7 +654,7 @@ Strip Divi class/attribute noise using `clean_divi_html()` from `divi_to_html.py
from divi_to_html import clean_divi_html, rewrite_internal_links
cleaned = clean_divi_html(raw_html)
cleaned = rewrite_internal_links(cleaned, staging_hosts=("vibrantyou.yoga",))
cleaned = rewrite_internal_links(cleaned, staging_hosts=("{domain}",))
```
### Design System Extraction
@@ -712,13 +712,13 @@ full 5-step scale around the primary hue:
Map extracted Divi content into AM HTML templates.
Build order:
1. `src/assets/css/main.css` design tokens, reset, typography, layout grid
2. `src/assets/css/components.css` header, footer, hero, cards, forms, nav
3. `src/components/header.html` navigation
4. `src/components/footer.html` footer links, contact info
5. `src/assets/js/components.js` fetch + inject header/footer
6. `src/assets/js/main.js` scroll animations, intersection observer
7. `src/index.html` home page (this IS the design system in working form)
1. `src/assets/css/main.css`: design tokens, reset, typography, layout grid
2. `src/assets/css/components.css`: header, footer, hero, cards, forms, nav
3. `src/components/header.html`: navigation
4. `src/components/footer.html`: footer links, contact info
5. `src/assets/js/components.js`: fetch + inject header/footer
6. `src/assets/js/main.js`: scroll animations, intersection observer
7. `src/index.html`: home page (this IS the design system in working form)
8. Remaining pages: about, classes, contact, blog
9. `src/robots.txt`, `src/sitemap.xml`, `src/404.html`, `src/500.html`
@@ -790,7 +790,7 @@ Priority order for SEO fields:
2. `post_title` with AM format appended: `{Title} | {Brand Name}`
3. Never leave title as the raw WP default
Rank Math title templates use `%` tokens strip them and rebuild:
Rank Math title templates use `%` tokens: strip them and rebuild:
```python
import re
@@ -837,7 +837,7 @@ grep -r "et_pb_\|wp:divi" $SITE --include="*.html"
### Run Order (Complete Execution Sequence)
```bash
export DOMAIN="vibrantyou.yoga"
export DOMAIN="{domain}"
export PROJECT="/home/sirdrez/arisingmedia-websites/$DOMAIN"
export SOPS="/home/sirdrez/arisingmedia-websites/.am-webdesign-sops"
export WPRESS=$(ls $PROJECT/.planning/*.wpress | head -1)
@@ -854,7 +854,7 @@ python3 $SOPS/wp-divi-pipeline/scripts/analyze_db.py "$PROJECT/.planning/wpress-
# Phase 3: Content extraction (Divi 5 example)
python3 $SOPS/wp-divi-pipeline/scripts/extract_divi5.py "$PROJECT/.planning/data/pages.json" "$PROJECT/.planning/data/content/"
# Phase 4: Design system (manual read design-system.json, write main.css)
# Phase 4: Design system (manual: read design-system.json, write main.css)
# Phase 5: Media migration
find $PROJECT/.planning/wpress-extract/uploads -type f \( -name "*.jpg" -o -name "*.png" \) | \
@@ -870,7 +870,7 @@ for img in *.jpg *.png; do
cwebp -q 82 "$img" -o "${img%.*}.webp" && rm "$img"
done
# Phase 6: Build HTML (manual per 05-content-migration.md)
# Phase 6: Build HTML (manual: per 05-content-migration.md)
# Phase 7: SEO audit
cd $PROJECT/src
@@ -930,17 +930,17 @@ Port assignments are unique per project. Track in
### Dockerfile (nginx web container)
CRITICAL the Dockerfile must explicitly list which folders to copy. Never use
CRITICAL: the Dockerfile must explicitly list which folders to copy. Never use
`COPY . /usr/share/nginx/html/` because that copies `.env`, `Dockerfile`,
build scripts, etc. into the web root where they become URL-accessible.
```dockerfile
FROM nginx:alpine
# nginx config server-only, never served as a static file
# nginx config: server-only, never served as a static file
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Public website only explicit list, no wildcards
# Public website only: explicit list, no wildcards
COPY index.html /usr/share/nginx/html/
COPY assets /usr/share/nginx/html/assets/
COPY components /usr/share/nginx/html/components/
@@ -975,7 +975,7 @@ server {
root /usr/share/nginx/html;
index index.html;
# Defense in depth deny dotfiles, configs, scripts, source files
# Defense in depth: deny dotfiles, configs, scripts, source files
location ~ /\. {
deny all;
return 404;
@@ -989,7 +989,7 @@ server {
return 404;
}
# API proxy strip /api/ prefix, forward to Python service
# API proxy: strip /api/ prefix, forward to Python service
location /api/ {
proxy_pass http://api:3001/;
proxy_http_version 1.1;
@@ -1000,7 +1000,7 @@ server {
proxy_connect_timeout 5s;
}
# Flat HTML routing /locations/buffalo serves /locations/buffalo.html
# Flat HTML routing: /locations/buffalo serves /locations/buffalo.html
location / {
try_files $uri $uri/ $uri.html =404;
}
@@ -1100,10 +1100,10 @@ scripts, `.git/`, etc.) and fails if any returns 200.
```
Exit codes:
- `0` PASS every sensitive path 404, every required path reachable.
- `0` PASS (with warnings) protection clean but `/robots.txt` or
- `0` PASS: every sensitive path 404, every required path reachable.
- `0` PASS (with warnings): protection clean but `/robots.txt` or
`/sitemap.xml` missing (content gap, not a leak).
- `1` FAIL at least one sensitive path returned 200, or `/` is unreachable.
- `1` FAIL: at least one sensitive path returned 200, or `/` is unreachable.
Run it manually after every `docker compose up -d --build`. Wire it into CI
once the site has a remote pipeline. Treat a FAIL as a deploy rollback.
@@ -1132,7 +1132,7 @@ compose project:
docker stop {container-name}
docker rm {container-name}
# Now bring up from the renamed folder clean start
# Now bring up from the renamed folder: clean start
docker compose -f /path/to/renamed-folder/docker-compose.yml up -d
```
@@ -1152,7 +1152,7 @@ WHM, Bluehost, HostGator, SiteGround, etc.) instead of a VPS running Docker.
### Key Rule: Repo Path ≠ Webroot
cPanel Git requires an EMPTY directory as the repository path. The webroot
(`public_html/{domain}/`) is never the repo path cPanel rejects it if it
(`public_html/{domain}/`) is never the repo path: cPanel rejects it if it
already contains files.
```
@@ -1165,7 +1165,7 @@ Deploy target (webroot): /home/{username}/public_html/{domain}/
1. cPanel → Git Version Control → Create Repository
2. Repository Path: `/home/{username}/repositories/{domain}/` (must be empty)
3. Clone URL: your Git remote (GitHub, Bitbucket, etc.)
4. cPanel clones into the repo path never into the webroot
4. cPanel clones into the repo path: never into the webroot
### .cpanel.yml
@@ -1220,7 +1220,7 @@ deployment:
1. Push to the connected remote (GitHub)
2. cPanel → Git Version Control → Manage → Pull or Deploy
3. cPanel runs the `.cpanel.yml` tasks, copying files to webroot
4. Apache serves from webroot automatically no nginx, no Docker
4. Apache serves from webroot automatically: no nginx, no Docker
### Apache vs nginx
@@ -1232,7 +1232,7 @@ Options -Indexes
RewriteEngine On
# Directory-style URLs: /services/carpet-cleaning/ → index.html inside that folder
# Apache handles this automatically with DirectoryIndex no extra rules needed
# Apache handles this automatically with DirectoryIndex: no extra rules needed
# Deny sensitive files
<FilesMatch "\.(py|yml|yaml|md|log|sh|env|conf|dockerfile)$">
@@ -1312,7 +1312,7 @@ Lahrcarpetcleaning.com is the reference implementation for both paths.
1. https://resend.com/domains → **Add Domain**
2. Enter the domain (the one you'll send FROM, not necessarily the website domain)
3. Resend gives 3-4 DNS records. Add them all in Cloudflare (or whatever DNS host)
4. Wait 5-15 minutes, click **Verify** in Resend all records must show green
4. Wait 5-15 minutes, click **Verify** in Resend: all records must show green
### Records Resend Provides
@@ -1324,7 +1324,7 @@ Lahrcarpetcleaning.com is the reference implementation for both paths.
(Resend uses Amazon SES under the hood, hence `amazonses.com` in the SPF.)
### DMARC REQUIRED for Inbox Placement
### DMARC: REQUIRED for Inbox Placement
Without DMARC, Gmail flags otherwise-correctly-configured email as suspicious
and routes it to spam. Resend doesn't auto-create this record. You must add it.
@@ -1334,9 +1334,9 @@ and routes it to spam. Resend doesn't auto-create this record. You must add it.
| TXT | `_dmarc` | `v=DMARC1; p=none; rua=mailto:dev@{domain}` | DNS only | Auto |
Components:
- `v=DMARC1` declares a DMARC policy exists
- `p=none` monitor mode, doesn't reject anything yet (safe to start)
- `rua=mailto:...` DMARC failure reports go to this inbox (review weekly)
- `v=DMARC1`: declares a DMARC policy exists
- `p=none`: monitor mode, doesn't reject anything yet (safe to start)
- `rua=mailto:...`: DMARC failure reports go to this inbox (review weekly)
After 30 days of clean DMARC reports with no false positives, optionally
upgrade to `p=quarantine` then `p=reject`.
@@ -1389,7 +1389,7 @@ Run this checklist:
### Cloudflare-Specific Notes
The user-agent quirk Cloudflare in front of Resend's API blocks Python's default
The user-agent quirk: Cloudflare in front of Resend's API blocks Python's default
`User-Agent: Python-urllib/3.x`. Always set a custom `User-Agent` in the API request headers.
If the DNS provider is Cloudflare, ensure all Resend records have **proxy status: DNS only**
@@ -1404,7 +1404,7 @@ Rotate Resend API keys annually:
4. Confirm a test submission still works
5. Revoke the old key in Resend dashboard
### Resend HTTP 403 Domain Not Verified
### Resend HTTP 403: Domain Not Verified
A 403 from the Resend API does NOT mean the API key is wrong. The specific
error is:
@@ -1435,7 +1435,7 @@ in Cloudflare, then click verify. Old key remains active until they remove it.
---
## Form Handling Resend
## Form Handling: Resend
Static sites can't send email by themselves. Every project that needs a
contact form gets a small Python service running in its own Docker container,
@@ -1565,11 +1565,11 @@ are deduplicated by Resend automatically. This prevents:
- API key in `.env` file, NOT in source control. `.gitignore` it.
- API key NEVER reaches the browser bundle (only the server has it)
- `.env` file lives in `api/`, NOT in the nginx web root
- Server-side validation on EVERY field never trust client
- Server-side validation on EVERY field: never trust client
- HTML-escape every field rendered into the email body to prevent injection
- Rate limit per IP (5 / 15 min default)
- 16 KB body cap reject anything larger
- 10-second upstream timeout don't hold connections open
- 16 KB body cap: reject anything larger
- 10-second upstream timeout: don't hold connections open
- CORS locked to the production domain only (`Access-Control-Allow-Origin: https://{domain}`)
- reCAPTCHA v3 with score threshold (default 0.5) once secret is configured
@@ -1650,7 +1650,7 @@ Use this pattern when a project requires server-side processing that static HTML
- **PHP 8.3** (php:8.3-fpm-alpine base image)
- **Nginx** (Alpine package, same container via supervisord)
- **SQLite** (pdo_sqlite extension, no separate DB container needed)
- **libsodium** (built into PHP 8.x use for all encryption)
- **libsodium** (built into PHP 8.x: use for all encryption)
- **ImageMagick** (pecl imagick for image processing)
- **msmtp** (SMTP relay for outbound email)
- **supervisord** (manages nginx + php-fpm + crond in one container)
@@ -1697,19 +1697,19 @@ project/
### Security Requirements (Non-Negotiable)
**CSRF** every POST form and API endpoint must verify a CSRF token tied to the session.
**CSRF**: every POST form and API endpoint must verify a CSRF token tied to the session.
**Rate limiting** two layers:
**Rate limiting**: two layers:
1. nginx: `limit_req_zone` on /api/ (10 req/s, burst 20)
2. PHP: per-IP daily counter in SQLite rate_limits table
**reCAPTCHA v3** on conversion/upload endpoints. Verify server-side via Google API. Cache result in session (verify once per session, not per request).
**reCAPTCHA v3**: on conversion/upload endpoints. Verify server-side via Google API. Cache result in session (verify once per session, not per request).
**At-rest encryption** any user-uploaded file must be encrypted before writing to disk. Use `sodium_crypto_secretstream_xchacha20poly1305_*` for files, `sodium_crypto_secretbox` for strings. Key stored in `.env` as `QC_ENCRYPTION_KEY` (32 bytes hex).
**At-rest encryption**: any user-uploaded file must be encrypted before writing to disk. Use `sodium_crypto_secretstream_xchacha20poly1305_*` for files, `sodium_crypto_secretbox` for strings. Key stored in `.env` as `QC_ENCRYPTION_KEY` (32 bytes hex).
**Signed download tokens** never expose file paths. Issue a 64-char hex token stored in SQLite with expiry and single-use enforcement.
**Signed download tokens**: never expose file paths. Issue a 64-char hex token stored in SQLite with expiry and single-use enforcement.
**Magic link auth** prefer magic link over password. On register: create account unverified, send verify email, block login until verified. Token: 64-char hex, 1-hour expiry, stored in `magic_tokens` table, consumed on use.
**Magic link auth**: prefer magic link over password. On register: create account unverified, send verify email, block login until verified. Token: 64-char hex, 1-hour expiry, stored in `magic_tokens` table, consumed on use.
### Nginx Security Headers
@@ -1720,7 +1720,7 @@ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; object-src 'none'; base-uri 'self'; form-action 'self' https://checkout.stripe.com;" always;
# Stripe webhook POST only
# Stripe webhook: POST only
location = /api/stripe-webhook.php {
limit_except POST { deny all; }
}
@@ -1741,7 +1741,7 @@ catch (Throwable $e) { /* column already exists */ }
### Stripe Integration
- Checkout: create session server-side, redirect to Stripe-hosted page
- Webhook: verify `Stripe-Signature` header using HMAC-SHA256 (implement without Stripe SDK use curl)
- Webhook: verify `Stripe-Signature` header using HMAC-SHA256 (implement without Stripe SDK: use curl)
- Webhook tolerance: 300 seconds (5 min) on timestamp
- Register webhook endpoint at: `https://{domain}/api/stripe-webhook.php`
- Events to subscribe: `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_succeeded`, `invoice.payment_failed`