Compare commits

...
4 Commits
23 changed files with 1076 additions and 31 deletions
+9 -4
View File
@@ -1,6 +1,11 @@
# retoor <retoor@molodetz.nl>
.venv
data
out
.venv/
data/
out/
.git/
__pycache__/
*.pyc
.env
.git
.pytest_cache/
.coverage*
*.egg-info/
+10 -1
View File
@@ -7,14 +7,23 @@ MOLODETZ_ADMIN_PASSWORD=
MOLODETZ_PORT=8088
MOLODETZ_TEST_PORT=8099
MOLODETZ_SITE_URL=
MOLODETZ_DATA_DIR=
MOLODETZ_DATA_DIR=./data/development
MOLODETZ_DATABASE_URL=sqlite:///./data/development/molodetz.db
MOLODETZ_INTERNAL_BASE_URL=http://localhost:8088
MOLODETZ_DISABLE_SERVICES=
MOLODETZ_DISABLE_RATE_LIMIT=
MOLODETZ_RATE_LIMIT=60
MOLODETZ_WEB_WORKERS=
MOLODETZ_TEMPLATE_AUTO_RELOAD=1
MOLODETZ_STATIC_VERSION=
MOLODETZ_LOG_LEVEL=INFO
MOLODETZ_PRESENCE_TIMEOUT_SECONDS=60
MOLODETZ_PRESENCE_ONLINE_LIMIT=30
MOLODETZ_PRESENCE_TRACK_LIMIT=500
MOLODETZ_PRESENCE_ONLINE_MARGIN_SECONDS=20
MOLODETZ_OUTBOUND_PROXY_URL=
MOLODETZ_RCLONE_BIN=rclone
MOLODETZ_RCLONE_CONFIG=/home/retoor/.config/rclone/rclone.conf
MOLODETZ_BACKUP_OFFLOAD_REMOTE=
MOLODETZ_SITEMAP_TTL=3600
MOLODETZ_LANDING_TTL=30
+6
View File
@@ -9,3 +9,9 @@ __pycache__/
.pytest_cache/
.coverage
.coverage.*
build/
dist/
temp_*
*.swp
*~
.DS_Store
+16
View File
@@ -0,0 +1,16 @@
# AGENTS.md
## Commands
- `make test` - run the full test suite
- `make dev` - run the development task
- `make prod` - start the project
## Code Map
- `molodetz` - application source
- `tests` - automated tests
## Conventions
- Use ES6 module syntax in JavaScript files.
+84
View File
@@ -0,0 +1,84 @@
<!-- retoor <retoor@molodetz.nl> -->
# Development flow
The complete blueprint for generating and running projects. This is the evolution of the frozen DPP template Grok originally followed (not present on this machine): everything below was learned by shipping Molodetz to production and must be applied at once, from the first commit, on every future project of any type.
## Storage law
All application data lives under `./data/<env>` inside the project. No exceptions, no second locations.
- `development` for local runs, `staging` and `production` as container file mounts, `test` for persistent test services.
- Ephemeral runs use dotted subdirectories that clean themselves: `./data/.tmp-pytest-<pid>`, `./data/.tmp-locust`, `./data/.tmp-shots-<pid>`.
- Each env dir holds the database file plus one subdirectory per kind (`uploads`, `backups`, `keys`, `locks`, `seo_reports`, ...). Database and files are separated by directory, never by system location.
- Enforcement beats convention: the application refuses to start when its data dir resolves outside `./data` or its sqlite file outside the data dir. Prove it with a maintained test that runs the real import in a subprocess against inside and outside paths.
- Named docker volumes are forbidden. Every mount is a file mount. Containers run as uid/gid 1000 so files stay human-owned; bind host directories must pre-exist with correct ownership because docker creates missing ones as root.
## Environments
Four environments, one shape each, fixed localhost ports (example offsets: staging 19847, production 19848, test 19849).
- Production owns the holy database. Nothing writes to it except production itself.
- Staging is refreshed from production snapshots only: online sqlite backup API while prod keeps running, integrity check, stop, swap, start, verify health. Never the reverse direction.
- Test is hermetic: persistent service data for the public test site, wiped or temp dirs for gallery and suite runs.
- Snapshots flow one way: production to staging. Test never touches production data.
## Docker law
One compose file per environment, each with its own top-level `name:` so projects can never steal each other's containers.
- Dev and test run the dev server (`uvicorn --reload`) with the package, the manifest (`pyproject.toml`), and `./data/<env>` mounted: every saved change is live within seconds.
- Production runs workers, no reload, no source mount except the data dir.
- Production secrets use dedicated `PROD_`-prefixed variables with `:?` guards, so the dev `.env` can never silently satisfy them. Missing secrets fail loudly at startup, never with defaults.
## Environment files law
`.env.example` lists every variable the code reads, no more, no less. A maintained contract test asserts the exact key set plus non-empty values for every path-like key, and fails on the old file before passing on the new one.
- Never leave a key empty when empty overrides a good default (paths, binaries, URLs): use safe literals (`./data/development`, `rclone`).
- The private `.env` is generated from the example with fresh random secrets, mode 600, never committed. The admin password applies at first bootstrap only and is reported to the owner once.
## Serving law (molohttp)
One front door on 80/443, all local reverse-proxy upstreams.
- Back up before every edit, one format only: `<file>.bak-<reason>-<YYYYMMDDHHMMSS>`, `root:root 640`, reason slug required, never deleted.
- Assert unique site ids AND unique hostnames before appending: on reload molohttp keeps the first site per hostname and silently drops later duplicates. Re-read the file after reload and assert the new site survived.
- Change order: backup, edit, restore `molohttp:molohttp 640` ownership, validate JSON, reload, verify the site over HTTPS. Rollback is the same steps with the previous values; the old backend stays running until rollback is no longer wanted.
- The admin surface is never exposed publicly. Local CLI only.
## Gallery law
Every environment gets a screenshot gallery, generated the same way every time.
- Boot mode builds a throwaway server on a fresh data dir and seeds one fixed record; base-url mode shoots a live environment and never writes seed data.
- Full-page desktop shots plus a responsive matrix (down to 320px) with a horizontal-overflow check per cell; overflows are reported and exit nonzero.
- One gallery per environment plus a hub page that rebuilds from whichever environments exist. Same script, same selectors, same waits for all.
## Test law
Mirror layout (`tests/unit`, `tests/api`, `tests/e2e`), full suite green before done.
- Browser tests wait up to 60 seconds per step and retry a stalled page load once through one shared helper. Filter only proven environmental console noise, never application errors.
- Static gates run on every change: imports, linter, syntax, schema lint, UI invariants. Text gates (for example the em-dash ban) apply to docs too.
- Every behavior change ships the smallest maintained test exercising it, proven red before the fix and green after. Never weaken a failing test to get green.
## Git law
Stage named files only, never broad-add a dirty tree. Commit and push only on explicit ask, never as a side effect of finishing.
- `.gitignore` contains exactly: `.venv/`, `__pycache__/`, `*.pyc`, `.pytest_cache/`, `.coverage*`, `data/`, `out/`, `.env`, `*.egg-info/`, `build/`, `dist/`, `temp_*`, `*.swp`, `*~`, `.DS_Store`. Never ignore source, tests, docs, compose files, Dockerfiles, `.env.example`, Makefiles, or CI configs.
- Any scratch, probe, or temporary file must be prefixed `temp_` and is never committed. `temp_*` in `.gitignore` enforces it.
- Markdown files are never committed without explicit user confirmation. Docs are reviewed prose, not byproducts.
- A pre-commit hook owns the patch version bump; nothing else edits the version.
## Code law
Explicit over implicit, flat over nested, sparse over dense, readable above all. No comments anywhere except the author line `retoor <retoor@molodetz.nl>` at the top of every file; code that needs a comment is rewritten until it does not. Defensive everywhere, errors never pass silently, no invented names with suffixes. JavaScript is ES6 modules, one class per file, one `Application` instantiated as `app` on `window`. Storage is dataset plus SQLite. The frontend is served through the backend. No emojis, no hype language, professional and scientific.
## Docs law
`README.md` is the living entry point (running, testing, docker, screenshots, data, operations) and stays true to the repo. Each deployment gets an operator guide with access facts, file owners, backup specification, and toggle procedures. Document only inspected facts, mark dated observations, and record anomalies instead of hiding them.
## Handover law
Secrets are generated with a cryptographic RNG, stored only in their target (`.env` 600, container env), and reported to the owner once in chat, never written to the repo, docs, or logs beyond that handoff.
+9
View File
@@ -0,0 +1,9 @@
# retoor <retoor@molodetz.nl>
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY pyproject.toml README.md ./
COPY molodetz ./molodetz
RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -e .
EXPOSE 8088
CMD ["python", "-m", "uvicorn", "molodetz.main:app", "--host", "0.0.0.0", "--port", "8088", "--reload", "--reload-dir", "molodetz"]
+22 -2
View File
@@ -10,12 +10,18 @@ HOST ?= 127.0.0.1
PORT ?= 8088
WORKERS ?= 2
LOCUST_PORT ?= 8097
LOCUST_DATA := /tmp/molodetz-locust-data
LOCUST_DATA := $(CURDIR)/data/.tmp-locust
SCREENSHOTS_DIR ?= $(CURDIR)/../static/pizdetz
SCREENSHOTS_PORT ?= 8094
SCREENSHOTS_STAGING_URL ?= https://staging.app.molodetz.nl
SCREENSHOTS_PRODUCTION_URL ?= https://molodetz.nl
STAGING_ADMIN_PASSWORD ?= staging-admin-pass-1
PYTEST := $(PY) -m pytest
.PHONY: install dev prod test test-headed test-unit test-api test-e2e test-fast test-failed \
test-first-failure test-slowest test-cache-clean check-ui preflight coverage coverage-headed \
coverage-html locust locust-headless prune prune-dry-run tree tree-loc zip delete-pyc clean
coverage-html locust locust-headless screenshots screenshots-staging screenshots-production \
staging-refresh prune prune-dry-run tree tree-loc zip delete-pyc clean
$(STAMP): pyproject.toml
python3 -m venv .venv
@@ -103,6 +109,20 @@ locust-run:
-for i in $$(seq 1 40); do kill -0 $$(cat $(LOCUST_DATA)/server.pid) 2>/dev/null || break; sleep 0.25; done
rm -rf $(LOCUST_DATA)
screenshots: $(STAMP)
SCREENSHOTS_DIR=$(SCREENSHOTS_DIR) SCREENSHOTS_PORT=$(SCREENSHOTS_PORT) SCREENSHOTS_ENV=test $(PY) screenshots.py
screenshots-staging: $(STAMP)
SCREENSHOTS_DIR=$(SCREENSHOTS_DIR) SCREENSHOTS_ENV=staging SCREENSHOTS_BASE_URL=$(SCREENSHOTS_STAGING_URL) \
SCREENSHOTS_ADMIN_PASSWORD=$(STAGING_ADMIN_PASSWORD) $(PY) screenshots.py
screenshots-production: $(STAMP)
SCREENSHOTS_DIR=$(SCREENSHOTS_DIR) SCREENSHOTS_ENV=production SCREENSHOTS_BASE_URL=$(SCREENSHOTS_PRODUCTION_URL) \
SCREENSHOTS_ADMIN_PASSWORD=$(SCREENSHOTS_ADMIN_PASSWORD) $(PY) screenshots.py
staging-refresh:
bin/staging-refresh.sh $(SNAPSHOT)
prune: $(STAMP)
bin/maintenance.sh
+49 -1
View File
@@ -14,6 +14,8 @@ make dev # http://127.0.0.1:8088, reload
make prod # workers, pinned static version
```
`make install` needs a `python3` with the `venv` module (Debian/Ubuntu ship it as `python3-venv`).
Without make:
```
@@ -33,10 +35,56 @@ make coverage
make locust-headless
```
Tests boot their own server on port 8099; set `MOLODETZ_TEST_PORT` to a free port when 8099 is taken. Browser tests wait up to 60 seconds per step and retry a stalled page load once via `goto_ready` in `tests/conftest.py`.
## Docker
```
docker compose up -d --build # staging dev server on http://127.0.0.1:19847, reload on change
docker compose -f docker-compose.test.yml up -d # public test server on http://127.0.0.1:19849
PROD_SECRET_KEY=... PROD_ADMIN_PASSWORD=... docker compose -f docker-compose.prod.yml up -d --build # production on :19848
docker compose logs -f # follow logs
docker compose down # stop
```
Each compose file is its own project (`molodetz-dev`, `molodetz-test`, `molodetz-prod`) with its own `./data/<env>` file mount, so the three never interfere. Production secrets come only from `PROD_SECRET_KEY` / `PROD_ADMIN_PASSWORD` and refuse to boot without them; `staging.app.molodetz.nl`, `molodetz-test.app.molodetz.nl`, and `molodetz.nl` proxy to `:19847`, `:19849`, and `:19848` (see `molohttp_deploy.md`).
The container runs the dev server (`uvicorn --reload`) with `./molodetz` mounted, so every saved change is live within seconds. Data lives in the file mount `./data/staging` (plain files, no named volumes anywhere). The admin account comes from `MOLODETZ_ADMIN_USERNAME` / `MOLODETZ_ADMIN_EMAIL` / `MOLODETZ_ADMIN_PASSWORD` (default password `staging-admin-pass-1`, override per shell); an empty password skips admin bootstrap. The compose port is fixed at `127.0.0.1:19847` and is what `staging.app.molodetz.nl` proxies to, so keep it stable.
```
make staging-refresh # copy the holy prod database over staging, then verify
```
Refresh snapshots `./data/production` (online backup API, production keeps running), integrity-checks the copy, stops staging, swaps the staging file mount content, restarts, and verifies health. After a refresh the staging admin password is the production one, so pass it to `make screenshots-staging STAGING_ADMIN_PASSWORD=...`.
## Screenshots
```
make screenshots # test gallery into ../static/pizdetz/test
make screenshots-staging # staging gallery into ../static/pizdetz/staging
make screenshots-production SCREENSHOTS_ADMIN_PASSWORD=... # production gallery into ../static/pizdetz/production
```
Each run captures 32 full-page desktop shots (public, docs, admin) plus a 25-cell responsive matrix (home, roll, flyers, join, docs at 1440x900, 768x1024, 390x844, 360x740, 320x568) with a horizontal-overflow check per cell, then rebuilds the hub page at `../static/pizdetz/index.html` from whichever environments exist on disk.
Rules, exactly: `test` always wipes `./data/test`, boots its own server there, and seeds one join request; `staging` and `production` shoot a live base URL and never seed. Staging logs in with `STAGING_ADMIN_PASSWORD` (default `staging-admin-pass-1`, must match the compose admin password); production has no default and fails loudly without `SCREENSHOTS_ADMIN_PASSWORD`. Any overflow is reported and exits nonzero. Override the output root with `SCREENSHOTS_DIR=...`.
## CLI
`molodetz --help`: roles, API keys, posts list/import (.md and .pdf via pypdf), join requests, backups, prunes, sql-lint, emoji regenerate.
## Data
Everything lives under `data/` (database `molodetz.db`, uploads, backups, keys, locks). `.env` and `data/` are in `.gitignore`.
All application data lives under `./data/<env>`, enforced in code: `molodetz/config.py` refuses to start with a `MOLODETZ_DATA_DIR` outside `./data` or a sqlite file outside the data dir.
- `development` - local `make dev` runs (see `.env`)
- `staging` - docker dev server on `:19847` (file mount)
- `production` - holy production database (file mount, docker prod on `:19848`)
- `test` - screenshot runs, wiped at the start of every run
- `.tmp-pytest-<pid>`, `.tmp-locust` - single test runs, removed afterwards
Each env dir holds `molodetz.db` (plus WAL files while running) and one subdirectory per kind: `uploads` (blobs, attachments), `backups`, `keys`, `locks`, `seo_reports`. The whole tree is git-ignored. Nothing else on the system holds application data; `molodetz/static/js/generated/EmojiMap.js` is a vendored build artifact the app only rewrites if missing.
## Operations
Production serving runs through molohttp on this box. `molohttp_deploy.md` is the operator guide: access, file owners, the backup specification, site changes, and static publishing.
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# retoor <retoor@molodetz.nl>
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SRC="${1:-$ROOT/data/production}"
DEST="$ROOT/data/staging"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
test -f "$SRC/molodetz.db" || { echo "no database at $SRC/molodetz.db"; exit 1; }
test -d "$DEST" || { echo "no staging data dir at $DEST"; exit 1; }
python3 -c "import sqlite3; s = sqlite3.connect('$SRC/molodetz.db'); d = sqlite3.connect('$WORK/molodetz.db'); s.backup(d); s.close(); d.close()"
for sub in uploads keys seo_reports; do
if [ -d "$SRC/$sub" ]; then cp -a "$SRC/$sub" "$WORK/"; fi
done
CHECK="$(python3 -c "import sqlite3; c = sqlite3.connect('file:$WORK/molodetz.db?mode=ro', uri=True); print(c.execute('pragma integrity_check').fetchone()[0])")"
test "$CHECK" = "ok" || { echo "snapshot integrity check failed: $CHECK"; exit 1; }
cd "$ROOT"
docker compose stop web
rm -rf "$DEST/molodetz.db-wal" "$DEST/molodetz.db-shm" "$DEST/molodetz.db" "$DEST/uploads" "$DEST/keys" "$DEST/seo_reports"
cp -a "$WORK/." "$DEST/"
docker compose up -d web
for i in $(seq 1 60); do curl -fs http://127.0.0.1:19847/health >/dev/null && break; sleep 1; done
curl -fs http://127.0.0.1:19847/health
echo "staging refreshed from $SRC"
+22
View File
@@ -0,0 +1,22 @@
# retoor <retoor@molodetz.nl>
name: molodetz-prod
services:
web:
build: .
image: molodetz-prod
container_name: molodetz-prod
restart: unless-stopped
user: "1000:1000"
ports:
- "127.0.0.1:19848:8088"
volumes:
- ./data/production:/app/data
environment:
MOLODETZ_DATA_DIR: /app/data
MOLODETZ_ADMIN_USERNAME: ${MOLODETZ_ADMIN_USERNAME:-retoor}
MOLODETZ_ADMIN_EMAIL: ${MOLODETZ_ADMIN_EMAIL:-retoor@molodetz.nl}
MOLODETZ_ADMIN_PASSWORD: ${PROD_ADMIN_PASSWORD:?set PROD_ADMIN_PASSWORD for production}
SECRET_KEY: ${PROD_SECRET_KEY:?set PROD_SECRET_KEY for production}
MOLODETZ_STATIC_VERSION: ${MOLODETZ_STATIC_VERSION:-prod}
MOLODETZ_TEMPLATE_AUTO_RELOAD: "0"
command: ["python", "-m", "uvicorn", "molodetz.main:app", "--host", "0.0.0.0", "--port", "8088", "--workers", "2", "--proxy-headers", "--forwarded-allow-ips", "*"]
+22
View File
@@ -0,0 +1,22 @@
# retoor <retoor@molodetz.nl>
name: molodetz-test
services:
web:
build: .
image: molodetz-dev
container_name: molodetz-test
restart: unless-stopped
user: "1000:1000"
ports:
- "127.0.0.1:19849:8088"
volumes:
- ./molodetz:/app/molodetz
- ./pyproject.toml:/app/pyproject.toml
- ./data/test:/app/data
environment:
MOLODETZ_DATA_DIR: /app/data
MOLODETZ_ADMIN_USERNAME: ${MOLODETZ_ADMIN_USERNAME:-retoor}
MOLODETZ_ADMIN_EMAIL: ${MOLODETZ_ADMIN_EMAIL:-retoor@molodetz.nl}
MOLODETZ_ADMIN_PASSWORD: ${TEST_ADMIN_PASSWORD:-test-admin-pass-1}
SECRET_KEY: ${SECRET_KEY:-molodetz-docker-dev-secret}
MOLODETZ_STATIC_VERSION: ${MOLODETZ_STATIC_VERSION:-test}
+22
View File
@@ -0,0 +1,22 @@
# retoor <retoor@molodetz.nl>
name: molodetz-dev
services:
web:
build: .
image: molodetz-dev
container_name: molodetz-dev
restart: unless-stopped
user: "1000:1000"
ports:
- "127.0.0.1:19847:8088"
volumes:
- ./molodetz:/app/molodetz
- ./pyproject.toml:/app/pyproject.toml
- ./data/staging:/app/data
environment:
MOLODETZ_DATA_DIR: /app/data
MOLODETZ_ADMIN_USERNAME: ${MOLODETZ_ADMIN_USERNAME:-retoor}
MOLODETZ_ADMIN_EMAIL: ${MOLODETZ_ADMIN_EMAIL:-retoor@molodetz.nl}
MOLODETZ_ADMIN_PASSWORD: ${MOLODETZ_ADMIN_PASSWORD:-staging-admin-pass-1}
SECRET_KEY: ${SECRET_KEY:-molodetz-docker-dev-secret}
MOLODETZ_STATIC_VERSION: ${MOLODETZ_STATIC_VERSION:-docker}
+9
View File
@@ -32,6 +32,15 @@ def _env_int(name, default):
DATA_DIR = Path(_env("DATA_DIR", str(ROOT / "data"))).resolve()
DATABASE_URL = _env("DATABASE_URL", f"sqlite:///{DATA_DIR / 'molodetz.db'}")
DATA_ROOT = ROOT / "data"
if DATA_DIR != DATA_ROOT and DATA_ROOT not in DATA_DIR.parents:
raise RuntimeError(f"refusing MOLODETZ_DATA_DIR outside {DATA_ROOT}: {DATA_DIR}")
if DATABASE_URL.startswith("sqlite:///"):
DB_PATH = Path(DATABASE_URL.removeprefix("sqlite:///"))
if not DB_PATH.is_absolute():
DB_PATH = Path.cwd() / DB_PATH
if DATA_DIR not in DB_PATH.resolve().parents:
raise RuntimeError(f"refusing sqlite database file outside {DATA_DIR}: {DB_PATH}")
PORT = _env_int("PORT", APP_PORT)
TEST_PORT = _env_int("TEST_PORT", DEFAULT_TEST_PORT)
SECRET_KEY = os.environ.get("SECRET_KEY", "molodetz-development-secret-change-in-production")
+153
View File
@@ -0,0 +1,153 @@
<!-- retoor <retoor@molodetz.nl> -->
# molohttp deploy and operations
Production server `molodetz`. molohttp 0.9.1. Facts below were read from the live system on 2026-10-05. This is a production box: follow the backup specification before every change.
## Access
Operator account is `retoor`. Root login is never needed.
- `retoor` is in the `sudo` group with `(ALL) NOPASSWD: ALL` (verified with `sudo -n true` and `sudo -n -l`).
- Every privileged command in this guide runs under `sudo` and also works non-interactively in scripts.
- `/etc/molohttp` is `molohttp:molohttp 750`: reads and edits both require `sudo`.
## How it works
molohttp is the single front door on ports 80 and 443. One systemd unit runs one process as user `molohttp` (24+ days uptime at time of writing).
- Binary: `/usr/local/bin/molohttp` (`root:root 755`).
- Unit: `/etc/systemd/system/molohttp.service` (`root:root 644`), enabled and active.
- Start: `molohttp run --config /etc/molohttp/config.json --env /etc/molohttp/.env.json`, working directory `/etc/molohttp`.
- Reload: `ExecReload=/bin/kill -HUP $MAINPID`, so `sudo systemctl reload molohttp` applies config without dropping connections. There is no config-validate subcommand; validate JSON by hand (see change procedure).
- Privileged ports are bound via `CAP_NET_BIND_SERVICE`; the process itself is unprivileged with `ProtectSystem=strict` and `ProtectHome=true`.
- `config.json` holds 95 sites, all `enabled`, all `reverse_proxy` to `http://127.0.0.1:<port>` with one exception: a single upstream on `http://pravda.education:10500`. One site carries a basic-auth middleware. Molodetz owns three: `site-022` `molodetz.nl` (production, see Cutover), `site-094` `staging.app.molodetz.nl` (see Staging), `site-095` `molodetz-test.app.molodetz.nl` (see Test).
- Hostnames must be unique across sites. On reload molohttp keeps the first site per hostname and silently drops later duplicates, persisting the result. Always assert the hostname is free before appending (observed 2026-10-05: a duplicate `test.app.molodetz.nl` entry vanished on reload because `site-085` already claimed it).
- TLS: minimum TLS 1.2, HSTS one year with subdomains. Certificates live per hostname as `/etc/molohttp/certs/<host>.pem` plus `<host>.key`.
- ACME: production Let's Encrypt, account `retoor@molodetz.nl`, renewal check every 12 hours starting 30 days before expiry, state in `/var/lib/molohttp/acme`.
- Logging: JSON to stdout, collected by journald (`SyslogIdentifier=molohttp`). The `access_log_path`/`error_log_path` settings are not materialized as live files.
- Admin: the admin UI is deliberately not exposed. `admin.molodetz.nl` is not hosted, routed, or resolvable: no site entry, no DNS record. Exposing it would be a security flaw. The only admin path is the local CLI: `molohttp admin user-list`, `molohttp admin hash`. `molohttp install`/`uninstall` must run as root and are out of scope for routine work.
## Files and owners
Canonical state. Verify with `sudo find /etc/molohttp /var/lib/molohttp /var/log/molohttp -maxdepth 2 -exec stat -c '%U:%G %a %n' {} +`.
| Path | Owner | Mode | Notes |
|---|---|---|---|
| `/etc/molohttp` | `molohttp:molohttp` | 750 | config root, sudo to enter |
| `/etc/molohttp/config.json` | `molohttp:molohttp` | 640 | site table, exact path read |
| `/etc/molohttp/.env.json` | `molohttp:molohttp` | 640 | global settings, holds password hash |
| `/etc/molohttp/certs` | `molohttp:molohttp` | 700 | per-host pairs |
| `/etc/molohttp/certs/*.key` | `molohttp:molohttp` | 600 | private keys |
| `/etc/molohttp/certs/*.pem` | `molohttp:molohttp` | 644 | certificates |
| `/etc/molohttp/*.bak-*` | `root:root` | 640 | operator backups, this spec only |
| `/var/lib/molohttp` | `molohttp:molohttp` | 750 | state root |
| `/var/lib/molohttp/acme` | `molohttp:molohttp` | 750 | live ACME state |
| `/var/log/molohttp` | `molohttp:molohttp` | 750 | empty dir, daemon logs to journal |
| `/var/www/.well-known` | `molohttp:molohttp` | 750 | ACME challenges |
| `/etc/systemd/system/molohttp.service` | `root:root` | 644 | unit file |
| `/usr/local/bin/molohttp` | `root:root` | 755 | binary |
Known anomalies, observed 2026-10-05, left untouched pending a decision:
- Six key pairs under `certs/` are owned `caddy:caddy` (the `pravda.education` hosts plus `mail.molodetz.nl`). The `.key` files at mode 600 are unreadable by the `molohttp` user; confirm those hosts serve TLS correctly, then normalize with `sudo chown molohttp:molohttp` on the pair.
- `/var/lib/molohttp/analytics` plus `/var/lib/molohttp/stats` (`caddy:caddy`, stale since Jul 27) are leftovers from a previous server. Nothing reads them. (A stale 2.5 GB `caddy`-owned `access.log` in `/var/log/molohttp` was already removed on 2026-10-05 after confirming nothing held it open.)
- Older backups in `/etc/molohttp` use four different naming schemes and mixed owners. They are harmless history; all new backups follow the one format below.
## Backup specification
Always back up before editing. One format, no variants:
`<file>.bak-<reason>-<YYYYMMDDHHMMSS>`, owned `root:root`, mode 640, next to the original. Siblings are safe: the daemon reads `config.json` and `.env.json` by exact path only, never by glob.
```
FILE=/etc/molohttp/config.json
REASON=site-add-example
STAMP=$(date +%Y%m%d%H%M%S)
sudo cp -p "$FILE" "$FILE.bak-$REASON-$STAMP"
sudo chown root:root "$FILE.bak-$REASON-$STAMP"
sudo chmod 640 "$FILE.bak-$REASON-$STAMP"
ls -la "$FILE"*
```
Rules: reason is a required lowercase slug (`site-add-<host>`, `site-remove-<host>`, `upstream-<host>`, `tls-<host>`, `env-<what>`). Never delete old backups; at about 52 KB each they are the audit trail. Never invent a second format.
## Change procedure
1. Back up per the specification above.
2. Edit with `sudo` (for example `sudo -e /etc/molohttp/config.json`).
3. Restore ownership, since root-written files lock the daemon out: `sudo chown molohttp:molohttp /etc/molohttp/config.json`, `sudo chmod 640 /etc/molohttp/config.json`.
4. Validate: `sudo -n cat /etc/molohttp/config.json | python3 -m json.tool > /dev/null`.
5. Apply: `sudo systemctl reload molohttp`.
6. Verify: `systemctl is-active molohttp`, `curl -s -o /dev/null -w "%{http_code}\n" https://<host>/`, and `sudo journalctl -u molohttp --no-pager -n 20`.
7. Roll back on any failure: `sudo cp -p <backup> /etc/molohttp/config.json`, `sudo chown molohttp:molohttp /etc/molohttp/config.json`, `sudo chmod 640 /etc/molohttp/config.json`, then reload again and re-verify.
Use `restart` instead of `reload` only when the process itself must be replaced. Never `stop` without `start` on this box.
## Add or remove a site
Append one object to the `sites` array. Keep `id` unique (`site-094`, `site-095`, ...); `name` equals the primary hostname.
```
{
"id": "site-094",
"name": "new.molodetz.nl",
"enabled": true,
"matchers": {"hosts": ["new.molodetz.nl"], "headers": {}, "paths": []},
"handler": {
"type": "reverse_proxy",
"upstreams": [{"url": "http://127.0.0.1:8XXX", "weight": 1}],
"load_balancing": "round_robin",
"websocket": false,
"connection_timeout_ms": 5000,
"read_timeout_ms": 30000
}
}
```
Pick a free localhost port for the upstream and keep the app itself bound to 127.0.0.1. Certificates are provisioned automatically through ACME within seconds of the first HTTPS traffic; verify with `curl` and `openssl s_client -connect <host>:443 -servername <host>`, and check the journal on any TLS error. Per-host files under `certs/` may materialize later on molohttp's own schedule (observed with staging: valid certificate served immediately, files not yet present), so their absence right after creation is not a failure. Removal is the reverse: back up, delete the object, validate, reload, verify the hostname no longer routes.
## Staging
`staging.app.molodetz.nl` (`site-094`) proxies to `http://127.0.0.1:19847`, the Molodetz docker dev server from this repo (see Docker in `README.md`). It carries a normal Let's Encrypt certificate. Every saved change under `./molodetz` is live on staging within seconds through uvicorn reload; app edits never need a molohttp change. Staging data is the file mount `./data/staging`, refreshed from holy production (`./data/production`) with `make staging-refresh` (see `bin/staging-refresh.sh`). All environments live under `./data/<env>`; named volumes are never used.
## Test
`molodetz-test.app.molodetz.nl` (`site-095`) proxies to `http://127.0.0.1:19849`, the Molodetz docker test server (`docker-compose.test.yml`, dev server with reload, data in `./data/test`, default admin password `test-admin-pass-1`). `test.app.molodetz.nl` belongs to another app (`site-085`) and must not be touched.
## Production cutover
`molodetz.nl` (`site-022`) was cut over from molodev (`127.0.0.1:8084`) to the Molodetz prod container (`127.0.0.1:19848`) on 2026-10-05, backup `config.json.bak-site-cutover-prod-20261005145603`. Production data is holy in `./data/production`; the container runs `docker-compose.prod.yml` (2 workers, no reload) with secrets from `PROD_SECRET_KEY` and `PROD_ADMIN_PASSWORD`, which have no defaults and fail loudly when unset.
Toggle back to the old app (molodev is still running on `:8084` for exactly this): back up per the specification with reason `site-cutover-rollback`, set `site-022` upstreams back to `[{"url": "http://127.0.0.1:8084", "weight": 1}]`, restore ownership, validate JSON, reload, verify `https://molodetz.nl/` answers the old site. Toggle forward again with the same steps in reverse (`:19848`).
## Static publishing
`static.molodetz.nl` proxies to `http://127.0.0.1:8117`, served by `rserver` running as `retoor` with working directory `/home/retoor/projects/static`. Publishing static content needs no molohttp change: write files under that directory and they are live (verified end to end with `/pizdetz/`, which redirects to `/pizdetz/index.html`). The Molodetz screenshot gallery is produced this way; see the Screenshots section in `README.md`.
## Ownership ensure
Run after any manual intervention to restore the canonical state. It does not touch the documented anomalies.
```
sudo chown molohttp:molohttp /etc/molohttp /etc/molohttp/config.json /etc/molohttp/.env.json
sudo chmod 750 /etc/molohttp
sudo chmod 640 /etc/molohttp/config.json /etc/molohttp/.env.json
sudo chown molohttp:molohttp /etc/molohttp/certs /var/lib/molohttp /var/log/molohttp /var/www/.well-known
sudo chmod 700 /etc/molohttp/certs
sudo chmod 750 /var/lib/molohttp /var/log/molohttp /var/www/.well-known
sudo find /etc/molohttp/certs -name '*.key' -user molohttp -exec chmod 600 {} +
sudo find /etc/molohttp/certs -name '*.pem' -user molohttp -exec chmod 644 {} +
sudo find /etc/molohttp -maxdepth 1 -name '*.bak-*' -exec chown root:root {} + -exec chmod 640 {} +
```
## Quick commands
| Task | Command |
|---|---|
| Status | `systemctl is-active molohttp`, `molohttp service-status` |
| Logs | `sudo journalctl -u molohttp -f`, `molohttp service-logs` |
| Reload config | `sudo systemctl reload molohttp` |
| Restart | `sudo systemctl restart molohttp` |
| Site table | `sudo -n cat /etc/molohttp/config.json \| python3 -m json.tool \| less` |
| Port owners | `sudo -n ss -tlnp` |
| Version | `molohttp --version` |
+1 -1
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "molodetz"
version = "1.0.9"
version = "1.0.13"
description = "Molodetz, a calm community blog roll."
readme = "README.md"
requires-python = ">=3.12"
+477
View File
@@ -0,0 +1,477 @@
# retoor <retoor@molodetz.nl>
import html
import os
import shutil
import subprocess
import sys
import time
import tomllib
from datetime import datetime, timezone
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent
SHOTS_DATA_DIR = REPO_ROOT / "data" / f".tmp-shots-{os.getpid()}"
OUTPUT_DIR = Path(os.environ.get("SCREENSHOTS_DIR", str(REPO_ROOT.parent / "static" / "pizdetz")))
ENV = os.environ.get("SCREENSHOTS_ENV", "test")
BASE_URL = os.environ.get("SCREENSHOTS_BASE_URL", "").rstrip("/")
PORT = os.environ.get("SCREENSHOTS_PORT", "8094")
LOCAL_BASE = f"http://127.0.0.1:{PORT}"
ADMIN_USERNAME = os.environ.get("SCREENSHOTS_ADMIN_USERNAME", "retoor")
ADMIN_PASSWORD = os.environ.get("SCREENSHOTS_ADMIN_PASSWORD", "")
BOOT_ADMIN_PASSWORD = "shots-admin-pass-9d3c"
FULL_DESKTOP = {"width": 1440, "height": 900}
KNOWN_ENVS = ["test", "staging", "production"]
PUBLIC_SHOTS = [
("home", "/", "Home"),
("roll", "/roll", "Roll"),
("standard", "/standard", "Standard"),
("flyers", "/flyers", "Flyers"),
("memes", "/memes", "Memes"),
("people", "/people", "People"),
("person", "/people/retoor", "Person"),
("join", "/join", "Join"),
("terms", "/terms", "Terms"),
("privacy", "/privacy", "Privacy"),
("login", "/auth/login", "Log in"),
]
DOCS_SHOTS = [
("docs", "/docs", "Docs"),
("docs-welcome", "/docs/welcome", "Docs welcome"),
("docs-api", "/docs/api", "Docs API index"),
("docs-api-content", "/docs/api/content", "Docs API content"),
("docs-component-code", "/docs/component-code", "Docs component code"),
]
ADMIN_SHOTS = [
("admin", "/admin", "Admin"),
("admin-posts", "/admin/posts", "Admin posts"),
("admin-post-new", "/admin/posts/new", "Admin editor"),
("admin-joins", "/admin/joins", "Admin joins"),
("admin-users", "/admin/users", "Admin users"),
("admin-settings", "/admin/settings", "Admin settings"),
("admin-services", "/admin/services", "Admin services"),
("admin-services-backup", "/admin/services/backup", "Admin service backup"),
("admin-backups", "/admin/backups", "Admin backups"),
("admin-audit", "/admin/audit", "Admin audit"),
("admin-stats", "/admin/stats", "Admin stats"),
("admin-trash", "/admin/trash", "Admin trash"),
("notifications", "/notifications", "Notifications"),
("profile-api-key", "/profile/api-key", "Profile API key"),
("docs-api-admin", "/docs/api/admin", "Docs API admin"),
]
RESPONSIVE_VIEWPORTS = [
("1440x900", {"width": 1440, "height": 900}),
("768x1024", {"width": 768, "height": 1024}),
("390x844", {"width": 390, "height": 844}),
("360x740", {"width": 360, "height": 740}),
("320x568", {"width": 320, "height": 568}),
]
RESPONSIVE_PAGES = [
("home", "/", "Home"),
("roll", "/roll", "Roll"),
("flyers", "/flyers", "Flyers"),
("join", "/join", "Join"),
("docs", "/docs", "Docs"),
]
def server_env(data_dir):
return {
"MOLODETZ_DATA_DIR": str(data_dir),
"MOLODETZ_DATABASE_URL": f"sqlite:///{data_dir / 'molodetz.db'}",
"MOLODETZ_ADMIN_USERNAME": ADMIN_USERNAME,
"MOLODETZ_ADMIN_EMAIL": "retoor@molodetz.nl",
"MOLODETZ_ADMIN_PASSWORD": BOOT_ADMIN_PASSWORD,
"MOLODETZ_DISABLE_SERVICES": "1",
"MOLODETZ_DISABLE_RATE_LIMIT": "1",
"MOLODETZ_PORT": PORT,
"MOLODETZ_INTERNAL_BASE_URL": LOCAL_BASE,
"MOLODETZ_SITEMAP_TTL": "0",
"MOLODETZ_LANDING_TTL": "0",
"MOLODETZ_UNREAD_TTL": "0",
"MOLODETZ_STATIC_VERSION": "shots",
"SECRET_KEY": "shots-secret-key-not-for-production",
}
def wait_ready(process, base):
import httpx
deadline = time.monotonic() + 60.0
while time.monotonic() < deadline:
if process.poll() is not None:
raise RuntimeError("shots server exited during startup")
try:
if httpx.get(f"{base}/health", timeout=1.0).status_code == 200:
return
except httpx.HTTPError:
pass
time.sleep(0.2)
raise RuntimeError("shots server did not become ready")
def start_server(data_dir):
log = open(data_dir / "server.log", "w")
process = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "molodetz.main:app", "--host", "127.0.0.1", "--port", PORT],
cwd=REPO_ROOT,
env={**os.environ, **server_env(data_dir)},
stdout=log,
stderr=subprocess.STDOUT,
)
try:
wait_ready(process, LOCAL_BASE)
except Exception:
process.terminate()
log.close()
raise
return process, log
def stop_server(process, log):
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
log.close()
def goto_ready(page, url):
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
page.goto(url, wait_until="domcontentloaded")
try:
page.wait_for_selector("html[data-app=ready]")
except PlaywrightTimeoutError:
page.reload(wait_until="domcontentloaded")
page.wait_for_selector("html[data-app=ready]")
def login_admin(page, base, password):
page.goto(base + "/auth/login", wait_until="domcontentloaded")
page.fill("input[name=username]", ADMIN_USERNAME)
page.fill("input[name=password]", password)
page.click("button[type=submit]")
page.wait_for_url("**/admin", wait_until="domcontentloaded")
def seed_join(base):
import httpx
response = httpx.post(
base + "/join",
data={
"name": "Gallery Writer",
"contact": "gallery@example.invalid",
"repo_url": "https://example.com/gallery",
"message": "I want to write along.",
"website": "",
},
timeout=15.0,
)
if response.status_code not in (302, 303):
raise RuntimeError(f"seed join failed: {response.status_code}")
def first_post_slug(base):
import httpx
response = httpx.get(base + "/roll", headers={"Accept": "application/json"}, timeout=15.0)
posts = response.json().get("posts", [])
if not posts:
raise RuntimeError("roll has no posts")
return posts[0]["slug"]
def capture(page, out_dir, base, counter, name, path, title, section, viewport, full_page):
goto_ready(page, base + path)
filename = f"{counter:02d}-{name}-{viewport}.png"
page.screenshot(path=str(out_dir / filename), full_page=full_page)
return {"section": section, "title": title, "path": path, "viewport": viewport, "file": filename}
def check_overflow(page, path, viewport):
width, inner = page.evaluate("[document.documentElement.scrollWidth, window.innerWidth]")
if width > inner:
return f"{path} at {viewport}: scroll {width} wider than viewport {inner}"
return ""
def capture_full(browser, out_dir, base, password, records, counter):
anon = browser.new_context(viewport=FULL_DESKTOP)
page = anon.new_page()
page.set_default_timeout(60000)
for name, path, title in PUBLIC_SHOTS:
records.append(capture(page, out_dir, base, counter, name, path, title, "Public", "full", True))
counter += 1
slug = first_post_slug(base)
records.append(capture(page, out_dir, base, counter, "post", f"/posts/{slug}", "Post", "Public", "full", True))
counter += 1
for name, path, title in DOCS_SHOTS:
records.append(capture(page, out_dir, base, counter, name, path, title, "Docs", "full", True))
counter += 1
anon.close()
admin = browser.new_context(viewport=FULL_DESKTOP)
admin_page = admin.new_page()
admin_page.set_default_timeout(60000)
login_admin(admin_page, base, password)
for name, path, title in ADMIN_SHOTS:
records.append(capture(admin_page, out_dir, base, counter, name, path, title, "Administration", "full", True))
counter += 1
admin.close()
return counter
def capture_responsive(browser, out_dir, base, records, failures, counter):
for viewport, size in RESPONSIVE_VIEWPORTS:
context = browser.new_context(viewport=size)
page = context.new_page()
page.set_default_timeout(60000)
for name, path, title in RESPONSIVE_PAGES:
goto_ready(page, base + path)
failure = check_overflow(page, path, viewport)
if failure:
failures.append(failure)
filename = f"{counter:02d}-{name}-{viewport}.png"
page.screenshot(path=str(out_dir / filename), full_page=False)
records.append({"section": "Responsive", "title": title, "path": path, "viewport": viewport, "file": filename})
counter += 1
context.close()
context = browser.new_context(viewport={"width": 390, "height": 844})
page = context.new_page()
page.set_default_timeout(60000)
goto_ready(page, base + "/")
page.click("[data-nav-toggle]")
filename = f"{counter:02d}-home-nav-open-390x844.png"
page.screenshot(path=str(out_dir / filename), full_page=False)
records.append({"section": "Responsive", "title": "Home navigation open", "path": "/", "viewport": "390x844", "file": filename})
context.close()
return counter + 1
def app_version():
with (REPO_ROOT / "pyproject.toml").open("rb") as handle:
return tomllib.load(handle)["project"]["version"]
def render_index(records, failures, checked, env, version, stamped):
sections = []
for section in ["Public", "Docs", "Administration", "Responsive"]:
figures = []
for record in records:
if record["section"] != section:
continue
figures.append(
'<figure><a href="{f}"><img src="{f}" alt="{t}" loading="lazy"></a>'
"<figcaption>{t} <code>{p}</code> ({v})</figcaption></figure>".format(
f=html.escape(record["file"]),
t=html.escape(record["title"]),
p=html.escape(record["path"]),
v=html.escape(record["viewport"]),
)
)
note = ""
if section == "Responsive":
passed = checked - len(failures)
items = "".join(f"<li>{html.escape(failure)}</li>" for failure in failures)
failures_html = f"<ul>{items}</ul>" if items else ""
note = f"<p class=\"checks\">{passed} of {checked} responsive checks passed, no horizontal overflow.</p>{failures_html}"
sections.append("<h2>{s}</h2>{n}<div class=\"shots\">{f}</div>".format(s=section, n=note, f="".join(figures)))
return """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Molodetz in pictures ({e})</title>
<meta name="description" content="Screenshot gallery of the Molodetz site.">
<style>
body{{margin:0 auto;max-width:1120px;padding:48px 24px 96px;font-family:Georgia,'Times New Roman',serif;color:#1a1815;background:#fff}}
header{{border-bottom:1px solid #e2ddd4;padding-bottom:24px}}
header nav{{font-size:14px;margin-bottom:16px}}
header nav a{{color:#555}}
h1{{font-size:34px;margin:0 0 8px;font-weight:600}}
header p{{margin:4px 0;font-size:15px;color:#555}}
h2{{font-size:22px;margin:44px 0 4px}}
.checks{{font-size:14px;color:#555;margin:8px 0 0}}
ul{{font-size:14px;color:#8a1f1f}}
.shots{{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:28px 24px;margin-top:16px}}
figure{{margin:0}}
figure a{{display:block;border:1px solid #e2ddd4;border-radius:4px;overflow:hidden}}
figure img{{display:block;width:100%;height:220px;object-fit:cover;object-position:top}}
figcaption{{font-size:13px;color:#555;margin-top:8px}}
figcaption code{{font-family:ui-monospace,Menlo,monospace;font-size:12px}}
footer{{margin-top:56px;border-top:1px solid #e2ddd4;padding-top:16px;font-size:13px;color:#555}}
</style>
</head>
<body>
<header>
<nav><a href="../index.html">All environments</a></nav>
<h1>Molodetz in pictures ({e})</h1>
<p>{n} screenshots of version {v}, taken {d}.</p>
<p>Every thumbnail links to the full image.</p>
</header>
{s}
<footer>
<p>Molodetz screenshot gallery. Generated from a seeded review build.</p>
</footer>
</body>
</html>
""".format(n=len(records), e=html.escape(env), v=html.escape(version), d=html.escape(stamped), s="".join(sections))
def env_previews(out_dir):
preferred = ["01-home-full.png", "18-admin-full.png"]
found = [name for name in preferred if (out_dir / name).exists()]
for shot in sorted(out_dir.glob("*-home-390x844.png")):
if shot.name not in found:
found.append(shot.name)
if len(found) == 3:
break
for shot in sorted(out_dir.glob("*.png")):
if shot.name not in found:
found.append(shot.name)
if len(found) == 3:
break
return found
def render_hub(entries):
cards = []
for env, count, previews in entries:
thumbs = "".join(
'<a href="{e}/index.html"><img src="{e}/{f}" alt="{e}" loading="lazy"></a>'.format(e=env, f=html.escape(name))
for name in previews
)
cards.append(
'<section><h2><a href="{e}/index.html">{e}</a></h2><p>{n} screenshots.</p><div class="previews">{t}</div></section>'.format(
e=html.escape(env), n=count, t=thumbs
)
)
return """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Molodetz screenshot environments</title>
<meta name="description" content="Screenshot galleries of the Molodetz site per environment.">
<style>
body{{margin:0 auto;max-width:1120px;padding:48px 24px 96px;font-family:Georgia,'Times New Roman',serif;color:#1a1815;background:#fff}}
header{{border-bottom:1px solid #e2ddd4;padding-bottom:24px}}
h1{{font-size:34px;margin:0 0 8px;font-weight:600}}
header p{{margin:4px 0;font-size:15px;color:#555}}
section{{margin-top:40px}}
section h2{{font-size:24px;margin:0 0 4px;text-transform:capitalize}}
section h2 a{{color:#1a1815}}
section p{{font-size:14px;color:#555;margin:4px 0 12px}}
.previews{{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}}
.previews a{{display:block;border:1px solid #e2ddd4;border-radius:4px;overflow:hidden}}
.previews img{{display:block;width:100%;height:200px;object-fit:cover;object-position:top}}
footer{{margin-top:56px;border-top:1px solid #e2ddd4;padding-top:16px;font-size:13px;color:#555}}
footer code{{font-family:ui-monospace,Menlo,monospace;font-size:12px}}
</style>
</head>
<body>
<header>
<h1>Molodetz screenshot environments</h1>
<p>One gallery per environment. Regenerate with <code>make screenshots</code>, <code>make screenshots-staging</code>, <code>make screenshots-production</code>.</p>
</header>
{c}
<footer>
<p>Molodetz screenshot hub. Rebuilt on every gallery run from the environments present.</p>
</footer>
</body>
</html>
""".format(c="".join(cards))
def rebuild_hub():
entries = []
for env in KNOWN_ENVS:
out_dir = OUTPUT_DIR / env
if not (out_dir / "index.html").exists():
continue
count = len(list(out_dir.glob("*.png")))
entries.append((env, count, env_previews(out_dir)))
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
(OUTPUT_DIR / "index.html").write_text(render_hub(entries), encoding="utf-8")
return [env for env, _, _ in entries]
def clear_output(out_dir):
out_dir.mkdir(parents=True, exist_ok=True)
for stale in out_dir.glob("*.png"):
stale.unlink()
index = out_dir / "index.html"
if index.exists():
index.unlink()
def wait_live(base):
import httpx
try:
response = httpx.get(f"{base}/health", timeout=15.0)
except httpx.HTTPError as exc:
raise RuntimeError(f"base url {base} is not reachable: {exc}") from exc
if response.status_code != 200:
raise RuntimeError(f"base url {base} health is {response.status_code}")
def main():
from playwright.sync_api import sync_playwright
if ENV not in KNOWN_ENVS:
raise RuntimeError(f"unknown SCREENSHOTS_ENV {ENV!r}, want one of {', '.join(KNOWN_ENVS)}")
boot = not BASE_URL
if boot and ENV != "test":
raise RuntimeError("booted runs always target the test gallery, set SCREENSHOTS_ENV=test")
if not boot and ENV == "test":
raise RuntimeError("base-url runs target staging or production, set SCREENSHOTS_ENV accordingly")
if not boot and not ADMIN_PASSWORD:
raise RuntimeError("base-url runs need SCREENSHOTS_ADMIN_PASSWORD for the admin shots")
base = LOCAL_BASE if boot else BASE_URL
password = BOOT_ADMIN_PASSWORD if boot else ADMIN_PASSWORD
out_dir = OUTPUT_DIR / ENV
process = None
log = None
records = []
failures = []
try:
if boot:
if REPO_ROOT / "data" not in SHOTS_DATA_DIR.parents:
raise RuntimeError(f"refusing test data outside {REPO_ROOT / 'data'}")
shutil.rmtree(SHOTS_DATA_DIR, ignore_errors=True)
SHOTS_DATA_DIR.mkdir(parents=True, exist_ok=True)
process, log = start_server(SHOTS_DATA_DIR)
seed_join(base)
else:
wait_live(base)
headless = os.environ.get("PLAYWRIGHT_HEADLESS", "1") == "1"
slow_mo = int(os.environ.get("PLAYWRIGHT_SLOW_MO", "0") or 0)
clear_output(out_dir)
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=headless, slow_mo=slow_mo)
counter = capture_full(browser, out_dir, base, password, records, 1)
capture_responsive(browser, out_dir, base, records, failures, counter)
browser.close()
checked = len(RESPONSIVE_PAGES) * len(RESPONSIVE_VIEWPORTS)
stamped = datetime.now(timezone.utc).date().isoformat()
(out_dir / "index.html").write_text(render_index(records, failures, checked, ENV, app_version(), stamped), encoding="utf-8")
present = rebuild_hub()
finally:
if process is not None:
stop_server(process, log)
shutil.rmtree(SHOTS_DATA_DIR, ignore_errors=True)
print(f"wrote {len(records)} screenshots for {ENV} to {out_dir}, hub covers {', '.join(present)}")
for failure in failures:
print(f"OVERFLOW {failure}")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
+21 -3
View File
@@ -3,14 +3,15 @@ import os
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
TEST_DATA_DIR = Path(tempfile.mkdtemp(prefix="molodetz-test-"))
TEST_DATA_DIR = REPO_ROOT / "data" / f".tmp-pytest-{os.getpid()}"
shutil.rmtree(TEST_DATA_DIR, ignore_errors=True)
TEST_DATA_DIR.mkdir(parents=True, exist_ok=True)
TEST_PORT = int(os.environ.get("MOLODETZ_TEST_PORT", "8099"))
ADMIN_USERNAME = "retoor"
ADMIN_PASSWORD = "test-admin-pass-4f9c2a"
@@ -128,17 +129,34 @@ def browser(app_server):
browser.close()
def _record_console_error(page, message):
if message.type == "error" and "ERR_NETWORK_CHANGED" not in message.text:
page.console_errors.append(message.text)
@pytest.fixture()
def page(browser):
context = browser.new_context(viewport={"width": 1280, "height": 900}, locale="nl-NL")
page = context.new_page()
page.set_default_timeout(60000)
page.console_errors = []
page.on("pageerror", lambda error: page.console_errors.append(str(error)))
page.on("console", lambda message: message.type == "error" and page.console_errors.append(message.text))
page.on("console", lambda message: _record_console_error(page, message))
yield page
context.close()
def goto_ready(page, url):
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
page.goto(url, wait_until="domcontentloaded")
try:
page.wait_for_selector("html[data-app=ready]")
except PlaywrightTimeoutError:
page.reload(wait_until="domcontentloaded")
page.wait_for_selector("html[data-app=ready]")
def pytest_sessionfinish(session, exitstatus):
shutil.rmtree(TEST_DATA_DIR, ignore_errors=True)
+2 -3
View File
@@ -1,5 +1,5 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import login_admin
from tests.conftest import goto_ready, login_admin
ADMIN_PAGES = ["/admin", "/admin/posts", "/admin/joins", "/admin/services", "/admin/services/backup", "/admin/trash", "/admin/settings", "/admin/users", "/admin/audit", "/admin/backups", "/admin/stats", "/notifications", "/profile/api-key", "/docs/api/admin"]
@@ -7,8 +7,7 @@ ADMIN_PAGES = ["/admin", "/admin/posts", "/admin/joins", "/admin/services", "/ad
def test_admin_pages_have_no_js_errors(page, app_server):
login_admin(page, app_server)
for path in ADMIN_PAGES:
page.goto(app_server + path, wait_until="domcontentloaded")
page.wait_for_selector("html[data-app=ready]")
goto_ready(page, app_server + path)
page.goto(app_server + "/admin/services/backup", wait_until="domcontentloaded")
page.click("[data-tab=logs]")
assert page.locator("[data-panel=logs]").is_visible()
+2 -3
View File
@@ -1,11 +1,10 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import login_admin
from tests.conftest import goto_ready, login_admin
def test_login_and_editor_preview(page, app_server):
login_admin(page, app_server)
page.goto(app_server + "/admin/posts/new", wait_until="domcontentloaded")
page.wait_for_selector("html[data-app=ready]")
goto_ready(page, app_server + "/admin/posts/new")
page.fill("#body", "Een **vette** zin <script>alert(1)</script> [link](javascript:alert(1)) <b onclick=alert(1)>b</b>")
page.wait_for_function("document.querySelector('[data-preview-target] strong')")
preview = page.locator("[data-preview-target]").inner_html()
+3 -4
View File
@@ -1,17 +1,16 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import goto_ready
def test_docs_tabs_switch(page, app_server):
page.goto(app_server + "/docs/api/content", wait_until="domcontentloaded")
page.wait_for_selector("html[data-app=ready]")
goto_ready(page, app_server + "/docs/api/content")
page.click("[data-endpoint] [data-tab=expected] >> nth=0")
assert page.locator("[data-endpoint] [data-panel=expected]").first.is_visible()
assert page.console_errors == []
def test_docs_code_blocks_get_highlight_and_copy(page, app_server):
page.goto(app_server + "/docs/api", wait_until="domcontentloaded")
page.wait_for_selector("html[data-app=ready]")
goto_ready(page, app_server + "/docs/api")
page.wait_for_selector("dp-code .copy-button")
assert page.locator("dp-code pre code.hljs").count() >= 1
assert page.console_errors == []
+2 -2
View File
@@ -1,9 +1,9 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import goto_ready
def test_lightbox_opens_and_closes(page, app_server):
page.goto(app_server + "/flyers", wait_until="domcontentloaded")
page.wait_for_selector("html[data-app=ready]")
goto_ready(page, app_server + "/flyers")
page.locator("[data-lightbox]").first.click()
lightbox = page.locator("dp-lightbox")
assert lightbox.evaluate("el => el.classList.contains('visible')")
+5 -7
View File
@@ -1,12 +1,11 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import assert_no_horizontal_overflow
from tests.conftest import assert_no_horizontal_overflow, goto_ready
PUBLIC_PAGES = ["/", "/roll", "/standard", "/flyers", "/memes", "/people", "/people/retoor", "/join", "/docs", "/docs/welcome", "/docs/api/content", "/docs/component-code", "/terms", "/privacy"]
def test_home_renders_without_errors(page, app_server):
page.goto(app_server + "/", wait_until="domcontentloaded")
page.wait_for_selector("html[data-app=ready]")
goto_ready(page, app_server + "/")
assert page.locator("h1").first.inner_text().strip().lower() == "molodetz"
assert page.locator("nav .nav-links a", has_text="Join").count() == 1
assert page.console_errors == []
@@ -15,8 +14,8 @@ def test_home_renders_without_errors(page, app_server):
def test_mobile_nav_toggle(browser, app_server):
context = browser.new_context(viewport={"width": 390, "height": 800})
page = context.new_page()
page.goto(app_server + "/", wait_until="domcontentloaded")
page.wait_for_selector("html[data-app=ready]")
page.set_default_timeout(60000)
goto_ready(page, app_server + "/")
assert_no_horizontal_overflow(page)
assert not page.locator("#nav-links").is_visible()
page.click("[data-nav-toggle]")
@@ -26,6 +25,5 @@ def test_mobile_nav_toggle(browser, app_server):
def test_public_pages_have_no_js_errors(page, app_server):
for path in PUBLIC_PAGES:
page.goto(app_server + path, wait_until="domcontentloaded")
page.wait_for_selector("html[data-app=ready]")
goto_ready(page, app_server + path)
assert page.console_errors == []
+106
View File
@@ -0,0 +1,106 @@
# retoor <retoor@molodetz.nl>
import os
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
EXPECTED_KEYS = [
"SECRET_KEY",
"PLAYWRIGHT_HEADLESS",
"PLAYWRIGHT_SLOW_MO",
"MOLODETZ_ADMIN_USERNAME",
"MOLODETZ_ADMIN_EMAIL",
"MOLODETZ_ADMIN_PASSWORD",
"MOLODETZ_PORT",
"MOLODETZ_TEST_PORT",
"MOLODETZ_SITE_URL",
"MOLODETZ_DATA_DIR",
"MOLODETZ_DATABASE_URL",
"MOLODETZ_INTERNAL_BASE_URL",
"MOLODETZ_DISABLE_SERVICES",
"MOLODETZ_DISABLE_RATE_LIMIT",
"MOLODETZ_RATE_LIMIT",
"MOLODETZ_WEB_WORKERS",
"MOLODETZ_TEMPLATE_AUTO_RELOAD",
"MOLODETZ_STATIC_VERSION",
"MOLODETZ_LOG_LEVEL",
"MOLODETZ_PRESENCE_TIMEOUT_SECONDS",
"MOLODETZ_PRESENCE_ONLINE_LIMIT",
"MOLODETZ_PRESENCE_TRACK_LIMIT",
"MOLODETZ_PRESENCE_ONLINE_MARGIN_SECONDS",
"MOLODETZ_OUTBOUND_PROXY_URL",
"MOLODETZ_RCLONE_BIN",
"MOLODETZ_RCLONE_CONFIG",
"MOLODETZ_BACKUP_OFFLOAD_REMOTE",
"MOLODETZ_SITEMAP_TTL",
"MOLODETZ_LANDING_TTL",
"MOLODETZ_UNREAD_TTL",
]
NONEMPTY_PATH_KEYS = [
"MOLODETZ_DATA_DIR",
"MOLODETZ_DATABASE_URL",
"MOLODETZ_INTERNAL_BASE_URL",
"MOLODETZ_RCLONE_BIN",
"MOLODETZ_RCLONE_CONFIG",
]
def read_example():
values = {}
for line in (ROOT / ".env.example").read_text().splitlines():
match = re.match(r"^([A-Z][A-Z0-9_]*)=(.*)$", line)
if match:
values[match.group(1)] = match.group(2)
return values
def test_env_example_lists_every_key():
values = read_example()
assert sorted(values) == sorted(EXPECTED_KEYS)
def test_env_example_keeps_path_values_nonempty():
values = read_example()
for key in NONEMPTY_PATH_KEYS:
assert values[key].strip() != ""
def test_config_reads_no_unknown_keys():
source = (ROOT / "molodetz" / "config.py").read_text()
prefixed = set(re.findall(r'_env(?:_int)?\("([A-Z_]+)"', source))
assert set(re.findall(r'os\.environ\.get\("([A-Z_]+)"', source)) <= {"SECRET_KEY", "PLAYWRIGHT_HEADLESS", "PLAYWRIGHT_SLOW_MO"}
assert {f"MOLODETZ_{name}" for name in prefixed} <= set(EXPECTED_KEYS)
def run_config_import(data_dir, database_url):
env = {**os.environ, "MOLODETZ_DATA_DIR": data_dir, "MOLODETZ_DATABASE_URL": database_url}
return subprocess.run(
[sys.executable, "-c", "from molodetz import config"],
cwd=ROOT,
env=env,
capture_output=True,
text=True,
)
def test_data_dir_outside_root_refused():
result = run_config_import("/tmp/evil", "sqlite:////tmp/evil/molodetz.db")
assert result.returncode != 0
assert "outside" in result.stderr
def test_data_dir_inside_root_allowed():
target = ROOT / "data" / ".tmp-guard-probe"
result = run_config_import(str(target), f"sqlite:///{target / 'molodetz.db'}")
assert result.returncode == 0
def test_database_url_outside_data_dir_refused():
target = ROOT / "data" / ".tmp-guard-probe"
result = run_config_import(str(target), "sqlite:////tmp/evil/molodetz.db")
assert result.returncode != 0
assert "outside" in result.stderr