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:
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user