diff --git a/CHANGELOG.md b/CHANGELOG.md index aef8c7e..a4079c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,18 @@ what to check. resources; share links carry the fields only in the URL fragment. SVG and PNG downloads are byte-identical to the CLI, enforced by `scripts/web-smoke.mjs` in CI. See README "Web". +- Web form: name, IBAN, amount and remittance text up front; reference, + BIC, purpose code and the note to the payer fold under "More fields" + (IBAN-only is the SEPA norm). Typed letters never land in the amount + field; pasted ones still reach the validator. A share link from a newer + format version is refused instead of guessed. `web-smoke.mjs` now also + greps the page sources for storage APIs, address-bar writes and external + resources. + +### Changed + +- The PNG scale (8 px per module) is one constant, `render.DefaultPNGScale`, + shared by `--png` and the web download; behaviour is unchanged. ## [0.2.0] - 2026-09-04 diff --git a/README.md b/README.md index e40b136..b067405 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ Generate EPC QR codes ("GiroCode") for SEPA credit transfers — a single static binary with zero runtime dependencies, including its own QR encoder -core. +core. The same code runs in the browser at + (see [Web](#web)): no server, no storage. An EPC QR code ([EPC069-12](https://www.europeanpaymentscouncil.eu/document-library/guidance-documents/quick-response-code-guidelines-enable-data-capture-initiation)) encodes recipient, IBAN, amount, and remittance text so a banking app can @@ -10,7 +11,9 @@ pre-fill a SEPA transfer from a single scan. ## Install -Requires Go 1.26 or newer: +No install: open . + +For the command line, requires Go 1.26 or newer: ```sh go install github.com/bmmmm/epcii@latest @@ -71,6 +74,12 @@ The same generator runs in the browser at and the SVG and PNG you download are byte-identical to the CLI's output — `scripts/web-smoke.mjs` proves that in CI on every change. +The form shows name, IBAN, amount and remittance text; reference, BIC, +purpose code and the note to the payer are folded under "More fields", since +IBAN-only is the SEPA norm (a BIC is only needed for accounts outside the +EEA). Every field of the flag table above is available and travels in share +links. The page is in English and German; the switch keeps nothing. + What the page does not do: - **No server, no storage.** GitHub Pages serves a handful of static files; @@ -115,8 +124,15 @@ after `go build -o epcii .`. go build -o epcii . # build go test ./... # unit, golden, and round-trip tests go vet ./... && gofmt -l . +scripts/build-web.sh && node scripts/web-smoke.mjs # web build + CLI-identity gate ``` +The browser build lives in `cmd/epcii-wasm` (a `js && wasm` entry point +over `internal/webapi`, the CLI pipeline as one function) and `web/` +(static page, no framework). `scripts/build-web.sh` assembles `web/dist/`; +`pages.yml` deploys it to GitHub Pages on every push to `main`, after the +same smoke gate CI runs on pull requests. + `--version` reports whatever the build info carries: the module version for `go install`, a VCS pseudo-version for a plain `go build` in a checkout. Release builds stamp it explicitly: @@ -132,7 +148,10 @@ segno's reference implementation (regenerate via reconstruction test that rebuilds the module matrix from the emitted path, and matrix fingerprints for every payload length generated from the upstream piglig/go-qr encoder (`go run -C scripts/qrfixtures .`, a separate -module so upstream never enters `go.mod`). +module so upstream never enters `go.mod`). The web build adds a fifth: +`scripts/web-smoke.mjs` runs the wasm through Go's `wasm_exec.js` and +compares its SVG and PNG with the CLI byte for byte, then greps the page +sources for storage APIs, address-bar writes and external resources. ## Contributing diff --git a/internal/render/png.go b/internal/render/png.go index 12f94f4..feb4fa9 100644 --- a/internal/render/png.go +++ b/internal/render/png.go @@ -7,6 +7,11 @@ import ( "io" ) +// DefaultPNGScale is the pixels-per-module scale the CLI (--png) and the web +// version share, so both produce the same file: 69 modules + quiet zone +// => 616 px at most. +const DefaultPNGScale = 8 + // PNG renders the module matrix as a PNG with the given pixels-per-module // scale, including the quiet zone. func PNG(w io.Writer, modules [][]bool, scale int) error { diff --git a/internal/webapi/webapi.go b/internal/webapi/webapi.go index 1b9ca8d..40a1ea2 100644 --- a/internal/webapi/webapi.go +++ b/internal/webapi/webapi.go @@ -12,10 +12,6 @@ import ( "github.com/bmmmm/epcii/internal/render" ) -// pngScale is pixels per module for the PNG download. Keep equal to -// pngScale in main.go so the web PNG matches `epcii --png` byte for byte. -const pngScale = 8 - // Input mirrors the CLI flags (see the flag table in main.go). type Input struct { Name, IBAN, BIC, Amount, Purpose, Ref, Text, Info string @@ -28,7 +24,7 @@ type Output struct { Version int // QR symbol version Size int // modules per side, without quiet zone SVG string // identical to the CLI's stdout - PNG []byte // identical to the CLI's --png file + PNG []byte // identical to the CLI's --png file (render.DefaultPNGScale) Error string // validation/encoding error text, without the "epcii:" prefix } @@ -48,7 +44,7 @@ func Generate(in Input) Output { } matrix := code.Matrix() var png bytes.Buffer - if err := render.PNG(&png, matrix, pngScale); err != nil { + if err := render.PNG(&png, matrix, render.DefaultPNGScale); err != nil { return Output{Error: err.Error()} } return Output{ diff --git a/internal/webapi/webapi_test.go b/internal/webapi/webapi_test.go index a17deb3..af6dd61 100644 --- a/internal/webapi/webapi_test.go +++ b/internal/webapi/webapi_test.go @@ -33,7 +33,7 @@ func TestGenerateMatchesCLIPipeline(t *testing.T) { t.Error("SVG differs from render.SVG") } var png bytes.Buffer - if err := render.PNG(&png, code.Matrix(), 8); err != nil { + if err := render.PNG(&png, code.Matrix(), render.DefaultPNGScale); err != nil { t.Fatal(err) } if !bytes.Equal(out.PNG, png.Bytes()) { diff --git a/main.go b/main.go index 7510d87..c13cf4f 100644 --- a/main.go +++ b/main.go @@ -19,8 +19,6 @@ import ( var version = "dev" -const pngScale = 8 // pixels per module; 69 modules + quiet zone => 616 px max - func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } @@ -184,7 +182,7 @@ func writePNG(path string, matrix [][]bool) error { os.Remove(name) return err } - if err := encodePNG(f, matrix, pngScale); err != nil { + if err := encodePNG(f, matrix, render.DefaultPNGScale); err != nil { f.Close() return fail(err) } diff --git a/scripts/web-smoke.mjs b/scripts/web-smoke.mjs index d41283c..a12d0d1 100644 --- a/scripts/web-smoke.mjs +++ b/scripts/web-smoke.mjs @@ -78,4 +78,21 @@ else console.log(`ok invalid IBAN → "${bad.error}"`); if (typeof globalThis.epcii.version !== 'string' || !globalThis.epcii.version) fail('version string missing'); else console.log(`ok version ${globalThis.epcii.version}`); +// --- zero-storage contract (CONTRIBUTING "The web version is the CLI"): +// the page sources must not name a storage API, write the address bar, or +// pull anything from another origin. A grep gate, but one that can go red. +const forbidden = [ + [/localStorage|sessionStorage|indexedDB|document\.cookie|serviceWorker|caches\./, 'storage API'], + [/history\.(pushState|replaceState|go|back|forward)|location\.(hash|href|search|assign|replace)\s*[=(]/, 'address bar write'], + [/<(script|link|img|iframe)[^>]+(src|href)=["']https?:/i, 'external resource tag'], + [/@import|url\(\s*["']?https?:/i, 'external stylesheet resource'], + [/\bimport\s*\(|\bfetch\(\s*["']https?:/, 'dynamic import / cross-origin fetch'], +]; +for (const name of ['index.html', 'app.js', 'style.css']) { + const src = readFileSync(join(root, 'web', name), 'utf8'); + const hits = forbidden.filter(([re]) => re.test(src)).map(([, what]) => what); + if (hits.length) fail(`web/${name} violates the zero-storage contract: ${hits.join(', ')}`); + else console.log(`ok web/${name} names no storage, address-bar write or external resource`); +} + process.exit(failures ? 1 : 0); diff --git a/web/app.js b/web/app.js index 9a69ecc..8aa37ef 100644 --- a/web/app.js +++ b/web/app.js @@ -1,8 +1,9 @@ // epcii web: form → globalThis.epcii.generate (Go/WASM) → inline SVG. // -// Zero-storage contract: this file never touches localStorage, sessionStorage, -// cookies, IndexedDB, history or location.hash. The only state that can leave -// the page is a share link the user asks for, and it lives in the #fragment. +// Zero-storage contract: this file never touches a storage API, cookies, the +// history or the address bar (scripts/web-smoke.mjs greps for that). The only +// state that can leave the page is a share link the user asks for, and it +// lives in the #fragment. 'use strict'; const FIELDS = ['name', 'iban', 'amount', 'text', 'ref', 'bic', 'purpose', 'info']; @@ -20,13 +21,20 @@ const STR = { amount: 'Amount (EUR)', amount_ph: 'e.g. 12.50 or 12,50', text: 'Remittance text', + more_fields: 'More fields (reference, BIC, purpose code, note to the payer)', ref: 'Creditor reference', ref_ph: 'RF… (excludes remittance text)', + bic_ph: 'only for accounts outside the EEA', purpose: 'Purpose code', - info: 'Information to the payer', + purpose_ph: 'e.g. SALA, 4 letters', + info: 'Note shown to the payer', + language: 'Language', loading: 'Loading generator…', load_failed: 'The generator could not be loaded. Your browser needs WebAssembly.', empty: 'Enter at least a name and an IBAN.', + bad_link: 'This link was made by a newer version of the page and was not loaded.', + dl_svg: 'Download SVG', + dl_png: 'Download PNG', copy_link: 'Copy link', copied: 'Link copied', copy_failed: 'Copying failed — the link is:', @@ -43,13 +51,20 @@ const STR = { amount: 'Betrag (EUR)', amount_ph: 'z. B. 12,50 oder 12.50', text: 'Verwendungszweck', + more_fields: 'Weitere Felder (Referenz, BIC, Verwendungscode, Hinweis an die zahlende Person)', ref: 'Strukturierte Referenz', ref_ph: 'RF… (schließt Verwendungszweck aus)', - purpose: 'Purpose-Code', - info: 'Hinweis an die zahlende Person', + bic_ph: 'nur für Konten außerhalb des EWR', + purpose: 'Verwendungscode', + purpose_ph: 'z. B. SALA, 4 Zeichen', + info: 'Hinweis, der der zahlenden Person angezeigt wird', + language: 'Sprache', loading: 'Generator wird geladen…', load_failed: 'Der Generator konnte nicht geladen werden. Der Browser braucht WebAssembly.', empty: 'Mindestens Name und IBAN eingeben.', + bad_link: 'Dieser Link stammt von einer neueren Version der Seite und wurde nicht geladen.', + dl_svg: 'SVG herunterladen', + dl_png: 'PNG herunterladen', copy_link: 'Link kopieren', copied: 'Link kopiert', copy_failed: 'Kopieren fehlgeschlagen — der Link lautet:', @@ -93,6 +108,7 @@ function applyLang(code) { } $('lang-en').setAttribute('aria-pressed', String(lang === 'en')); $('lang-de').setAttribute('aria-pressed', String(lang === 'de')); + $('lang-nav').setAttribute('aria-label', t.language); if (!ready) showStatus(); else render(); // details header and the page's own messages are localized } @@ -155,7 +171,7 @@ function scheduleRender() { function shareParams() { const p = new URLSearchParams(); - p.set('v', '1'); + p.set('v', LINK_VERSION); const fields = readForm(); for (const k of FIELDS) if (fields[k] !== '') p.set(k, fields[k]); return p; @@ -165,18 +181,36 @@ function shareURL() { return location.origin + location.pathname + '#' + shareParams().toString(); } +const LINK_VERSION = '1'; + +// loadFragment fills the form from a share link. Returns true when fields +// were filled; a link from a newer format version is refused, not guessed. function loadFragment() { const raw = location.hash.slice(1); if (!raw) return false; const p = new URLSearchParams(raw); + if ((p.get('v') || LINK_VERSION) !== LINK_VERSION) { + showError(STR[lang].bad_link); + return false; + } let any = false; for (const k of FIELDS) { const v = p.get(k); - if (v !== null) { $(k).value = v; any = true; } + if (v !== null) { + $(k).value = v; + any = true; + if (v !== '' && $('more').contains($(k))) $('more').open = true; + } } return any; } +// Typed characters outside the amount alphabet are dropped before they land; +// a paste is left alone so the validator can name what is wrong with it. +function guardAmountInput(e) { + if (e.inputType === 'insertText' && e.data && /[^0-9 .,€]/.test(e.data)) e.preventDefault(); +} + // --- downloads function fileStem() { @@ -199,10 +233,11 @@ function svgBlob() { return new Blob([last.svg], { type: 'image/svg+xml' }); } function pngBlob() { return new Blob([last.png], { type: 'image/png' }); } function flash(button, text) { - const old = button.textContent; button.textContent = text; button.disabled = true; - setTimeout(() => { button.textContent = old; button.disabled = false; }, 1500); + // Restore from the i18n table, not from a captured string: the language + // may have changed in the meantime. + setTimeout(() => { button.textContent = STR[lang][button.dataset.i18n]; button.disabled = false; }, 1500); } async function copyLink() { @@ -265,6 +300,7 @@ async function main() { els.fields.disabled = false; $('form').addEventListener('input', scheduleRender); + $('amount').addEventListener('beforeinput', guardAmountInput); $('form').addEventListener('submit', (e) => { e.preventDefault(); render(); }); els.dlSvg.addEventListener('click', () => download(svgBlob(), fileStem() + '.svg')); els.dlPng.addEventListener('click', () => download(pngBlob(), fileStem() + '.png')); diff --git a/web/index.html b/web/index.html index 913360b..fc8702d 100644 --- a/web/index.html +++ b/web/index.html @@ -18,7 +18,7 @@

epcii

EPC QR code (GiroCode) for SEPA credit transfers — generated in your browser, nothing leaves it.

-