From a26b9eb627440b81bdf68190454a47c13533b555 Mon Sep 17 00:00:00 2001 From: bmmmm Date: Mon, 21 Sep 2026 11:00:28 +0200 Subject: [PATCH] Add the web version: Go/WASM page on GitHub Pages, zero storage The CLI pipeline compiled to WebAssembly (cmd/epcii-wasm over the new internal/webapi) behind a static page in web/. No server, no cookies, no web storage, no service worker, no external resource; share links carry the fields only in the URL fragment and the address bar is never written automatically. UI in English and German, switchable without persistence. scripts/web-smoke.mjs loads the built wasm through Go's wasm_exec.js and compares SVG and PNG byte for byte with the CLI; it runs in ci.yml and in the new pages.yml before every deploy. The build job holds read access only; the deploy job alone gets pages/id-token write. --- .github/workflows/ci.yml | 9 ++ .github/workflows/pages.yml | 57 +++++++ AGENTS.md | 4 + CHANGELOG.md | 9 ++ CONTRIBUTING.md | 9 ++ README.md | 31 ++++ cmd/epcii-wasm/main.go | 67 ++++++++ internal/webapi/webapi.go | 61 +++++++ internal/webapi/webapi_test.go | 70 ++++++++ scripts/build-web.sh | 23 +++ scripts/web-smoke.mjs | 81 ++++++++++ web/app.js | 281 +++++++++++++++++++++++++++++++++ web/index.html | 83 ++++++++++ web/style.css | 100 ++++++++++++ 14 files changed, 885 insertions(+) create mode 100644 .github/workflows/pages.yml create mode 100644 cmd/epcii-wasm/main.go create mode 100644 internal/webapi/webapi.go create mode 100644 internal/webapi/webapi_test.go create mode 100755 scripts/build-web.sh create mode 100644 scripts/web-smoke.mjs create mode 100644 web/app.js create mode 100644 web/index.html create mode 100644 web/style.css diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d10f733..5df9eec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,15 @@ jobs: - run: go vet ./... - run: go test ./... - run: go build . + # The web build (cmd/epcii-wasm, tagged js&&wasm, so `go vet ./...` + # never sees it) must compile, and its SVG/PNG must equal the CLI's byte + # for byte. One runner: the wasm target does not vary by host OS. + - name: Web build + smoke + if: runner.os == 'Linux' + run: | + GOOS=js GOARCH=wasm go vet ./cmd/epcii-wasm + scripts/build-web.sh + node scripts/web-smoke.mjs # The upstream fixture must stay reproducible from the pinned # piglig/go-qr, so the generator (its own module, outside ./...) is # built and its output compared byte for byte on every run. diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..6d6d47f --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,57 @@ +name: pages + +# Builds the static web version (web/ + cmd/epcii-wasm) and publishes it to +# GitHub Pages. The site is fully static: no server code, no storage; see +# README "Web". The same build and smoke gate also run in ci.yml on PRs. + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: {} + +# Never abort a running deployment: a cancelled deploy-pages can leave the +# site in a failed state until the next run finishes. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + # Runs repo code (build script, smoke test), so it gets read access only; + # the deploy job alone holds the Pages/OIDC rights. + permissions: + contents: read + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # git describe needs the tags for the version stamp + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - name: Build site and prove it matches the CLI + run: | + go build -o epcii . + scripts/build-web.sh + node scripts/web-smoke.mjs + - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: web/dist + + deploy: + needs: build + permissions: + pages: write + id-token: write + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/AGENTS.md b/AGENTS.md index 2c1bf7c..8630bd6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,10 @@ go vet ./... && test -z "$(gofmt -l .)" # must both pass before commit - `internal/epc` — payload builder + ALL validation - `internal/qr` — QR encoder (byte mode, level M, versions 1–13) - `internal/render` — SVG / PNG / terminal renderers +- `internal/webapi` + `cmd/epcii-wasm` + `web/` — browser version (WASM); + `scripts/build-web.sh` builds `web/dist/`, `scripts/web-smoke.mjs` proves + it equals the CLI byte for byte. Zero-storage page: never add storage, + external resources, or writes to the address bar. ## Traps diff --git a/CHANGELOG.md b/CHANGELOG.md index 296af2f..aef8c7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ what to check. ## [Unreleased] +### Added + +- Web version at : the CLI pipeline compiled + to WebAssembly (`cmd/epcii-wasm`, `internal/webapi`) behind a static page + (`web/`) deployed by `pages.yml`. No server, no storage, no external + 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". + ## [0.2.0] - 2026-09-04 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 192acd5..f26a8b5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,11 @@ asks for and why. | `internal/epc` | EPC069-12 payload builder and all validation: IBAN (SEPA country, length, mod-97), amount normalization, field limits, UTF-8 enforcement | | `internal/qr` | QR encoder core — byte mode, ECC level M, versions 1–13, mask selection. Derived from [piglig/go-qr](https://github.com/piglig/go-qr) (MIT, see `NOTICE`) | | `internal/render` | SVG / PNG / ANSI-terminal renderers over the module matrix | +| `internal/webapi` | The CLI pipeline as one pure function for the browser build; native tests | +| `cmd/epcii-wasm` | `js && wasm` entry point exposing `internal/webapi` as `globalThis.epcii` | +| `web/` | Static page (HTML/JS/CSS, no framework, no external resources); `web/dist/` is the gitignored build output | +| `scripts/build-web.sh` | Assembles `web/dist/` (page files, Go's `wasm_exec.js`, the wasm build) | +| `scripts/web-smoke.mjs` | Node gate: the wasm build's SVG/PNG must equal the CLI's byte for byte | | `scripts/gen_segno_fixtures.py` | One-shot generator for the segno golden fixtures in `internal/epc/testdata/` | | `scripts/qrfixtures/` | Separate Go module: regenerates the upstream matrix fingerprints in `internal/qr/testdata/` from piglig/go-qr | @@ -54,6 +59,10 @@ differently — not the commits. fields trimmed together with their separators. - **Zero runtime dependencies.** `go.mod` may only grow test-only entries, each with a one-line justification. +- **The web version is the CLI.** Its SVG and PNG stay byte-identical to + `epcii` / `epcii --png` (`scripts/web-smoke.mjs`), and the page stores and + sends nothing: no cookies, no web storage, no service worker, no external + resource, no automatic write to the address bar. See README "Web". `internal/` is not a public Go API — its shape may change freely. diff --git a/README.md b/README.md index 2463b32..e40b136 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,37 @@ input fails with a named-field error before anything is encoded. For print, keep the symbol at least ~40 mm wide (EPC recommendation); the SVG scales losslessly and already includes the 4-module quiet zone. +## Web + +The same generator runs in the browser at +: the Go code is compiled to WebAssembly +(`cmd/epcii-wasm`), so validation, encoder and renderers are the ones above, +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. + +What the page does not do: + +- **No server, no storage.** GitHub Pages serves a handful of static files; + there is no backend, no cookie, no local storage, no service worker, no + analytics, and no external resource. After the initial load the page makes + no further request. (GitHub keeps ordinary access logs for the page load + itself, like any web host.) +- **Share links stay in the fragment.** "Copy link" and "Share" build a URL + of the form `…/epcii/#v=1&name=…&iban=…&amount=…`; browsers never send the + `#fragment` to a server, so payment data does not reach GitHub's logs or a + `Referer`. The address bar is never written automatically — a link exists + only when you ask for one, and opening one fills the form and renders. + Whoever opens such a link has it in their own browser history, as with any + URL; the page cannot prevent that. +- **Content Security Policy.** Pages cannot send HTTP headers, so the policy + is a `` tag: `default-src 'none'`, scripts and styles only from the + page's own origin, no inline script. Directives that a meta CSP cannot + carry (`frame-ancestors`, `sandbox`, `report-uri`) are therefore absent. + +Build it locally with `scripts/build-web.sh` (output in `web/dist/`, served +by any static file server) and check it with `node scripts/web-smoke.mjs` +after `go build -o epcii .`. + ## Design - EPC069-12 version 002 payload, UTF-8, LF separators, ≤331 bytes, diff --git a/cmd/epcii-wasm/main.go b/cmd/epcii-wasm/main.go new file mode 100644 index 0000000..6770565 --- /dev/null +++ b/cmd/epcii-wasm/main.go @@ -0,0 +1,67 @@ +//go:build js && wasm + +// epcii-wasm is the browser build of epcii. It exposes the CLI pipeline to +// the page as globalThis.epcii.generate(fields) and stays resident; the +// static page lives in web/ and is assembled by scripts/build-web.sh. +package main + +import ( + "runtime/debug" + "syscall/js" + + "github.com/bmmmm/epcii/internal/webapi" +) + +var version = "dev" + +// generate maps a JS object with the eight string fields to webapi.Generate +// and returns a plain JS object. Missing or non-string properties read as "", +// so a partial form is a validation error from epc, never a JS exception. +func generate(_ js.Value, args []js.Value) any { + var in webapi.Input + if len(args) > 0 && args[0].Type() == js.TypeObject { + get := func(key string) string { + v := args[0].Get(key) + if v.Type() != js.TypeString { + return "" + } + return v.String() + } + in = webapi.Input{ + Name: get("name"), IBAN: get("iban"), BIC: get("bic"), Amount: get("amount"), + Purpose: get("purpose"), Ref: get("ref"), Text: get("text"), Info: get("info"), + } + } + out := webapi.Generate(in) + res := map[string]any{ + "error": out.Error, + "payload": out.Payload, + "version": out.Version, + "size": out.Size, + "svg": out.SVG, + } + png := js.Global().Get("Uint8Array").New(len(out.PNG)) + js.CopyBytesToJS(png, out.PNG) + res["png"] = png + return js.ValueOf(res) +} + +// versionString mirrors main.go: an -ldflags override wins, then the module +// version from the build info, then "dev". +func versionString() string { + if version != "dev" { + return version + } + if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" && bi.Main.Version != "(devel)" { + return bi.Main.Version + } + return version +} + +func main() { + js.Global().Set("epcii", js.ValueOf(map[string]any{ + "generate": js.FuncOf(generate), + "version": versionString(), + })) + select {} +} diff --git a/internal/webapi/webapi.go b/internal/webapi/webapi.go new file mode 100644 index 0000000..1b9ca8d --- /dev/null +++ b/internal/webapi/webapi.go @@ -0,0 +1,61 @@ +// Package webapi is the browser-facing entry point: one pure function that +// runs the same pipeline as the CLI (validate → encode → render) and returns +// every artefact the page shows. It has no syscall/js dependency, so it is +// built and tested natively; cmd/epcii-wasm is the thin JS bridge on top. +package webapi + +import ( + "bytes" + + "github.com/bmmmm/epcii/internal/epc" + "github.com/bmmmm/epcii/internal/qr" + "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 +} + +// Output is everything the page renders. On a validation or encoding error +// only Error is set; the other fields stay at their zero value. +type Output struct { + Payload string // exact encoded EPC069-12 payload (LF-separated) + 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 + Error string // validation/encoding error text, without the "epcii:" prefix +} + +// Generate runs the CLI pipeline (main.go run()) on in. +func Generate(in Input) Output { + p := epc.Payment{ + Name: in.Name, IBAN: in.IBAN, BIC: in.BIC, Amount: in.Amount, + Purpose: in.Purpose, Ref: in.Ref, Text: in.Text, Info: in.Info, + } + payload, err := p.Payload() + if err != nil { + return Output{Error: err.Error()} + } + code, err := qr.EncodeM([]byte(payload)) + if err != nil { + return Output{Error: err.Error()} + } + matrix := code.Matrix() + var png bytes.Buffer + if err := render.PNG(&png, matrix, pngScale); err != nil { + return Output{Error: err.Error()} + } + return Output{ + Payload: payload, + Version: code.Version(), + Size: code.Size(), + SVG: string(render.SVG(matrix)), + PNG: png.Bytes(), + } +} diff --git a/internal/webapi/webapi_test.go b/internal/webapi/webapi_test.go new file mode 100644 index 0000000..a17deb3 --- /dev/null +++ b/internal/webapi/webapi_test.go @@ -0,0 +1,70 @@ +package webapi + +import ( + "bytes" + "strings" + "testing" + + "github.com/bmmmm/epcii/internal/qr" + "github.com/bmmmm/epcii/internal/render" +) + +const testIBAN = "DE02120300000000202051" + +func TestGenerateMatchesCLIPipeline(t *testing.T) { + in := Input{Name: "Test Persona", IBAN: testIBAN, Amount: "12,5", Text: "invoice 42"} + out := Generate(in) + if out.Error != "" { + t.Fatalf("unexpected error: %q", out.Error) + } + want := "BCD\n002\n1\nSCT\n\nTest Persona\n" + testIBAN + "\nEUR12.50\n\n\ninvoice 42" + if out.Payload != want { + t.Errorf("payload:\n got %q\nwant %q", out.Payload, want) + } + code, err := qr.EncodeM([]byte(want)) + if err != nil { + t.Fatal(err) + } + if out.Version != code.Version() || out.Size != code.Size() { + t.Errorf("geometry: got v%d %dx%d, want v%d %dx%d", + out.Version, out.Size, out.Size, code.Version(), code.Size(), code.Size()) + } + if out.SVG != string(render.SVG(code.Matrix())) { + t.Error("SVG differs from render.SVG") + } + var png bytes.Buffer + if err := render.PNG(&png, code.Matrix(), 8); err != nil { + t.Fatal(err) + } + if !bytes.Equal(out.PNG, png.Bytes()) { + t.Error("PNG differs from render.PNG at scale 8") + } +} + +func TestGenerateErrors(t *testing.T) { + cases := []struct { + name string + in Input + want string // substring of the error, case-insensitive + }{ + {"missing iban", Input{Name: "Test Persona"}, "iban"}, + {"ref and text", Input{Name: "Test Persona", IBAN: testIBAN, Ref: "RF18539007547034", Text: "x"}, "ref"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out := Generate(tc.in) + if out.Error == "" { + t.Fatal("expected an error") + } + if !strings.Contains(strings.ToLower(out.Error), tc.want) { + t.Errorf("error %q does not mention %q", out.Error, tc.want) + } + if strings.HasPrefix(out.Error, "epcii:") { + t.Errorf("error carries the CLI prefix: %q", out.Error) + } + if out.SVG != "" || out.PNG != nil || out.Payload != "" { + t.Errorf("error output must be otherwise empty: %+v", out) + } + }) + } +} diff --git a/scripts/build-web.sh b/scripts/build-web.sh new file mode 100755 index 0000000..d4022f0 --- /dev/null +++ b/scripts/build-web.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Assemble the static site for GitHub Pages into web/dist: the page files, +# Go's wasm_exec.js (must match the compiling toolchain), and the wasm build +# of cmd/epcii-wasm. Run from the repo root; the output dir is gitignored. +set -euo pipefail +cd "$(dirname "$0")/.." + +OUT=web/dist +rm -rf "$OUT" +mkdir -p "$OUT" + +cp web/index.html web/app.js web/style.css "$OUT"/ +cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" "$OUT"/ # Go >= 1.24 ships it under lib/wasm + +VERSION="$(git describe --tags --always --dirty 2>/dev/null || echo dev)" +GOOS=js GOARCH=wasm go build -trimpath \ + -ldflags "-s -w -X main.version=${VERSION}" \ + -o "$OUT/epcii.wasm" ./cmd/epcii-wasm + +touch "$OUT/.nojekyll" # Pages must not run Jekyll over the output + +echo "built $OUT (version ${VERSION}):" +ls -l "$OUT" diff --git a/scripts/web-smoke.mjs b/scripts/web-smoke.mjs new file mode 100644 index 0000000..d41283c --- /dev/null +++ b/scripts/web-smoke.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +// Gate: the web build must produce exactly what the CLI produces. +// +// Loads web/dist/epcii.wasm through the same wasm_exec.js the browser uses, +// calls globalThis.epcii.generate with a fixed input, and compares the SVG +// and PNG byte for byte with `epcii` run on the same input. A second case +// checks that an invalid IBAN surfaces as an error, not an exception. +// +// Usage: node scripts/web-smoke.mjs [path/to/epcii-binary] (default ./epcii) +// Requires a prior `go build -o epcii .` and `scripts/build-web.sh`. +import { createRequire } from 'node:module'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const root = resolve(fileURLToPath(new URL('..', import.meta.url))); +const dist = join(root, 'web', 'dist'); +const cli = resolve(process.argv[2] || join(root, 'epcii')); + +const input = { + name: 'Test Persona', + iban: 'DE02120300000000202051', + amount: '12,5', + text: 'invoice 42', +}; + +// --- reference from the CLI +const tmp = mkdtempSync(join(tmpdir(), 'epcii-smoke-')); +const pngPath = join(tmp, 'ref.png'); +const refSVG = execFileSync(cli, [ + '--name', input.name, '--iban', input.iban, '--amount', input.amount, + '--text', input.text, '--png', pngPath, +]); +const refPNG = readFileSync(pngPath); +rmSync(tmp, { recursive: true, force: true }); + +// --- the wasm build, loaded like the browser does (plus node's globals) +globalThis.fs = require('node:fs'); +globalThis.path = require('node:path'); +require(join(dist, 'wasm_exec.js')); +const go = new globalThis.Go(); +const { instance } = await WebAssembly.instantiate(readFileSync(join(dist, 'epcii.wasm')), go.importObject); +go.run(instance); // never resolves: main blocks in select{} +for (let i = 0; i < 100 && !globalThis.epcii; i++) await new Promise((r) => setTimeout(r, 10)); +if (!globalThis.epcii) fail('globalThis.epcii was not registered by the wasm module'); + +// --- compare +let failures = 0; +function fail(msg) { + console.error('FAIL: ' + msg); + failures++; +} +function firstDiff(a, b) { + const n = Math.min(a.length, b.length); + for (let i = 0; i < n; i++) if (a[i] !== b[i]) return i; + return a.length === b.length ? -1 : n; +} +function expectEqual(label, got, want) { + const at = firstDiff(got, want); + if (at === -1) console.log(`ok ${label} (${got.length} bytes identical)`); + else fail(`${label} differs at byte ${at} (web ${got.length} bytes, cli ${want.length} bytes)`); +} + +const res = globalThis.epcii.generate(input); +if (res.error) fail(`unexpected error: ${res.error}`); +expectEqual('svg', Buffer.from(res.svg, 'utf8'), refSVG); +expectEqual('png', Buffer.from(res.png), refPNG); +if (typeof res.version !== 'number' || typeof res.size !== 'number') fail('version/size not numbers'); + +const bad = globalThis.epcii.generate({ ...input, iban: 'DE00' }); +if (!bad.error) fail('invalid IBAN did not produce an error'); +else if (bad.svg !== '' || bad.png.length !== 0) fail('error result still carries svg/png'); +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}`); + +process.exit(failures ? 1 : 0); diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..9a69ecc --- /dev/null +++ b/web/app.js @@ -0,0 +1,281 @@ +// 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. +'use strict'; + +const FIELDS = ['name', 'iban', 'amount', 'text', 'ref', 'bic', 'purpose', 'info']; + +// Mirrors payloadFieldNames in main.go (printDetails). +const PAYLOAD_FIELDS = [ + 'service tag', 'version', 'charset', 'identification', + 'bic', 'name', 'iban', 'amount', 'purpose', 'ref', 'text', 'info', +]; + +const STR = { + en: { + tagline: 'EPC QR code (GiroCode) for SEPA credit transfers — generated in your browser, nothing leaves it.', + name: 'Beneficiary name', + amount: 'Amount (EUR)', + amount_ph: 'e.g. 12.50 or 12,50', + text: 'Remittance text', + ref: 'Creditor reference', + ref_ph: 'RF… (excludes remittance text)', + purpose: 'Purpose code', + info: 'Information to the payer', + loading: 'Loading generator…', + load_failed: 'The generator could not be loaded. Your browser needs WebAssembly.', + empty: 'Enter at least a name and an IBAN.', + copy_link: 'Copy link', + copied: 'Link copied', + copy_failed: 'Copying failed — the link is:', + share: 'Share', + share_text: 'Payment QR code', + details: 'Encoded payload', + details_head: (n, v, s) => `encoded GiroCode payload (${n} bytes, QR version ${v}, ${s}x${s} modules):`, + privacy: 'No server, no cookies, no storage: the code is generated by this page alone. A shared link carries the payment data only in its #fragment, which browsers never send to any server.', + more: 'How this works', + }, + de: { + tagline: 'EPC-QR-Code (GiroCode) für SEPA-Überweisungen — erzeugt im Browser, nichts verlässt ihn.', + name: 'Name der Empfängerin / des Empfängers', + amount: 'Betrag (EUR)', + amount_ph: 'z. B. 12,50 oder 12.50', + text: 'Verwendungszweck', + ref: 'Strukturierte Referenz', + ref_ph: 'RF… (schließt Verwendungszweck aus)', + purpose: 'Purpose-Code', + info: 'Hinweis an die zahlende Person', + loading: 'Generator wird geladen…', + load_failed: 'Der Generator konnte nicht geladen werden. Der Browser braucht WebAssembly.', + empty: 'Mindestens Name und IBAN eingeben.', + copy_link: 'Link kopieren', + copied: 'Link kopiert', + copy_failed: 'Kopieren fehlgeschlagen — der Link lautet:', + share: 'Teilen', + share_text: 'Zahlungs-QR-Code', + details: 'Kodierte Nutzdaten', + details_head: (n, v, s) => `kodierte GiroCode-Nutzdaten (${n} Bytes, QR-Version ${v}, ${s}x${s} Module):`, + privacy: 'Kein Server, keine Cookies, kein Speicher: der Code entsteht allein auf dieser Seite. Ein geteilter Link trägt die Zahlungsdaten nur im #Fragment, das Browser nie an einen Server senden.', + more: 'So funktioniert es', + }, +}; + +const $ = (id) => document.getElementById(id); +const els = { + fields: $('fields'), status: $('status'), error: $('error'), qr: $('qr'), + actions: $('actions'), detailsBox: $('details-box'), details: $('details'), + dlSvg: $('dl-svg'), dlPng: $('dl-png'), copyLink: $('copy-link'), share: $('share'), + version: $('version'), +}; + +let lang = 'en'; +let last = null; // last successful generate() result +let statusKey = 'loading'; // STR key shown in #status while the wasm is not usable +let statusDetail = ''; // appended to the load_failed message +let ready = false; // wasm loaded and globalThis.epcii registered + +function showStatus() { + els.status.textContent = STR[lang][statusKey] + statusDetail; +} + +function applyLang(code) { + lang = STR[code] ? code : 'en'; + document.documentElement.lang = lang; + const t = STR[lang]; + for (const el of document.querySelectorAll('[data-i18n]')) { + const s = t[el.dataset.i18n]; + if (typeof s === 'string') el.textContent = s; + } + for (const el of document.querySelectorAll('[data-i18n-placeholder]')) { + el.placeholder = t[el.dataset.i18nPlaceholder]; + } + $('lang-en').setAttribute('aria-pressed', String(lang === 'en')); + $('lang-de').setAttribute('aria-pressed', String(lang === 'de')); + if (!ready) showStatus(); + else render(); // details header and the page's own messages are localized +} + +function readForm() { + const fields = {}; + for (const k of FIELDS) fields[k] = $(k).value; + return fields; +} + +function showError(msg) { + els.error.textContent = msg; + els.error.hidden = !msg; +} + +function showDetails(res) { + const bytes = new TextEncoder().encode(res.payload).length; + const lines = [STR[lang].details_head(bytes, res.version, res.size)]; + res.payload.split('\n').forEach((v, i) => { + lines.push(' ' + (PAYLOAD_FIELDS[i] + ':').padEnd(15) + ' ' + (v === '' ? '(empty)' : v)); + }); + els.details.textContent = lines.join('\n'); +} + +function render() { + const fields = readForm(); + const empty = FIELDS.every((k) => fields[k].trim() === ''); + let res; + try { + res = empty ? { error: STR[lang].empty } : globalThis.epcii.generate(fields); + } catch (e) { + // Fail closed: a stale QR next to new field values must never survive. + res = { error: String(e && e.message ? e.message : e) }; + } + if (res.error) { + last = null; + showError(res.error); + els.qr.hidden = true; + els.actions.hidden = true; + els.detailsBox.hidden = true; + return; + } + last = res; + showError(''); + // Safe: render.SVG emits only numeric path data, never user input. + els.qr.innerHTML = res.svg; + els.qr.hidden = false; + showDetails(res); + els.detailsBox.hidden = false; + els.actions.hidden = false; +} + +let timer = 0; +function scheduleRender() { + clearTimeout(timer); + timer = setTimeout(render, 150); +} + +// --- share link: #v=1&name=… (fragment only; never written to the address bar) + +function shareParams() { + const p = new URLSearchParams(); + p.set('v', '1'); + const fields = readForm(); + for (const k of FIELDS) if (fields[k] !== '') p.set(k, fields[k]); + return p; +} + +function shareURL() { + return location.origin + location.pathname + '#' + shareParams().toString(); +} + +function loadFragment() { + const raw = location.hash.slice(1); + if (!raw) return false; + const p = new URLSearchParams(raw); + let any = false; + for (const k of FIELDS) { + const v = p.get(k); + if (v !== null) { $(k).value = v; any = true; } + } + return any; +} + +// --- downloads + +function fileStem() { + const iban = $('iban').value.replace(/\s+/g, '').toUpperCase(); + return /^[A-Z0-9]+$/.test(iban) ? 'epc-' + iban : 'epc'; +} + +function download(blob, name) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +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); +} + +async function copyLink() { + const url = shareURL(); + try { + await navigator.clipboard.writeText(url); + flash(els.copyLink, STR[lang].copied); + } catch { + // Clipboard denied (e.g. no user gesture, permissions): show it instead. + showError(STR[lang].copy_failed + ' ' + url); + } +} + +async function share() { + const data = { title: 'epcii', text: STR[lang].share_text, url: shareURL() }; + const file = new File([svgBlob()], fileStem() + '.svg', { type: 'image/svg+xml' }); + if (navigator.canShare && navigator.canShare({ files: [file] })) data.files = [file]; + try { + await navigator.share(data); + } catch (e) { + if (e && e.name !== 'AbortError') showError(String(e.message || e)); + } +} + +// --- boot + +async function loadWasm() { + const go = new Go(); + let result; + try { + result = await WebAssembly.instantiateStreaming(fetch('epcii.wasm'), go.importObject); + } catch { + // Wrong MIME type or no streaming support: fall back to a plain buffer. + const buf = await (await fetch('epcii.wasm')).arrayBuffer(); + result = await WebAssembly.instantiate(buf, go.importObject); + } + go.run(result.instance); // resolves only when the Go program exits; it never does + for (let i = 0; i < 100 && !globalThis.epcii; i++) { + await new Promise((r) => setTimeout(r, 10)); + } + if (!globalThis.epcii) throw new Error('epcii global not registered'); +} + +async function main() { + applyLang((navigator.language || 'en').toLowerCase().startsWith('de') ? 'de' : 'en'); + $('lang-en').addEventListener('click', () => applyLang('en')); + $('lang-de').addEventListener('click', () => applyLang('de')); + + try { + await loadWasm(); + } catch (e) { + statusKey = 'load_failed'; + statusDetail = ' (' + (e && e.message ? e.message : e) + ')'; + showStatus(); + return; + } + ready = true; + els.status.hidden = true; + els.version.textContent = globalThis.epcii.version; + els.fields.disabled = false; + + $('form').addEventListener('input', scheduleRender); + $('form').addEventListener('submit', (e) => { e.preventDefault(); render(); }); + els.dlSvg.addEventListener('click', () => download(svgBlob(), fileStem() + '.svg')); + els.dlPng.addEventListener('click', () => download(pngBlob(), fileStem() + '.png')); + els.copyLink.addEventListener('click', copyLink); + if (navigator.share) { + els.share.hidden = false; + els.share.addEventListener('click', share); + } + + if (loadFragment()) render(); + else $('name').focus(); +} + +main(); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..913360b --- /dev/null +++ b/web/index.html @@ -0,0 +1,83 @@ + + + + + + + + + + + + +epcii — GiroCode generator + + +
+

epcii

+

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

+ +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+

Loading generator…

+ + + + +
+
+ +
+

No server, no cookies, no storage: the code is generated by this page alone. A shared link carries the payment data only in its #fragment, which browsers never send to any server.

+

How this works · Source (GPL-3.0-or-later) ·

+
+ + + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..83f5d52 --- /dev/null +++ b/web/style.css @@ -0,0 +1,100 @@ +:root { + color-scheme: light dark; + --bg: #fff; + --fg: #1a1a1a; + --muted: #666; + --line: #ccc; + --accent: #0b57d0; + --error: #b3261e; + --error-bg: #fdecea; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #121212; + --fg: #e6e6e6; + --muted: #9a9a9a; + --line: #444; + --accent: #8ab4f8; + --error: #f2b8b5; + --error-bg: #3a1f1d; + } +} + +* { box-sizing: border-box; } +/* Author display rules below would otherwise beat the UA's [hidden] rule. */ +[hidden] { display: none !important; } + +body { + margin: 0 auto; + padding: 16px; + max-width: 900px; + font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--fg); +} + +header { position: relative; } +h1 { margin: 0 0 4px; font-size: 1.6rem; } +header p { margin: 0 0 16px; color: var(--muted); } +nav { position: absolute; top: 0; right: 0; } +.lang { padding: 2px 8px; font-size: 0.85rem; } +.lang[aria-pressed="true"] { background: var(--accent); color: var(--bg); border-color: var(--accent); } + +main { display: grid; gap: 24px; } +@media (min-width: 720px) { + main { grid-template-columns: 1fr 340px; align-items: start; } +} + +fieldset { border: 0; padding: 0; margin: 0; display: grid; gap: 4px; } +fieldset:disabled { opacity: 0.6; } +label { margin-top: 10px; font-size: 0.9rem; } +label small { color: var(--error); } +input { + width: 100%; + padding: 8px; + font: inherit; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--line); + border-radius: 4px; +} +input:focus { outline: 2px solid var(--accent); outline-offset: 1px; } + +button { + padding: 8px 14px; + font: inherit; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--line); + border-radius: 4px; + cursor: pointer; +} +button:hover:not(:disabled) { border-color: var(--accent); } +button:disabled { cursor: default; opacity: 0.7; } + +#result { position: sticky; top: 16px; } +#status { color: var(--muted); } +#error { + margin: 0 0 12px; + padding: 8px 12px; + color: var(--error); + background: var(--error-bg); + border-radius: 4px; + overflow-wrap: anywhere; +} +#qr { width: 100%; max-width: 320px; } +#qr svg { display: block; width: 100%; height: auto; } +#actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } +details { margin-top: 12px; font-size: 0.85rem; } +summary { cursor: pointer; color: var(--muted); } +pre { + margin: 8px 0 0; + padding: 8px; + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 4px; + font-size: 0.8rem; +} + +footer { margin-top: 32px; padding-top: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: 0.85rem; } +footer a { color: var(--accent); }