From 6549d8af81fd4f874582f3fc5e075ec041540cdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?eW=C9=98yn?= <5607939+Llewellynvdm@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:29:37 +0000 Subject: [PATCH 1/4] Reshape the v1 study API around whole books, words, and plain text The generated API could only be read one chapter at a time, duplicated every entry as both text and HTML, and silently discarded book introductions. This reshapes all three, keeping output under v1/ so the version stays in the folder rather than the repository name. Commentaries gain book and whole-commentary documents. The three 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, so one client parser handles all three. Composed documents stream from the documents they contain rather than being assembled in memory. Book introductions (chapter 0) and chapter introductions (verse 0) are published again, at {book}/0.json and as the first entry of their chapter. The README had promised this while the writer dropped them. Dictionaries replace keys.json and the 256 SHA-256 shards with a single sorted index.json. A client cannot know which SHA-256 shard holds a word without already hashing it, so the shards enabled no search at all. Records carry an accent-insensitive search term and drop the url that was always derivable from the id. Entries now carry the dictionary's own link graph as see_also and backlinks, resolved over two passes because forward and reverse links are only knowable once every key has an identifier. Whole-dictionary documents are published for offline clients. The html member is gone from every document and both schemas. It duplicated text almost everywhere, and with no markup republished the builder needs no HTML sanitizer, so bleach is dropped and the API carries nothing a consumer must sanitize before rendering. Entries whose stripped text is empty fall back to text derived from the rendered form instead of vanishing. Also: unify references to the object form in dictionaries as well as commentaries; slim the catalogs and hoist url templates to the envelope; guard module identifiers against colliding with a root document; retire the per-file .sha sidecars, halving the file count, in favour of hashes.json, which doubles as the manifest of builder-owned paths; serve the schemas beside the data so every $id resolves; and retarget output at getbible/commentaries and getbible/dictionaries. --- .github/workflows/build.yml | 4 +- AGENTS.md | 37 ++- README.md | 113 +++++++-- docs/nginx.conf | 35 ++- docs/target-repositories.md | 9 +- pyproject.toml | 1 - requirements.txt | 1 - schemas/commentary-book.schema.json | 20 ++ schemas/commentary-books.schema.json | 41 ++++ schemas/commentary-chapter.schema.json | 59 +++-- schemas/commentary.schema.json | 19 ++ schemas/dictionary-entry.schema.json | 35 ++- schemas/dictionary-index.schema.json | 42 ++++ schemas/dictionary.schema.json | 19 ++ scripts/validate_build.py | 105 +++++++-- src/study_builder/cli.py | 4 +- src/study_builder/commentaries.py | 246 ++++++++++++------- src/study_builder/content.py | 97 ++++---- src/study_builder/dictionaries.py | 311 ++++++++++++++++++++----- src/study_builder/pipeline.py | 96 +++++--- src/study_builder/util.py | 56 ++++- tests/test_cli.py | 4 +- tests/test_commentaries.py | 292 +++++++++++------------ tests/test_content.py | 37 +-- tests/test_dictionaries.py | 153 ++++++++++-- tests/test_pipeline.py | 67 ++++++ tests/test_pipeline_build.py | 156 +++++++++++++ tests/test_util.py | 33 ++- 28 files changed, 1592 insertions(+), 500 deletions(-) create mode 100644 schemas/commentary-book.schema.json create mode 100644 schemas/commentary-books.schema.json create mode 100644 schemas/commentary.schema.json create mode 100644 schemas/dictionary-index.schema.json create mode 100644 schemas/dictionary.schema.json create mode 100644 tests/test_pipeline.py create mode 100644 tests/test_pipeline_build.py 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/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..d676aa8 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,9 +215,9 @@ 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 @@ -232,8 +295,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/nginx.conf b/docs/nginx.conf index bf1d4e8..a6a1491 100644 --- a/docs/nginx.conf +++ b/docs/nginx.conf @@ -1,30 +1,45 @@ # Add the relevant server block to each API virtual host. Set root to the -# checkout of v1_commentaries or v1_dictionaries respectively. +# checkout of getbible/commentaries or getbible/dictionaries respectively. +# +# The builder owns the v1/ directory of each checkout, so the public URL keeps an +# explicit version segment and a future v2/ can be served beside it unchanged. +# +# Generate the precompressed variants at deploy time rather than committing them; +# binary blobs delta poorly in Git and would grow both repositories every month: +# +# find v1 -name '*.json' -exec brotli -kf {} \; -exec gzip -kf9 {} \; server { - listen 443 ssl http2; + listen 443 ssl; + http2 on; server_name commentaries.getbible.net; - root /var/www/getbible/v1_commentaries; + root /var/www/getbible/commentaries; etag on; gzip on; - gzip_types application/json text/plain; + gzip_types application/json; + gzip_static on; + # brotli_static on; # requires ngx_brotli + # Chapter, book, and whole-commentary documents change only when the source + # module changes, so they are cached hard between monthly builds. 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; + add_header Cache-Control "public, max-age=86400, stale-while-revalidate=604800" always; } - location ~* \.sha$ { + # A regular expression location wins over the prefix above, so the three + # discovery documents stay short-lived while everything else does not. + location ~* ^/v1/(commentaries|build|hashes)\.json$ { 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; + add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always; + add_header Cache-Control "public, max-age=300, stale-while-revalidate=86400" always; } } # Duplicate the block for dictionaries.getbible.net and set: -# root /var/www/getbible/v1_dictionaries; - +# root /var/www/getbible/dictionaries; +# and match (dictionaries|build|hashes)\.json for the discovery documents. diff --git a/docs/target-repositories.md b/docs/target-repositories.md index 4eae3d6..18da63b 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`: 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/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/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/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: From 3b878cb04769fe1802ee62fbf48fc5d9fbdbc885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?eW=C9=98yn?= <5607939+Llewellynvdm@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:55:03 +0000 Subject: [PATCH 2/4] Make the study API origins production ready The single example server block was a starting point, not a deployment. This replaces it with the origin configuration, a deploy engine, and a live verifier for both hosts, so bringing the API up is a known procedure rather than an exercise left to the reader. docs/nginx/ holds the real configuration, split so the two hosts differ only in server_name, root, and certificate paths, and the locations they share live in one snippet. It sets two cache tiers (discovery documents are short-lived, the corpus is not), serves the precompressed variants the deploy writes, restricts the API to safe methods, answers preflight cheaply, and returns JSON for 404, 405, and 429 rather than an HTML error page a client has to special-case. It also carries nosniff, a default-src 'none' policy, cross-origin CORP, and HSTS, and exposes ETag to browsers so their own revalidation works. scripts/deploy_static_api.sh pulls, verifies the whole tree against hashes.json, compresses only what changed, syncs one version directory at a time, and reloads. Verification runs before the sync, so a build that fails its digests never reaches the live root and the previous one keeps serving. It can require a valid GPG signature, which ties the origin to the build key. The deploy pulls into a persistent checkout and syncs with rsync rather than cloning, because nginx derives ETags from mtime and size: rewriting every file each month would change every ETag and force every client to re-download a corpus that had not changed. Git and rsync write only what differs, and the compressed variants are stamped with their document's mtime for the same reason. The live root now holds only version directories, so no repository metadata reaches the origin at all. scripts/verify_live_api.sh reads the catalog and follows it, asserting every promise the API makes: paths resolve, both cache tiers are right, compressed variants are served, revalidation returns 304, the CORS and security headers are present, and failures are JSON. Nothing is hardcoded to a module, so it works against any build and is safe to run from a monitor. tests/nginx_config_check.sh wires it together in CI: generate a real tree, put it in a repository, deploy it, serve it through the shipped configuration, and run the verifier against it. That covers what the Python tests cannot reach. docs/deployment.md documents the server layout, the caching model and why ETags survive a deploy, the atomicity trade-off, CDN and real-IP handling, the security posture, rollback, and monitoring. --- .github/workflows/ci.yml | 9 + README.md | 18 + docs/deployment.md | 275 +++++++++++++++ docs/nginx.conf | 45 --- docs/nginx/commentaries.getbible.net.conf | 80 +++++ docs/nginx/dictionaries.getbible.net.conf | 74 +++++ docs/nginx/getbible-api-http.conf | 96 ++++++ docs/nginx/snippets/getbible-api-headers.conf | 24 ++ docs/nginx/snippets/getbible-api-v1.conf | 121 +++++++ docs/target-repositories.md | 9 +- scripts/deploy_static_api.sh | 314 ++++++++++++++++++ scripts/verify_live_api.sh | 173 ++++++++++ tests/nginx_config_check.sh | 144 ++++++++ tests/support/build_sample_tree.py | 58 ++++ 14 files changed, 1393 insertions(+), 47 deletions(-) create mode 100644 docs/deployment.md delete mode 100644 docs/nginx.conf create mode 100644 docs/nginx/commentaries.getbible.net.conf create mode 100644 docs/nginx/dictionaries.getbible.net.conf create mode 100644 docs/nginx/getbible-api-http.conf create mode 100644 docs/nginx/snippets/getbible-api-headers.conf create mode 100644 docs/nginx/snippets/getbible-api-v1.conf create mode 100755 scripts/deploy_static_api.sh create mode 100755 scripts/verify_live_api.sh create mode 100755 tests/nginx_config_check.sh create mode 100755 tests/support/build_sample_tree.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01af92a..84a837b 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 + - name: Check the origin configuration parses + run: | + sudo apt-get install -y -qq nginx + bash tests/nginx_config_check.sh diff --git a/README.md b/README.md index d676aa8..db229a2 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,24 @@ 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 and `docs/deployment.md` describes the server +layout, the caching model, the CDN and security posture, and rollback. + ## Local development ```bash diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..6955074 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,275 @@ +# 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 +/etc/nginx/snippets/getbible-api-headers.conf +/etc/nginx/snippets/getbible-api-v1.conf +/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 or newer (for `http2 on`), git, rsync, python3, gzip, and +ideally brotli. + +```bash +apt-get install -y nginx git rsync python3 brotli +# Brotli for nginx is a separate module; without it only .gz is served, which +# still works — it is roughly 15-20% larger on JSON than brotli. +apt-get install -y libnginx-mod-http-brotli # where packaged + +install -d -m 755 /var/www/getbible /var/lib/getbible /var/www/acme + +cp docs/nginx/getbible-api-http.conf /etc/nginx/conf.d/ +cp docs/nginx/snippets/*.conf /etc/nginx/snippets/ +cp docs/nginx/*.getbible.net.conf /etc/nginx/sites-available/ +ln -sf /etc/nginx/sites-available/commentaries.getbible.net.conf /etc/nginx/sites-enabled/ +ln -sf /etc/nginx/sites-available/dictionaries.getbible.net.conf /etc/nginx/sites-enabled/ +``` + +If the brotli module is installed, uncomment the three `brotli_static on;` lines +in `snippets/getbible-api-v1.conf`. + +Issue certificates before the first `nginx -t`, since the server blocks +reference them: + +```bash +certbot certonly --webroot -w /var/www/acme \ + -d commentaries.getbible.net -d dictionaries.getbible.net +nginx -t && systemctl reload nginx +``` + +## 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. + +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. + +## 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. + +## 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 a6a1491..0000000 --- a/docs/nginx.conf +++ /dev/null @@ -1,45 +0,0 @@ -# Add the relevant server block to each API virtual host. Set root to the -# checkout of getbible/commentaries or getbible/dictionaries respectively. -# -# The builder owns the v1/ directory of each checkout, so the public URL keeps an -# explicit version segment and a future v2/ can be served beside it unchanged. -# -# Generate the precompressed variants at deploy time rather than committing them; -# binary blobs delta poorly in Git and would grow both repositories every month: -# -# find v1 -name '*.json' -exec brotli -kf {} \; -exec gzip -kf9 {} \; - -server { - listen 443 ssl; - http2 on; - server_name commentaries.getbible.net; - root /var/www/getbible/commentaries; - - etag on; - gzip on; - gzip_types application/json; - gzip_static on; - # brotli_static on; # requires ngx_brotli - - # Chapter, book, and whole-commentary documents change only when the source - # module changes, so they are cached hard between monthly builds. - 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=86400, stale-while-revalidate=604800" always; - } - - # A regular expression location wins over the prefix above, so the three - # discovery documents stay short-lived while everything else does not. - location ~* ^/v1/(commentaries|build|hashes)\.json$ { - 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; - } -} - -# Duplicate the block for dictionaries.getbible.net and set: -# root /var/www/getbible/dictionaries; -# and match (dictionaries|build|hashes)\.json for the discovery documents. diff --git a/docs/nginx/commentaries.getbible.net.conf b/docs/nginx/commentaries.getbible.net.conf new file mode 100644 index 0000000..75c7496 --- /dev/null +++ b/docs/nginx/commentaries.getbible.net.conf @@ -0,0 +1,80 @@ +# 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; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/acme; + 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.json getbible buffer=64k flush=5s; + error_log /var/log/nginx/commentaries.getbible.net.error.log warn; + + 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; + + # 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-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..e2565c6 --- /dev/null +++ b/docs/nginx/dictionaries.getbible.net.conf @@ -0,0 +1,74 @@ +# 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; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/acme; + 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.json getbible buffer=64k flush=5s; + error_log /var/log/nginx/dictionaries.getbible.net.error.log warn; + + charset off; + default_type application/json; + etag on; + + client_max_body_size 1k; + client_body_timeout 10s; + keepalive_timeout 65s; + keepalive_requests 1000; + + # 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-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..08eb355 --- /dev/null +++ b/docs/nginx/getbible-api-http.conf @@ -0,0 +1,96 @@ +# GetBible study API — http-context settings. +# +# Install as /etc/nginx/conf.d/getbible-api-http.conf. Everything here must sit +# in the http { } block: shared memory zones, the open-file cache, TLS defaults, +# and the log format are not valid inside a server { } block. +# +# Requires nginx 1.25.1 or newer (for "http2 on" in the server files). + +# --------------------------------------------------------------------------- +# 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_cache shared:GetBibleTLS:10m; +ssl_session_timeout 1d; +ssl_session_tickets off; +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 ipv6=on; +resolver_timeout 5s; + +# --------------------------------------------------------------------------- +# 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; + +limit_req_status 429; +limit_conn_status 429; + +# --------------------------------------------------------------------------- +# 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-v1.conf b/docs/nginx/snippets/getbible-api-v1.conf new file mode 100644 index 0000000..3aa2736 --- /dev/null +++ b/docs/nginx/snippets/getbible-api-v1.conf @@ -0,0 +1,121 @@ +# 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. +location ~ /\. { + deny all; + access_log off; + log_not_found off; +} + +# 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 18da63b..a1a55cc 100644 --- a/docs/target-repositories.md +++ b/docs/target-repositories.md @@ -27,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/scripts/deploy_static_api.sh b/scripts/deploy_static_api.sh new file mode 100755 index 0000000..8277eb0 --- /dev/null +++ b/scripts/deploy_static_api.sh @@ -0,0 +1,314 @@ +#!/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() { + sed -n '3,28p' "$0" | sed 's/^# \{0,1\}//' + 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}") + +published = { + path.relative_to(root).as_posix() + for path in root.rglob("*.json") + if path.name != "hashes.json" +} +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" + read -r status type < <( + curl --silent --show-error --fail --location --max-time 30 \ + --output /dev/null --write-out '%{http_code} %{content_type}\n' \ + "$VERIFY_URL" + ) + [[ "$status" == "200" ]] || die "$VERIFY_URL returned HTTP $status" + [[ "$type" == application/json* ]] || die "$VERIFY_URL returned Content-Type $type" + log "live check passed ($status $type)" +fi + +log "deployed $REVISION to $ROOT" 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/tests/nginx_config_check.sh b/tests/nginx_config_check.sh new file mode 100755 index 0000000..9bc4c92 --- /dev/null +++ b/tests/nginx_config_check.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-2.0-only +# +# Stand the shipped origin configuration up over a real generated tree and run +# the live verifier against it. +# +# This covers the parts of the delivery that Python tests cannot reach: that +# docs/nginx/ parses, that deploy_static_api.sh produces a tree nginx can serve, +# and that the promises in verify_live_api.sh actually hold end to end. +# +# Requires: nginx, git, rsync, python3, openssl, curl, and the builder installed. + +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORKSPACE="$(mktemp -d)" +PORT="${NGINX_CHECK_PORT:-8443}" +NGINX_PREFIX="$WORKSPACE/nginx/conf" + +cleanup() { + if [[ -f "$WORKSPACE/nginx/logs/nginx.pid" ]]; then + nginx -p "$NGINX_PREFIX/" -c "$NGINX_PREFIX/nginx.conf" -s quit 2>/dev/null || true + sleep 1 + fi + rm -rf "$WORKSPACE" +} +trap cleanup EXIT + +for tool in nginx git rsync python3 openssl curl; do + command -v "$tool" >/dev/null || { echo "$tool is required" >&2; exit 2; } +done + +mkdir -p "$NGINX_PREFIX"/{conf.d,snippets,sites} "$WORKSPACE/nginx"/{logs,certs} + +# --------------------------------------------------------------------------- +# 1. Generate a real API tree, publish it as a repository, and deploy it. +# --------------------------------------------------------------------------- +echo "== generating an API tree ==" +python3 "$REPO/tests/support/build_sample_tree.py" "$WORKSPACE/build" + +git -C "$WORKSPACE" init -q -b main sample +git -C "$WORKSPACE/sample" config user.name "nginx config check" +git -C "$WORKSPACE/sample" config user.email "check@example.invalid" +cp -a "$WORKSPACE/build/dist/commentaries/v1" "$WORKSPACE/sample/v1" +printf '# published commentaries\n' > "$WORKSPACE/sample/README.md" +git -C "$WORKSPACE/sample" add -A +git -C "$WORKSPACE/sample" commit -qm "Sample build" + +echo "== deploying ==" +"$REPO/scripts/deploy_static_api.sh" \ + --repo "$WORKSPACE/sample" \ + --root "$WORKSPACE/live" \ + --work "$WORKSPACE/checkout" \ + --no-reload + +[[ -d "$WORKSPACE/live/v1" ]] || { echo "deploy produced no v1 directory" >&2; exit 1; } +if [[ -e "$WORKSPACE/live/README.md" ]]; then + echo "deploy leaked a non-version file into the live root" >&2 + exit 1 +fi + +# --------------------------------------------------------------------------- +# 2. Adapt the shipped configuration to this host, changing as little as +# possible so the check keeps testing what is actually shipped. +# --------------------------------------------------------------------------- +cp "$REPO/docs/nginx/getbible-api-http.conf" "$NGINX_PREFIX/conf.d/" +cp "$REPO/docs/nginx/snippets/"*.conf "$NGINX_PREFIX/snippets/" + +HOST=commentaries.getbible.net +openssl req -x509 -newkey rsa:2048 -nodes -days 2 \ + -keyout "$WORKSPACE/nginx/certs/key.pem" -out "$WORKSPACE/nginx/certs/cert.pem" \ + -subj "/CN=$HOST" -addext "subjectAltName=DNS:$HOST" 2>/dev/null + +adapt=(-e "s|/var/www/getbible/commentaries|$WORKSPACE/live|" + -e "s|/etc/letsencrypt/live/$HOST/fullchain.pem|$WORKSPACE/nginx/certs/cert.pem|" + -e "s|/etc/letsencrypt/live/$HOST/privkey.pem|$WORKSPACE/nginx/certs/key.pem|" + -e "s|/etc/letsencrypt/live/$HOST/chain.pem|$WORKSPACE/nginx/certs/cert.pem|" + -e "s|/var/log/nginx/$HOST|$WORKSPACE/nginx/logs/$HOST|" + -e 's|^ listen 80;$| listen '"$((PORT + 1))"';|') + +# "http2 on" is nginx 1.25.1+. On older nginx, fold it back into the listen line. +version="$(nginx -v 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')" +if [[ "$(printf '%s\n1.25.1\n' "$version" | sort -V | head -1)" != "1.25.1" ]]; then + echo "== nginx $version predates \"http2 on\"; folding it into listen ==" + adapt+=(-e 's|^ http2 on;$| # http2 folded into listen for nginx < 1.25.1|' + -e "s|^ listen 443 ssl;\$| listen $PORT ssl http2;|" + -e "s|^ listen \\[::\\]:443 ssl;\$| listen [::]:$PORT ssl http2;|") +else + adapt+=(-e "s|^ listen 443 ssl;\$| listen $PORT ssl;|" + -e "s|^ listen \\[::\\]:443 ssl;\$| listen [::]:$PORT ssl;|") +fi + +# Not every build host has IPv6. +if [[ ! -f /proc/net/if_inet6 ]]; then + echo "== no IPv6 on this host; dropping the [::] listeners ==" + adapt+=(-e '/^ listen \[::\]:/d' -e '/^ listen \[::\]:/d') +fi + +sed "${adapt[@]}" "$REPO/docs/nginx/$HOST.conf" > "$NGINX_PREFIX/sites/$HOST.conf" + +# nginx workers must be able to read the workspace. As root they default to an +# unprivileged user that cannot; otherwise they inherit the invoking user. +privileged="" +[[ "$EUID" -eq 0 ]] && privileged="user root;" + +cat > "$NGINX_PREFIX/nginx.conf" </dev/null 2>&1 && echo /etc/nginx/mime.types || echo mime.types); + default_type application/octet-stream; + client_body_temp_path $WORKSPACE/nginx/logs/body; + proxy_temp_path $WORKSPACE/nginx/logs/proxy; + fastcgi_temp_path $WORKSPACE/nginx/logs/fastcgi; + uwsgi_temp_path $WORKSPACE/nginx/logs/uwsgi; + scgi_temp_path $WORKSPACE/nginx/logs/scgi; + include conf.d/getbible-api-http.conf; + include sites/*.conf; +} +CONF + +# --------------------------------------------------------------------------- +# 3. Parse it, serve it, and hold it to its own promises. +# --------------------------------------------------------------------------- +echo "== nginx -t ==" +nginx -p "$NGINX_PREFIX/" -c "$NGINX_PREFIX/nginx.conf" -t + +echo "== serving on $PORT ==" +nginx -p "$NGINX_PREFIX/" -c "$NGINX_PREFIX/nginx.conf" +sleep 1 + +export no_proxy='*' NO_PROXY='*' +"$REPO/scripts/verify_live_api.sh" "https://127.0.0.1:$PORT" --insecure + +echo "== error log ==" +if grep -Ei '\[(emerg|alert|crit|error)\]' "$WORKSPACE/nginx/logs/error.log" | grep -v ssl_stapling; then + echo "nginx logged errors while serving" >&2 + exit 1 +fi +echo " clean" 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()) From 96286eb1e9bda59115e530fa9523859ca8746826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?eW=C9=98yn?= <5607939+Llewellynvdm@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:56:04 +0000 Subject: [PATCH 3/4] Make the origin configuration install cleanly on a stock nginx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standing the configuration up against a real distribution nginx rather than a purpose-built test config exposed four things that would each have broken a first deployment. A stock nginx.conf already declares 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 error — so the http-context drop-in refused to start on a stock Ubuntu box. conf.d/ now carries only the shared memory zones and the log format, which genuinely cannot live in a server block; everything else moved to snippets/getbible-api-server.conf, where it overrides the http-level settings instead of colliding with them. The access log was named .json, outside the stock /etc/logrotate.d/nginx glob of /var/log/nginx/*.log, so it would never have rotated and would eventually have filled the disk. It is now .access.log. "http2 on" needs nginx 1.25.1, but Ubuntu 24.04 LTS ships 1.24, so a first nginx -t would have failed on the LTS release most likely to be hosting this. brotli_static and IPv6 listeners have the same shape of problem. Rather than leave three version footguns in a file people copy by hand, scripts/install_nginx_config.sh detects each one, adapts the shipped files, stages them, and runs nginx -t before installing. It also refuses to proceed when a certificate is missing, naming the certbot command to run, because nginx otherwise fails with a filename and no indication of what to do. The ACME challenge now resolves from the stock /var/www/html rather than a dedicated webroot nothing serves yet, which removes the chicken-and-egg between issuing the first certificate and installing the configuration that serves the challenge for renewals. Dotfiles return 404 instead of deny: the caller sees the same JSON 404 as any unknown path, which confirms nothing, and a scanned origin no longer fills its error log with lines that would bury real failures. tests/nginx_config_check.sh now drives the real installer against the distribution's own nginx.conf and serves both hosts, which is what caught all of the above; the previous hand-rolled test config could not have. It also asserts the access log stays inside the logrotate glob. CI runs it as root and shellchecks the tests as well as the scripts. Also: deploy_static_api.sh reports an unreachable --verify-url instead of exiting bare, and docs/deployment.md covers the installer, the configuration split and why it exists, certificate issuance and renewal, and log rotation. --- .github/workflows/ci.yml | 6 +- README.md | 10 +- docs/deployment.md | 82 ++++++-- docs/nginx/commentaries.getbible.net.conf | 18 +- docs/nginx/dictionaries.getbible.net.conf | 17 +- docs/nginx/getbible-api-http.conf | 77 ++----- docs/nginx/snippets/getbible-api-server.conf | 84 ++++++++ docs/nginx/snippets/getbible-api-v1.conf | 7 +- scripts/deploy_static_api.sh | 17 +- scripts/install_nginx_config.sh | 193 +++++++++++++++++ tests/nginx_config_check.sh | 208 +++++++++---------- 11 files changed, 491 insertions(+), 228 deletions(-) create mode 100644 docs/nginx/snippets/getbible-api-server.conf create mode 100755 scripts/install_nginx_config.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84a837b..4395ddc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,8 +37,8 @@ jobs: run: | sudo apt-get update -qq sudo apt-get install -y -qq shellcheck - shellcheck --severity=style scripts/*.sh - - name: Check the origin configuration parses + shellcheck --severity=style scripts/*.sh tests/*.sh + - name: Install, deploy, and serve the origin configuration run: | sudo apt-get install -y -qq nginx - bash tests/nginx_config_check.sh + sudo --preserve-env=PATH bash tests/nginx_config_check.sh diff --git a/README.md b/README.md index db229a2..23841de 100644 --- a/README.md +++ b/README.md @@ -238,9 +238,13 @@ 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 and `docs/deployment.md` describes the server -layout, the caching model, the CDN and security posture, and rollback. +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 diff --git a/docs/deployment.md b/docs/deployment.md index 6955074..4c8210b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -36,9 +36,10 @@ the deployment stops being safe: ``` /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 -/etc/nginx/snippets/getbible-api-headers.conf -/etc/nginx/snippets/getbible-api-v1.conf +/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 ``` @@ -48,34 +49,58 @@ off the origin entirely rather than relying on a rule to hide it. ## First-time setup -Requires nginx 1.25.1 or newer (for `http2 on`), git, rsync, python3, gzip, and -ideally brotli. +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 brotli +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 — it is roughly 15-20% larger on JSON than brotli. +# 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/www/getbible /var/lib/getbible /var/www/acme +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: -cp docs/nginx/getbible-api-http.conf /etc/nginx/conf.d/ -cp docs/nginx/snippets/*.conf /etc/nginx/snippets/ -cp docs/nginx/*.getbible.net.conf /etc/nginx/sites-available/ -ln -sf /etc/nginx/sites-available/commentaries.getbible.net.conf /etc/nginx/sites-enabled/ -ln -sf /etc/nginx/sites-available/dictionaries.getbible.net.conf /etc/nginx/sites-enabled/ +```bash +scripts/install_nginx_config.sh --reload ``` -If the brotli module is installed, uncomment the three `brotli_static on;` lines -in `snippets/getbible-api-v1.conf`. +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. -Issue certificates before the first `nginx -t`, since the server blocks -reference them: +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 -certbot certonly --webroot -w /var/www/acme \ - -d commentaries.getbible.net -d dictionaries.getbible.net -nginx -t && systemctl reload nginx +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 @@ -146,6 +171,20 @@ 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: @@ -264,6 +303,9 @@ Watch these, in rough order of how much they matter: - 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 diff --git a/docs/nginx/commentaries.getbible.net.conf b/docs/nginx/commentaries.getbible.net.conf index 75c7496..ccd787d 100644 --- a/docs/nginx/commentaries.getbible.net.conf +++ b/docs/nginx/commentaries.getbible.net.conf @@ -10,8 +10,11 @@ server { 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/acme; + root /var/www/html; default_type "text/plain"; } @@ -40,19 +43,9 @@ server { 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.json getbible buffer=64k flush=5s; + 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; - 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; - # 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 @@ -62,6 +55,7 @@ server { # 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. diff --git a/docs/nginx/dictionaries.getbible.net.conf b/docs/nginx/dictionaries.getbible.net.conf index e2565c6..b32dfa5 100644 --- a/docs/nginx/dictionaries.getbible.net.conf +++ b/docs/nginx/dictionaries.getbible.net.conf @@ -13,8 +13,11 @@ server { 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/acme; + root /var/www/html; default_type "text/plain"; } @@ -39,24 +42,16 @@ server { 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.json getbible buffer=64k flush=5s; + 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; - charset off; - default_type application/json; - etag on; - - client_max_body_size 1k; - client_body_timeout 10s; - keepalive_timeout 65s; - keepalive_requests 1000; - # 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) { diff --git a/docs/nginx/getbible-api-http.conf b/docs/nginx/getbible-api-http.conf index 08eb355..5b9312c 100644 --- a/docs/nginx/getbible-api-http.conf +++ b/docs/nginx/getbible-api-http.conf @@ -1,67 +1,19 @@ # GetBible study API — http-context settings. # -# Install as /etc/nginx/conf.d/getbible-api-http.conf. Everything here must sit -# in the http { } block: shared memory zones, the open-file cache, TLS defaults, -# and the log format are not valid inside a server { } block. +# Install as /etc/nginx/conf.d/getbible-api-http.conf. # -# Requires nginx 1.25.1 or newer (for "http2 on" in the server files). - -# --------------------------------------------------------------------------- -# 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_cache shared:GetBibleTLS:10m; -ssl_session_timeout 1d; -ssl_session_tickets off; -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 ipv6=on; -resolver_timeout 5s; +# 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 @@ -74,9 +26,6 @@ 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; -limit_req_status 429; -limit_conn_status 429; - # --------------------------------------------------------------------------- # Logging # --------------------------------------------------------------------------- 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 index 3aa2736..6fbe1fb 100644 --- a/docs/nginx/snippets/getbible-api-v1.conf +++ b/docs/nginx/snippets/getbible-api-v1.conf @@ -14,10 +14,15 @@ # 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 ~ /\. { - deny all; access_log off; log_not_found off; + return 404; } # commentaries.json / dictionaries.json / build.json / hashes.json. diff --git a/scripts/deploy_static_api.sh b/scripts/deploy_static_api.sh index 8277eb0..e1d5c2e 100755 --- a/scripts/deploy_static_api.sh +++ b/scripts/deploy_static_api.sh @@ -301,14 +301,17 @@ fi if [[ -n "$VERIFY_URL" ]]; then command -v curl >/dev/null || die "--verify-url needs curl" log "verifying $VERIFY_URL" - read -r status type < <( - curl --silent --show-error --fail --location --max-time 30 \ - --output /dev/null --write-out '%{http_code} %{content_type}\n' \ - "$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" - [[ "$type" == application/json* ]] || die "$VERIFY_URL returned Content-Type $type" - log "live check passed ($status $type)" + [[ "$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/tests/nginx_config_check.sh b/tests/nginx_config_check.sh index 9bc4c92..1120b7e 100755 --- a/tests/nginx_config_check.sh +++ b/tests/nginx_config_check.sh @@ -1,12 +1,17 @@ #!/usr/bin/env bash # SPDX-License-Identifier: GPL-2.0-only # -# Stand the shipped origin configuration up over a real generated tree and run -# the live verifier against it. +# 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. # -# This covers the parts of the delivery that Python tests cannot reach: that -# docs/nginx/ parses, that deploy_static_api.sh produces a tree nginx can serve, -# and that the promises in verify_live_api.sh actually hold end to end. +# 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. @@ -14,131 +19,120 @@ set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" WORKSPACE="$(mktemp -d)" -PORT="${NGINX_CHECK_PORT:-8443}" -NGINX_PREFIX="$WORKSPACE/nginx/conf" - -cleanup() { - if [[ -f "$WORKSPACE/nginx/logs/nginx.pid" ]]; then - nginx -p "$NGINX_PREFIX/" -c "$NGINX_PREFIX/nginx.conf" -s quit 2>/dev/null || true - sleep 1 - fi - rm -rf "$WORKSPACE" -} -trap cleanup EXIT +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 -mkdir -p "$NGINX_PREFIX"/{conf.d,snippets,sites} "$WORKSPACE/nginx"/{logs,certs} +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 a real API tree, publish it as a repository, and deploy it. +# 1. Generate real API trees and publish each as a repository. # --------------------------------------------------------------------------- -echo "== generating an API tree ==" +echo "== generating API trees ==" python3 "$REPO/tests/support/build_sample_tree.py" "$WORKSPACE/build" -git -C "$WORKSPACE" init -q -b main sample -git -C "$WORKSPACE/sample" config user.name "nginx config check" -git -C "$WORKSPACE/sample" config user.email "check@example.invalid" -cp -a "$WORKSPACE/build/dist/commentaries/v1" "$WORKSPACE/sample/v1" -printf '# published commentaries\n' > "$WORKSPACE/sample/README.md" -git -C "$WORKSPACE/sample" add -A -git -C "$WORKSPACE/sample" commit -qm "Sample build" - -echo "== deploying ==" -"$REPO/scripts/deploy_static_api.sh" \ - --repo "$WORKSPACE/sample" \ - --root "$WORKSPACE/live" \ - --work "$WORKSPACE/checkout" \ - --no-reload - -[[ -d "$WORKSPACE/live/v1" ]] || { echo "deploy produced no v1 directory" >&2; exit 1; } -if [[ -e "$WORKSPACE/live/README.md" ]]; then - echo "deploy leaked a non-version file into the live root" >&2 - exit 1 -fi +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. Adapt the shipped configuration to this host, changing as little as -# possible so the check keeps testing what is actually shipped. +# 2. Install the configuration exactly the way an operator would. # --------------------------------------------------------------------------- -cp "$REPO/docs/nginx/getbible-api-http.conf" "$NGINX_PREFIX/conf.d/" -cp "$REPO/docs/nginx/snippets/"*.conf "$NGINX_PREFIX/snippets/" - -HOST=commentaries.getbible.net -openssl req -x509 -newkey rsa:2048 -nodes -days 2 \ - -keyout "$WORKSPACE/nginx/certs/key.pem" -out "$WORKSPACE/nginx/certs/cert.pem" \ - -subj "/CN=$HOST" -addext "subjectAltName=DNS:$HOST" 2>/dev/null - -adapt=(-e "s|/var/www/getbible/commentaries|$WORKSPACE/live|" - -e "s|/etc/letsencrypt/live/$HOST/fullchain.pem|$WORKSPACE/nginx/certs/cert.pem|" - -e "s|/etc/letsencrypt/live/$HOST/privkey.pem|$WORKSPACE/nginx/certs/key.pem|" - -e "s|/etc/letsencrypt/live/$HOST/chain.pem|$WORKSPACE/nginx/certs/cert.pem|" - -e "s|/var/log/nginx/$HOST|$WORKSPACE/nginx/logs/$HOST|" - -e 's|^ listen 80;$| listen '"$((PORT + 1))"';|') - -# "http2 on" is nginx 1.25.1+. On older nginx, fold it back into the listen line. -version="$(nginx -v 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')" -if [[ "$(printf '%s\n1.25.1\n' "$version" | sort -V | head -1)" != "1.25.1" ]]; then - echo "== nginx $version predates \"http2 on\"; folding it into listen ==" - adapt+=(-e 's|^ http2 on;$| # http2 folded into listen for nginx < 1.25.1|' - -e "s|^ listen 443 ssl;\$| listen $PORT ssl http2;|" - -e "s|^ listen \\[::\\]:443 ssl;\$| listen [::]:$PORT ssl http2;|") -else - adapt+=(-e "s|^ listen 443 ssl;\$| listen $PORT ssl;|" - -e "s|^ listen \\[::\\]:443 ssl;\$| listen [::]:$PORT ssl;|") -fi - -# Not every build host has IPv6. -if [[ ! -f /proc/net/if_inet6 ]]; then - echo "== no IPv6 on this host; dropping the [::] listeners ==" - adapt+=(-e '/^ listen \[::\]:/d' -e '/^ listen \[::\]:/d') -fi - -sed "${adapt[@]}" "$REPO/docs/nginx/$HOST.conf" > "$NGINX_PREFIX/sites/$HOST.conf" +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 -# nginx workers must be able to read the workspace. As root they default to an -# unprivileged user that cannot; otherwise they inherit the invoking user. -privileged="" -[[ "$EUID" -eq 0 ]] && privileged="user root;" +# 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 -cat > "$NGINX_PREFIX/nginx.conf" </dev/null 2>&1 && echo /etc/nginx/mime.types || echo mime.types); - default_type application/octet-stream; - client_body_temp_path $WORKSPACE/nginx/logs/body; - proxy_temp_path $WORKSPACE/nginx/logs/proxy; - fastcgi_temp_path $WORKSPACE/nginx/logs/fastcgi; - uwsgi_temp_path $WORKSPACE/nginx/logs/uwsgi; - scgi_temp_path $WORKSPACE/nginx/logs/scgi; - include conf.d/getbible-api-http.conf; - include sites/*.conf; -} -CONF +echo "== installing ==" +"$REPO/scripts/install_nginx_config.sh" # --------------------------------------------------------------------------- -# 3. Parse it, serve it, and hold it to its own promises. +# 3. Serve both hosts and hold each to its own promises. # --------------------------------------------------------------------------- -echo "== nginx -t ==" -nginx -p "$NGINX_PREFIX/" -c "$NGINX_PREFIX/nginx.conf" -t - -echo "== serving on $PORT ==" -nginx -p "$NGINX_PREFIX/" -c "$NGINX_PREFIX/nginx.conf" +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='*' -"$REPO/scripts/verify_live_api.sh" "https://127.0.0.1:$PORT" --insecure +for host in "${HOSTS[@]}"; do + echo + "$REPO/scripts/verify_live_api.sh" "https://$host" --insecure +done -echo "== error log ==" -if grep -Ei '\[(emerg|alert|crit|error)\]' "$WORKSPACE/nginx/logs/error.log" | grep -v ssl_stapling; then +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 From b469f7ccc0a3ef0aa83672df9f8f7eed6532dd20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?eW=C9=98yn?= <5607939+Llewellynvdm@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:58:30 +0000 Subject: [PATCH 4/4] Reject any unlisted file before it reaches the origin Deploy verification only rejected an unexpected .json, so a stray .html or .js committed into a version directory would have passed and been served from the API's own hostname. hashes.json is the complete list of what the builder publishes, so anything else in the tree is unaccounted for: every file is now checked, with only the .gz and .br variants this script writes exempted. Also: --help printed a line of the script itself, because the header comment block had outgrown the fixed line range it was extracted with; it is now bounded by the block. And document that --require-signature verifies against the deploying user's GPG keyring, so the build key has to be imported and trusted on each origin first or every deploy fails closed. --- docs/deployment.md | 13 +++++++++++++ scripts/deploy_static_api.sh | 22 ++++++++++++++++------ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index 4c8210b..bba9a4c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -135,6 +135,19 @@ 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 diff --git a/scripts/deploy_static_api.sh b/scripts/deploy_static_api.sh index e1d5c2e..d7bc27f 100755 --- a/scripts/deploy_static_api.sh +++ b/scripts/deploy_static_api.sh @@ -48,7 +48,9 @@ log() { } usage() { - sed -n '3,28p' "$0" | sed 's/^# \{0,1\}//' + # 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: @@ -166,11 +168,19 @@ for relative, expected in sorted(files.items()): if digest.hexdigest() != expected: problems.append(f"digest mismatch: {relative}") -published = { - path.relative_to(root).as_posix() - for path in root.rglob("*.json") - if path.name != "hashes.json" -} +# 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}")