Serve the packet text at print time instead of bundling all 11 languages
A single-file build inlines every import, dynamic ones included, so packet-strings.json (~115 KB minified: every language's prayer points, captions and headings) rode in every bundle that can print a packet, list or card deck. With the wall's print messages translated, country-posters crossed the 1.5 MB ceiling (1,502,226 bytes). The table is now served at <assets>/lib/packet-strings.json and fetched at the first print by library/packet/packetStrings.js, the same move as the sheet generator and ne-places.json: - Pinned like them: PACKET_STRINGS_SHA384 in poster-render.integrity.js (pin-poster-render.mjs); the text is hashed before it is parsed, and an unmatched table is not used. These strings are laid into the print document, so an unverified copy never reaches it. - English is bundled as the fallback (a named import of the JSON, so only the en block ships): with no asset base, on file://, on a network error or an integrity mismatch the print comes out in English and the console says why. A print never fails for want of this file. - generate-tree copies it into the pool AND the served allowlist; verified by regenerating the tree (the served copy is byte-identical to source). - check:poster-render-pinned, two-sided: the pin matches source and served copy, the genuine copy loads all 11 languages, a tampered copy falls back to English (mutation-tested: disabling the hash check fails it), and an unserved one falls back rather than failing. - sha384 is one shared helper (library/packet/sha384.js). Sizes: country-posters 1,378,463 (was 1,484,053), my-upg-100-list 1,143,456 (was 1,243,509), regions-dashboard 973,802 (was 1,079,448).
This commit is contained in:
parent
03839179b7
commit
a3e58ac7ca
10 changed files with 2847 additions and 18 deletions
|
|
@ -12,7 +12,7 @@
|
|||
* · a TAMPERED copy is refused, and its code never executes (a planted side effect stays unset);
|
||||
* · the GENUINE copy loads and draws a real sheet.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs'
|
||||
import { readFileSync, writeFileSync, mkdtempSync, mkdirSync, rmSync, existsSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
|
@ -98,5 +98,36 @@ ok(typeof html === 'string' && html.length > 10000 && /<svg\b/.test(html), `the
|
|||
ok(a === b && a.length > 10000, `run as a classic script it draws the module's sheet byte for byte (${a.length} bytes)`)
|
||||
}
|
||||
|
||||
// THE PACKET TEXT (library/packet/packetStrings.js), served at lib/packet-strings.json and pinned. Two-sided:
|
||||
// the GENUINE served copy loads every language, and a TAMPERED one is refused — the print falls back to the
|
||||
// bundled English block, so the forged text never reaches a print document.
|
||||
{
|
||||
const { PACKET_STRINGS_SHA384 } = await import(join(REPO, 'library/packet/poster-render.integrity.js'))
|
||||
const stringsSrc = readFileSync(join(REPO, 'library/scripts/posters/packet-strings.json'), 'utf8')
|
||||
const servedStrings = join(REPO, 'public/dx-maps/assets/lib/packet-strings.json')
|
||||
ok(PACKET_STRINGS_SHA384 === pinOf(stringsSrc),
|
||||
`the packet-strings pin matches its source${PACKET_STRINGS_SHA384 === pinOf(stringsSrc) ? '' : ' — run: node library/scripts/posters/pin-poster-render.mjs'}`)
|
||||
ok(existsSync(servedStrings) && pinOf(readFileSync(servedStrings, 'utf8')) === pinOf(stringsSrc),
|
||||
'the served copy (assets/lib/packet-strings.json) is the pinned file')
|
||||
const { loadPacketStrings } = await import(join(REPO, 'library/packet/packetStrings.js'))
|
||||
const genuine = await loadPacketStrings(pathToFileURL(join(REPO, 'public/dx-maps/assets')).href)
|
||||
const langs = Object.keys(genuine).filter((k) => k !== '_meta')
|
||||
ok(langs.length === 11 && genuine.zh?.zone_affinity_A017 === JSON.parse(stringsSrc).zh.zone_affinity_A017,
|
||||
`the genuine served copy loads all ${langs.length} languages`)
|
||||
const tmp3 = mkdtempSync(join(tmpdir(), 'strings-pin-'))
|
||||
const warn = console.warn; console.warn = () => {}
|
||||
try {
|
||||
mkdirSync(join(tmp3, 'lib'), { recursive: true })
|
||||
const forged = JSON.parse(stringsSrc); forged.zh.zone_affinity_A017 = 'FORGED'
|
||||
writeFileSync(join(tmp3, 'lib/packet-strings.json'), JSON.stringify(forged, null, 2) + '\n')
|
||||
const tampered = await loadPacketStrings(pathToFileURL(tmp3).href)
|
||||
ok(!JSON.stringify(tampered).includes('FORGED') && Object.keys(tampered).join() === 'en'
|
||||
&& tampered.en.zone_affinity_A017 === JSON.parse(stringsSrc).en.zone_affinity_A017,
|
||||
'a tampered packet-strings table is refused — the print falls back to the bundled English block')
|
||||
const absent = await loadPacketStrings(pathToFileURL(join(tmp3, 'nowhere')).href)
|
||||
ok(Object.keys(absent).join() === 'en', 'an unserved table falls back to English rather than failing the print')
|
||||
} finally { console.warn = warn; rmSync(tmp3, { recursive: true, force: true }) }
|
||||
}
|
||||
|
||||
if (fails) { console.log(`POSTER-RENDER-PINNED: FAIL (${fails})`); process.exit(1) }
|
||||
console.log('POSTER-RENDER-PINNED: PASS — the served generator and places table are the pinned ones, a tampered generator is refused unrun, a tampered table is not used, and the genuine copies draw')
|
||||
|
|
|
|||
|
|
@ -1722,6 +1722,10 @@ function main() {
|
|||
// <script integrity>), written by the same transform the pin is computed from.
|
||||
writeFileSync(join(libDir, 'poster-render.global.js'),
|
||||
globalVariant(readFileSync(resolve(__dirname, '..', 'library', 'packet', 'poster-render.js'), 'utf8')))
|
||||
// …and the packet/list/card TEXT in all 11 languages (~115 KB), for the same reason: every bundle that can
|
||||
// print inlined the whole table. library/packet/packetStrings.js fetches this copy at print time, checks
|
||||
// it against PACKET_STRINGS_SHA384, and falls back to the bundled English block if it cannot.
|
||||
cpSync(resolve(__dirname, '..', 'library', 'scripts', 'posters', 'packet-strings.json'), join(libDir, 'packet-strings.json'))
|
||||
|
||||
// DATA LIVES IN data/, THE WEB-ROOT IS GENERATED (the D1 reorganization):
|
||||
// source-of-truth folders that used to sit ONLY inside public/ now live in
|
||||
|
|
@ -1794,6 +1798,7 @@ function main() {
|
|||
'boundaries/ne-places.json',
|
||||
'lib/poster-render.js',
|
||||
'lib/poster-render.global.js', // the classic-script copy, same generator (see the pool stage)
|
||||
'lib/packet-strings.json', // the packet/list/card text, fetched at print time (packetStrings.js)
|
||||
// people-groups — all-people-groups.vue (the slim JSON only; NOT the 16 MB CSV,
|
||||
// which no element fetches and which dropin.exclude already withholds).
|
||||
'people-groups/people-groups-slim.json',
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import { ref } from 'vue';
|
|||
import { openPrintFrame } from './printInPlace.js';
|
||||
import { fetchPosterRaster, fetchPosterSource } from '../packet/poster-fetch.js';
|
||||
import { keyedError, errorText } from '../i18n/keyedError.js';
|
||||
// The packet/card text, fetched from the served tree at print time (English bundled as the fallback).
|
||||
import { loadPacketStrings } from '../packet/packetStrings.js';
|
||||
|
||||
const esc = (s) => String(s ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
|
@ -48,7 +50,8 @@ const esc = (s) => String(s ?? '')
|
|||
* label (item) => string
|
||||
* stock () => string — card stock id (see card-render.js STOCK).
|
||||
* state ref — the host's HUD state ref, when it renders its own HUD.
|
||||
* strings () => Promise<object> — the packet/card catalog; defaults to packet-strings.json.
|
||||
* strings () => Promise<object> — the packet/card catalog; defaults to the served, pinned
|
||||
* packet-strings.json (packet/packetStrings.js, English fallback).
|
||||
* sheet async (item, format) => html — a map SHEET the host generates now (posterSheets.js
|
||||
* generateSheet): 'letter' for the poster print (a vector page),
|
||||
* 'packet' for the packet's page 1. Tried FIRST by both.
|
||||
|
|
@ -254,7 +257,7 @@ export function usePosterPrints(opts = {}) {
|
|||
const data = await rows(item);
|
||||
const [{ createCardRenderer }, strings] = await Promise.all([
|
||||
import('../packet/card-render.js'),
|
||||
opts.strings ? opts.strings() : import('../scripts/posters/packet-strings.json').then((m) => m.default || m),
|
||||
opts.strings ? opts.strings() : loadPacketStrings(opts.assetsBase ? opts.assetsBase() : null),
|
||||
]);
|
||||
set(item, kind, 'render', tr('wall.hud.layingOutCards', 'laying out {n} cards', { n: data.length }, data.length), 70);
|
||||
const R = createCardRenderer({ strings, lang: lang(), stock: stock() });
|
||||
|
|
@ -336,7 +339,7 @@ export function usePosterPrints(opts = {}) {
|
|||
import('../packet/packet-render.js'),
|
||||
import('../packet/packetSets.js'),
|
||||
import('../packet/evangelicalShare.js'),
|
||||
opts.strings ? opts.strings() : import('../scripts/posters/packet-strings.json').then((m) => m.default || m),
|
||||
opts.strings ? opts.strings() : loadPacketStrings(opts.assetsBase ? opts.assetsBase() : null),
|
||||
opts.christian ? opts.christian() : Promise.resolve(null),
|
||||
opts.christian ? Promise.resolve(null)
|
||||
: import('../../data/people-groups/evangelical-by-slug.json').then((m) => m.default || m).catch(() => null),
|
||||
|
|
|
|||
49
library/packet/packetStrings.js
Normal file
49
library/packet/packetStrings.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* packetStrings — the text of every printed packet, list and card deck, in all 11 languages, FETCHED at print
|
||||
* time from the served tree (<assets>/lib/packet-strings.json) instead of bundled.
|
||||
*
|
||||
* WHY. A single-file build inlines every import, dynamic ones included, so the whole table (~115 KB
|
||||
* minified: every language's prayer points, captions and headings) rode in every bundle that can print —
|
||||
* country-posters crossed the 1.5 MB ceiling once its wall's print messages were translated. A reader needs
|
||||
* the table only when they print. Same move as the sheet generator and ne-places.json (posterSheets.js).
|
||||
*
|
||||
* PINNED like them (PACKET_STRINGS_SHA384): the text is hashed before it is parsed, and a table that is not
|
||||
* the pinned one is not used. These strings are laid into the print document, so an unverified copy must
|
||||
* never reach it.
|
||||
*
|
||||
* ★ ENGLISH IS BUNDLED AS THE FALLBACK, so a print never fails for want of this file: without an asset base
|
||||
* (a host that sets none), on file:// where a page cannot fetch, on a network error, or on an integrity
|
||||
* mismatch, the packet prints in English and the console says why. `{ en }` is a named import of the JSON, so
|
||||
* only the English block is bundled; the other ten languages stay on the server.
|
||||
*/
|
||||
import { en as EN } from '../scripts/posters/packet-strings.json'
|
||||
import { PACKET_STRINGS_SHA384 } from './poster-render.integrity.js'
|
||||
import { sha384 } from './sha384.js'
|
||||
|
||||
// Captured when the module evaluates, as in posterSheets.js: a relative asset base resolves against the page
|
||||
// that loaded the map, even if a single-page host changes its URL before the click.
|
||||
const PAGE_BASE = globalThis.document?.baseURI || undefined
|
||||
const ENGLISH_ONLY = Object.freeze({ en: EN })
|
||||
const cache = new Map()
|
||||
|
||||
/** The full table ({ en, es, …, zh }), or `{ en }` when the served copy cannot be used. Never rejects. */
|
||||
export function loadPacketStrings(assetsBase) {
|
||||
if (!assetsBase) return Promise.resolve(ENGLISH_ONLY)
|
||||
let url
|
||||
try { url = new URL(`${assetsBase}/lib/packet-strings.json`, PAGE_BASE).href } catch { return Promise.resolve(ENGLISH_ONLY) }
|
||||
if (!cache.has(url)) {
|
||||
const p = (async () => {
|
||||
const res = await fetch(url, { cache: 'force-cache' })
|
||||
if (!res.ok) throw new Error(`not served (${res.status})`)
|
||||
const text = await res.text()
|
||||
if ((await sha384(text)) !== PACKET_STRINGS_SHA384) throw new Error('failed its integrity check')
|
||||
return JSON.parse(text)
|
||||
})().catch((e) => {
|
||||
console.warn(`[packetStrings] ${url}: ${e?.message || e} — printing in English`)
|
||||
cache.delete(url) // a later print tries again rather than inheriting one failure
|
||||
return ENGLISH_ONLY
|
||||
})
|
||||
cache.set(url, p)
|
||||
}
|
||||
return cache.get(url)
|
||||
}
|
||||
|
|
@ -9,3 +9,7 @@ export const NE_PLACES_SHA384 = 'sha384-Qtz2q8mcnA9Ei8Kwa2MRMyX95hSbSnUXjXdtsy8r
|
|||
// The sha384 of the classic-script copy (<assets>/lib/poster-render.global.js, globalVariant of the file above),
|
||||
// loaded with <script integrity> where a page's policy forbids importing a blob: of the verified text.
|
||||
export const POSTER_RENDER_GLOBAL_SHA384 = 'sha384-gRuzl6j8EMIN3bQXFZH3CSXKWx7w8LCf45PlTgXorFrFs/9jIKX3Ic3D/iCOpWwU'
|
||||
// The sha384 of library/scripts/posters/packet-strings.json, served at <assets>/lib/packet-strings.json. The
|
||||
// packet, list and card text in every language, fetched at print time (packet/packetStrings.js) and used only
|
||||
// when it matches; otherwise the print falls back to the bundled English block.
|
||||
export const PACKET_STRINGS_SHA384 = 'sha384-XtvN8GulVpQPnSgA3Qr2HHbUC9ivL3OsuhhO+jDhBz6/J1PTlzOx8uTPVQjI8bjR'
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
* ISO3_NAME, series: { totalGroups, totalLists, index } })
|
||||
*/
|
||||
import { POSTER_RENDER_SHA384, NE_PLACES_SHA384, POSTER_RENDER_GLOBAL_SHA384 } from './poster-render.integrity.js'
|
||||
import { sha384 } from './sha384.js'
|
||||
|
||||
// ★ THE GENERATOR IS SERVED, NOT BUNDLED, AND IT IS INTEGRITY-PINNED. poster-render.js is served verbatim
|
||||
// at <assets>/lib/poster-render.js (generate-tree) and loaded at the FIRST print: a single-file build
|
||||
|
|
@ -30,12 +31,6 @@ import { POSTER_RENDER_SHA384, NE_PLACES_SHA384, POSTER_RENDER_GLOBAL_SHA384 } f
|
|||
// and a relative dynamic import would resolve against the BUNDLE's location instead of the page's.
|
||||
const PAGE_BASE = globalThis.document?.baseURI || undefined
|
||||
const abs = (u) => new URL(u, PAGE_BASE).href
|
||||
async function sha384(text) {
|
||||
const d = new Uint8Array(await crypto.subtle.digest('SHA-384', new TextEncoder().encode(text)))
|
||||
let bin = ''
|
||||
for (const b of d) bin += String.fromCharCode(b)
|
||||
return `sha384-${btoa(bin)}`
|
||||
}
|
||||
// The classic-script path: the browser checks POSTER_RENDER_GLOBAL_SHA384 itself and refuses to run a
|
||||
// file that does not match (onerror), so nothing unverified executes here either.
|
||||
function loadClassic(url) {
|
||||
|
|
|
|||
9
library/packet/sha384.js
Normal file
9
library/packet/sha384.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// The Subresource-Integrity form of a text's SHA-384 ("sha384-<base64>"), via WebCrypto, so a file fetched at
|
||||
// print time can be compared with the pin baked into the bundle (poster-render.integrity.js) before it is used.
|
||||
// The same value pin-poster-render.mjs computes with node:crypto.
|
||||
export async function sha384(text) {
|
||||
const d = new Uint8Array(await crypto.subtle.digest('SHA-384', new TextEncoder().encode(text)))
|
||||
let bin = ''
|
||||
for (const b of d) bin += String.fromCharCode(b)
|
||||
return `sha384-${btoa(bin)}`
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ if (isMain) {
|
|||
const pin = pinOf(readFileSync(join(LIB, 'poster-render.js'), 'utf8'))
|
||||
const places = pinOf(readFileSync(join(LIB, '..', 'scripts', 'posters', 'data', 'ne-places.json'), 'utf8'))
|
||||
const classic = pinOf(globalVariant(readFileSync(join(LIB, 'poster-render.js'), 'utf8')))
|
||||
const strings = pinOf(readFileSync(join(LIB, '..', 'scripts', 'posters', 'packet-strings.json'), 'utf8'))
|
||||
writeFileSync(join(LIB, 'poster-render.integrity.js'), `// GENERATED by library/scripts/posters/pin-poster-render.mjs — do not edit by hand.
|
||||
// The sha384 of library/packet/poster-render.js. The browser fetches the served copy of that file
|
||||
// (<assets>/lib/poster-render.js), hashes it, and runs it only when it matches this pin: code loaded at
|
||||
|
|
@ -40,6 +41,10 @@ export const NE_PLACES_SHA384 = '${places}'
|
|||
// The sha384 of the classic-script copy (<assets>/lib/poster-render.global.js, globalVariant of the file above),
|
||||
// loaded with <script integrity> where a page's policy forbids importing a blob: of the verified text.
|
||||
export const POSTER_RENDER_GLOBAL_SHA384 = '${classic}'
|
||||
// The sha384 of library/scripts/posters/packet-strings.json, served at <assets>/lib/packet-strings.json. The
|
||||
// packet, list and card text in every language, fetched at print time (packet/packetStrings.js) and used only
|
||||
// when it matches; otherwise the print falls back to the bundled English block.
|
||||
export const PACKET_STRINGS_SHA384 = '${strings}'
|
||||
`)
|
||||
console.log(`[pin-poster-render] ${pin}\n[pin-poster-render] ne-places ${places}\n[pin-poster-render] classic ${classic}`)
|
||||
console.log(`[pin-poster-render] ${pin}\n[pin-poster-render] ne-places ${places}\n[pin-poster-render] classic ${classic}\n[pin-poster-render] packet-strings ${strings}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,8 @@ import { countryName } from '@map/utils/countryName.js'
|
|||
// Errors a reader sees carry their catalogue key; errorText() shows them in the reader's language.
|
||||
// Strings only this map shows live in their own area, upgMap.json, so no other bundle carries them.
|
||||
import { keyedError, errorText } from '@map/i18n/keyedError.js'
|
||||
// The packet's text table, served and integrity-pinned rather than bundled (library/packet/packetStrings.js).
|
||||
import { loadPacketStrings } from '@map/packet/packetStrings.js'
|
||||
import { resolveAllZones } from './data/zone-geometry.js'
|
||||
// slice 09b-9 §3 (L18/L29 FIX): the per-zone color OVERRIDE map. zone-colors.json
|
||||
// holds the region-inherited tapestry (purple ME, red Africa, green
|
||||
|
|
@ -1507,7 +1509,7 @@ async function localizedZone(zone) {
|
|||
const [{ zoneTitle }, { countryName }, strings] = await Promise.all([
|
||||
import('./data/zone-title.js'),
|
||||
import('@map/utils/countryName.js'),
|
||||
import('@map/scripts/posters/packet-strings.json').then((m) => m.default || m),
|
||||
loadPacketStrings(resolveAssetsBase(profileConfig.value)),
|
||||
]);
|
||||
// the packet's own strings for this language, English per key where a block lacks one — the same
|
||||
// fallback the renderer applies (an affinity-bloc zone such as Deaf is named from them)
|
||||
|
|
@ -1689,13 +1691,11 @@ function selectedLists() {
|
|||
// still leave the caller to merge.
|
||||
// The packet's own string block for the reader's locale, per-KEY fallback to English exactly as
|
||||
// packet.mjs and packet-render do it — a half-translated block must not blank a key that en has.
|
||||
let _packetStrings = null;
|
||||
// Not memoised here: loadPacketStrings caches a SUCCESS itself, and memoising the merged block would pin an
|
||||
// English-only fallback (a transient fetch failure) for the rest of the session.
|
||||
async function packetStrings() {
|
||||
if (!_packetStrings) {
|
||||
const all = await import('@map/scripts/posters/packet-strings.json').then((m) => m.default || m);
|
||||
_packetStrings = { ...(all.en || {}), ...(all[packetLang()] || {}) };
|
||||
}
|
||||
return _packetStrings;
|
||||
const all = await loadPacketStrings(resolveAssetsBase(profileConfig.value));
|
||||
return { ...(all.en || {}), ...(all[packetLang()] || {}) };
|
||||
}
|
||||
/** `{a}` / `{b}` / `{n}` interpolation; null when the pattern is missing so the caller can default. */
|
||||
function fillJoin(pattern, params) {
|
||||
|
|
|
|||
2728
public/dx-maps/assets/lib/packet-strings.json
Normal file
2728
public/dx-maps/assets/lib/packet-strings.json
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue