diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 65f0fd0..b6aa546 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -88,8 +88,8 @@ jobs: RESOURCE: ${{ github.event_name == 'schedule' && 'all' || inputs.resource }} REFRESH: ${{ github.event_name == 'workflow_dispatch' && inputs.refresh || 'false' }} PUBLISH: ${{ steps.publication.outputs.enabled }} - STUDY_BUILDER_COMMENTARIES_REPO: ${{ secrets.GETBIBLE_COMMENTARIES_REPO || 'git@github.com:getbible/v1_commentaries.git' }} - STUDY_BUILDER_DICTIONARIES_REPO: ${{ secrets.GETBIBLE_DICTIONARIES_REPO || 'git@github.com:getbible/v1_dictionaries.git' }} + STUDY_BUILDER_COMMENTARIES_REPO: ${{ secrets.GETBIBLE_COMMENTARIES_REPO || 'git@github.com:getbible/commentaries.git' }} + STUDY_BUILDER_DICTIONARIES_REPO: ${{ secrets.GETBIBLE_DICTIONARIES_REPO || 'git@github.com:getbible/dictionaries.git' }} STUDY_BUILDER_SIGN_COMMITS: ${{ steps.publication.outputs.enabled }} shell: bash run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01af92a..4395ddc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,3 +33,12 @@ jobs: study-builder --help study-builder engine --help study-builder build --help + - name: Lint deployment scripts + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq shellcheck + shellcheck --severity=style scripts/*.sh tests/*.sh + - name: Install, deploy, and serve the origin configuration + run: | + sudo apt-get install -y -qq nginx + sudo --preserve-env=PATH bash tests/nginx_config_check.sh diff --git a/AGENTS.md b/AGENTS.md index 7b88670..cb3c5f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,9 @@ publication workflow. - Runtime: Python 3.12. - Extractor: the separately released `getbiblesword` executable, pinned in `conf/getbiblesword.json` and invoked only as a subprocess. -- Outputs: static JSON trees for `v1_commentaries` and `v1_dictionaries`. +- Outputs: static JSON trees under `v1/` in `getbible/commentaries` and + `getbible/dictionaries`. The version lives in the folder, not the repository + name, so a future `v2/` can be published beside it. This repository does not build or link the CrossWire SWORD C++ engine. Changes to that engine belong in `getbible/getbiblesword`. Do not reintroduce a local C++ @@ -32,21 +34,42 @@ Never use a `utf8` convenience field as the authoritative value. Decode `base64` verify it, then create the public text projection. Unknown additive fields must be retained in the internal source record. Validated entries remain disk-backed and writers stream them; do not restore whole-module entry or commentary collections in -memory. A missing footer, failed digest, failed artifact, unsupported major contract, -or extractor error blocks all publication. +memory. Composed documents are streamed from the documents they embed, never built +up as one object. A missing footer, failed digest, failed artifact, unsupported major +contract, or extractor error blocks all publication. ## API stability -Commentary files remain addressable by GetBible book number and chapter. Dictionary -Strong's keys remain compatible with Bible API v3 (`G3056`, `H0430`). Any breaking -path or document change requires a new API version; do not silently mutate v1. +The published API is plain text. No document may reintroduce an `html` member, and +the builder must not grow an HTML sanitizer; the value of the text-only contract is +that no consumer has to sanitize a response. + +Commentary files remain addressable by GetBible book number and chapter. Chapter `0` +is a book introduction and verse `0` a chapter introduction; neither may be dropped. +Book and whole-commentary documents embed their parts byte-for-byte, so +`book.chapters[n]` must stay identical to the chapter document served on its own — +`scripts/validate_build.py` asserts this and it is the property clients rely on. + +Dictionary Strong's keys remain compatible with Bible API v3 (`G3056`, `H0430`). Repeated dictionary keys retain the unsuffixed direct path for their first definition; later definitions use deterministic `--2`, `--3`, and subsequent -suffixes and must all remain discoverable through `keys.json`. +suffixes and must all remain discoverable through `index.json`, which stays sorted +by its `search` term. Cross-references between words resolve only to keys that +exist in the same dictionary. + +A module identifier may never collide with a document at the `v1/` root; see +`RESERVED_MODULE_IDS`. Any breaking path or document change requires a new API +version; do not silently mutate v1. Generated repositories are replace-only outputs. A partial `--module` build may be used for tests but must never be pushed. +## Commits + +Commits in this repository are authored in the maintainer's name. Do not add a +`Co-Authored-By` trailer, a session link, an assistant name, or any other +tool attribution to a commit message, tag, or pull request. + ## Verification Run before publishing changes: diff --git a/README.md b/README.md index 40bb3df..23841de 100644 --- a/README.md +++ b/README.md @@ -5,21 +5,24 @@ `v1_study_builder` converts policy-approved CrossWire SWORD commentary and dictionary modules into two independently deployable static JSON APIs: -- `https://commentaries.getbible.net/v1/` from `getbible/v1_commentaries` -- `https://dictionaries.getbible.net/v1/` from `getbible/v1_dictionaries` +- `https://commentaries.getbible.net/v1/` from `getbible/commentaries` +- `https://dictionaries.getbible.net/v1/` from `getbible/dictionaries` The Bible API v3 builder remains unchanged. Study Builder deliberately uses the same book numbers, chapters, verses, and Strong's keys so a client can move from a Bible response to commentary or dictionary data with a direct path lookup. +Every document is plain text. Nothing in either API publishes HTML, so a consuming +application never has to sanitize a response before rendering it. + ## Repository boundaries | Repository | Responsibility | Runtime | | --- | --- | --- | | `getbible/getbiblesword` | Official SWORD C++ extraction and deterministic NDJSON | Released Linux executable | | `getbible/v1_study_builder` | Download policy, strict contract validation, normalization, schemas, and publication | Python 3.12 at build time | -| `getbible/v1_commentaries` | Generated commentary JSON under `v1/` | Nginx/CDN only | -| `getbible/v1_dictionaries` | Generated dictionary JSON under `v1/` | Nginx/CDN only | +| `getbible/commentaries` | Generated commentary JSON under `v1/` | Nginx/CDN only | +| `getbible/dictionaries` | Generated dictionary JSON under `v1/` | Nginx/CDN only | Study Builder does not contain C++, link `libsword`, use a Python SWORD binding, or parse a module's binary driver format. `getbiblesword` is a separately versioned @@ -65,30 +68,42 @@ and independently checks all of the rules that protect publication: - exact stream SHA-256 over every line before the footer, including LF; - exact footer record/entry/artifact/byte counts and `success: true`. -Raw bytes remain authoritative. The adapter derives safe text/HTML for the public -API only after verification and retains the original contract records internally. +Raw bytes remain authoritative. The adapter derives the public plain text only +after verification and retains the original contract records internally. Validated entries are held in a compressed, disk-backed spool. Commentary entries are then normalized into disk-backed chapter buckets and emitted in canonical GetBible book/chapter order; this supports source modules whose versification orders canonical or deuterocanonical books differently. Dictionary definitions are written -one at a time. This keeps memory bounded for large modules without weakening the -contract or the all-or-nothing publication rule. Any missing footer, checksum -failure, failed diagnostic, extractor error, or classification mismatch stops the -complete build before publication. +one at a time. Book, whole-commentary, and whole-dictionary documents are streamed +from the documents they contain rather than assembled in memory. This keeps memory +bounded for large modules without weakening the contract or the all-or-nothing +publication rule. Any missing footer, checksum failure, failed diagnostic, +extractor error, or classification mismatch stops the complete build before +publication. ## Commentary API ```text GET https://commentaries.getbible.net/v1/commentaries.json +GET https://commentaries.getbible.net/v1/{commentary}.json GET https://commentaries.getbible.net/v1/{commentary}/metadata.json GET https://commentaries.getbible.net/v1/{commentary}/books.json GET https://commentaries.getbible.net/v1/{commentary}/{book}.json GET https://commentaries.getbible.net/v1/{commentary}/{book}/{chapter}.json ``` -The chapter path is the primary high-volume endpoint. `book` is the GetBible API -v3 numeric identifier: Genesis is `1`, Matthew `40`, and Revelation `66`. Each -entry contains its natural Bible coordinate: +`book` is the GetBible API v3 numeric identifier: Genesis is `1`, Daniel `27`, +Matthew `40`, and Revelation `66`. Deuterocanonical books continue to `83`. + +The three content levels are self-similar. A chapter document is one member of a +book document, which is one member of a whole-commentary document, embedded +byte-for-byte. One client parser therefore handles all three: + +```text +{commentary}/{book}/{chapter}.json one chapter, the high-volume endpoint +{commentary}/{book}.json every chapter of that book +{commentary}.json every book of that commentary +``` ```json { @@ -106,24 +121,41 @@ entry contains its natural Bible coordinate: "name": "John 1:1", "anchor": {"book": 43, "chapter": 1, "verse": 1, "osis": "John.1.1"}, "text": "...", - "html": "

...

" + "references": [{"osis": "Gen.1.1", "book": 1, "chapter": 1, "verse": 1}] } ] } ``` -Book and chapter introductions use chapter or verse `0`; they are not discarded. +Introductions are published, not discarded. A book introduction is chapter `0`, +so Clarke's introduction to Daniel is `clarke/27/0.json`. A chapter introduction +is verse `0`, and appears as the first entry of its own chapter document. + +`books.json` reports which books and chapters a commentary covers, and +`metadata.json` reports its licence, counts, and the byte size of the +whole-commentary document so a client can decide before requesting it. ## Dictionary API ```text GET https://dictionaries.getbible.net/v1/dictionaries.json +GET https://dictionaries.getbible.net/v1/{dictionary}.json GET https://dictionaries.getbible.net/v1/{dictionary}/metadata.json -GET https://dictionaries.getbible.net/v1/{dictionary}/keys.json +GET https://dictionaries.getbible.net/v1/{dictionary}/index.json GET https://dictionaries.getbible.net/v1/{dictionary}/{entry}.json -GET https://dictionaries.getbible.net/v1/{dictionary}/indexes/{sha256-prefix}.json ``` +Searching a dictionary takes two requests. `index.json` lists every word once, +sorted by an accent-insensitive lowercase `search` term, so a client can fetch it +once and then search, prefix-match, or binary-search entirely in memory: + +```json +{"id": "k-KADESH", "key": "KADESH", "search": "kadesh"} +``` + +The record's `id` is the path of the word itself — `{entry}.json` — so a hit in +the index resolves to exactly one document with no further lookup. + Strong's paths match Bible API v3 tokens directly: ```text @@ -133,17 +165,48 @@ H0430 -> https://dictionaries.getbible.net/v1/strongshebrew/H0430.json Greek keys use `G` plus the unpadded number; Hebrew keys use `H0` plus the unpadded number. Other dictionary keys receive deterministic, path-safe IDs. -`keys.json` maps source keys and aliases, while 256 SHA-256-prefix shards provide -smaller lookup indexes for constrained clients. + +Each word document carries the dictionary's own link graph, so a client can +navigate in either direction without rebuilding an index: + +```json +{ + "schema": "getbible-dictionary-entry-v1", + "dictionary": "easton", + "id": "k-KADESH", + "key": "KADESH", + "occurrence": 1, + "aliases": ["KADESH"], + "text": "Holy; a place in the wilderness of Zin.", + "see_also": [{"id": "k-MERIBAH", "key": "MERIBAH"}], + "backlinks": [{"id": "k-ZIN", "key": "ZIN"}], + "references": [{"osis": "Num.20.1", "book": 4, "chapter": 20, "verse": 1}] +} +``` + +`see_also` lists the words this entry points at and `backlinks` the words that +point back. Only targets that resolve to a real key in the same dictionary are +published. Scripture references stay in `references`, in the same shape the +commentary API uses. Some SWORD dictionaries legitimately contain more than one definition for the same public key. The first definition keeps the canonical direct path, and later definitions receive deterministic `--2`, `--3`, and subsequent suffixes. For example, Easton's repeated `KADESH` records are available as `k-KADESH.json` and -`k-KADESH--2.json`. Every definition appears in `keys.json` with an `occurrence` +`k-KADESH--2.json`. Every definition appears in `index.json` with an `occurrence` value. Dictionary metadata reports both the total `entry_count` and the distinct `unique_key_count`. +`{dictionary}.json` is the complete dictionary in index order, for offline +clients that would otherwise request every word individually. + +## Integrity and schemas + +Each API root publishes `hashes.json`, a SHA-256 digest of every other generated +document, which is also the manifest of the paths a build owns. The JSON Schemas +for every document type are served beside the data under `v1/schema/`, so each +schema `$id` resolves to the document that defines it. + ## Build flow ```mermaid @@ -152,15 +215,37 @@ flowchart TD B --> C["NDJSON v1 subprocess stream"] C --> D["Independent stream + artifact validator"] D --> E["Python API adapter + JSON Schema"] - E --> F["Atomic static v1 trees + hash sidecars"] - F --> G["v1_commentaries, when publication secrets exist"] - F --> H["v1_dictionaries, when publication secrets exist"] + E --> F["Atomic static v1 trees + SHA-256 manifest"] + F --> G["commentaries, when publication secrets exist"] + F --> H["dictionaries, when publication secrets exist"] ``` The static output is the system of record. Nginx and a CDN can serve direct lookups without an application process, database connection pool, or request throttling bottleneck. +## Deployment + +A production origin is a pull, a verify, a compress, and a sync: + +```bash +scripts/deploy_static_api.sh \ + --repo git@github.com:getbible/commentaries.git \ + --root /var/www/getbible/commentaries \ + --require-signature + +scripts/verify_live_api.sh https://commentaries.getbible.net +``` + +The whole tree is checked against `hashes.json` before it reaches the live root, +so a failed build leaves the previous one serving. + +`docs/nginx/` holds the origin configuration for both hosts; install it with +`scripts/install_nginx_config.sh`, which adapts it to the host's nginx version, +brotli availability, and IPv6 support rather than leaving those as footguns. +`docs/deployment.md` describes the server layout, the caching model, the CDN and +security posture, rollback, and monitoring. + ## Local development ```bash @@ -232,8 +317,8 @@ Publication secret set: | `GETBIBLE_SSH_KEY` | SSH private key with write access to both outputs | | `GETBIBLE_SSH_PUB` | Matching public key | -The default output remotes are `getbible/v1_commentaries` and -`getbible/v1_dictionaries`. Optional `GETBIBLE_COMMENTARIES_REPO` and +The default output remotes are `getbible/commentaries` and +`getbible/dictionaries`. Optional `GETBIBLE_COMMENTARIES_REPO` and `GETBIBLE_DICTIONARIES_REPO` secrets may select staging remotes. ## Redistribution policy diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..bba9a4c --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,330 @@ +# Deploying the study APIs + +Two origins, each serving one generated repository as static JSON. There is no +application process, no database, and no request-time logic — a request is a +file read, which is why the whole design leans on the filesystem and the CDN +rather than on code. + +| Origin | Serves | Live root | +| --- | --- | --- | +| `commentaries.getbible.net` | `getbible/commentaries` | `/var/www/getbible/commentaries` | +| `dictionaries.getbible.net` | `getbible/dictionaries` | `/var/www/getbible/dictionaries` | + +Everything below applies identically to both. Where a command names one, run the +same command with the other's repository and root. + +## What the builder guarantees + +The deploy depends on four properties. If you change the builder, keep them, or +the deployment stops being safe: + +1. **Output is byte-stable.** A module that has not changed rebuilds to an + identical file. Content documents carry no timestamp — only `build.json` and + the catalog do — so an unchanged corpus produces an unchanged tree. +2. **`v1/hashes.json` describes the whole tree.** It holds a SHA-256 for every + other document, so it is both the integrity manifest and the list of paths + the builder owns. +3. **The builder owns only version directories.** Anything else in the + publication repository — README, licence, workflows — is the repository's + own and is never served. +4. **Every document is plain text JSON.** No HTML is published anywhere, which + is why the origin can send `Content-Security-Policy: default-src 'none'` and + why no consumer has to sanitize a response. + +## Server layout + +``` +/var/lib/getbible/commentaries persistent Git checkout (working copy) +/var/www/getbible/commentaries live root nginx serves — version dirs only +/etc/nginx/conf.d/getbible-api-http.conf zones and log format (http only) +/etc/nginx/snippets/getbible-api-server.conf tuning, TLS, compression +/etc/nginx/snippets/getbible-api-headers.conf CORS and security headers +/etc/nginx/snippets/getbible-api-v1.conf the v1 locations +/etc/nginx/sites-available/commentaries.getbible.net.conf +``` + +The live root is **not** a Git checkout. The checkout stays in `/var/lib`, and +only the version directories are copied across. That keeps repository metadata +off the origin entirely rather than relying on a rule to hide it. + +## First-time setup + +Requires nginx (1.25.1+ preferred; older is adapted automatically), git, +rsync, python3, gzip, and ideally brotli. + +```bash +apt-get install -y nginx git rsync python3 certbot brotli +# Brotli for nginx is a separate module; without it only .gz is served, which +# still works and is roughly 15-20% larger on JSON. +apt-get install -y libnginx-mod-http-brotli # where packaged + +install -d -m 755 /var/lib/getbible +``` + +Issue the certificates first — nginx refuses to start when a referenced +certificate is missing. The stock nginx site already serves `/var/www/html` on +port 80, so this works before anything below is installed, and the installed +configuration keeps serving the same webroot afterwards so renewals need no +further changes: + +```bash +certbot certonly --webroot -w /var/www/html \ + -d commentaries.getbible.net -d dictionaries.getbible.net +``` + +Then install the origin configuration: + +```bash +scripts/install_nginx_config.sh --reload +``` + +Do not copy `docs/nginx/` into place by hand. Three things legitimately differ +between hosts, and guessing wrong on any of them stops nginx from starting, so +the installer detects them instead: + +- **`http2 on` is nginx 1.25.1+.** On older nginx — Ubuntu 24.04 LTS still ships + 1.24 — it is folded back into the `listen` line. +- **`brotli_static` needs `ngx_brotli`**, which many distributions do not + package. It is enabled only when the module is actually present. +- **IPv6 listeners fail outright** on a host without IPv6. + +The installer stages the adapted files, refuses to proceed if a certificate is +missing (naming the exact certbot command), and runs `nginx -t` before +reloading. `--dry-run` shows what it would install and leaves the adapted files +for inspection. + +Reload nginx automatically after each renewal: + +```bash +cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'HOOK' +#!/bin/sh +systemctl reload nginx +HOOK +chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh +``` + +## Deploying + +```bash +scripts/deploy_static_api.sh \ + --repo git@github.com:getbible/commentaries.git \ + --root /var/www/getbible/commentaries \ + --require-signature \ + --verify-url https://commentaries.getbible.net/v1/commentaries.json +``` + +The script does five things, and the order is the point — nothing reaches the +live root until it has been proven good: + +1. **Pull.** `git reset --hard` into the persistent checkout. Git rewrites only + files whose content changed, so every unchanged document keeps its mtime. +2. **Verify.** Every digest in `hashes.json` is recomputed, and any `.json` in + the tree that the manifest does not list is an error. A build that fails here + never reaches the live root; the previous deploy keeps serving. +3. **Compress.** `.gz` and `.br` are written beside each document, but only + where missing or stale, and each variant is stamped with its document's + mtime. A monthly rebuild that changes two modules recompresses two modules. +4. **Sync.** `rsync --delete --delay-updates`, one version directory at a time. + Scoping the delete to a single version means no failure of this script can + remove anything outside a version directory. +5. **Reload.** `nginx -t` then reload. New workers start with an empty + open-file cache, which is what makes the new documents visible at once. + +`--require-signature` refuses any commit without a valid GPG signature. The +build workflow signs its commits when the publication secrets are present, so on +a production origin this should always be on: it means the origin serves only +what the build key signed. + +It verifies against the deploying user's own GPG keyring, so import and trust +the build key once on each origin before enabling it — otherwise every deploy +fails closed with an unsigned-commit error: + +```bash +sudo -u deploy gpg --import getbible-build-key.asc +sudo -u deploy gpg --lsign-key +``` + +Verification covers every file, not only the JSON documents: anything present in +a version directory that `hashes.json` does not list fails the deploy. A stray +`.html` or `.js` would otherwise be served from the API's own hostname. + +Use `--dry-run` to see the exact change set without touching the live root. + +### Automating it + +```ini +# /etc/systemd/system/getbible-commentaries-deploy.service +[Unit] +Description=Deploy the GetBible commentary API +After=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/deploy_static_api.sh \ + --repo git@github.com:getbible/commentaries.git \ + --root /var/www/getbible/commentaries \ + --require-signature \ + --verify-url https://commentaries.getbible.net/v1/commentaries.json +``` + +```ini +# /etc/systemd/system/getbible-commentaries-deploy.timer +[Unit] +Description=Check for a new commentary API build + +[Timer] +OnCalendar=*-*-* 05:30:00 +RandomizedDelaySec=30m +Persistent=true + +[Install] +WantedBy=timers.target +``` + +The builder runs monthly, so a daily timer simply finds nothing to do most days +— the pull is a no-op, verification passes, and rsync transfers nothing. + +### Why the configuration is split the way it is + +`conf.d/getbible-api-http.conf` holds only the shared memory zones and the log +format, because those cannot live in a server block. Everything else is in +`snippets/getbible-api-server.conf` and included per host. + +That is not tidiness. A distribution's stock `nginx.conf` already sets +`sendfile`, `gzip`, `tcp_nopush`, `ssl_protocols`, and +`ssl_prefer_server_ciphers` at http level, and nginx treats a second +declaration in the same context as a fatal `directive is duplicate` error — so +an http-context drop-in would refuse to start on a stock Ubuntu box. Server +context directives override the http-level ones instead. Keep new settings in +the server snippet unless they genuinely cannot go there. + +## The caching model + +Two tiers, because the documents have two very different change rates: + +| Documents | `max-age` | Why | +| --- | --- | --- | +| `{resource}.json`, `build.json`, `hashes.json` | 300s | Rewritten by every build | +| Everything else | 86400s | Changes only when a source module changes | + +Both carry `stale-while-revalidate` and `stale-if-error`, so a slow or briefly +unreachable origin degrades into serving slightly stale JSON rather than errors. + +Nothing is marked `immutable`, deliberately. Paths are not content-hashed, so a +document's URL is stable across rebuilds; promising immutability would strand +clients on an old copy after a correction. + +### Why ETags survive a deploy + +nginx derives a static file's ETag from its mtime and size. A deploy that +rewrote every file — a fresh clone, or `cp -r` — would change every mtime and so +every ETag, and several thousand clients would re-download a corpus that had not +changed. Git and rsync both write only what actually differs and rsync preserves +mtimes, so unchanged documents keep their ETag and revalidation stays a 304. + +This is why the script pulls into a persistent checkout instead of cloning, and +why the compressed variants are stamped with `touch -r`. + +### Atomicity + +`rsync --delay-updates` stages the whole change set and swaps it in at the end, +which narrows the window where a client could see a mix of old and new documents +to a fast sequence of renames. It does not eliminate it. + +That is a deliberate trade. Eliminating it entirely means deploying to a new +release directory and swapping a symlink — which changes every inode on every +deploy and throws away the ETag stability above. For a read-only corpus where +old and new documents are each individually valid, and where a build lands +monthly, stable ETags are worth far more than a perfectly atomic switch. + +## Behind a CDN + +Put a CDN in front of both origins. The two cache tiers are already what a CDN +wants, and the bulk documents in particular should be served from the edge. + +Restore the real client IP before the rate limits apply, or they will limit the +CDN rather than the client — the commented `set_real_ip_from` block in each +server file is where that goes. Refresh the address ranges from the CDN's +published list on a schedule; do not freeze them into the config. + +If the origin sits only behind a CDN, consider raising the `limit_req` rate: +the limits shipped here assume the origin is directly reachable. + +## Security posture + +- **Read-only.** Anything other than `GET`, `HEAD`, or `OPTIONS` gets a JSON + 405. `client_max_body_size` is 1k — nothing is ever uploaded. +- **No markup anywhere.** `default-src 'none'`, `sandbox`, and `nosniff` mean a + response that somehow was not JSON is inert in a browser. +- **No repository metadata on the origin.** The live root holds only version + directories; the dotfile deny rule is a second line, not the first. +- **Signed input.** `--require-signature` ties the origin to the build key. +- **Verified input.** The whole tree is checked against `hashes.json` before and + after it goes live. +- **No version disclosure.** `server_tokens off`. +- **Abuse limits.** Per-IP request and connection limits, with a tighter + connection cap and a rate cap on the bulk documents so one offline sync cannot + monopolise the origin's upstream. + +## Verifying a live origin + +```bash +scripts/verify_live_api.sh https://commentaries.getbible.net +scripts/verify_live_api.sh https://dictionaries.getbible.net +``` + +The script reads the catalog and follows it, so it works against any build +without being told which modules exist. It asserts every promise the API makes: +paths resolve, both cache tiers are right, precompressed variants are served, +revalidation returns 304, CORS and the security headers are present, and +failures come back as JSON. It exits non-zero on the first broken promise, so it +is safe to run from a monitor. + +## Rolling back + +The live root records what it is serving: + +```bash +cat /var/www/getbible/commentaries/.revision +``` + +Generated repositories are replace-only, so the clean rollback is to revert the +bad commit in the publication repository and deploy again: + +```bash +git -C /var/lib/getbible/commentaries revert --no-edit +git -C /var/lib/getbible/commentaries push origin main +scripts/deploy_static_api.sh --root /var/www/getbible/commentaries +``` + +That keeps the history honest about what was served and when, which a symlink +flip does not. + +A failed deploy needs no rollback: verification runs before the sync, so the +previous build is still live and untouched. + +## Monitoring + +Watch these, in rough order of how much they matter: + +- `verify_live_api.sh` exit status, on a schedule. +- The deploy unit's exit status. A non-zero exit means the origin is serving a + build older than the newest published one. +- `hashes.json` age versus the builder's monthly schedule — a stale one means + builds have stopped, which is invisible from the API itself. +- 5xx rate and `p99` request time from the JSON access log. +- 429 rate. A rising 429 rate usually means a client is ignoring + `Cache-Control`, not that the limits are too tight. +- Certificate expiry. +- Access log growth. The logs are named `*.access.log` so the stock + `/etc/logrotate.d/nginx` glob (`/var/log/nginx/*.log`) rotates them; renaming + them outside that glob silently fills the disk. + +## Adding a v2 later + +The version lives in the directory, not the repository name, so a future +`v2/` is published beside `v1/` in the same repository. The deploy script +already discovers and syncs every `v[0-9]*` directory it finds and retires ones +that disappear. Serving it needs a copy of `snippets/getbible-api-v1.conf` with +the paths changed, included alongside the existing one. `v1` keeps working +untouched, which is the whole reason the version segment is there. diff --git a/docs/nginx.conf b/docs/nginx.conf deleted file mode 100644 index bf1d4e8..0000000 --- a/docs/nginx.conf +++ /dev/null @@ -1,30 +0,0 @@ -# Add the relevant server block to each API virtual host. Set root to the -# checkout of v1_commentaries or v1_dictionaries respectively. - -server { - listen 443 ssl http2; - server_name commentaries.getbible.net; - root /var/www/getbible/v1_commentaries; - - etag on; - gzip on; - gzip_types application/json text/plain; - - location /v1/ { - try_files $uri =404; - add_header Access-Control-Allow-Origin "*" always; - add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always; - add_header Cache-Control "public, max-age=300, stale-while-revalidate=86400" always; - } - - location ~* \.sha$ { - try_files $uri =404; - default_type text/plain; - add_header Access-Control-Allow-Origin "*" always; - add_header Cache-Control "public, max-age=31536000, immutable" always; - } -} - -# Duplicate the block for dictionaries.getbible.net and set: -# root /var/www/getbible/v1_dictionaries; - diff --git a/docs/nginx/commentaries.getbible.net.conf b/docs/nginx/commentaries.getbible.net.conf new file mode 100644 index 0000000..ccd787d --- /dev/null +++ b/docs/nginx/commentaries.getbible.net.conf @@ -0,0 +1,74 @@ +# GetBible commentary API — https://commentaries.getbible.net/ +# +# Install as /etc/nginx/sites-available/commentaries.getbible.net.conf and link +# it into sites-enabled. Requires conf.d/getbible-api-http.conf and both files +# in snippets/. Requires nginx 1.25.1+ for "http2 on"; on older nginx use +# "listen 443 ssl http2;" and drop the separate directive. + +server { + listen 80; + listen [::]:80; + server_name commentaries.getbible.net; + + # Served from the stock nginx document root so the very first certificate + # can be issued before this file is installed, and so renewals keep working + # afterwards without a second webroot. + location ^~ /.well-known/acme-challenge/ { + root /var/www/html; + default_type "text/plain"; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + # HTTP/3 needs a QUIC-capable build; enable both lines together with the + # Alt-Svc header below. + # listen 443 quic reuseport; + # listen [::]:443 quic reuseport; + # http3 on; + + server_name commentaries.getbible.net; + + # The live root holds only the version directories, deployed by + # scripts/deploy_static_api.sh. It is not a Git checkout. + root /var/www/getbible/commentaries; + + ssl_certificate /etc/letsencrypt/live/commentaries.getbible.net/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/commentaries.getbible.net/privkey.pem; + ssl_trusted_certificate /etc/letsencrypt/live/commentaries.getbible.net/chain.pem; + + access_log /var/log/nginx/commentaries.getbible.net.access.log getbible buffer=64k flush=5s; + error_log /var/log/nginx/commentaries.getbible.net.error.log warn; + + # add_header Alt-Svc 'h3=":443"; ma=86400' always; # with HTTP/3 above + + # Behind Cloudflare or another CDN, restore the client IP before the rate + # limits in conf.d/getbible-api-http.conf are applied to it. Refresh the + # ranges from the CDN's published list; do not hardcode them permanently. + # include /etc/nginx/conf.d/cloudflare-real-ip.conf; + # real_ip_header CF-Connecting-IP; + # real_ip_recursive on; + + include snippets/getbible-api-server.conf; + include snippets/getbible-api-headers.conf; + + # Read-only API. Answer preflight cheaply and reject everything unsafe. + if ($request_method = OPTIONS) { + return 204; + } + if ($request_method !~ ^(GET|HEAD|OPTIONS)$) { + return 405 '{"error":"method_not_allowed","allow":"GET, HEAD, OPTIONS"}'; + } + + include snippets/getbible-api-v1.conf; + + location / { + return 404; + } +} diff --git a/docs/nginx/dictionaries.getbible.net.conf b/docs/nginx/dictionaries.getbible.net.conf new file mode 100644 index 0000000..b32dfa5 --- /dev/null +++ b/docs/nginx/dictionaries.getbible.net.conf @@ -0,0 +1,69 @@ +# GetBible dictionary API — https://dictionaries.getbible.net/ +# +# Install as /etc/nginx/sites-available/dictionaries.getbible.net.conf and link +# it into sites-enabled. Requires conf.d/getbible-api-http.conf and both files +# in snippets/. Requires nginx 1.25.1+ for "http2 on"; on older nginx use +# "listen 443 ssl http2;" and drop the separate directive. +# +# This file is the commentary host with three values changed: server_name, root, +# and the certificate and log paths. The locations themselves are shared. + +server { + listen 80; + listen [::]:80; + server_name dictionaries.getbible.net; + + # Served from the stock nginx document root so the very first certificate + # can be issued before this file is installed, and so renewals keep working + # afterwards without a second webroot. + location ^~ /.well-known/acme-challenge/ { + root /var/www/html; + default_type "text/plain"; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + # listen 443 quic reuseport; + # listen [::]:443 quic reuseport; + # http3 on; + + server_name dictionaries.getbible.net; + + root /var/www/getbible/dictionaries; + + ssl_certificate /etc/letsencrypt/live/dictionaries.getbible.net/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/dictionaries.getbible.net/privkey.pem; + ssl_trusted_certificate /etc/letsencrypt/live/dictionaries.getbible.net/chain.pem; + + access_log /var/log/nginx/dictionaries.getbible.net.access.log getbible buffer=64k flush=5s; + error_log /var/log/nginx/dictionaries.getbible.net.error.log warn; + + # add_header Alt-Svc 'h3=":443"; ma=86400' always; # with HTTP/3 above + + # include /etc/nginx/conf.d/cloudflare-real-ip.conf; + # real_ip_header CF-Connecting-IP; + # real_ip_recursive on; + + include snippets/getbible-api-server.conf; + include snippets/getbible-api-headers.conf; + + if ($request_method = OPTIONS) { + return 204; + } + if ($request_method !~ ^(GET|HEAD|OPTIONS)$) { + return 405 '{"error":"method_not_allowed","allow":"GET, HEAD, OPTIONS"}'; + } + + include snippets/getbible-api-v1.conf; + + location / { + return 404; + } +} diff --git a/docs/nginx/getbible-api-http.conf b/docs/nginx/getbible-api-http.conf new file mode 100644 index 0000000..5b9312c --- /dev/null +++ b/docs/nginx/getbible-api-http.conf @@ -0,0 +1,45 @@ +# GetBible study API — http-context settings. +# +# Install as /etc/nginx/conf.d/getbible-api-http.conf. +# +# This file holds only what CANNOT live in a server block: shared memory zones +# and a log format. Everything else the API needs is in +# snippets/getbible-api-server.conf, which each host includes. +# +# That split is deliberate. A distribution's stock nginx.conf already sets +# directives like sendfile, gzip, and ssl_protocols at http level, and nginx +# treats a second declaration in the same context as a fatal "directive is +# duplicate" error. Server-context directives simply override the http-level +# ones instead, so the API configuration drops into any distribution without +# having to edit its nginx.conf first. +# +# Names here are unique to this API, so nothing in this file can collide. + +# --------------------------------------------------------------------------- +# Abuse limits +# --------------------------------------------------------------------------- +# These protect the origin when it is reachable directly. Behind a CDN they +# limit the CDN unless the real client IP is restored first — see the +# set_real_ip_from block in the server files. + +limit_req_zone $binary_remote_addr zone=getbible_api:16m rate=40r/s; +limit_conn_zone $binary_remote_addr zone=getbible_conn:16m; +limit_conn_zone $binary_remote_addr zone=getbible_bulk:16m; + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- + +log_format getbible escape=json '{' + '"time":"$time_iso8601",' + '"host":"$host",' + '"remote_addr":"$remote_addr",' + '"method":"$request_method",' + '"uri":"$uri",' + '"status":$status,' + '"bytes_sent":$body_bytes_sent,' + '"request_time":$request_time,' + '"referer":"$http_referer",' + '"user_agent":"$http_user_agent",' + '"encoding":"$http_accept_encoding"' +'}'; diff --git a/docs/nginx/snippets/getbible-api-headers.conf b/docs/nginx/snippets/getbible-api-headers.conf new file mode 100644 index 0000000..2d2e767 --- /dev/null +++ b/docs/nginx/snippets/getbible-api-headers.conf @@ -0,0 +1,24 @@ +# GetBible study API — response headers shared by every location. +# +# nginx replaces rather than merges add_header sets: a location that declares +# any add_header of its own loses every inherited one. So this file is included +# at server level AND in each location that adds a Cache-Control of its own. +# Do not add a header in a location without including this file beside it. + +add_header Access-Control-Allow-Origin "*" always; +add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always; +add_header Access-Control-Allow-Headers "Accept, Accept-Encoding, If-None-Match, If-Modified-Since, Range" always; +add_header Access-Control-Expose-Headers "ETag, Content-Length, Content-Range" always; +add_header Access-Control-Max-Age "86400" always; + +# The API is public, read-only, and returns no markup. default-src 'none' makes +# any response that is somehow not JSON inert in a browser, and cross-origin +# CORP keeps the API usable from cross-origin-isolated pages. +add_header X-Content-Type-Options "nosniff" always; +add_header Content-Security-Policy "default-src 'none'; frame-ancestors 'none'; sandbox" always; +add_header Referrer-Policy "no-referrer" always; +add_header Cross-Origin-Resource-Policy "cross-origin" always; + +# Add includeSubDomains and preload only once every getbible.net host is HTTPS +# and you have accepted that the choice is effectively irreversible. +add_header Strict-Transport-Security "max-age=31536000" always; diff --git a/docs/nginx/snippets/getbible-api-server.conf b/docs/nginx/snippets/getbible-api-server.conf new file mode 100644 index 0000000..8b00ea0 --- /dev/null +++ b/docs/nginx/snippets/getbible-api-server.conf @@ -0,0 +1,84 @@ +# GetBible study API — server-context tuning, shared by both hosts. +# +# Every directive here is valid in a server block, so it overrides whatever the +# distribution's nginx.conf sets at http level rather than colliding with it. +# Keep it that way: moving any of this to http context reintroduces the +# "directive is duplicate" failures that make the configuration distribution +# specific. Requires conf.d/getbible-api-http.conf for the zones and log format. + +# --------------------------------------------------------------------------- +# Static file serving +# --------------------------------------------------------------------------- + +# A study API request is one small file read. Caching descriptors and stat() +# results removes most of its syscall cost. Workers start with an empty cache, +# so a deploy must reload nginx — deploy_static_api.sh does. +open_file_cache max=200000 inactive=5m; +open_file_cache_valid 2m; +open_file_cache_min_uses 1; +open_file_cache_errors on; + +sendfile on; +sendfile_max_chunk 2m; +tcp_nopush on; +tcp_nodelay on; + +# Whole-commentary documents reach tens of megabytes. Threaded reads keep one +# bulk transfer from blocking a worker that is serving chapter lookups. +aio threads; +directio 16m; +output_buffers 2 512k; + +server_tokens off; + +# --------------------------------------------------------------------------- +# Compression +# --------------------------------------------------------------------------- +# The deploy writes .gz and .br beside every .json, so compression normally +# costs no request-time CPU at all. On-the-fly gzip stays on only as a safety +# net for a document the deploy has not compressed yet. + +gzip on; +gzip_vary on; +gzip_proxied any; +gzip_comp_level 5; +gzip_min_length 1024; +gzip_types application/json; + +# --------------------------------------------------------------------------- +# TLS +# --------------------------------------------------------------------------- +# Mozilla "intermediate" profile. Certificates are per host, in the server files. + +ssl_protocols TLSv1.2 TLSv1.3; +ssl_prefer_server_ciphers off; +ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; +ssl_session_timeout 1d; +ssl_session_tickets off; + +# Harmless to leave on: if the certificate carries no OCSP responder URL — Let's +# Encrypt no longer issues one — nginx logs a single warning at startup and +# serves without stapling. It still works for a CA that does publish one. +ssl_stapling on; +ssl_stapling_verify on; + +# Needed for OCSP stapling. Use the host's own resolver if it has one. +resolver 1.1.1.1 9.9.9.9 valid=300s; +resolver_timeout 5s; + +# --------------------------------------------------------------------------- +# Request handling +# --------------------------------------------------------------------------- + +limit_req_status 429; +limit_conn_status 429; + +charset off; +default_type application/json; +etag on; + +# A bulk document is tens of megabytes; nothing here is ever uploaded. +client_max_body_size 1k; +client_body_timeout 10s; +keepalive_timeout 65s; +keepalive_requests 1000; diff --git a/docs/nginx/snippets/getbible-api-v1.conf b/docs/nginx/snippets/getbible-api-v1.conf new file mode 100644 index 0000000..6fbe1fb --- /dev/null +++ b/docs/nginx/snippets/getbible-api-v1.conf @@ -0,0 +1,126 @@ +# GetBible study API — the v1 locations, identical for both hosts. +# +# The served root contains only the version directories the builder produces, +# so v1/ is the whole API surface and anything else is a 404. +# +# nginx evaluates regular-expression locations in the order they appear here and +# before the "/v1/" prefix location, so the order below is load-bearing: +# +# 1. dotfiles denied outright +# 2. discovery docs short cache, they change on every build +# 3. bulk documents long cache, throttled, tens of megabytes each +# 4. everything else long cache — chapters, books, words, indexes, schemas + +# Refuse dotfiles before any other rule can serve one. The live root is built by +# rsync and holds no repository metadata, but this costs nothing and closes the +# hole permanently if someone ever points the root at a Git checkout. +# +# 404 rather than "deny all": the caller sees the same JSON 404 it would get for +# any unknown path, which confirms nothing about what exists, and nginx does not +# write an error-log line for every probe. A scanned origin would otherwise fill +# its error log with noise that buries real failures. +location ~ /\. { + access_log off; + log_not_found off; + return 404; +} + +# commentaries.json / dictionaries.json / build.json / hashes.json. +# Both resource names appear here so a single snippet serves both hosts; only +# one of them exists in any given root. +location ~ ^/v1/(commentaries|dictionaries|build|hashes)\.json$ { + limit_req zone=getbible_api burst=200 nodelay; + limit_conn getbible_conn 64; + + gzip_static on; + # brotli_static on; + + try_files $uri =404; + + include snippets/getbible-api-headers.conf; + add_header Cache-Control "public, max-age=300, stale-while-revalidate=3600, stale-if-error=86400" always; +} + +# Whole-commentary and whole-dictionary documents: /v1/clarke.json, /v1/easton.json. +# One of these is worth thousands of chapter requests, so it is deliberately +# cheap to fetch once and deliberately hard to fetch in a loop. +location ~ ^/v1/[^/]+\.json$ { + limit_req zone=getbible_api burst=20 nodelay; + limit_conn getbible_bulk 2; + + # Full speed for the first 10 MB, then throttled, so one offline sync cannot + # monopolise the origin's upstream. + limit_rate_after 10m; + limit_rate 5m; + + gzip_static on; + # brotli_static on; + + try_files $uri =404; + + include snippets/getbible-api-headers.conf; + add_header Cache-Control "public, max-age=86400, stale-while-revalidate=604800, stale-if-error=86400" always; +} + +# Chapters, books, word entries, search indexes, metadata, and schemas. +# These change only when a source module changes, which is monthly at most. +location /v1/ { + limit_req zone=getbible_api burst=200 nodelay; + limit_conn getbible_conn 64; + + gzip_static on; + # brotli_static on; + + try_files $uri =404; + + include snippets/getbible-api-headers.conf; + add_header Cache-Control "public, max-age=86400, stale-while-revalidate=604800, stale-if-error=86400" always; +} + +# Unversioned root: point a caller at the current version rather than 404. +location = / { + include snippets/getbible-api-headers.conf; + add_header Cache-Control "public, max-age=3600" always; + default_type application/json; + return 200 '{"api":"getbible-study","versions":["v1"],"base_url":"$scheme://$host/v1/"}\n'; +} + +# Load balancer probe. Never logged, never cached, no headers needed. +location = /health { + access_log off; + add_header Cache-Control "no-store" always; + default_type application/json; + return 200 '{"status":"ok"}\n'; +} + +# A JSON API must fail in JSON. Without this a missing chapter returns nginx's +# HTML error page, which every client then has to special-case. +error_page 403 404 = @not_found; + +location @not_found { + internal; + include snippets/getbible-api-headers.conf; + add_header Cache-Control "public, max-age=60" always; + default_type application/json; + return 404 '{"error":"not_found","message":"No such document in this API version.","base_url":"$scheme://$host/v1/"}\n'; +} + +error_page 429 = @rate_limited; + +location @rate_limited { + internal; + include snippets/getbible-api-headers.conf; + add_header Cache-Control "no-store" always; + default_type application/json; + return 429 '{"error":"rate_limited","message":"Too many requests. This API is cacheable; honour Cache-Control before retrying."}\n'; +} + +error_page 500 502 503 504 = @server_error; + +location @server_error { + internal; + include snippets/getbible-api-headers.conf; + add_header Cache-Control "no-store" always; + default_type application/json; + return 500 '{"error":"server_error"}\n'; +} diff --git a/docs/target-repositories.md b/docs/target-repositories.md index 4eae3d6..a1a55cc 100644 --- a/docs/target-repositories.md +++ b/docs/target-repositories.md @@ -1,13 +1,14 @@ # Target repository setup -Create two empty repositories with `main` as their default branch: +Create two repositories with `main` as their default branch: -- `getbible/v1_commentaries` -- `getbible/v1_dictionaries` +- `getbible/commentaries` +- `getbible/dictionaries` Seed each repository with a README and commit it before the first builder run. The builder owns only the `v1/` directory; repository documentation and server -configuration outside that directory are preserved. +configuration outside that directory are preserved, and a future `v2/` can be +published beside it without disturbing v1 consumers. Add these Actions secrets to `v1_study_builder`: @@ -26,8 +27,13 @@ default target URLs are already compiled into the CLI. Set the optional using forks, staging repositories, or non-GitHub remotes. Protect `main` on both generated repositories against manual changes, while -allowing the builder deploy key to push. Serve the repository checkout directly -with Nginx; no application runtime is required. +allowing the builder deploy key to push. + +Do not serve a checkout directly. `scripts/deploy_static_api.sh` pulls into a +working copy, verifies it against `hashes.json`, and syncs only the version +directories to the live root, so no repository metadata reaches the origin. See +[deployment.md](deployment.md) for the full origin setup; no application runtime +is required. The production workflow always completes the requested build. It invokes the publication steps only when all six Git author/signing/SSH values are present and diff --git a/pyproject.toml b/pyproject.toml index 6bfbbbe..40fb15b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,6 @@ requires-python = ">=3.12" license = {text = "GPL-2.0-only"} authors = [{name = "GetBible", email = "github@vdm.io"}] dependencies = [ - "bleach>=6.2,<7", "jsonschema>=4.23,<5", ] diff --git a/requirements.txt b/requirements.txt index 30b3c20..41a1657 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,2 @@ -bleach>=6.2,<7 jsonschema>=4.23,<5 diff --git a/schemas/commentary-book.schema.json b/schemas/commentary-book.schema.json new file mode 100644 index 0000000..08dbe8d --- /dev/null +++ b/schemas/commentary-book.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://commentaries.getbible.net/schema/v1/commentary-book.json", + "title": "GetBible Commentary Book v1", + "description": "Every chapter of one book of one commentary. Each member of chapters is byte-for-byte the document served at {commentary}/{book}/{chapter}.json.", + "type": "object", + "required": ["schema", "commentary", "language", "book", "name", "chapters"], + "properties": { + "schema": {"const": "getbible-commentary-book-v1"}, + "commentary": {"type": "string", "minLength": 1}, + "language": {"type": "string", "minLength": 2}, + "book": {"type": "integer", "minimum": 1, "maximum": 83}, + "name": {"type": "string", "minLength": 1}, + "chapters": { + "type": "array", + "items": {"$ref": "https://commentaries.getbible.net/schema/v1/commentary-chapter.json"} + } + }, + "additionalProperties": false +} diff --git a/schemas/commentary-books.schema.json b/schemas/commentary-books.schema.json new file mode 100644 index 0000000..482a74e --- /dev/null +++ b/schemas/commentary-books.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://commentaries.getbible.net/schema/v1/commentary-books.json", + "title": "GetBible Commentary Books Index v1", + "description": "Which books and chapters one commentary covers. Chapter 0 is a book introduction.", + "type": "object", + "required": [ + "schema", + "commentary", + "language", + "name", + "book_url_template", + "chapter_url_template", + "book_count", + "books" + ], + "properties": { + "schema": {"const": "getbible-commentary-books-v1"}, + "commentary": {"type": "string", "minLength": 1}, + "language": {"type": "string", "minLength": 2}, + "name": {"type": "string", "minLength": 1}, + "book_url_template": {"const": "{book}.json"}, + "chapter_url_template": {"const": "{book}/{chapter}.json"}, + "book_count": {"type": "integer", "minimum": 0}, + "books": { + "type": "array", + "items": { + "type": "object", + "required": ["book", "name", "chapters", "entry_count"], + "properties": { + "book": {"type": "integer", "minimum": 1, "maximum": 83}, + "name": {"type": "string", "minLength": 1}, + "chapters": {"type": "array", "items": {"type": "integer", "minimum": 0}}, + "entry_count": {"type": "integer", "minimum": 0} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/schemas/commentary-chapter.schema.json b/schemas/commentary-chapter.schema.json index eda1b6e..b63391b 100644 --- a/schemas/commentary-chapter.schema.json +++ b/schemas/commentary-chapter.schema.json @@ -2,6 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://commentaries.getbible.net/schema/v1/commentary-chapter.json", "title": "GetBible Commentary Chapter v1", + "description": "One chapter of one commentary. Chapter 0 carries the book introduction and verse 0 carries a chapter introduction.", "type": "object", "required": ["schema", "commentary", "language", "book", "name", "chapter", "entries"], "properties": { @@ -10,35 +11,51 @@ "language": {"type": "string", "minLength": 2}, "book": {"type": "integer", "minimum": 1, "maximum": 83}, "name": {"type": "string", "minLength": 1}, - "chapter": {"type": "integer", "minimum": 1}, + "chapter": {"type": "integer", "minimum": 0}, "entries": { + "type": "array", + "items": {"$ref": "#/$defs/entry"} + } + }, + "additionalProperties": false, + "$defs": { + "entry": { + "type": "object", + "required": ["book", "chapter", "verse", "name", "anchor", "text"], + "properties": { + "book": {"type": "integer", "minimum": 1, "maximum": 83}, + "chapter": {"type": "integer", "minimum": 0}, + "verse": {"type": "integer", "minimum": 0}, + "name": {"type": "string"}, + "anchor": { + "type": "object", + "required": ["book", "chapter", "verse"], + "properties": { + "book": {"type": "integer", "minimum": 1, "maximum": 83}, + "chapter": {"type": "integer", "minimum": 0}, + "verse": {"type": "integer", "minimum": 0}, + "osis": {"type": "string"} + }, + "additionalProperties": false + }, + "text": {"type": "string"}, + "references": {"$ref": "#/$defs/references"} + }, + "additionalProperties": false + }, + "references": { "type": "array", "items": { "type": "object", - "required": ["book", "chapter", "verse", "name", "anchor", "text"], + "required": ["osis", "book", "chapter"], "properties": { + "osis": {"type": "string", "minLength": 1}, "book": {"type": "integer", "minimum": 1, "maximum": 83}, - "chapter": {"type": "integer", "minimum": 1}, - "verse": {"type": "integer", "minimum": 0}, - "name": {"type": "string"}, - "anchor": { - "type": "object", - "required": ["book", "chapter", "verse"], - "properties": { - "book": {"type": "integer"}, - "chapter": {"type": "integer", "minimum": 1}, - "verse": {"type": "integer"}, - "osis": {"type": "string"} - }, - "additionalProperties": false - }, - "text": {"type": "string"}, - "html": {"type": "string"}, - "references": {"type": "array", "items": {"type": "object"}} + "chapter": {"type": "integer", "minimum": 0}, + "verse": {"type": "integer", "minimum": 0} }, "additionalProperties": false } } - }, - "additionalProperties": false + } } diff --git a/schemas/commentary.schema.json b/schemas/commentary.schema.json new file mode 100644 index 0000000..25f44cf --- /dev/null +++ b/schemas/commentary.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://commentaries.getbible.net/schema/v1/commentary.json", + "title": "GetBible Commentary v1", + "description": "One complete commentary. Each member of books is byte-for-byte the document served at {commentary}/{book}.json. This is a bulk document; metadata.json publishes its size in bytes.", + "type": "object", + "required": ["schema", "commentary", "language", "name", "books"], + "properties": { + "schema": {"const": "getbible-commentary-v1"}, + "commentary": {"type": "string", "minLength": 1}, + "language": {"type": "string", "minLength": 2}, + "name": {"type": "string", "minLength": 1}, + "books": { + "type": "array", + "items": {"$ref": "https://commentaries.getbible.net/schema/v1/commentary-book.json"} + } + }, + "additionalProperties": false +} diff --git a/schemas/dictionary-entry.schema.json b/schemas/dictionary-entry.schema.json index aec1a25..50d1b15 100644 --- a/schemas/dictionary-entry.schema.json +++ b/schemas/dictionary-entry.schema.json @@ -2,6 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://dictionaries.getbible.net/schema/v1/dictionary-entry.json", "title": "GetBible Dictionary Entry v1", + "description": "One word of one dictionary. see_also lists the words this entry points at; backlinks lists the words that point back.", "type": "object", "required": ["schema", "dictionary", "language", "id", "key", "occurrence", "aliases", "text"], "properties": { @@ -13,8 +14,36 @@ "occurrence": {"type": "integer", "minimum": 1}, "aliases": {"type": "array", "items": {"type": "string"}, "minItems": 1}, "text": {"type": "string"}, - "html": {"type": "string"}, - "references": {"type": "array", "items": {"type": "string"}} + "see_also": {"$ref": "#/$defs/links"}, + "backlinks": {"$ref": "#/$defs/links"}, + "references": { + "type": "array", + "items": { + "type": "object", + "required": ["osis", "book", "chapter"], + "properties": { + "osis": {"type": "string", "minLength": 1}, + "book": {"type": "integer", "minimum": 1, "maximum": 83}, + "chapter": {"type": "integer", "minimum": 0}, + "verse": {"type": "integer", "minimum": 0} + }, + "additionalProperties": false + } + } }, - "additionalProperties": false + "additionalProperties": false, + "$defs": { + "links": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "key"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "key": {"type": "string", "minLength": 1} + }, + "additionalProperties": false + } + } + } } diff --git a/schemas/dictionary-index.schema.json b/schemas/dictionary-index.schema.json new file mode 100644 index 0000000..6dbab95 --- /dev/null +++ b/schemas/dictionary-index.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dictionaries.getbible.net/schema/v1/dictionary-index.json", + "title": "GetBible Dictionary Index v1", + "description": "Every word in one dictionary, sorted by the accent-insensitive lowercase search term. One fetch is enough to search a dictionary in any direction; the word itself is then at entry_url_template with the record's id.", + "type": "object", + "required": [ + "schema", + "dictionary", + "language", + "name", + "entry_url_template", + "entry_count", + "unique_key_count", + "entries" + ], + "properties": { + "schema": {"const": "getbible-dictionary-index-v1"}, + "dictionary": {"type": "string", "minLength": 1}, + "language": {"type": "string", "minLength": 2}, + "name": {"type": "string", "minLength": 1}, + "entry_url_template": {"const": "{entry}.json"}, + "entry_count": {"type": "integer", "minimum": 0}, + "unique_key_count": {"type": "integer", "minimum": 0}, + "entries": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "key", "search"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "key": {"type": "string", "minLength": 1}, + "search": {"type": "string", "minLength": 1}, + "aliases": {"type": "array", "items": {"type": "string"}, "minItems": 1}, + "occurrence": {"type": "integer", "minimum": 2} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/schemas/dictionary.schema.json b/schemas/dictionary.schema.json new file mode 100644 index 0000000..82dbd1e --- /dev/null +++ b/schemas/dictionary.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dictionaries.getbible.net/schema/v1/dictionary.json", + "title": "GetBible Dictionary v1", + "description": "One complete dictionary in index order. Each member of entries is byte-for-byte the document served at {dictionary}/{entry}.json. This is a bulk document for offline clients; metadata.json publishes its size in bytes.", + "type": "object", + "required": ["schema", "dictionary", "language", "name", "entries"], + "properties": { + "schema": {"const": "getbible-dictionary-v1"}, + "dictionary": {"type": "string", "minLength": 1}, + "language": {"type": "string", "minLength": 2}, + "name": {"type": "string", "minLength": 1}, + "entries": { + "type": "array", + "items": {"$ref": "https://dictionaries.getbible.net/schema/v1/dictionary-entry.json"} + } + }, + "additionalProperties": false +} diff --git a/scripts/deploy_static_api.sh b/scripts/deploy_static_api.sh new file mode 100755 index 0000000..d7bc27f --- /dev/null +++ b/scripts/deploy_static_api.sh @@ -0,0 +1,327 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-2.0-only +# +# Deploy one generated GetBible study API onto a static origin. +# +# deploy_static_api.sh --repo git@github.com:getbible/commentaries.git \ +# --root /var/www/getbible/commentaries +# +# The deploy is a pull, a verify, a compress, and a sync — in that order, and it +# stops at the first failure, so a bad build never reaches the live root. +# +# Two properties of the generated API make this safe and cheap, and the script +# is built around them: +# +# 1. Output is byte-stable. A module that did not change rebuilds identically, +# so `git reset --hard` rewrites only genuinely changed files and every +# other file keeps its mtime. nginx derives ETags from mtime and size, so +# unchanged documents keep their ETag across a deploy and clients keep +# getting 304 instead of re-downloading the corpus every month. +# +# 2. hashes.json lists a SHA-256 for every other document in the tree. It is +# both the integrity manifest and the list of paths the builder owns, so it +# is what this script verifies before anything goes live. +# +# Requires: bash 4, git, rsync, python3, gzip. Uses brotli when present. + +set -euo pipefail + +REPO="" +REF="main" +ROOT="" +WORK="" +JOBS="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)" +RELOAD=1 +DRY_RUN=0 +REQUIRE_SIGNATURE=0 +VERIFY_URL="" + +readonly PROGRAM="${0##*/}" + +die() { + printf '%s: %s\n' "$PROGRAM" "$*" >&2 + exit 1 +} + +log() { + printf '[%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" +} + +usage() { + # The header comment block, minus the shebang and licence line. Bounded by + # the block itself rather than a line range, which drifts when it is edited. + awk 'NR > 2 && /^#/ { sub(/^# ?/, ""); print; next } NR > 2 { exit }' "$0" + cat <<'USAGE' + +Options: + --repo URL Source repository (required unless --work already exists) + --ref NAME Branch or tag to deploy (default: main) + --root DIR Live document root nginx serves (required) + --work DIR Persistent checkout (default: /var/lib/getbible/) + --jobs N Parallel compression jobs (default: CPU count) + --require-signature Refuse to deploy a commit without a valid GPG signature + --verify-url URL After reload, assert this URL returns 200 and JSON + --no-reload Do not reload nginx + --dry-run Show what would change without touching the live root + -h, --help This message +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --repo) REPO="${2:?--repo needs a value}"; shift 2 ;; + --ref) REF="${2:?--ref needs a value}"; shift 2 ;; + --root) ROOT="${2:?--root needs a value}"; shift 2 ;; + --work) WORK="${2:?--work needs a value}"; shift 2 ;; + --jobs) JOBS="${2:?--jobs needs a value}"; shift 2 ;; + --verify-url) VERIFY_URL="${2:?--verify-url needs a value}"; shift 2 ;; + --require-signature) REQUIRE_SIGNATURE=1; shift ;; + --no-reload) RELOAD=0; shift ;; + --dry-run) DRY_RUN=1; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1 (try --help)" ;; + esac +done + +[[ -n "$ROOT" ]] || die "--root is required" +[[ "$JOBS" =~ ^[1-9][0-9]*$ ]] || die "--jobs must be a positive integer" +[[ -n "$WORK" ]] || WORK="/var/lib/getbible/$(basename "$ROOT")" + +for tool in git rsync python3 gzip; do + command -v "$tool" >/dev/null || die "$tool is required but not installed" +done + +# --------------------------------------------------------------------------- +# Only one deploy per root at a time. +# --------------------------------------------------------------------------- +mkdir -p "$(dirname "$WORK")" +LOCK="${WORK}.lock" +exec 9>"$LOCK" +flock -n 9 || die "another deploy is already running for $ROOT" + +# --------------------------------------------------------------------------- +# 1. Pull. Only changed files are rewritten, so mtimes and ETags survive. +# --------------------------------------------------------------------------- +if [[ ! -d "$WORK/.git" ]]; then + [[ -n "$REPO" ]] || die "$WORK is not a checkout and no --repo was given" + log "cloning $REPO into $WORK" + git clone --branch "$REF" --single-branch "$REPO" "$WORK" +else + if [[ -n "$REPO" ]]; then + actual="$(git -C "$WORK" remote get-url origin)" + [[ "$actual" == "$REPO" ]] || die "$WORK points at $actual, expected $REPO" + fi + log "fetching $REF" + git -C "$WORK" fetch --prune origin "$REF" + git -C "$WORK" reset --hard "origin/$REF" + # Drop anything untracked except the compressed variants, which this script + # owns and prunes itself. Cleaning those away would force a full brotli pass + # over the whole corpus on every deploy. + git -C "$WORK" clean -fdx -e '*.json.gz' -e '*.json.br' +fi + +REVISION="$(git -C "$WORK" rev-parse HEAD)" +log "deploying $REVISION" + +if [[ "$REQUIRE_SIGNATURE" -eq 1 ]]; then + git -C "$WORK" verify-commit HEAD \ + || die "commit $REVISION has no valid signature and --require-signature was given" + log "signature verified" +fi + +# --------------------------------------------------------------------------- +# 2. Verify. hashes.json must describe the tree exactly before it goes live. +# --------------------------------------------------------------------------- +verify_tree() { + python3 - "$1" <<'PY' +import hashlib +import json +import sys +from pathlib import Path + +root = Path(sys.argv[1]) +manifest_path = root / "hashes.json" +if not manifest_path.is_file(): + sys.exit(f"no hashes.json under {root}") + +manifest = json.loads(manifest_path.read_text(encoding="utf-8")) +if manifest.get("algorithm") != "sha256": + sys.exit(f"unsupported digest algorithm: {manifest.get('algorithm')!r}") + +files = manifest.get("files") or {} +if not files: + sys.exit("hashes.json lists no files") + +problems = [] +for relative, expected in sorted(files.items()): + path = root / relative + if ".." in Path(relative).parts or path.is_symlink(): + problems.append(f"unsafe manifest path: {relative}") + continue + if not path.is_file(): + problems.append(f"missing: {relative}") + continue + digest = hashlib.sha256() + with path.open("rb") as handle: + for block in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(block) + if digest.hexdigest() != expected: + problems.append(f"digest mismatch: {relative}") + +# Every file, not just every .json. The manifest is the complete list of what +# the builder publishes, so anything else in the tree is unaccounted for and +# must not reach the origin — an unexpected .html or .js would otherwise be +# served from the API's own hostname. +variants = (".json.gz", ".json.br") +published = set() +for path in root.rglob("*"): + if not path.is_file(): + continue + relative = path.relative_to(root).as_posix() + if relative == "hashes.json" or relative.endswith(variants): + continue + published.add(relative) +for extra in sorted(published - set(files)): + problems.append(f"not in manifest: {extra}") + +if problems: + for problem in problems[:20]: + print(problem, file=sys.stderr) + remaining = len(problems) - 20 + if remaining > 0: + print(f"... and {remaining} more", file=sys.stderr) + sys.exit(f"{len(problems)} integrity problem(s) under {root}") + +print(f"verified {len(files)} documents") +PY +} + +shopt -s nullglob +VERSIONS=("$WORK"/v[0-9]*/) +shopt -u nullglob +[[ ${#VERSIONS[@]} -gt 0 ]] || die "$WORK contains no version directory" + +for version in "${VERSIONS[@]}"; do + log "verifying ${version#"$WORK"/}" + verify_tree "$version" +done + +# --------------------------------------------------------------------------- +# 3. Compress. Only for documents whose variant is missing or stale, and the +# variant carries the document's mtime so its ETag is stable too. +# --------------------------------------------------------------------------- +compress_one() { + local file="$1" + if [[ ! -f "$file.gz" || "$file" -nt "$file.gz" ]]; then + gzip -9 -c -- "$file" > "$file.gz.tmp" + mv -f -- "$file.gz.tmp" "$file.gz" + touch -r "$file" -- "$file.gz" + fi + if command -v brotli >/dev/null && [[ ! -f "$file.br" || "$file" -nt "$file.br" ]]; then + brotli -q 11 -c -- "$file" > "$file.br.tmp" + mv -f -- "$file.br.tmp" "$file.br" + touch -r "$file" -- "$file.br" + fi +} +export -f compress_one + +log "compressing with $JOBS job(s)" +# shellcheck disable=SC2016 # $1 is the child shell's argument, not this one's +find "$WORK" -path "$WORK/.git" -prune -o -type f -name '*.json' -print0 \ + | xargs -0 -r -P "$JOBS" -I{} bash -c 'compress_one "$1"' _ {} + +# Drop variants whose document no longer exists, so a removed module cannot +# leave a stale compressed copy that nginx would still serve. +pruned=0 +while IFS= read -r -d '' variant; do + if [[ ! -f "${variant%.*}" ]]; then + rm -f -- "$variant" + pruned=$((pruned + 1)) + fi +done < <(find "$WORK" -path "$WORK/.git" -prune -o -type f \( -name '*.json.gz' -o -name '*.json.br' \) -print0) +[[ "$pruned" -eq 0 ]] || log "pruned $pruned orphaned compressed file(s)" + +# --------------------------------------------------------------------------- +# 4. Sync into the live root, one version directory at a time. Scoping --delete +# to a single version means no failure mode of this script can remove +# anything outside a version directory, and the live root ends up holding +# only what is actually served — no repository metadata, no README. +# --------------------------------------------------------------------------- +RSYNC_FLAGS=( + --archive + --delete --delete-delay --delay-updates + "--chmod=D755,F644" + --omit-dir-times + --human-readable + --exclude='*.tmp' +) + +if [[ "$DRY_RUN" -eq 1 ]]; then + log "dry run — changes that would be applied to $ROOT:" + for version in "${VERSIONS[@]}"; do + rsync "${RSYNC_FLAGS[@]}" --dry-run --itemize-changes \ + "$version" "$ROOT/$(basename "$version")/" + done + exit 0 +fi + +mkdir -p "$ROOT" +for version in "${VERSIONS[@]}"; do + name="$(basename "$version")" + log "syncing $name into $ROOT" + mkdir -p "$ROOT/$name" + rsync "${RSYNC_FLAGS[@]}" "$version" "$ROOT/$name/" +done + +# Retire a version directory that the publication repository no longer carries. +for existing in "$ROOT"/v[0-9]*/; do + [[ -d "$existing" ]] || continue + name="$(basename "$existing")" + if [[ ! -d "$WORK/$name" ]]; then + log "removing retired version $name" + rm -rf -- "${ROOT:?}/$name" + fi +done + +for version in "${VERSIONS[@]}"; do + log "verifying live $(basename "$version")" + verify_tree "$ROOT/$(basename "$version")" +done + +printf '%s\n' "$REVISION" > "$ROOT/.revision" + +# --------------------------------------------------------------------------- +# 5. Reload. New workers start with an empty open-file cache, which is what +# makes the newly deployed documents visible immediately. +# --------------------------------------------------------------------------- +if [[ "$RELOAD" -eq 1 ]]; then + if command -v nginx >/dev/null; then + nginx -t + if command -v systemctl >/dev/null && systemctl is-active --quiet nginx; then + systemctl reload nginx + else + nginx -s reload + fi + log "nginx reloaded" + else + log "nginx not found; skipping reload" + fi +fi + +if [[ -n "$VERIFY_URL" ]]; then + command -v curl >/dev/null || die "--verify-url needs curl" + log "verifying $VERIFY_URL" + # No --fail: the status code is what is being checked, so it has to come + # back rather than turning into a bare non-zero exit with no explanation. + response="$(curl --silent --show-error --location --max-time 30 \ + --output /dev/null --write-out '%{http_code} %{content_type}' "$VERIFY_URL" || true)" + status="${response%% *}" + content_type="${response#* }" + [[ -n "$status" && "$status" != "000" ]] || die "could not reach $VERIFY_URL" + [[ "$status" == "200" ]] || die "$VERIFY_URL returned HTTP $status" + [[ "$content_type" == application/json* ]] \ + || die "$VERIFY_URL returned Content-Type ${content_type:-none}" + log "live check passed ($status $content_type)" +fi + +log "deployed $REVISION to $ROOT" diff --git a/scripts/install_nginx_config.sh b/scripts/install_nginx_config.sh new file mode 100755 index 0000000..13ec82d --- /dev/null +++ b/scripts/install_nginx_config.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-2.0-only +# +# Install the GetBible study API origin configuration, adapted to this host. +# +# install_nginx_config.sh --root-dir /var/www/getbible +# install_nginx_config.sh --host commentaries.getbible.net --reload +# +# The shipped configuration in docs/nginx/ targets current nginx. Three things +# legitimately differ between hosts, and guessing wrong on any of them makes +# nginx refuse to start, so they are detected rather than assumed: +# +# * "http2 on" is nginx 1.25.1+. Older nginx needs it folded into listen. +# * brotli_static needs ngx_brotli, which many distributions do not package. +# * IPv6 listeners fail outright on a host without IPv6. +# +# Nothing is written until every adapted file has passed nginx -t. +# +# Requires: bash 4, nginx. + +set -euo pipefail + +readonly PROGRAM="${0##*/}" +SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../docs/nginx" && pwd)" +readonly SOURCE_DIR + +PREFIX="/etc/nginx" +ROOT_DIR="/var/www/getbible" +HOSTS=() +RELOAD=0 +DRY_RUN=0 + +die() { printf '%s: %s\n' "$PROGRAM" "$*" >&2; exit 1; } +log() { printf ' %s\n' "$*"; } + +usage() { + cat <<'USAGE' +usage: install_nginx_config.sh [options] + + --host NAME Install only this host (repeatable; default: both) + --prefix DIR nginx configuration prefix (default: /etc/nginx) + --root-dir DIR Parent of the live document roots (default: /var/www/getbible) + --reload Reload nginx after a successful install + --dry-run Show what would be installed, write nothing + -h, --help This message +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --host) HOSTS+=("${2:?--host needs a value}"); shift 2 ;; + --prefix) PREFIX="${2:?--prefix needs a value}"; shift 2 ;; + --root-dir) ROOT_DIR="${2:?--root-dir needs a value}"; shift 2 ;; + --reload) RELOAD=1; shift ;; + --dry-run) DRY_RUN=1; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1 (try --help)" ;; + esac +done + +command -v nginx >/dev/null || die "nginx is not installed" +[[ ${#HOSTS[@]} -gt 0 ]] || HOSTS=(commentaries.getbible.net dictionaries.getbible.net) + +for host in "${HOSTS[@]}"; do + [[ -f "$SOURCE_DIR/$host.conf" ]] || die "no shipped configuration for $host" +done + +# nginx refuses to start when a referenced certificate is missing, and the +# resulting error names a file rather than the thing to do about it. Check first +# and say the actual next step. +if [[ "$DRY_RUN" -eq 0 ]]; then + missing=() + for host in "${HOSTS[@]}"; do + [[ -f "/etc/letsencrypt/live/$host/fullchain.pem" ]] || missing+=("$host") + done + if [[ ${#missing[@]} -gt 0 ]]; then + printf '%s: no certificate for: %s\n\n' "$PROGRAM" "${missing[*]}" >&2 + printf 'Issue them first. The stock nginx site already serves the ACME\n' >&2 + printf 'webroot on port 80, so this works before anything here is installed:\n\n' >&2 + printf ' certbot certonly --webroot -w /var/www/html%s\n\n' \ + "$(printf ' \\\n -d %s' "${missing[@]}")" >&2 + exit 1 + fi +fi + +# --------------------------------------------------------------------------- +# Detect what this host actually supports. +# --------------------------------------------------------------------------- +VERSION="$(nginx -v 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true)" +[[ -n "$VERSION" ]] || die "could not determine the nginx version" + +MODULES="$(nginx -V 2>&1)" +adapt=() + +echo "Detected:" +log "nginx $VERSION" + +if [[ "$(printf '%s\n1.25.1\n' "$VERSION" | sort -V | head -1)" != "1.25.1" ]]; then + log "HTTP/2: folding \"http2 on\" into listen (needs 1.25.1+)" + adapt+=(-e 's|^ http2 on;$| # http2 folded into listen below (nginx < 1.25.1)|' + -e 's|^ listen 443 ssl;$| listen 443 ssl http2;|' + -e 's|^ listen \[::\]:443 ssl;$| listen [::]:443 ssl http2;|') +else + log "HTTP/2: native \"http2 on\"" +fi + +snippet_adapt=() +if [[ "$MODULES" == *brotli* ]] || ls /usr/lib/nginx/modules/ngx_http_brotli_static_module.so >/dev/null 2>&1; then + log "Brotli: available, enabling brotli_static" + snippet_adapt+=(-e 's|^\( *\)# brotli_static on;$|\1brotli_static on;|') +else + log "Brotli: not available, serving gzip only (roughly 15-20% larger on JSON)" +fi + +if [[ ! -f /proc/net/if_inet6 ]]; then + log "IPv6: unavailable, commenting out the [::] listeners" + adapt+=(-e 's|^\( *\)listen \(.*\)\[::\]\(.*\)$|\1# listen \2[::]\3|') +else + log "IPv6: available" +fi + +if [[ "$ROOT_DIR" != "/var/www/getbible" ]]; then + log "Document roots: $ROOT_DIR" + adapt+=(-e "s|/var/www/getbible/|${ROOT_DIR%/}/|") +fi + +# --------------------------------------------------------------------------- +# Build the adapted set in a staging directory and test it before installing. +# --------------------------------------------------------------------------- +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT +mkdir -p "$STAGE/conf.d" "$STAGE/snippets" "$STAGE/sites-available" + +cp "$SOURCE_DIR/getbible-api-http.conf" "$STAGE/conf.d/" +for snippet in "$SOURCE_DIR"/snippets/*.conf; do + if [[ ${#snippet_adapt[@]} -gt 0 ]]; then + sed "${snippet_adapt[@]}" "$snippet" > "$STAGE/snippets/$(basename "$snippet")" + else + cp "$snippet" "$STAGE/snippets/" + fi +done +for host in "${HOSTS[@]}"; do + if [[ ${#adapt[@]} -gt 0 ]]; then + sed "${adapt[@]}" "$SOURCE_DIR/$host.conf" > "$STAGE/sites-available/$host.conf" + else + cp "$SOURCE_DIR/$host.conf" "$STAGE/sites-available/$host.conf" + fi +done + +echo +echo "Would install into $PREFIX:" +for file in "$STAGE"/conf.d/* "$STAGE"/snippets/* "$STAGE"/sites-available/*; do + log "${file#"$STAGE"/}" +done + +if [[ "$DRY_RUN" -eq 1 ]]; then + echo + echo "Dry run; nothing written. Adapted files were left in:" + trap - EXIT + echo " $STAGE" + exit 0 +fi + +[[ -w "$PREFIX" ]] || die "$PREFIX is not writable; run as root" + +echo +echo "Installing:" +install -d -m 755 "$PREFIX/conf.d" "$PREFIX/snippets" "$PREFIX/sites-available" "$PREFIX/sites-enabled" +install -m 644 "$STAGE/conf.d/getbible-api-http.conf" "$PREFIX/conf.d/" +install -m 644 "$STAGE"/snippets/*.conf "$PREFIX/snippets/" +for host in "${HOSTS[@]}"; do + install -m 644 "$STAGE/sites-available/$host.conf" "$PREFIX/sites-available/" + ln -sfn "$PREFIX/sites-available/$host.conf" "$PREFIX/sites-enabled/$host.conf" + install -d -m 755 "${ROOT_DIR%/}/${host%%.*}" + log "$host -> ${ROOT_DIR%/}/${host%%.*}" +done + +echo +echo "Testing:" +if ! nginx -t; then + die "nginx rejected the installed configuration; it has NOT been reloaded" +fi + +if [[ "$RELOAD" -eq 1 ]]; then + if command -v systemctl >/dev/null && systemctl is-active --quiet nginx; then + systemctl reload nginx + else + nginx -s reload + fi + echo " reloaded" +else + echo " not reloaded; run 'systemctl reload nginx' when ready" +fi diff --git a/scripts/validate_build.py b/scripts/validate_build.py index edacca0..adb7758 100644 --- a/scripts/validate_build.py +++ b/scripts/validate_build.py @@ -10,42 +10,104 @@ from study_builder.util import read_json, slug -def validate_commentary(root: Path) -> dict[str, Any]: +def _reject_markup(document: Any, where: str) -> None: + if isinstance(document, dict): + if "html" in document: + raise RuntimeError(f"{where} still publishes an html member") + for key, value in document.items(): + _reject_markup(value, f"{where}.{key}") + elif isinstance(document, list): + for index, value in enumerate(document): + _reject_markup(value, f"{where}[{index}]") + + +def _assert_composed(composed: list[Any], parts: list[Path], where: str) -> None: + """A composed document must contain its parts exactly as they are served alone.""" + if len(composed) != len(parts): + raise RuntimeError(f"{where} holds {len(composed)} members for {len(parts)} documents") + for member, path in zip(composed, parts, strict=True): + if member != read_json(path): + raise RuntimeError(f"{where} does not match the document served at {path}") + + +def validate_commentary(root: Path, complete_path: Path) -> dict[str, Any]: metadata = read_json(root / "metadata.json") books = read_json(root / "books.json") if metadata.get("schema") != "getbible-commentary-metadata-v1": raise RuntimeError("Unexpected commentary metadata schema") - if int(metadata.get("entry_count", 0)) <= 0 or not books: + if books.get("schema") != "getbible-commentary-books-v1": + raise RuntimeError("Unexpected commentary books index schema") + if int(metadata.get("entry_count", 0)) <= 0 or not books.get("books"): raise RuntimeError("Commentary produced no addressable entries") - first_book = books[0] - book_index = read_json(root / str(first_book["url"])) - if not book_index.get("chapters"): - raise RuntimeError("Commentary book index produced no chapters") - chapter = read_json(root / str(book_index["chapters"][0]["url"])) - if chapter.get("schema") != "getbible-commentary-chapter-v1": - raise RuntimeError("Unexpected commentary chapter schema") - if not chapter.get("entries"): + + first_book = books["books"][0] + book_path = root / f"{first_book['book']}.json" + book = read_json(book_path) + if book.get("schema") != "getbible-commentary-book-v1" or not book.get("chapters"): + raise RuntimeError("Commentary book document produced no chapters") + chapter_paths = [ + root / str(first_book["book"]) / f"{number}.json" for number in first_book["chapters"] + ] + _assert_composed(book["chapters"], chapter_paths, f"{book_path}.chapters") + + chapter = read_json(chapter_paths[0]) + if chapter.get("schema") != "getbible-commentary-chapter-v1" or not chapter.get("entries"): raise RuntimeError("Commentary chapter produced no entries") first = chapter["entries"][0] if not all(name in first for name in ("book", "chapter", "verse", "anchor", "text")): raise RuntimeError("Commentary entry is not linked to a Bible API coordinate") - return {"entries": metadata["entry_count"], "books": metadata["book_count"]} + _reject_markup(chapter, "chapter") + complete = read_json(complete_path) + if complete.get("schema") != "getbible-commentary-v1": + raise RuntimeError("Unexpected whole-commentary schema") + book_paths = [root / f"{record['book']}.json" for record in books["books"]] + _assert_composed(complete["books"], book_paths, f"{complete_path}.books") -def validate_dictionary(root: Path) -> dict[str, Any]: + return { + "books": metadata["book_count"], + "chapters": metadata["chapter_count"], + "entries": metadata["entry_count"], + "bytes": metadata["bytes"], + "introductions": sum(1 for record in books["books"] if 0 in record["chapters"]), + } + + +def validate_dictionary(root: Path, complete_path: Path) -> dict[str, Any]: metadata = read_json(root / "metadata.json") - keys = read_json(root / "keys.json") + index = read_json(root / "index.json") if metadata.get("schema") != "getbible-dictionary-metadata-v1": raise RuntimeError("Unexpected dictionary metadata schema") - if int(metadata.get("entry_count", 0)) <= 0 or not keys: + if index.get("schema") != "getbible-dictionary-index-v1": + raise RuntimeError("Unexpected dictionary index schema") + if int(metadata.get("entry_count", 0)) <= 0 or not index.get("entries"): raise RuntimeError("Dictionary produced no addressable entries") - first = keys[0] - document = read_json(root / str(first["url"])) + + terms = [record["search"] for record in index["entries"]] + if terms != sorted(terms): + raise RuntimeError("Dictionary index is not sorted by its search term") + + entry_paths = [root / f"{record['id']}.json" for record in index["entries"]] + document = read_json(entry_paths[0]) if document.get("schema") != "getbible-dictionary-entry-v1": raise RuntimeError("Unexpected dictionary entry schema") if not all(name in document for name in ("dictionary", "id", "key", "aliases", "text")): raise RuntimeError("Dictionary entry is missing its lookup contract") - return {"entries": metadata["entry_count"], "strong_prefix": metadata["strong_prefix"]} + _reject_markup(document, "entry") + + complete = read_json(complete_path) + if complete.get("schema") != "getbible-dictionary-v1": + raise RuntimeError("Unexpected whole-dictionary schema") + _assert_composed(complete["entries"], entry_paths, f"{complete_path}.entries") + + linked = sum(1 for entry in complete["entries"] if entry.get("see_also")) + return { + "entries": metadata["entry_count"], + "unique_keys": metadata["unique_key_count"], + "strong_prefix": metadata["strong_prefix"], + "bytes": metadata["bytes"], + "entries_with_links": linked, + } def main() -> int: @@ -54,9 +116,14 @@ def main() -> int: parser.add_argument("--module", required=True) parser.add_argument("--dist-dir", type=Path, default=Path("dist")) args = parser.parse_args() - root = args.dist_dir / args.resource / "v1" / slug(args.module) + module_id = slug(args.module) + version_root = args.dist_dir / args.resource / "v1" + root = version_root / module_id + complete_path = version_root / f"{module_id}.json" result = ( - validate_commentary(root) if args.resource == "commentaries" else validate_dictionary(root) + validate_commentary(root, complete_path) + if args.resource == "commentaries" + else validate_dictionary(root, complete_path) ) print(json.dumps({"resource": args.resource, "module": args.module, **result}, indent=2)) return 0 diff --git a/scripts/verify_live_api.sh b/scripts/verify_live_api.sh new file mode 100755 index 0000000..6775a0a --- /dev/null +++ b/scripts/verify_live_api.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-2.0-only +# +# Smoke-test a deployed GetBible study API origin. +# +# verify_live_api.sh https://commentaries.getbible.net +# verify_live_api.sh https://dictionaries.getbible.net +# +# Everything checked here is a promise the API makes to its clients: the paths +# resolve, the two cache tiers are right, precompressed variants are served, +# revalidation returns 304, CORS and the security headers are present, and +# failures come back as JSON rather than an HTML error page. +# +# Nothing is hardcoded to a module — the catalog is read and followed, so this +# works against any build. Exits non-zero on the first broken promise. +# +# Requires: bash 4, curl, python3. + +set -uo pipefail + +readonly PROGRAM="${0##*/}" +BASE="${1:-}" + +if [[ -z "$BASE" || "$BASE" == -h || "$BASE" == --help ]]; then + printf 'usage: %s https://commentaries.getbible.net [--insecure]\n' "$PROGRAM" >&2 + exit 2 +fi +BASE="${BASE%/}" +shift || true + +CURL=(curl --silent --show-error --location --max-time 30) +[[ "${1:-}" == "--insecure" ]] && CURL+=(--insecure) + +for tool in curl python3; do + command -v "$tool" >/dev/null || { printf '%s: %s is required\n' "$PROGRAM" "$tool" >&2; exit 2; } +done + +pass=0 +fail=0 + +check() { + local label="$1" expected="$2" actual="$3" + # HTTP/2 lowercases header names, so compare case-insensitively. + if [[ "${actual,,}" == *"${expected,,}"* ]]; then + printf ' ok %s\n' "$label" + pass=$((pass + 1)) + else + printf ' FAIL %-46s expected %-24s got: %s\n' "$label" "$expected" "$actual" + fail=$((fail + 1)) + fi +} + +body() { "${CURL[@]}" "$1"; } +headers() { "${CURL[@]}" --output /dev/null --dump-header - "$1"; } +status() { "${CURL[@]}" --output /dev/null --write-out '%{http_code}' "$@"; } +header() { headers "$1" | grep -i "^$2:" | tr -d '\r' | tail -1; } + +# Read one value out of a JSON document without needing jq on the server. +pick() { + python3 -c ' +import json, sys +document = json.loads(sys.stdin.read()) +for key in sys.argv[1:]: + document = document[int(key)] if isinstance(document, list) else document[key] +print(document) +' "$@" +} + +echo "== $BASE ==" + +# --------------------------------------------------------------------------- +# Discover which resource this origin serves, then follow its own catalog. +# --------------------------------------------------------------------------- +RESOURCE="" +for candidate in commentaries dictionaries; do + if [[ "$(status "$BASE/v1/$candidate.json")" == "200" ]]; then + RESOURCE="$candidate" + break + fi +done +[[ -n "$RESOURCE" ]] || { printf '%s: no catalog at %s/v1/\n' "$PROGRAM" "$BASE" >&2; exit 1; } +echo " serving: $RESOURCE" + +CATALOG="$(body "$BASE/v1/$RESOURCE.json")" +MODULE="$(printf '%s' "$CATALOG" | pick "$RESOURCE" 0 id)" || exit 1 +echo " sampling module: $MODULE" + +echo +echo "-- documents --" +check "catalog" "200" "$(status "$BASE/v1/$RESOURCE.json")" +check "build stamp" "200" "$(status "$BASE/v1/build.json")" +check "integrity manifest" "200" "$(status "$BASE/v1/hashes.json")" +check "module metadata" "200" "$(status "$BASE/v1/$MODULE/metadata.json")" +check "bulk document" "200" "$(status "$BASE/v1/$MODULE.json")" + +if [[ "$RESOURCE" == commentaries ]]; then + BOOKS="$(body "$BASE/v1/$MODULE/books.json")" + BOOK="$(printf '%s' "$BOOKS" | pick books 0 book)" + CHAPTER="$(printf '%s' "$BOOKS" | pick books 0 chapters 0)" + HOT="$BASE/v1/$MODULE/$BOOK/$CHAPTER.json" + check "books index" "200" "$(status "$BASE/v1/$MODULE/books.json")" + check "book document" "200" "$(status "$BASE/v1/$MODULE/$BOOK.json")" + check "chapter document ($MODULE $BOOK:$CHAPTER)" "200" "$(status "$HOT")" + check "schema published" "200" "$(status "$BASE/v1/schema/commentary-chapter.json")" +else + INDEX="$(body "$BASE/v1/$MODULE/index.json")" + ENTRY="$(printf '%s' "$INDEX" | pick entries 0 id)" + HOT="$BASE/v1/$MODULE/$ENTRY.json" + check "search index" "200" "$(status "$BASE/v1/$MODULE/index.json")" + check "word document ($MODULE/$ENTRY)" "200" "$(status "$HOT")" + check "schema published" "200" "$(status "$BASE/v1/schema/dictionary-entry.json")" +fi + +check "content type" "application/json" "$(header "$HOT" content-type)" + +echo +echo "-- caching --" +check "discovery is short-lived" "max-age=300" "$(header "$BASE/v1/$RESOURCE.json" cache-control)" +check "content is long-lived" "max-age=86400" "$(header "$HOT" cache-control)" +ETAG="$(header "$HOT" etag | cut -d' ' -f2)" +check "etag present" '"' "$ETAG" +check "revalidation returns 304" "304" \ + "$("${CURL[@]}" --output /dev/null --write-out '%{http_code}' --header "If-None-Match: $ETAG" "$HOT")" + +echo +echo "-- compression --" +check "gzip variant served" "content-encoding: gzip" \ + "$("${CURL[@]}" --output /dev/null --dump-header - --header 'Accept-Encoding: gzip' "$HOT" \ + | grep -i '^content-encoding' | tr -d '\r')" +check "varies on encoding" "accept-encoding" "$(header "$HOT" vary)" +plain=$("${CURL[@]}" --output /dev/null --write-out '%{size_download}' --header 'Accept-Encoding: identity' "$HOT") +small=$("${CURL[@]}" --output /dev/null --write-out '%{size_download}' --header 'Accept-Encoding: gzip, br' "$HOT") +if (( small < plain )); then + printf ' ok compressed transfer is smaller (%s -> %s bytes)\n' "$plain" "$small" + pass=$((pass + 1)) +else + printf ' FAIL compressed transfer is smaller (%s -> %s bytes)\n' "$plain" "$small" + fail=$((fail + 1)) +fi + +echo +echo "-- CORS and security --" +HEAD="$(headers "$HOT")" +check "allows any origin" "access-control-allow-origin: *" \ + "$(echo "$HEAD" | grep -i '^access-control-allow-origin' | tr -d '\r')" +check "exposes etag" "etag" \ + "$(echo "$HEAD" | grep -i '^access-control-expose-headers' | tr -d '\r')" +check "nosniff" "nosniff" "$(echo "$HEAD" | grep -i '^x-content-type-options' | tr -d '\r')" +check "content policy" "default-src 'none'" "$(echo "$HEAD" | grep -i '^content-security-policy' | tr -d '\r')" +check "resource policy" "cross-origin" "$(echo "$HEAD" | grep -i '^cross-origin-resource-policy' | tr -d '\r')" +check "strict transport" "max-age=" "$(echo "$HEAD" | grep -i '^strict-transport-security' | tr -d '\r')" +check "preflight" "204" "$(status --request OPTIONS "$HOT")" + +if echo "$HEAD" | grep -qiE '^server: nginx/[0-9]'; then + printf ' FAIL server version is disclosed (set server_tokens off)\n' + fail=$((fail + 1)) +else + printf ' ok server version withheld\n' + pass=$((pass + 1)) +fi + +echo +echo "-- failure modes --" +MISSING="$BASE/v1/$MODULE/does-not-exist-$$.json" +check "missing document 404" "404" "$(status "$MISSING")" +check "404 body is JSON" '"error"' "$(body "$MISSING")" +check "404 content type" "application/json" "$(header "$MISSING" content-type)" +check "writes rejected" "405" "$(status --request POST "$HOT")" +check "repository metadata hidden" "404" "$(status "$BASE/.git/config")" + +echo +printf '== %d passed, %d failed ==\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] diff --git a/src/study_builder/cli.py b/src/study_builder/cli.py index 843a9cc..6f91e1f 100644 --- a/src/study_builder/cli.py +++ b/src/study_builder/cli.py @@ -55,14 +55,14 @@ def parser() -> argparse.ArgumentParser: "--commentaries-repo", default=os.environ.get( "STUDY_BUILDER_COMMENTARIES_REPO", - "git@github.com:getbible/v1_commentaries.git", + "git@github.com:getbible/commentaries.git", ), ) build.add_argument( "--dictionaries-repo", default=os.environ.get( "STUDY_BUILDER_DICTIONARIES_REPO", - "git@github.com:getbible/v1_dictionaries.git", + "git@github.com:getbible/dictionaries.git", ), ) build.add_argument("--commentaries-branch", default="main") diff --git a/src/study_builder/commentaries.py b/src/study_builder/commentaries.py index 8617c88..517e29a 100644 --- a/src/study_builder/commentaries.py +++ b/src/study_builder/commentaries.py @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: GPL-2.0-only from __future__ import annotations from collections import defaultdict @@ -11,80 +12,42 @@ from study_builder.chapter_spool import CommentaryChapterSpool from study_builder.content import extract_osis_references, public_content from study_builder.models import ModuleDescriptor, NativeExport -from study_builder.util import read_json, slug, write_json +from study_builder.util import read_json, slug, write_composed_json, write_json + +# Reserved inside a commentary directory; an entry may never claim these names. +RESERVED_DOCUMENTS = {"metadata.json", "books.json"} class CommentaryWriter: - def __init__(self, root: Path, books: BookRegistry, schema_path: Path) -> None: + """Write the chapter, book, and whole-commentary documents for one module. + + Chapter documents are the addressable unit. Book and commentary documents embed + them byte-for-byte, so `book.chapters[n]` is exactly the chapter document served + at its own path and a client needs only one parser for all three levels. + """ + + def __init__(self, root: Path, books: BookRegistry, schemas_dir: Path) -> None: self.root = root self.books = books - self.schema = read_json(schema_path) + self.chapter_schema = read_json(schemas_dir / "commentary-chapter.schema.json") - def write(self, module: ModuleDescriptor, exported: NativeExport) -> dict[str, Any]: + def write(self, module: ModuleDescriptor, exported: NativeExport) -> tuple[dict, dict]: module_id = slug(module.name) module_root = self.root / module_id - chapter_indexes: dict[int, list[dict[str, Any]]] = defaultdict(list) + chapter_files: dict[int, list[Path]] = defaultdict(list) + chapter_counts: dict[int, list[tuple[int, int]]] = defaultdict(list) entry_count = 0 with CommentaryChapterSpool() as chapters: for source in exported.entries: - verse = source.get("verse") or {} - chapter = int(verse.get("chapter", 0) or 0) - verse_number = int(verse.get("verse", 0) or 0) - # The public commentary API is chapter-addressable. SWORD modules may - # also expose book introductions with chapter zero; those records do - # not have a chapter endpoint and intentionally remain unpublished. - if chapter <= 0 or verse_number < 0: - continue - try: - book = self.books.from_entry(source) - except ValueError: - continue - content = public_content(source) - if not content.get("text") and not content.get("html"): - continue - osis = str(verse.get("osis", "")) - related = [] - for reference in extract_osis_references( - str(source.get("raw", "")), str(source.get("html", "")) - ): - normalized = self.books.reference(reference) - if normalized: - related.append(normalized) - anchor = { - "book": book.number, - "chapter": chapter, - "verse": verse_number, - } - if osis: - anchor["osis"] = osis - label = book.name - if chapter: - label += f" {chapter}" - if verse_number: - label += f":{verse_number}" - entry: dict[str, Any] = { - "book": book.number, - "chapter": chapter, - "verse": verse_number, - "name": label, - "anchor": anchor, - **content, - } - if related: - entry["references"] = related - chapters.append(entry) + entry = self._entry(source) + if entry is not None: + chapters.append(entry) for book_number, chapter_number in chapters.coordinates(): - chapter_seen: set[tuple[int, str]] = set() - chapter_entries: list[dict[str, Any]] = [] - for entry in chapters.entries(book_number, chapter_number): - unique = (int(entry["verse"]), str(entry.get("text", ""))) - if unique in chapter_seen: - continue - chapter_seen.add(unique) - chapter_entries.append(entry) - chapter_entries.sort(key=lambda item: (item["verse"], item["name"])) + chapter_entries = self._chapter_entries(chapters, book_number, chapter_number) + if not chapter_entries: + continue book = self.books.by_number[book_number] document = { "schema": "getbible-commentary-chapter-v1", @@ -95,44 +58,159 @@ def write(self, module: ModuleDescriptor, exported: NativeExport) -> dict[str, A "chapter": chapter_number, "entries": chapter_entries, } - validate(document, self.schema) - write_json(module_root / str(book_number) / f"{chapter_number}.json", document) - chapter_indexes[book_number].append( - { - "chapter": chapter_number, - "entry_count": len(chapter_entries), - "url": f"{book_number}/{chapter_number}.json", - } - ) + validate(document, self.chapter_schema) + path = module_root / str(book_number) / f"{chapter_number}.json" + write_json(path, document) + chapter_files[book_number].append(path) + chapter_counts[book_number].append((chapter_number, len(chapter_entries))) entry_count += len(chapter_entries) + book_files: list[Path] = [] books_index: list[dict[str, Any]] = [] - for book_number in sorted(chapter_indexes): + for book_number in sorted(chapter_files): book = self.books.by_number[book_number] - chapter_index = chapter_indexes[book_number] - chapter_numbers = [record["chapter"] for record in chapter_index] - write_json( - module_root / f"{book_number}.json", + path = module_root / f"{book_number}.json" + if path.name in RESERVED_DOCUMENTS: + raise RuntimeError(f"Book document collides with a reserved name: {path.name}") + write_composed_json( + path, { - "schema": "getbible-commentary-book-index-v1", + "schema": "getbible-commentary-book-v1", "commentary": module_id, "language": module.language, "book": book_number, "name": book.name, - "chapters": chapter_index, }, + "chapters", + chapter_files[book_number], ) + book_files.append(path) books_index.append( { "book": book_number, "name": book.name, - "chapters": chapter_numbers, - "url": f"{book_number}.json", - "chapter_url_template": f"{book_number}/{{chapter}}.json", + "chapters": [number for number, _ in chapter_counts[book_number]], + "entry_count": sum(count for _, count in chapter_counts[book_number]), } ) - metadata = { + write_json( + module_root / "books.json", + { + "schema": "getbible-commentary-books-v1", + "commentary": module_id, + "language": module.language, + "name": module.description, + "book_url_template": "{book}.json", + "chapter_url_template": "{book}/{chapter}.json", + "book_count": len(books_index), + "books": books_index, + }, + ) + + complete = self.root / f"{module_id}.json" + write_composed_json( + complete, + { + "schema": "getbible-commentary-v1", + "commentary": module_id, + "language": module.language, + "name": module.description, + }, + "books", + book_files, + ) + + chapter_count = sum(len(records) for records in chapter_counts.values()) + metadata = self._metadata( + module, module_id, len(books_index), chapter_count, entry_count, complete.stat().st_size + ) + write_json(module_root / "metadata.json", metadata) + record = { + "id": module_id, + "name": module.description, + "language": module.language, + "license": module.license, + "book_count": len(books_index), + "chapter_count": chapter_count, + "entry_count": entry_count, + "bytes": metadata["bytes"], + } + return record, metadata + + def _entry(self, source: dict[str, Any]) -> dict[str, Any] | None: + verse = source.get("verse") or {} + chapter = int(verse.get("chapter", 0) or 0) + verse_number = int(verse.get("verse", 0) or 0) + # Chapter zero carries a book introduction and verse zero a chapter + # introduction. Both are published: chapter zero at {book}/0.json, and + # verse zero as the first entry of its chapter. + if chapter < 0 or verse_number < 0: + return None + try: + book = self.books.from_entry(source) + except ValueError: + return None + content = public_content(source) + if not content["text"]: + return None + label = book.name + if chapter: + label += f" {chapter}" + if verse_number: + label += f":{verse_number}" + anchor: dict[str, Any] = { + "book": book.number, + "chapter": chapter, + "verse": verse_number, + } + osis = str(verse.get("osis", "")) + if osis: + anchor["osis"] = osis + entry: dict[str, Any] = { + "book": book.number, + "chapter": chapter, + "verse": verse_number, + "name": label, + "anchor": anchor, + **content, + } + related = [] + for reference in extract_osis_references( + str(source.get("raw", "")), str(source.get("html", "")) + ): + normalized = self.books.reference(reference) + if normalized: + related.append(normalized) + if related: + entry["references"] = related + return entry + + @staticmethod + def _chapter_entries( + chapters: CommentaryChapterSpool, book_number: int, chapter_number: int + ) -> list[dict[str, Any]]: + seen: set[tuple[int, str]] = set() + collected: list[dict[str, Any]] = [] + for entry in chapters.entries(book_number, chapter_number): + unique = (int(entry["verse"]), str(entry.get("text", ""))) + if unique in seen: + continue + seen.add(unique) + collected.append(entry) + collected.sort(key=lambda item: (item["verse"], item["name"])) + return collected + + @staticmethod + def _metadata( + module: ModuleDescriptor, + module_id: str, + book_count: int, + chapter_count: int, + entry_count: int, + complete_bytes: int, + ) -> dict[str, Any]: + return { "schema": "getbible-commentary-metadata-v1", "id": module_id, "module": module.name, @@ -143,9 +221,12 @@ def write(self, module: ModuleDescriptor, exported: NativeExport) -> dict[str, A "driver": module.driver, "source_type": module.first("sourcetype"), "versification": module.first("versification", "KJV"), + "book_count": book_count, + "chapter_count": chapter_count, "entry_count": entry_count, - "book_count": len(books_index), + "bytes": complete_bytes, "books_url": "books.json", + "book_url_template": "{book}.json", "chapter_url_template": "{book}/{chapter}.json", "source": "CrossWire SWORD", "source_module_url": ( @@ -166,6 +247,3 @@ def write(self, module: ModuleDescriptor, exported: NativeExport) -> dict[str, A "Converted to GetBible static JSON; wording is supplied by the source module." ), } - write_json(module_root / "metadata.json", metadata) - write_json(module_root / "books.json", books_index) - return metadata diff --git a/src/study_builder/content.py b/src/study_builder/content.py index db8d796..ee993d2 100644 --- a/src/study_builder/content.py +++ b/src/study_builder/content.py @@ -1,22 +1,21 @@ +# SPDX-License-Identifier: GPL-2.0-only from __future__ import annotations import html import re +from html.parser import HTMLParser from typing import Any -import bleach - -ALLOWED_TAGS = { - "a", - "b", +# The public API publishes plain text only. Markup is never republished, so the +# builder needs no HTML sanitizer and the generated API carries no markup that a +# consuming application could inject into a page. +_SUPPRESSED_TAGS = {"script", "style"} +_BREAK_TAGS = { "blockquote", "br", - "code", "dd", "div", - "dl", "dt", - "em", "h1", "h2", "h3", @@ -24,28 +23,13 @@ "h5", "h6", "hr", - "i", "li", "ol", "p", - "span", - "strong", - "sub", - "sup", "table", - "tbody", - "td", - "th", - "thead", "tr", - "u", "ul", } -ALLOWED_ATTRIBUTES = { - "a": ["href", "title"], - "*": ["class", "dir", "lang", "title"], -} -ALLOWED_PROTOCOLS = {"http", "https", "mailto", "sword"} _OSIS_REF = re.compile( r"(?P[1-4]?[A-Za-z][A-Za-z0-9]+)\.(?P\d+)(?:\.(?P\d+))?" @@ -53,19 +37,49 @@ _SWORD_URI = re.compile(r"sword://(?P[^\s\"'<>]+)", re.IGNORECASE) -def clean_html(value: str) -> str: - return bleach.clean( - value, - tags=ALLOWED_TAGS, - attributes=ALLOWED_ATTRIBUTES, - protocols=ALLOWED_PROTOCOLS, - strip=True, - strip_comments=True, - ).strip() +class _MarkupStripper(HTMLParser): + """Reduce source markup to readable text without republishing any of it.""" + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self._parts: list[str] = [] + self._suppressed = 0 + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag in _SUPPRESSED_TAGS: + self._suppressed += 1 + elif tag in _BREAK_TAGS: + self._parts.append("\n") + + def handle_endtag(self, tag: str) -> None: + if tag in _SUPPRESSED_TAGS: + self._suppressed = max(0, self._suppressed - 1) + elif tag in _BREAK_TAGS: + self._parts.append("\n") + + def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag in _BREAK_TAGS: + self._parts.append("\n") + + def handle_data(self, data: str) -> None: + if not self._suppressed: + self._parts.append(data) + + def text(self) -> str: + return "".join(self._parts) + + +def strip_markup(value: str) -> str: + parser = _MarkupStripper() + parser.feed(value) + parser.close() + return parser.text() -def clean_text(value: str) -> str: - value = html.unescape(value).replace("\x00", "") +def clean_text(value: str, *, unescape: bool = True) -> str: + if unescape: + value = html.unescape(value) + value = value.replace("\x00", "") return "\n".join(line.rstrip() for line in value.strip().splitlines()).strip() @@ -81,11 +95,12 @@ def extract_osis_references(*values: str) -> list[str]: return sorted(references) -def public_content(entry: dict[str, Any]) -> dict[str, Any]: +def public_content(entry: dict[str, Any]) -> dict[str, str]: + """Project a validated contract entry onto the published text-only shape.""" text = clean_text(str(entry.get("plain", ""))) - rendered = clean_html(str(entry.get("html", ""))) - result: dict[str, Any] = {"text": text} - visible_rendered = clean_text(bleach.clean(rendered, tags=set(), strip=True)) - if rendered and visible_rendered and rendered != text: - result["html"] = rendered - return result + if not text: + # A few modules leave the extractor's stripped field empty and carry the + # definition only in the rendered form. Deriving text keeps those entries + # addressable instead of dropping them when markup is not republished. + text = clean_text(strip_markup(str(entry.get("html", ""))), unescape=False) + return {"text": text} diff --git a/src/study_builder/dictionaries.py b/src/study_builder/dictionaries.py index 9bc2ec5..19ea69d 100644 --- a/src/study_builder/dictionaries.py +++ b/src/study_builder/dictionaries.py @@ -1,21 +1,38 @@ +# SPDX-License-Identifier: GPL-2.0-only from __future__ import annotations import base64 import hashlib import re +import unicodedata from collections import defaultdict +from dataclasses import dataclass, field from pathlib import Path from typing import Any -from urllib.parse import quote +from urllib.parse import quote, unquote from jsonschema import validate +from study_builder.books import BookRegistry from study_builder.content import extract_osis_references, public_content from study_builder.models import ModuleDescriptor, NativeExport -from study_builder.util import read_json, slug, write_json +from study_builder.util import read_json, slug, write_composed_json, write_json _STRONG_KEY = re.compile(r"^(?:strong:)?([GH])?0*(\d{1,5})(?:!.*)?$", re.IGNORECASE) _SAFE_ENTRY_KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$") +_OSIS_LIKE = re.compile(r"^[1-4]?[A-Za-z][A-Za-z0-9]+\.\d+(?:\.\d+)?$") +_SWORD_LINK = re.compile(r"sword://(?P[^\s\"'<>]+)", re.IGNORECASE) +_MARKUP_TARGET = re.compile( + r"<(?:ref|reference|a)\b[^>]*?\b(?:target|href|osisRef)\s*=\s*[\"'](?P[^\"']+)[\"']", + re.IGNORECASE, +) +_STRONG_SEE = re.compile( + r"\bsee\s+(?PGREEK|HEBREW)\s+for\s+0*(?P\d{1,5})\b", re.IGNORECASE +) +_SEARCH_NOISE = re.compile(r"[^\w\s-]", re.UNICODE) + +# Reserved inside a dictionary directory; an entry may never claim these names. +RESERVED_DOCUMENTS = {"metadata.json", "index.json"} def strong_prefix(module: ModuleDescriptor, metadata: dict[str, Any]) -> str | None: @@ -51,27 +68,156 @@ def encoded_entry_id(key: str) -> str: return "h-" + hashlib.sha256(key.encode("utf-8")).hexdigest() +def search_key(key: str) -> str: + """Fold a source key to an accent-insensitive, lowercase search term.""" + decomposed = unicodedata.normalize("NFKD", key) + folded = "".join(part for part in decomposed if not unicodedata.combining(part)).casefold() + return re.sub(r"\s+", " ", _SEARCH_NOISE.sub(" ", folded)).strip() or key.casefold() + + +def link_candidates(*values: str) -> set[str]: + """Collect the raw cross-reference targets a dictionary entry points at. + + Source markup and the extractor's rendered form spell the same link in different + ways, so both are scanned. Only targets that resolve to a key in the same + dictionary survive, which keeps the sweep self-limiting. + """ + candidates: set[str] = set() + for value in values: + for match in _SWORD_LINK.finditer(value): + candidates.add(match.group("value").rsplit("/", 1)[-1]) + for match in _MARKUP_TARGET.finditer(value): + target = match.group("value") + if target.lower().startswith("sword://"): + target = target[len("sword://") :] + candidates.add(target.rsplit("/", 1)[-1].split(":", 1)[-1]) + for match in _STRONG_SEE.finditer(value): + language = "G" if match.group("language").upper() == "GREEK" else "H" + strong = canonical_strong(match.group("number"), language) + if strong: + candidates.add(strong) + resolved = set() + for candidate in candidates: + value = unquote(candidate).strip().strip("#") + # Scripture references belong in "references", not in the word link graph. + if value and not _OSIS_LIKE.fullmatch(value): + resolved.add(value) + return resolved + + +@dataclass +class _Staged: + entry_id: str + key: str + occurrence: int + aliases: list[str] + search: str + targets: set[str] = field(default_factory=set) + links: list[str] = field(default_factory=list) + backlinks: list[str] = field(default_factory=list) + + class DictionaryWriter: - def __init__(self, root: Path, schema_path: Path) -> None: + """Write the search index and per-word documents for one dictionary module. + + The build reads the validated entry spool twice: the first pass assigns stable + identifiers and collects cross-reference targets, and the second writes each word + with its links resolved. Forward and reverse links are only knowable once every + key has an identifier, so a single pass cannot produce a navigable link graph. + """ + + def __init__(self, root: Path, books: BookRegistry, schemas_dir: Path) -> None: self.root = root - self.schema = read_json(schema_path) + self.books = books + self.schema = read_json(schemas_dir / "dictionary-entry.schema.json") - def write(self, module: ModuleDescriptor, exported: NativeExport) -> dict[str, Any]: + def write(self, module: ModuleDescriptor, exported: NativeExport) -> tuple[dict, dict]: module_id = slug(module.name) module_root = self.root / module_id prefix = strong_prefix(module, exported.metadata) - key_index: list[dict[str, Any]] = [] - shards: dict[str, list[dict[str, Any]]] = defaultdict(list) - used_ids: dict[str, str] = {} - occurrences: dict[str, int] = defaultdict(int) + staged = self._stage(exported, prefix) + self._resolve_links(staged) + + by_id = {item.entry_id: item for item in staged} + entry_files: dict[str, Path] = {} + position = 0 for source in exported.entries: - key = str(source.get("key", "")).strip() - if not key: + content = self._publishable(source) + if content is None: continue - content = public_content(source) - if not content.get("text") and not content.get("html"): + item = staged[position] + position += 1 + path = module_root / f"{item.entry_id}.json" + if path.name in RESERVED_DOCUMENTS: + raise RuntimeError(f"Entry document collides with a reserved name: {path.name}") + write_json(path, self._document(module, module_id, item, content, source, by_id)) + entry_files[item.entry_id] = path + if position != len(staged): + raise RuntimeError( + f"Dictionary entries changed between passes: staged {len(staged)}, wrote {position}" + ) + + index = sorted(staged, key=lambda item: (item.search, item.key.casefold(), item.occurrence)) + unique_keys = len({item.key.casefold() for item in staged}) + write_json( + module_root / "index.json", + { + "schema": "getbible-dictionary-index-v1", + "dictionary": module_id, + "language": module.language, + "name": module.description, + "entry_url_template": "{entry}.json", + "entry_count": len(staged), + "unique_key_count": unique_keys, + "entries": [self._index_record(item) for item in index], + }, + ) + + complete = self.root / f"{module_id}.json" + write_composed_json( + complete, + { + "schema": "getbible-dictionary-v1", + "dictionary": module_id, + "language": module.language, + "name": module.description, + }, + "entries", + [entry_files[item.entry_id] for item in index], + ) + + metadata = self._metadata( + module, module_id, prefix, len(staged), unique_keys, complete.stat().st_size + ) + write_json(module_root / "metadata.json", metadata) + record = { + "id": module_id, + "name": module.description, + "language": module.language, + "license": module.license, + "entry_count": len(staged), + "unique_key_count": unique_keys, + "strong_prefix": prefix, + "bytes": metadata["bytes"], + } + return record, metadata + + @staticmethod + def _publishable(source: dict[str, Any]) -> dict[str, str] | None: + if not str(source.get("key", "")).strip(): + return None + content = public_content(source) + return content if content["text"] else None + + def _stage(self, exported: NativeExport, prefix: str | None) -> list[_Staged]: + staged: list[_Staged] = [] + used_ids: dict[str, str] = {} + occurrences: dict[str, int] = defaultdict(int) + for source in exported.entries: + if self._publishable(source) is None: continue + key = str(source["key"]).strip() canonical = canonical_strong(key, prefix) entry_id = canonical or encoded_entry_id(key) collision_key = entry_id.casefold() @@ -83,41 +229,100 @@ def write(self, module: ModuleDescriptor, exported: NativeExport) -> dict[str, A if occurrence > 1: entry_id = f"{entry_id}--{occurrence}" used_ids[entry_id.casefold()] = key - aliases = sorted({value for value in (key, canonical) if value}) - raw = str(source.get("raw", "")) - rendered = str(source.get("html", "")) - document: dict[str, Any] = { - "schema": "getbible-dictionary-entry-v1", - "dictionary": module_id, - "language": module.language, - "id": entry_id, - "key": key, - "occurrence": occurrence, - "aliases": aliases, - **content, - } - references = extract_osis_references(raw, rendered) - if references: - document["references"] = references - validate(document, self.schema) - write_json(module_root / f"{entry_id}.json", document) - index_record = { - "key": key, - "id": entry_id, - "occurrence": occurrence, - "aliases": aliases, - "url": f"{entry_id}.json", - } - key_index.append(index_record) - shard = hashlib.sha256(key.casefold().encode("utf-8")).hexdigest()[:2] - shards[shard].append(index_record) - - key_index.sort(key=lambda record: (record["key"].casefold(), record["occurrence"])) - for shard, records in sorted(shards.items()): - records.sort(key=lambda record: (record["key"].casefold(), record["occurrence"])) - write_json(module_root / "indexes" / f"{shard}.json", records) - write_json(module_root / "keys.json", key_index) - metadata = { + staged.append( + _Staged( + entry_id=entry_id, + key=key, + occurrence=occurrence, + aliases=sorted({value for value in (key, canonical) if value}), + search=search_key(key), + targets=link_candidates( + str(source.get("raw", "")), str(source.get("html", "")) + ), + ) + ) + return staged + + @staticmethod + def _resolve_links(staged: list[_Staged]) -> None: + """Turn raw targets into entry identifiers, then invert them into backlinks.""" + lookup: dict[str, str] = {} + for item in staged: + for alias in (item.key, *item.aliases, item.search): + lookup.setdefault(alias.casefold(), item.entry_id) + incoming: dict[str, list[str]] = defaultdict(list) + for item in staged: + seen: set[str] = set() + for target in sorted(item.targets): + resolved = lookup.get(target.casefold()) or lookup.get(search_key(target)) + if not resolved or resolved == item.entry_id or resolved in seen: + continue + seen.add(resolved) + item.links.append(resolved) + incoming[resolved].append(item.entry_id) + item.links.sort() + item.targets = set() # released once resolved; only the ids are published + for item in staged: + item.backlinks = sorted(set(incoming.get(item.entry_id, ()))) + + def _document( + self, + module: ModuleDescriptor, + module_id: str, + item: _Staged, + content: dict[str, str], + source: dict[str, Any], + by_id: dict[str, _Staged], + ) -> dict[str, Any]: + document: dict[str, Any] = { + "schema": "getbible-dictionary-entry-v1", + "dictionary": module_id, + "language": module.language, + "id": item.entry_id, + "key": item.key, + "occurrence": item.occurrence, + "aliases": item.aliases, + **content, + } + if item.links: + document["see_also"] = [ + {"id": target, "key": by_id[target].key} for target in item.links + ] + if item.backlinks: + document["backlinks"] = [ + {"id": target, "key": by_id[target].key} for target in item.backlinks + ] + references = [] + for reference in extract_osis_references( + str(source.get("raw", "")), str(source.get("html", "")) + ): + normalized = self.books.reference(reference) + if normalized: + references.append(normalized) + if references: + document["references"] = references + validate(document, self.schema) + return document + + @staticmethod + def _index_record(item: _Staged) -> dict[str, Any]: + record: dict[str, Any] = {"id": item.entry_id, "key": item.key, "search": item.search} + if item.aliases != [item.key]: + record["aliases"] = item.aliases + if item.occurrence > 1: + record["occurrence"] = item.occurrence + return record + + @staticmethod + def _metadata( + module: ModuleDescriptor, + module_id: str, + prefix: str | None, + entry_count: int, + unique_key_count: int, + complete_bytes: int, + ) -> dict[str, Any]: + return { "schema": "getbible-dictionary-metadata-v1", "id": module_id, "module": module.name, @@ -127,12 +332,12 @@ def write(self, module: ModuleDescriptor, exported: NativeExport) -> dict[str, A "license": module.license, "driver": module.driver, "source_type": module.first("sourcetype"), - "entry_count": len(key_index), - "unique_key_count": len({record["key"].casefold() for record in key_index}), + "entry_count": entry_count, + "unique_key_count": unique_key_count, "strong_prefix": prefix, - "keys_url": "keys.json", + "bytes": complete_bytes, + "index_url": "index.json", "entry_url_template": "{entry}.json", - "index_url_template": "indexes/{sha256_prefix}.json", "source": "CrossWire SWORD", "source_module_url": ( "https://www.crosswire.org/sword/modules/ModInfo.jsp?modName=" @@ -152,5 +357,3 @@ def write(self, module: ModuleDescriptor, exported: NativeExport) -> dict[str, A "Converted to GetBible static JSON; wording is supplied by the source module." ), } - write_json(module_root / "metadata.json", metadata) - return metadata diff --git a/src/study_builder/pipeline.py b/src/study_builder/pipeline.py index adfdb2a..dc66f8d 100644 --- a/src/study_builder/pipeline.py +++ b/src/study_builder/pipeline.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import shutil from dataclasses import dataclass from pathlib import Path from typing import Any @@ -18,16 +19,41 @@ from study_builder.native import SwordExporter from study_builder.policy import ModulePolicy from study_builder.util import ( + hash_tree, replace_tree, reset_directory, slug, utc_now, - write_hash_sidecars, write_json, ) LOG = logging.getLogger(__name__) +# A module directory and its whole-module document both sit at the v1 root, so a +# module identifier may never collide with a document the builder writes there. +RESERVED_MODULE_IDS = frozenset({"build", "commentaries", "dictionaries", "hashes", "schema"}) + +BASE_URLS: dict[str, str] = { + "commentaries": "https://commentaries.getbible.net/v1/", + "dictionaries": "https://dictionaries.getbible.net/v1/", +} + +CATALOG_TEMPLATES: dict[str, dict[str, str]] = { + "commentaries": { + "metadata_url_template": "{commentary}/metadata.json", + "books_url_template": "{commentary}/books.json", + "commentary_url_template": "{commentary}.json", + "book_url_template": "{commentary}/{book}.json", + "chapter_url_template": "{commentary}/{book}/{chapter}.json", + }, + "dictionaries": { + "metadata_url_template": "{dictionary}/metadata.json", + "index_url_template": "{dictionary}/index.json", + "dictionary_url_template": "{dictionary}.json", + "entry_url_template": "{dictionary}/{entry}.json", + }, +} + @dataclass(frozen=True) class PipelineConfig: @@ -47,8 +73,8 @@ class PipelineConfig: pull: bool = False push: bool = False dry_run: bool = False - commentaries_repo: str = "git@github.com:getbible/v1_commentaries.git" - dictionaries_repo: str = "git@github.com:getbible/v1_dictionaries.git" + commentaries_repo: str = "git@github.com:getbible/commentaries.git" + dictionaries_repo: str = "git@github.com:getbible/dictionaries.git" commentaries_branch: str = "main" dictionaries_branch: str = "main" @@ -76,12 +102,12 @@ def _repositories(self) -> dict[ResourceKind, GitRepository]: return { "commentaries": GitRepository( self.config.commentaries_repo, - self.config.work_dir / "repos" / "v1_commentaries", + self.config.work_dir / "repos" / "commentaries", self.config.commentaries_branch, ), "dictionaries": GitRepository( self.config.dictionaries_repo, - self.config.work_dir / "repos" / "v1_dictionaries", + self.config.work_dir / "repos" / "dictionaries", self.config.dictionaries_branch, ), } @@ -107,7 +133,13 @@ def run(self) -> BuildReport: raise RuntimeError("The policy did not approve any selected modules") identifiers: dict[tuple[ResourceKind, str], str] = {} for kind, module in approved: - key = (kind, slug(module.name)) + module_id = slug(module.name) + if module_id in RESERVED_MODULE_IDS: + raise RuntimeError( + f"Module {module.name!r} normalizes to the reserved identifier " + f"{module_id!r}, which would collide with a generated document" + ) + key = (kind, module_id) if key in identifiers and identifiers[key] != module.name: raise RuntimeError( f"Module identifiers collide after normalization: " @@ -167,19 +199,15 @@ def run(self) -> BuildReport: } for item in exported.diagnostics ] - if kind == "commentaries": - writer = CommentaryWriter( - generated_roots[kind], - self.books, - self.config.schemas_dir / "commentary-chapter.schema.json", - ) - else: - writer = DictionaryWriter( - generated_roots[kind], - self.config.schemas_dir / "dictionary-entry.schema.json", + writer = ( + CommentaryWriter(generated_roots[kind], self.books, self.config.schemas_dir) + if kind == "commentaries" + else DictionaryWriter( + generated_roots[kind], self.books, self.config.schemas_dir ) - summary = writer.write(module, exported) - summaries[kind].append(summary) + ) + record, _ = writer.write(module, exported) + summaries[kind].append(record) report.built[kind].append(module.name) except Exception as error: LOG.exception("Failed to build %s", module.name) @@ -198,19 +226,16 @@ def run(self) -> BuildReport: generated_at = utc_now() for kind in resources: records = sorted(summaries[kind], key=lambda item: item["id"]) - catalog_name = kind - base_url = ( - "https://commentaries.getbible.net/v1/" - if kind == "commentaries" - else "https://dictionaries.getbible.net/v1/" - ) + self._publish_schemas(generated_roots[kind], kind) write_json( - generated_roots[kind] / f"{catalog_name}.json", + generated_roots[kind] / f"{kind}.json", { "schema": f"getbible-{kind}-catalog-v1", "version": 1, "generated_at": generated_at, - "base_url": base_url, + "base_url": BASE_URLS[kind], + **CATALOG_TEMPLATES[kind], + "module_count": len(records), kind: records, }, ) @@ -229,12 +254,16 @@ def run(self) -> BuildReport: "module_count": len(records), }, ) - hashes = write_hash_sidecars(generated_roots[kind]) + # hashes.json covers every other generated document and is therefore + # also the manifest of the paths this build owns. write_json( generated_roots[kind] / "hashes.json", - {"algorithm": "sha256", "files": hashes}, + { + "schema": "getbible-hashes-v1", + "algorithm": "sha256", + "files": hash_tree(generated_roots[kind], exclude={"hashes.json"}), + }, ) - write_hash_sidecars(generated_roots[kind]) dist_root = self.config.dist_dir / kind / "v1" replace_tree(generated_roots[kind], dist_root) @@ -254,5 +283,14 @@ def run(self) -> BuildReport: self._write_report(report) return report + def _publish_schemas(self, root: Path, kind: ResourceKind) -> None: + """Serve the document schemas alongside the data so every $id resolves.""" + prefix = "commentary" if kind == "commentaries" else "dictionary" + destination = root / "schema" + destination.mkdir(parents=True, exist_ok=True) + for source in sorted(self.config.schemas_dir.glob(f"{prefix}*.schema.json")): + name = source.name.removesuffix(".schema.json") + ".json" + shutil.copyfile(source, destination / name) + def _write_report(self, report: BuildReport) -> None: write_json(self.config.work_dir / "reports" / "latest.json", report.as_dict()) diff --git a/src/study_builder/util.py b/src/study_builder/util.py index fff27a9..09dc25f 100644 --- a/src/study_builder/util.py +++ b/src/study_builder/util.py @@ -6,9 +6,10 @@ import re import shutil import tempfile +from collections.abc import Collection, Sequence from datetime import UTC, datetime from pathlib import Path -from typing import Any +from typing import IO, Any _SAFE_SLUG = re.compile(r"[^a-z0-9._-]+") @@ -52,18 +53,59 @@ def sha256_file(path: Path) -> str: return digest.hexdigest() -def write_hash_sidecars(root: Path) -> dict[str, str]: +def hash_tree(root: Path, *, exclude: Collection[str] = ()) -> dict[str, str]: + """Digest every generated document. Also the manifest of builder-owned paths.""" + excluded = set(exclude) hashes: dict[str, str] = {} for path in sorted(root.rglob("*.json")): relative = path.relative_to(root).as_posix() - data = path.read_bytes() - sha1 = hashlib.sha1(data, usedforsecurity=False).hexdigest() - sha256 = hashlib.sha256(data).hexdigest() - path.with_suffix(path.suffix + ".sha").write_text(sha1 + "\n", encoding="ascii") - hashes[relative] = sha256 + if relative in excluded: + continue + hashes[relative] = sha256_file(path) return hashes +def _write_indented(handle: IO[str], source: Path, prefix: str) -> None: + first = True + with source.open(encoding="utf-8") as reader: + for raw in reader: + line = raw.rstrip("\n") + if not first: + handle.write("\n") + first = False + handle.write(prefix + line if line else "") + + +def write_composed_json( + path: Path, header: dict[str, Any], member: str, sources: Sequence[Path] +) -> None: + """Write an envelope whose array member embeds already-written documents. + + The members are streamed from disk rather than held in memory, and each one is + embedded byte-for-byte. A composed document therefore contains its parts exactly + as they are served individually, so one client parser handles every zoom level. + """ + path.parent.mkdir(parents=True, exist_ok=True) + if member in header: + raise ValueError(f"Composed member {member!r} is already present in the envelope") + with tempfile.NamedTemporaryFile( + "w", encoding="utf-8", dir=path.parent, delete=False, prefix=f".{path.name}." + ) as handle: + opening = stable_json(header).rstrip() + handle.write(opening[:-1].rstrip()) + if header: + handle.write(",") + handle.write(f'\n "{member}": [') + for index, source in enumerate(sources): + handle.write(",\n" if index else "\n") + _write_indented(handle, source, " ") + if sources: + handle.write("\n ") + handle.write("]\n}\n") + temporary = Path(handle.name) + os.replace(temporary, path) + + def replace_tree(source: Path, destination: Path) -> None: """Atomically publish a generated directory on the same filesystem.""" source = source.resolve() diff --git a/tests/nginx_config_check.sh b/tests/nginx_config_check.sh new file mode 100755 index 0000000..1120b7e --- /dev/null +++ b/tests/nginx_config_check.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-2.0-only +# +# End-to-end check of everything the Python tests cannot reach: that the shipped +# origin configuration installs onto a stock nginx, that deploy_static_api.sh +# produces a tree nginx can serve, and that both hosts keep every promise +# verify_live_api.sh asserts. +# +# It deliberately uses the distribution's own nginx.conf and the real installer +# rather than a purpose-built test config. A hand-rolled nginx.conf would not +# have caught, for example, that a stock nginx.conf already declares sendfile +# and gzip at http level and that redeclaring them is a fatal error. +# +# Needs root: it installs into /etc/nginx and serves on :443. +# +# Requires: nginx, git, rsync, python3, openssl, curl, and the builder installed. + +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORKSPACE="$(mktemp -d)" +HOSTS=(commentaries.getbible.net dictionaries.getbible.net) +HOSTS_MARKER="# getbible-api-check" + +[[ "$EUID" -eq 0 ]] || { echo "must run as root (it installs into /etc/nginx)" >&2; exit 2; } +for tool in nginx git rsync python3 openssl curl; do + command -v "$tool" >/dev/null || { echo "$tool is required" >&2; exit 2; } +done + +cleanup() { + nginx -s quit 2>/dev/null || true + sed -i "/$HOSTS_MARKER/d" /etc/hosts 2>/dev/null || true + rm -rf "$WORKSPACE" +} +trap cleanup EXIT + +# --------------------------------------------------------------------------- +# 1. Generate real API trees and publish each as a repository. +# --------------------------------------------------------------------------- +echo "== generating API trees ==" +python3 "$REPO/tests/support/build_sample_tree.py" "$WORKSPACE/build" + +for host in "${HOSTS[@]}"; do + resource="${host%%.*}" + repository="$WORKSPACE/repo-$resource" + git init -q -b main "$repository" + git -C "$repository" config user.name "nginx config check" + git -C "$repository" config user.email "check@example.invalid" + cp -a "$WORKSPACE/build/dist/$resource/v1" "$repository/v1" + printf '# published %s\n' "$resource" > "$repository/README.md" + git -C "$repository" add -A + git -C "$repository" commit -qm "Sample $resource build" + + echo "== deploying $resource ==" + "$REPO/scripts/deploy_static_api.sh" \ + --repo "$repository" \ + --root "/var/www/getbible/$resource" \ + --work "$WORKSPACE/checkout-$resource" \ + --no-reload + + [[ -d "/var/www/getbible/$resource/v1" ]] || { echo "no v1 for $resource" >&2; exit 1; } + if [[ -e "/var/www/getbible/$resource/README.md" ]]; then + echo "deploy leaked a non-version file into the live root" >&2 + exit 1 + fi +done + +# --------------------------------------------------------------------------- +# 2. Install the configuration exactly the way an operator would. +# --------------------------------------------------------------------------- +for host in "${HOSTS[@]}"; do + install -d -m 755 "/etc/letsencrypt/live/$host" + openssl req -x509 -newkey rsa:2048 -nodes -days 2 \ + -keyout "/etc/letsencrypt/live/$host/privkey.pem" \ + -out "/etc/letsencrypt/live/$host/fullchain.pem" \ + -subj "/CN=$host" -addext "subjectAltName=DNS:$host" 2>/dev/null + cp "/etc/letsencrypt/live/$host/fullchain.pem" "/etc/letsencrypt/live/$host/chain.pem" + printf '127.0.0.1 %s %s\n' "$host" "$HOSTS_MARKER" >> /etc/hosts +done + +# The stock default site binds :80 and :443 as default_server, which collides +# with nothing here — but it also binds [::] unconditionally, so it fails on a +# build host without IPv6. Drop it so the check tests this configuration only. +rm -f /etc/nginx/sites-enabled/default + +echo "== installing ==" +"$REPO/scripts/install_nginx_config.sh" + +# --------------------------------------------------------------------------- +# 3. Serve both hosts and hold each to its own promises. +# --------------------------------------------------------------------------- +echo "== serving ==" +# Start from empty logs so the assertion below sees only this run. +for host in "${HOSTS[@]}"; do + : > "/var/log/nginx/$host.error.log" + : > "/var/log/nginx/$host.access.log" +done +nginx +sleep 1 + +export no_proxy='*' NO_PROXY='*' +for host in "${HOSTS[@]}"; do + echo + "$REPO/scripts/verify_live_api.sh" "https://$host" --insecure +done + +echo +echo "== error logs ==" +found=0 +for host in "${HOSTS[@]}"; do + log="/var/log/nginx/$host.error.log" + if [[ -f "$log" ]] && grep -Ei '\[(emerg|alert|crit|error)\]' "$log" | grep -v ssl_stapling; then + found=1 + fi +done +if [[ "$found" -eq 1 ]]; then + echo "nginx logged errors while serving" >&2 + exit 1 +fi +echo " clean" + +# --------------------------------------------------------------------------- +# 4. The access log must be rotated by the stock logrotate glob, or it grows +# without bound until it fills the disk. +# --------------------------------------------------------------------------- +if [[ -f /etc/logrotate.d/nginx ]]; then + for host in "${HOSTS[@]}"; do + access="/var/log/nginx/$host.access.log" + [[ -f "$access" ]] || { echo "no access log for $host" >&2; exit 1; } + if ! grep -q '/var/log/nginx/\*\.log' /etc/logrotate.d/nginx; then + echo " logrotate glob changed; re-check the access log name" >&2 + elif [[ "$access" != *.log ]]; then + echo "access log $access is outside the logrotate glob" >&2 + exit 1 + fi + done + echo "== access logs are within the logrotate glob ==" +fi diff --git a/tests/support/build_sample_tree.py b/tests/support/build_sample_tree.py new file mode 100755 index 0000000..75890a3 --- /dev/null +++ b/tests/support/build_sample_tree.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-only +"""Generate a complete API tree without CrossWire or the extractor. + +Used by tests/nginx_config_check.sh to give the origin configuration something +real to serve. It drives the actual pipeline, so the tree it produces — catalog, +hashes.json, published schemas, and all — is exactly what a build publishes. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +REPOSITORY = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPOSITORY / "src")) +sys.path.insert(0, str(REPOSITORY / "tests")) + +from test_pipeline_build import ( # noqa: E402 + COMMENTARY, + DICTIONARY, + StubExporter, + StubInstaller, +) + +from study_builder import pipeline as pipeline_module # noqa: E402 +from study_builder.pipeline import BuildPipeline, PipelineConfig # noqa: E402 + + +def main() -> int: + if len(sys.argv) != 2: + print(f"usage: {sys.argv[0]} TARGET_DIRECTORY", file=sys.stderr) + return 2 + target = Path(sys.argv[1]) + + pipeline_module.ModuleInstaller = StubInstaller + pipeline_module.SwordExporter = StubExporter + pipeline_module.GetBibleSwordManager.ensure = lambda self, path=None: Path("/stub") + BuildPipeline._catalog = lambda self: [COMMENTARY, DICTIONARY] + + report = BuildPipeline( + PipelineConfig( + root=REPOSITORY, + work_dir=target / "work", + dist_dir=target / "dist", + policy_path=REPOSITORY / "conf/module_policy.json", + books_path=REPOSITORY / "conf/book_registry.json", + schemas_dir=REPOSITORY / "schemas", + engine_manifest_path=REPOSITORY / "conf/getbiblesword.json", + engine_schema_path=REPOSITORY / "schemas/getbiblesword-ndjson-v1.schema.json", + ) + ).run() + print(f"built {report.built}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cli.py b/tests/test_cli.py index 59d06ac..957df1c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,7 +6,7 @@ def test_build_cli_defaults_to_both_resources() -> None: assert args.resource == "all" assert args.dry_run assert args.engine is None - assert args.commentaries_repo.endswith("getbible/v1_commentaries.git") - assert args.dictionaries_repo.endswith("getbible/v1_dictionaries.git") + assert args.commentaries_repo.endswith("getbible/commentaries.git") + assert args.dictionaries_repo.endswith("getbible/dictionaries.git") assert args.commentaries_branch == "main" assert args.dictionaries_branch == "main" diff --git a/tests/test_commentaries.py b/tests/test_commentaries.py index 2e2e76d..e4f533a 100644 --- a/tests/test_commentaries.py +++ b/tests/test_commentaries.py @@ -8,11 +8,11 @@ class OnePassEntries: def __init__(self, entries): self.entries = entries - self.iterated = False + self.passes = 0 def __iter__(self): - assert not self.iterated, "commentary entries were loaded or traversed more than once" - self.iterated = True + self.passes += 1 + assert self.passes <= 1, "commentary entries were loaded or traversed more than once" yield from self.entries def __len__(self): @@ -22,179 +22,171 @@ def __getitem__(self, index): return self.entries[index] -def test_commentary_matches_v3_book_chapter_verse_contract( - tmp_path, project_root, commentary_module -) -> None: - export = NativeExport( - metadata={"record_type": "module"}, - entries=[ - { - "record_type": "entry", - "key": "Genesis 1:1", - "raw": 'John 1:1', - "plain": "A comment on creation.", - "html": "

A comment on creation.

", - "verse": { - "osis": "Gen.1.1", - "testament": 1, - "book": 1, - "chapter": 1, - "verse": 1, - }, - } - ], - ) +def entry(osis, book, chapter, verse, text, *, raw=None, html=""): + return { + "record_type": "entry", + "key": osis, + "raw": raw if raw is not None else text, + "plain": text, + "html": html, + "verse": { + "osis": osis, + "testament": 1 if book <= 39 else 2, + "book": book if book <= 39 else book - 39, + "chapter": chapter, + "verse": verse, + }, + } + + +def write(tmp_path, project_root, module, entries): writer = CommentaryWriter( tmp_path, BookRegistry(project_root / "conf/book_registry.json"), - project_root / "schemas/commentary-chapter.schema.json", + project_root / "schemas", ) - summary = writer.write(commentary_module, export) - chapter_path = tmp_path / "testcom/1/1.json" - chapter = json.loads(chapter_path.read_text(encoding="utf-8")) - assert summary["chapter_url_template"] == "{book}/{chapter}.json" - assert (chapter["book"], chapter["chapter"]) == (1, 1) - assert chapter["entries"][0]["verse"] == 1 - assert chapter["entries"][0]["anchor"]["osis"] == "Gen.1.1" - assert chapter["entries"][0]["references"][0]["book"] == 43 + return writer.write(module, NativeExport(metadata={"record_type": "module"}, entries=entries)) -def test_commentary_consumes_source_entries_once(tmp_path, project_root, commentary_module) -> None: - entries = OnePassEntries( +def test_commentary_matches_v3_book_chapter_verse_contract( + tmp_path, project_root, commentary_module +) -> None: + record, metadata = write( + tmp_path, + project_root, + commentary_module, [ - { - "key": "Genesis 1:1", - "raw": "First chapter", - "plain": "First chapter", - "html": "", - "verse": { - "osis": "Gen.1.1", - "testament": 1, - "book": 1, - "chapter": 1, - "verse": 1, - }, - }, - { - "key": "Genesis 2:1", - "raw": "Second chapter", - "plain": "Second chapter", - "html": "", - "verse": { - "osis": "Gen.2.1", - "testament": 1, - "book": 1, - "chapter": 2, - "verse": 1, - }, - }, - ] + entry( + "John.1.1", + 43, + 1, + 1, + "A comment on creation.", + raw='John 1:1', + html="

A comment on creation.

", + ) + ], ) - export = NativeExport(metadata={}, entries=entries) + chapter = json.loads((tmp_path / "testcom/43/1.json").read_text(encoding="utf-8")) + assert metadata["chapter_url_template"] == "{book}/{chapter}.json" + assert (chapter["book"], chapter["chapter"]) == (43, 1) + assert chapter["entries"][0]["verse"] == 1 + assert chapter["entries"][0]["anchor"]["osis"] == "John.1.1" + assert chapter["entries"][0]["references"][0]["book"] == 43 + assert record["entry_count"] == 1 - summary = CommentaryWriter( - tmp_path, - BookRegistry(project_root / "conf/book_registry.json"), - project_root / "schemas/commentary-chapter.schema.json", - ).write(commentary_module, export) - assert entries.iterated - assert summary["entry_count"] == 2 - assert (tmp_path / "testcom/1/1.json").is_file() - assert (tmp_path / "testcom/1/2.json").is_file() +def test_commentary_publishes_text_without_markup( + tmp_path, project_root, commentary_module +) -> None: + write( + tmp_path, + project_root, + commentary_module, + [entry("Gen.1.1", 1, 1, 1, "Plain words.", html="

Plain words.

")], + ) + chapter = json.loads((tmp_path / "testcom/1/1.json").read_text(encoding="utf-8")) + published = chapter["entries"][0] + assert published["text"] == "Plain words." + assert "html" not in published + assert "html" not in (tmp_path / "testcom/1/1.json").read_text(encoding="utf-8") -def test_commentary_skips_entries_without_a_scripture_chapter( +def test_commentary_publishes_book_and_chapter_introductions( tmp_path, project_root, commentary_module ) -> None: - export = NativeExport( - metadata={}, - entries=[ - { - "key": "Genesis", - "raw": "Book introduction", - "plain": "Book introduction", - "html": "", - "verse": { - "osis": "Gen", - "testament": 1, - "book": 1, - "chapter": 0, - "verse": 0, - }, - }, - { - "key": "Genesis 1", - "raw": "Chapter introduction", - "plain": "Chapter introduction", - "html": "", - "verse": { - "osis": "Gen.1", - "testament": 1, - "book": 1, - "chapter": 1, - "verse": 0, - }, - }, + write( + tmp_path, + project_root, + commentary_module, + [ + entry("Dan.0.0", 27, 0, 0, "About the book of Daniel."), + entry("Dan.1.0", 27, 1, 0, "About chapter one."), + entry("Dan.1.1", 27, 1, 1, "On the first verse."), ], ) + introduction = json.loads((tmp_path / "testcom/27/0.json").read_text(encoding="utf-8")) + assert introduction["chapter"] == 0 + assert introduction["entries"][0]["name"] == "Daniel" + assert introduction["entries"][0]["text"] == "About the book of Daniel." - summary = CommentaryWriter( - tmp_path, - BookRegistry(project_root / "conf/book_registry.json"), - project_root / "schemas/commentary-chapter.schema.json", - ).write(commentary_module, export) + chapter = json.loads((tmp_path / "testcom/27/1.json").read_text(encoding="utf-8")) + assert [item["verse"] for item in chapter["entries"]] == [0, 1] + assert chapter["entries"][0]["name"] == "Daniel 1" - chapter = json.loads((tmp_path / "testcom/1/1.json").read_text(encoding="utf-8")) - assert summary["entry_count"] == 1 - assert not (tmp_path / "testcom/1/0.json").exists() - assert [entry["name"] for entry in chapter["entries"]] == ["Genesis 1"] + books = json.loads((tmp_path / "testcom/books.json").read_text(encoding="utf-8")) + assert books["books"][0]["chapters"] == [0, 1] -def test_commentary_canonicalizes_interleaved_source_chapters( +def test_book_and_commentary_documents_embed_their_parts_verbatim( tmp_path, project_root, commentary_module ) -> None: - def source(osis: str, testament: int, book: int, chapter: int, verse: int, text: str): - return { - "key": osis, - "raw": text, - "plain": text, - "html": "", - "verse": { - "osis": osis, - "testament": testament, - "book": book, - "chapter": chapter, - "verse": verse, - }, - } + write( + tmp_path, + project_root, + commentary_module, + [ + entry("Gen.1.1", 1, 1, 1, "First."), + entry("Gen.2.1", 1, 2, 1, "Second."), + entry("John.1.1", 43, 1, 1, "Third."), + ], + ) + chapters = [ + json.loads((tmp_path / f"testcom/1/{number}.json").read_text(encoding="utf-8")) + for number in (1, 2) + ] + book = json.loads((tmp_path / "testcom/1.json").read_text(encoding="utf-8")) + assert book["schema"] == "getbible-commentary-book-v1" + assert book["chapters"] == chapters + + complete = json.loads((tmp_path / "testcom.json").read_text(encoding="utf-8")) + books = [ + json.loads((tmp_path / f"testcom/{number}.json").read_text(encoding="utf-8")) + for number in (1, 43) + ] + assert complete["schema"] == "getbible-commentary-v1" + assert complete["books"] == books + + +def test_commentary_reports_counts_and_bulk_size(tmp_path, project_root, commentary_module) -> None: + record, metadata = write( + tmp_path, + project_root, + commentary_module, + [ + entry("Gen.1.1", 1, 1, 1, "First."), + entry("Gen.2.1", 1, 2, 1, "Second."), + entry("John.1.1", 43, 1, 1, "Third."), + ], + ) + assert (record["book_count"], record["chapter_count"], record["entry_count"]) == (2, 3, 3) + assert record["bytes"] == (tmp_path / "testcom.json").stat().st_size + assert metadata["bytes"] == record["bytes"] + +def test_commentary_consumes_source_entries_once(tmp_path, project_root, commentary_module) -> None: entries = OnePassEntries( [ - source("2Macc.7.1", 1, 48, 7, 1, "Maccabees first"), - source("Job.2.1", 1, 18, 2, 1, "Job second chapter"), - source("Job.1.2", 1, 18, 1, 2, "Job second verse"), - source("2Macc.7.1", 1, 48, 7, 1, "Maccabees first"), - source("Job.1.1", 1, 18, 1, 1, "Job first verse"), + entry("Gen.1.1", 1, 1, 1, "First chapter"), + entry("Gen.2.1", 1, 2, 1, "Second chapter"), ] ) - export = NativeExport(metadata={}, entries=entries) + write(tmp_path, project_root, commentary_module, entries) + assert entries.passes == 1 + assert (tmp_path / "testcom/1/1.json").is_file() + assert (tmp_path / "testcom/1/2.json").is_file() - summary = CommentaryWriter( - tmp_path, - BookRegistry(project_root / "conf/book_registry.json"), - project_root / "schemas/commentary-chapter.schema.json", - ).write(commentary_module, export) - books = json.loads((tmp_path / "testcom/books.json").read_text(encoding="utf-8")) - job = json.loads((tmp_path / "testcom/18.json").read_text(encoding="utf-8")) - job_one = json.loads((tmp_path / "testcom/18/1.json").read_text(encoding="utf-8")) - maccabees_seven = json.loads((tmp_path / "testcom/81/7.json").read_text(encoding="utf-8")) - - assert entries.iterated - assert summary["entry_count"] == 4 - assert [item["book"] for item in books] == [18, 81] - assert [item["chapter"] for item in job["chapters"]] == [1, 2] - assert [item["verse"] for item in job_one["entries"]] == [1, 2] - assert list(job_one["entries"][0]) == ["book", "chapter", "verse", "name", "anchor", "text"] - assert len(maccabees_seven["entries"]) == 1 +def test_commentary_skips_entries_without_a_bible_coordinate( + tmp_path, project_root, commentary_module +) -> None: + record, _ = write( + tmp_path, + project_root, + commentary_module, + [ + {"key": "Preface", "raw": "front matter", "plain": "front matter", "html": ""}, + entry("Gen.1.1", 1, 1, 1, "Kept."), + ], + ) + assert record["entry_count"] == 1 diff --git a/tests/test_content.py b/tests/test_content.py index 5e0c19a..a6e81d2 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -1,21 +1,32 @@ -from study_builder.content import clean_html, extract_osis_references, public_content +from study_builder.content import extract_osis_references, public_content, strip_markup -def test_html_sanitizer_removes_scripts_and_unsafe_links() -> None: - cleaned = clean_html( - "

Hello world

" - 'bad' +def test_markup_stripper_drops_scripts_and_keeps_readable_text() -> None: + text = strip_markup( + '

Safe text

' + 'link' ) - assert "world" in cleaned + assert "alert" not in text + assert "javascript" not in text + assert "Safe" in text and "text" in text and "link" in text -def test_reference_extraction_deduplicates() -> None: - assert extract_osis_references( - 'osisRef="Gen.1.1"', 'Genesis Matt.5.3' - ) == ["Gen.1.1", "Matt.5.3"] +def test_public_content_publishes_text_only() -> None: + content = public_content({"plain": "A word", "html": "

A word

"}) + assert content == {"text": "A word"} -def test_structural_html_without_text_is_not_public_content() -> None: +def test_public_content_falls_back_to_markup_when_stripped_text_is_empty() -> None: + content = public_content({"plain": "", "html": "

Only in the rendered form

"}) + assert content == {"text": "Only in the rendered form"} + + +def test_structural_markup_without_text_is_not_public_content() -> None: assert public_content({"plain": "", "html": '
'}) == {"text": ""} + + +def test_osis_references_are_extracted_from_markup_and_sword_uris() -> None: + references = extract_osis_references( + 'a', "sword://Bible/Gen.2.3" + ) + assert references == ["Gen.2.3", "John.1.1"] diff --git a/tests/test_dictionaries.py b/tests/test_dictionaries.py index 893e651..f53af69 100644 --- a/tests/test_dictionaries.py +++ b/tests/test_dictionaries.py @@ -1,9 +1,25 @@ import json -from study_builder.dictionaries import DictionaryWriter, canonical_strong, encoded_entry_id +from study_builder.books import BookRegistry +from study_builder.dictionaries import ( + DictionaryWriter, + canonical_strong, + encoded_entry_id, + link_candidates, + search_key, +) from study_builder.models import NativeExport +def write(tmp_path, project_root, module, entries, metadata=None): + writer = DictionaryWriter( + tmp_path, + BookRegistry(project_root / "conf/book_registry.json"), + project_root / "schemas", + ) + return writer.write(module, NativeExport(metadata=metadata or {}, entries=entries)) + + def test_strong_keys_match_bible_api_v3() -> None: assert canonical_strong("3056", "G") == "G3056" assert canonical_strong("00430", "H") == "H0430" @@ -19,12 +35,34 @@ def test_generic_entry_ids_are_url_safe_and_reversible_for_normal_keys() -> None assert "/" not in entry_id +def test_search_terms_fold_case_and_accents() -> None: + assert search_key("KADESH") == "kadesh" + assert search_key("ἀγάπη") == "αγαπη" + assert search_key("Beth-el, the") == "beth-el the" + + +def test_link_candidates_separate_words_from_scripture() -> None: + raw = 'Meribah and Num 20:1' + assert link_candidates(raw) == {"MERIBAH"} + assert link_candidates("see GREEK for 03056") == {"G3056"} + assert link_candidates("see HEBREW for 0430") == {"H0430"} + + +def test_link_candidates_read_both_the_source_and_rendered_forms() -> None: + # The same link is spelled differently in each form; both must be seen. + assert link_candidates( + 'Zin', 'Meribah' + ) == {"ZIN", "MERIBAH"} + + def test_dictionary_emits_direct_strong_lookup( tmp_path, project_root, greek_dictionary_module ) -> None: - export = NativeExport( - metadata={"feature": "GreekDef"}, - entries=[ + record, metadata = write( + tmp_path, + project_root, + greek_dictionary_module, + [ { "record_type": "entry", "key": "03056", @@ -33,41 +71,112 @@ def test_dictionary_emits_direct_strong_lookup( "html": "

logos: a word

", } ], + metadata={"feature": "GreekDef"}, ) - summary = DictionaryWriter( - tmp_path, project_root / "schemas/dictionary-entry.schema.json" - ).write(greek_dictionary_module, export) - path = tmp_path / "strongsgreek/G3056.json" - document = json.loads(path.read_text(encoding="utf-8")) - assert summary["strong_prefix"] == "G" + document = json.loads((tmp_path / "strongsgreek/G3056.json").read_text(encoding="utf-8")) + assert record["strong_prefix"] == "G" assert document["id"] == "G3056" assert document["occurrence"] == 1 assert document["aliases"] == ["03056", "G3056"] - assert document["references"] == ["John.1.1"] + assert document["text"] == "logos: a word" + assert "html" not in document + assert document["references"] == [{"osis": "John.1.1", "book": 43, "chapter": 1, "verse": 1}] + assert metadata["index_url"] == "index.json" + + +def test_dictionary_index_is_sorted_and_slim( + tmp_path, project_root, greek_dictionary_module +) -> None: + write( + tmp_path, + project_root, + greek_dictionary_module, + [ + {"key": "Zeta", "raw": "last", "plain": "last", "html": ""}, + {"key": "Alpha", "raw": "first", "plain": "first", "html": ""}, + ], + ) + index = json.loads((tmp_path / "strongsgreek/index.json").read_text(encoding="utf-8")) + assert index["schema"] == "getbible-dictionary-index-v1" + assert [record["key"] for record in index["entries"]] == ["Alpha", "Zeta"] + assert index["entries"][0] == {"id": "k-Alpha", "key": "Alpha", "search": "alpha"} + assert index["entry_url_template"] == "{entry}.json" + + +def test_dictionary_links_resolve_in_both_directions( + tmp_path, project_root, greek_dictionary_module +) -> None: + write( + tmp_path, + project_root, + greek_dictionary_module, + [ + { + "key": "KADESH", + "raw": 'also Meribah', + "plain": "A place in the wilderness.", + "html": "", + }, + { + "key": "MERIBAH", + "raw": "Waters of strife.", + "plain": "Waters of strife.", + "html": "", + }, + ], + ) + kadesh = json.loads((tmp_path / "strongsgreek/k-KADESH.json").read_text(encoding="utf-8")) + meribah = json.loads((tmp_path / "strongsgreek/k-MERIBAH.json").read_text(encoding="utf-8")) + assert kadesh["see_also"] == [{"id": "k-MERIBAH", "key": "MERIBAH"}] + assert "backlinks" not in kadesh + assert meribah["backlinks"] == [{"id": "k-KADESH", "key": "KADESH"}] + assert "see_also" not in meribah def test_dictionary_preserves_repeated_keys_as_distinct_definitions( tmp_path, project_root, greek_dictionary_module ) -> None: - export = NativeExport( - metadata={"feature": "GreekDef"}, - entries=[ + record, _ = write( + tmp_path, + project_root, + greek_dictionary_module, + [ {"key": "03056", "raw": "First", "plain": "First", "html": ""}, {"key": "03056", "raw": "Second", "plain": "Second", "html": ""}, ], + metadata={"feature": "GreekDef"}, ) - summary = DictionaryWriter( - tmp_path, project_root / "schemas/dictionary-entry.schema.json" - ).write(greek_dictionary_module, export) - first = json.loads((tmp_path / "strongsgreek/G3056.json").read_text(encoding="utf-8")) second = json.loads((tmp_path / "strongsgreek/G3056--2.json").read_text(encoding="utf-8")) - keys = json.loads((tmp_path / "strongsgreek/keys.json").read_text(encoding="utf-8")) + index = json.loads((tmp_path / "strongsgreek/index.json").read_text(encoding="utf-8")) assert first["text"] == "First" assert first["occurrence"] == 1 assert second["text"] == "Second" assert second["occurrence"] == 2 - assert [entry["url"] for entry in keys] == ["G3056.json", "G3056--2.json"] - assert summary["entry_count"] == 2 - assert summary["unique_key_count"] == 1 + assert [item["id"] for item in index["entries"]] == ["G3056", "G3056--2"] + assert index["entries"][1]["occurrence"] == 2 + assert record["entry_count"] == 2 + assert record["unique_key_count"] == 1 + + +def test_whole_dictionary_embeds_entries_in_index_order( + tmp_path, project_root, greek_dictionary_module +) -> None: + record, _ = write( + tmp_path, + project_root, + greek_dictionary_module, + [ + {"key": "Zeta", "raw": "last", "plain": "last", "html": ""}, + {"key": "Alpha", "raw": "first", "plain": "first", "html": ""}, + ], + ) + complete = json.loads((tmp_path / "strongsgreek.json").read_text(encoding="utf-8")) + entries = [ + json.loads((tmp_path / f"strongsgreek/k-{name}.json").read_text(encoding="utf-8")) + for name in ("Alpha", "Zeta") + ] + assert complete["schema"] == "getbible-dictionary-v1" + assert complete["entries"] == entries + assert record["bytes"] == (tmp_path / "strongsgreek.json").stat().st_size diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py new file mode 100644 index 0000000..e8dbb45 --- /dev/null +++ b/tests/test_pipeline.py @@ -0,0 +1,67 @@ +import pytest + +from study_builder.pipeline import ( + BASE_URLS, + CATALOG_TEMPLATES, + RESERVED_MODULE_IDS, + BuildPipeline, + PipelineConfig, +) + + +@pytest.fixture +def pipeline(tmp_path, project_root) -> BuildPipeline: + return BuildPipeline( + PipelineConfig( + root=project_root, + work_dir=tmp_path / "work", + dist_dir=tmp_path / "dist", + policy_path=project_root / "conf/module_policy.json", + books_path=project_root / "conf/book_registry.json", + schemas_dir=project_root / "schemas", + engine_manifest_path=project_root / "conf/getbiblesword.json", + engine_schema_path=project_root / "schemas/getbiblesword-ndjson-v1.schema.json", + ) + ) + + +def test_reserved_identifiers_cover_every_root_document() -> None: + # A module directory and its whole-module document share the v1 root with these. + for name in ("commentaries", "dictionaries", "build", "hashes", "schema"): + assert name in RESERVED_MODULE_IDS + + +def test_every_resource_publishes_a_base_url_and_url_templates() -> None: + for kind in ("commentaries", "dictionaries"): + assert BASE_URLS[kind].endswith("/v1/") + assert CATALOG_TEMPLATES[kind] + for template in CATALOG_TEMPLATES[kind].values(): + assert template.startswith("{" + kind.removesuffix("ies") + "y}") + + +def test_schemas_are_published_beside_the_data(pipeline, tmp_path) -> None: + root = tmp_path / "generated" + pipeline._publish_schemas(root, "commentaries") + published = {path.name for path in (root / "schema").glob("*.json")} + assert published == { + "commentary.json", + "commentary-book.json", + "commentary-books.json", + "commentary-chapter.json", + } + + dictionary_root = tmp_path / "generated-dictionaries" + pipeline._publish_schemas(dictionary_root, "dictionaries") + published = {path.name for path in (dictionary_root / "schema").glob("*.json")} + assert published == {"dictionary.json", "dictionary-entry.json", "dictionary-index.json"} + + +def test_published_schema_ids_match_their_served_paths(pipeline, tmp_path) -> None: + from study_builder.util import read_json + + for kind in ("commentaries", "dictionaries"): + root = tmp_path / kind + pipeline._publish_schemas(root, kind) + for path in sorted((root / "schema").glob("*.json")): + expected = f"{BASE_URLS[kind].removesuffix('v1/')}schema/v1/{path.name}" + assert read_json(path)["$id"] == expected diff --git a/tests/test_pipeline_build.py b/tests/test_pipeline_build.py new file mode 100644 index 0000000..a6625c6 --- /dev/null +++ b/tests/test_pipeline_build.py @@ -0,0 +1,156 @@ +"""Exercise the whole publication assembly without CrossWire or the extractor.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from study_builder import pipeline as pipeline_module +from study_builder.models import ModuleDescriptor, NativeExport +from study_builder.pipeline import BuildPipeline, PipelineConfig + + +def descriptor(name: str, driver: str, category: str, **extra: str) -> ModuleDescriptor: + fields = { + "description": (f"{name} Description",), + "lang": ("en",), + "moddrv": (driver,), + "category": (category,), + "distributionlicense": ("Public Domain",), + "version": ("1.0",), + } + fields.update({key: (value,) for key, value in extra.items()}) + return ModuleDescriptor(name=name, fields=fields, conf_path=f"mods.d/{name.lower()}.conf") + + +COMMENTARY = descriptor("Clarke", "zCom", "Commentaries") +DICTIONARY = descriptor("Easton", "RawLD", "Lexicons / Dictionaries") + +EXPORTS: dict[str, NativeExport] = { + "Clarke": NativeExport( + metadata={"classification": "commentary"}, + entries=[ + { + "key": "Dan.0.0", + "raw": "Introduction to Daniel.", + "plain": "Introduction to Daniel.", + "html": "", + "verse": {"osis": "Dan.0.0", "testament": 1, "book": 27, "chapter": 0, "verse": 0}, + }, + { + "key": "Dan.1.1", + "raw": "In the third year.", + "plain": "In the third year.", + "html": "

In the third year.

", + "verse": {"osis": "Dan.1.1", "testament": 1, "book": 27, "chapter": 1, "verse": 1}, + }, + ], + ), + "Easton": NativeExport( + metadata={"classification": "dictionary_or_lexicon"}, + entries=[ + { + "key": "KADESH", + "raw": 'see ZIN and Num.20.1', + "plain": "Holy.", + "html": "", + }, + {"key": "ZIN", "raw": "a low palm tree", "plain": "A low palm tree.", "html": ""}, + ], + ), +} + + +class StubInstaller: + def __init__(self, *args, **kwargs) -> None: + pass + + def install(self, module: ModuleDescriptor) -> Path: + return Path("/nonexistent") / module.name + + +class StubExporter: + def __init__(self, *args, **kwargs) -> None: + pass + + def export(self, installation: Path, name: str) -> NativeExport: + return EXPORTS[name] + + +@pytest.fixture +def built(tmp_path, project_root, monkeypatch): + monkeypatch.setattr(pipeline_module, "ModuleInstaller", StubInstaller) + monkeypatch.setattr(pipeline_module, "SwordExporter", StubExporter) + monkeypatch.setattr(BuildPipeline, "_catalog", lambda self: [COMMENTARY, DICTIONARY]) + monkeypatch.setattr( + pipeline_module.GetBibleSwordManager, "ensure", lambda self, path=None: Path("/stub") + ) + config = PipelineConfig( + root=project_root, + work_dir=tmp_path / "work", + dist_dir=tmp_path / "dist", + policy_path=project_root / "conf/module_policy.json", + books_path=project_root / "conf/book_registry.json", + schemas_dir=project_root / "schemas", + engine_manifest_path=project_root / "conf/getbiblesword.json", + engine_schema_path=project_root / "schemas/getbiblesword-ndjson-v1.schema.json", + ) + report = BuildPipeline(config).run() + return report, tmp_path / "dist" + + +def test_build_publishes_both_resources_under_v1(built) -> None: + report, dist = built + assert report.built == {"commentaries": ["Clarke"], "dictionaries": ["Easton"]} + assert not report.failed + assert (dist / "commentaries/v1/clarke.json").is_file() + assert (dist / "commentaries/v1/clarke/27/0.json").is_file() + assert (dist / "dictionaries/v1/easton/index.json").is_file() + assert (dist / "dictionaries/v1/easton/k-KADESH.json").is_file() + + +def test_catalog_url_templates_resolve_from_the_version_root(built) -> None: + _, dist = built + catalog = json.loads((dist / "commentaries/v1/commentaries.json").read_text(encoding="utf-8")) + assert catalog["base_url"] == "https://commentaries.getbible.net/v1/" + assert catalog["module_count"] == 1 + record = catalog["commentaries"][0] + assert record["id"] == "clarke" + assert "about" not in record and "copyright" not in record + + for template, replacements in ( + (catalog["chapter_url_template"], {"commentary": "clarke", "book": "27", "chapter": "1"}), + (catalog["book_url_template"], {"commentary": "clarke", "book": "27"}), + (catalog["commentary_url_template"], {"commentary": "clarke"}), + (catalog["books_url_template"], {"commentary": "clarke"}), + (catalog["metadata_url_template"], {"commentary": "clarke"}), + ): + relative = template + for key, value in replacements.items(): + relative = relative.replace("{" + key + "}", value) + assert (dist / "commentaries/v1" / relative).is_file(), relative + + +def test_hashes_manifest_covers_every_other_document(built) -> None: + _, dist = built + root = dist / "dictionaries/v1" + manifest = json.loads((root / "hashes.json").read_text(encoding="utf-8")) + assert manifest["algorithm"] == "sha256" + published = {path.relative_to(root).as_posix() for path in root.rglob("*.json")} + assert set(manifest["files"]) == published - {"hashes.json"} + assert all(len(digest) == 64 for digest in manifest["files"].values()) + + +def test_schemas_are_served_beside_the_data(built) -> None: + _, dist = built + assert (dist / "commentaries/v1/schema/commentary-chapter.json").is_file() + assert (dist / "dictionaries/v1/schema/dictionary-entry.json").is_file() + assert not (dist / "commentaries/v1/schema/dictionary-entry.json").exists() + + +def test_no_document_publishes_markup(built) -> None: + _, dist = built + for path in dist.rglob("*.json"): + assert '"html"' not in path.read_text(encoding="utf-8"), path diff --git a/tests/test_util.py b/tests/test_util.py index 6ecd2a2..9ea6fc1 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,6 +1,6 @@ import json -from study_builder.util import replace_tree, write_hash_sidecars, write_json +from study_builder.util import hash_tree, replace_tree, write_composed_json, write_json def test_json_writes_are_stable_and_hashed(tmp_path) -> None: @@ -9,9 +9,36 @@ def test_json_writes_are_stable_and_hashed(tmp_path) -> None: first = (root / "data.json").read_bytes() write_json(root / "data.json", {"hello": "world"}) assert (root / "data.json").read_bytes() == first - hashes = write_hash_sidecars(root) + hashes = hash_tree(root) assert len(hashes["data.json"]) == 64 - assert len((root / "data.json.sha").read_text().strip()) == 40 + assert not list(root.glob("*.sha")) + + +def test_hash_tree_can_exclude_its_own_manifest(tmp_path) -> None: + write_json(tmp_path / "data.json", {"hello": "world"}) + write_json(tmp_path / "hashes.json", {"files": {}}) + assert set(hash_tree(tmp_path, exclude={"hashes.json"})) == {"data.json"} + + +def test_composed_document_embeds_members_verbatim(tmp_path) -> None: + parts = [] + for index in (1, 2): + path = tmp_path / f"part{index}.json" + write_json(path, {"index": index, "nested": {"values": [index, index + 1]}}) + parts.append(path) + composed = tmp_path / "composed.json" + write_composed_json(composed, {"schema": "test", "name": "all"}, "members", parts) + + document = json.loads(composed.read_text(encoding="utf-8")) + assert document["schema"] == "test" + assert document["members"] == [json.loads(path.read_text(encoding="utf-8")) for path in parts] + assert composed.read_text(encoding="utf-8").endswith("]\n}\n") + + +def test_composed_document_handles_an_empty_member_list(tmp_path) -> None: + composed = tmp_path / "composed.json" + write_composed_json(composed, {"schema": "test"}, "members", []) + assert json.loads(composed.read_text(encoding="utf-8")) == {"schema": "test", "members": []} def test_replace_tree_preserves_only_new_generation(tmp_path) -> None: