diff --git a/.github/img/ratatoskr_logo.svg b/.github/img/ratatoskr_logo.svg index 428942a..3125e94 100644 --- a/.github/img/ratatoskr_logo.svg +++ b/.github/img/ratatoskr_logo.svg @@ -1,189 +1,574 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cmd/embedded/http/go.mod b/cmd/embedded/http/go.mod index c4b3368..84ffb5b 100644 --- a/cmd/embedded/http/go.mod +++ b/cmd/embedded/http/go.mod @@ -5,7 +5,6 @@ go 1.25.5 require ( github.com/gologme/log v1.3.0 github.com/klauspost/compress v1.17.4 - github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/voluminor/ratatoskr v0.0.0 github.com/yggdrasil-network/yggdrasil-go v0.5.13 golang.org/x/net v0.52.0 diff --git a/cmd/embedded/http/handler_ninfo.go b/cmd/embedded/http/handler_ninfo.go new file mode 100644 index 0000000..d961e15 --- /dev/null +++ b/cmd/embedded/http/handler_ninfo.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "net/http" + "time" + + htmlsigils "github.com/voluminor/ratatoskr/mod/html/sigils" + "github.com/voluminor/ratatoskr/mod/ninfo" +) + +// // // // // // // // // // + +type ninfoResponseJSON struct { + Target string `json:"target"` + RTT float64 `json:"rtt_ms"` + Version string `json:"version,omitempty"` + Software *ninfo.SoftwareObj `json:"software,omitempty"` + SigilsHTML map[string]string `json:"sigils_html,omitempty"` + ExtraHTML string `json:"extra_html,omitempty"` + CSS string `json:"css"` + Error string `json:"error,omitempty"` +} + +// // + +func newNinfoHandler(ni *ninfo.Obj) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + keyHex := r.URL.Query().Get("key") + if keyHex == "" { + writeNinfoError(w, "missing 'key' query parameter") + return + } + + keyBytes, err := hex.DecodeString(keyHex) + if err != nil || len(keyBytes) != ed25519.PublicKeySize { + writeNinfoError(w, "invalid public key: must be 64-char hex string (32 bytes)") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + result, err := ni.Ask(ctx, ed25519.PublicKey(keyBytes)) + if err != nil { + writeNinfoError(w, err.Error()) + return + } + + resp := ninfoResponseJSON{ + Target: keyHex, + RTT: float64(result.RTT.Microseconds()) / 1000.0, + CSS: string(htmlsigils.CSS), + } + + if result.Software != nil { + resp.Software = result.Software + } + + if result.Node != nil { + resp.Version = result.Node.Version + + rendered, err := htmlsigils.Render(result.Node) + if err == nil { + if len(rendered.Sigils) > 0 { + resp.SigilsHTML = make(map[string]string, len(rendered.Sigils)) + for name, buf := range rendered.Sigils { + if buf != nil { + resp.SigilsHTML[name] = string(buf) + } + } + } + if rendered.Extra != nil { + resp.ExtraHTML = string(rendered.Extra) + } + } + } + + data, _ := json.Marshal(resp) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(data) + }) +} + +// // + +func writeNinfoError(w http.ResponseWriter, msg string) { + resp := ninfoResponseJSON{Error: msg, CSS: string(htmlsigils.CSS)} + data, _ := json.Marshal(resp) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write(data) +} diff --git a/cmd/embedded/http/main.go b/cmd/embedded/http/main.go index 6a74f63..938813a 100644 --- a/cmd/embedded/http/main.go +++ b/cmd/embedded/http/main.go @@ -13,11 +13,12 @@ import ( "time" golog "github.com/gologme/log" - qrcode "github.com/skip2/go-qrcode" yggconfig "github.com/yggdrasil-network/yggdrasil-go/src/config" "github.com/voluminor/ratatoskr" "github.com/voluminor/ratatoskr/mod/core" + htmlimg "github.com/voluminor/ratatoskr/mod/html/img" + "github.com/voluminor/ratatoskr/mod/ninfo" "github.com/voluminor/ratatoskr/mod/peermgr" "github.com/voluminor/ratatoskr/mod/probe" ) @@ -87,22 +88,29 @@ func main() { } defer tr.Close() + ni, err := ninfo.New(coreNode.UnsafeCore(), logger) + if err != nil { + fmt.Println("Error: ninfo:", err) + os.Exit(1) + } + defer ni.Close() + info := newInfoHandler(node, tr, cfg, logger) traceHandler := newTraceHandler(tr) treeHandler := newTreeHandler(tr) treeWSHandler := newTreeWSHandler(tr) + ninfoHandler := newNinfoHandler(ni) yggAddr := node.Address().String() - qrURL := fmt.Sprintf("http://[%s]:%d/", yggAddr, cfg.YggPorts[0]) + qrSVG, err := htmlimg.QRCode(node.PublicKey()) + if err != nil { + fmt.Println("Error: generate QR:", err) + os.Exit(1) + } qrHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - png, err := qrcode.Encode(qrURL, qrcode.Medium, 256) - if err != nil { - http.Error(w, "qr error", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "image/png") + w.Header().Set("Content-Type", "image/svg+xml") w.Header().Set("Cache-Control", "no-store") - _, _ = w.Write(png) + _, _ = w.Write(qrSVG) }) // Plain HTTP servers @@ -113,7 +121,7 @@ func main() { os.Exit(1) } go (&http.Server{ - Handler: buildMux(*wwwPath, info, false, qrHandler, traceHandler, treeHandler, treeWSHandler), + Handler: buildMux(*wwwPath, info, false, qrHandler, traceHandler, treeHandler, treeWSHandler, ninfoHandler), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, }).Serve(l) @@ -129,7 +137,7 @@ func main() { os.Exit(1) } go (&http.Server{ - Handler: buildMux(*wwwPath, info, true, qrHandler, traceHandler, treeHandler, treeWSHandler), + Handler: buildMux(*wwwPath, info, true, qrHandler, traceHandler, treeHandler, treeWSHandler, ninfoHandler), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, }).Serve(l) @@ -141,13 +149,14 @@ func main() { // // -func buildMux(wwwPath string, info *InfoHandlerObj, isYgg bool, qr, trace, tree, treeWS http.Handler) *http.ServeMux { +func buildMux(wwwPath string, info *InfoHandlerObj, isYgg bool, qr, trace, tree, treeWS, ninfoH http.Handler) *http.ServeMux { mux := http.NewServeMux() mux.Handle("/yggdrasil-server.json", info.Handler(isYgg)) - mux.Handle("/ygg-qr.png", qr) + mux.Handle("/ygg-qr.svg", qr) mux.Handle("/probe.json", trace) mux.Handle("/tree.json", tree) mux.Handle("/tree-ws", treeWS) + mux.Handle("/ninfo.json", ninfoH) if isYgg { mux.Handle("/", newYggFileHandler(wwwPath)) } else { diff --git a/cmd/embedded/http/www/app.js b/cmd/embedded/http/www/app.js index 5890777..5acf1eb 100644 --- a/cmd/embedded/http/www/app.js +++ b/cmd/embedded/http/www/app.js @@ -334,7 +334,10 @@ function onTreeMessage(ev) { } document.addEventListener('keydown', function (e) { - if (e.key === 'Escape') closeTreeModal(); + if (e.key === 'Escape') { + closeTreeModal(); + closeNinfoModal(); + } }); // // @@ -533,6 +536,118 @@ document.getElementById('trace-key').addEventListener('keydown', function (e) { if (e.key === 'Enter') doTrace(); }); +// // // // // // // // // // + +// NodeInfo modal + +async function doNinfo() { + const keyInput = document.getElementById('trace-key'); + const btn = document.getElementById('ninfo-btn'); + const errorEl = document.getElementById('trace-error'); + + const key = keyInput.value.trim(); + if (!key || key.length !== 64 || !/^[0-9a-fA-F]+$/.test(key)) { + errorEl.textContent = 'Enter a valid 64-char hex public key'; + errorEl.classList.remove('hidden'); + return; + } + + btn.disabled = true; + btn.textContent = '\u2026'; + errorEl.classList.add('hidden'); + + try { + const resp = await fetch('/ninfo.json?key=' + key); + const data = await resp.json(); + + if (data.error) { + errorEl.textContent = data.error; + errorEl.classList.remove('hidden'); + return; + } + + renderNinfoModal(data); + } catch (e) { + errorEl.textContent = 'Request failed: ' + e.message; + errorEl.classList.remove('hidden'); + } finally { + btn.disabled = false; + btn.textContent = 'NodeInfo'; + } +} + +function renderNinfoModal(data) { + const body = document.getElementById('ninfo-modal-body'); + body.innerHTML = ''; + + // Inject scoped CSS from server + if (data.css) { + const style = document.createElement('style'); + style.textContent = data.css; + body.appendChild(style); + } + + // RTT badge + const rtt = document.createElement('div'); + rtt.className = 'ninfo-rtt'; + rtt.textContent = data.rtt_ms.toFixed(2) + ' ms'; + body.appendChild(rtt); + + // Version header (ratatoskr node) + if (data.version) { + const ver = document.createElement('div'); + ver.className = 'ninfo-version'; + ver.textContent = 'ratatoskr ' + data.version; + body.appendChild(ver); + } + + // Software metadata + if (data.software) { + const sw = data.software; + const parts = [sw.Name, sw.Version, sw.Platform, sw.Arch].filter(Boolean); + if (parts.length > 0) { + const el = document.createElement('div'); + el.className = 'ninfo-software'; + el.textContent = parts.join(' / '); + body.appendChild(el); + } + } + + // Sigils (rendered HTML from server) + if (data.sigils_html) { + const section = document.createElement('div'); + section.className = 'ninfo-sigils'; + for (const [name, html] of Object.entries(data.sigils_html)) { + const block = document.createElement('div'); + block.innerHTML = html; + section.appendChild(block); + } + body.appendChild(section); + } + + // Extra fields (rendered HTML from server) + if (data.extra_html) { + const section = document.createElement('div'); + section.className = 'ninfo-extra'; + const header = document.createElement('div'); + header.className = 'ninfo-section-header'; + header.textContent = 'Extra'; + section.appendChild(header); + + const content = document.createElement('div'); + content.innerHTML = data.extra_html; + section.appendChild(content); + body.appendChild(section); + } + + document.getElementById('ninfo-modal').classList.remove('hidden'); +} + +function closeNinfoModal(e) { + if (e && e.target !== e.currentTarget) return; + document.getElementById('ninfo-modal').classList.add('hidden'); +} + // // function tickChart() { diff --git a/cmd/embedded/http/www/index.html b/cmd/embedded/http/www/index.html index d21c3b7..b6932cd 100644 --- a/cmd/embedded/http/www/index.html +++ b/cmd/embedded/http/www/index.html @@ -40,7 +40,7 @@

Node

Share via Yggdrasil

- Yggdrasil QR code + Yggdrasil QR code

Scan to open in an Yggdrasil-connected browser

@@ -74,6 +74,7 @@

Traceroute

+ + + diff --git a/cmd/embedded/http/www/style.css b/cmd/embedded/http/www/style.css index d2b3286..bb8c167 100644 --- a/cmd/embedded/http/www/style.css +++ b/cmd/embedded/http/www/style.css @@ -276,6 +276,12 @@ canvas { margin: 8px auto; display: block; image-rendering: pixelated; + background: #fff; + color-scheme: only light; + forced-color-adjust: none; + filter: none !important; + -webkit-filter: none !important; + mix-blend-mode: normal !important; } .qr-hint { @@ -623,6 +629,47 @@ canvas { padding-left: 8px; } +/* ninfo modal */ +.ninfo-modal-body { + padding: 16px 20px; + overflow: auto; + color: var(--text); +} + +.ninfo-version { + font-family: var(--mono); + font-size: 0.85rem; + font-weight: 700; + color: var(--accent); + margin-bottom: 6px; +} + +.ninfo-rtt { + font-family: var(--mono); + font-size: 0.75rem; + color: var(--green); + margin-bottom: 4px; +} + +.ninfo-software { + font-size: 0.75rem; + color: var(--muted); + margin-bottom: 12px; +} + +.ninfo-sigils { + margin-bottom: 12px; +} + +.ninfo-section-header { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + margin: 12px 0 6px; +} + /* misc */ .hidden { display: none !important; diff --git a/mod/html/img/logo.go b/mod/html/img/logo.go new file mode 100644 index 0000000..27d3238 --- /dev/null +++ b/mod/html/img/logo.go @@ -0,0 +1,16 @@ +package img + +import _ "embed" + +// // // // // // // // // // + +//go:embed logo_simplified.svg +var logoSVG []byte + +//go:embed yggdrasil-leaf.svg +var leafSVG string + +// // // // // // // // // // + +// Logo returns the ratatoskr logo as SVG bytes. +func Logo() []byte { return logoSVG } diff --git a/mod/html/img/logo_simplified.svg b/mod/html/img/logo_simplified.svg new file mode 100644 index 0000000..fbeda20 --- /dev/null +++ b/mod/html/img/logo_simplified.svg @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mod/html/img/qr.go b/mod/html/img/qr.go new file mode 100644 index 0000000..628ba55 --- /dev/null +++ b/mod/html/img/qr.go @@ -0,0 +1,101 @@ +package img + +import ( + "crypto/ed25519" + "fmt" + "net" + "strings" + + yggaddr "github.com/yggdrasil-network/yggdrasil-go/src/address" + + "github.com/voluminor/ratatoskr/mod/html/img/qr" +) + +// // // // // // // // // // + +func extractSVGInner(svg string) string { + start := strings.Index(svg, ">") + if start < 0 { + return svg + } + end := strings.LastIndex(svg, "") + if end < 0 { + return svg[start+1:] + } + return svg[start+1 : end] +} + +// // + +// renderOverlay renders the QR matrix as SVG with a centered yggdrasil-leaf on top. +func renderOverlay(m [][]bool, overlay string) []byte { + size := len(m) + total := size + 2*qr.QuietZone + px := total * qr.ModulePixels + + var sb strings.Builder + sb.Grow(size*size*70 + len(overlay) + 512) + + fmt.Fprintf(&sb, + ``, + total, total, px, px, + ) + fmt.Fprintf(&sb, + ``, + total, total, + ) + + for r, row := range m { + for c, dark := range row { + if dark { + fmt.Fprintf(&sb, + ``, + c+qr.QuietZone, r+qr.QuietZone, + ) + } + } + } + + // Leaf viewBox "30 0 200 260", aspect 200:260 + center := float64(total) / 2 + leafW := 0.312 * float64(size) + leafH := leafW * 1.3 + leafX := center - leafW/2 + leafY := center - leafH/2 + + fmt.Fprintf(&sb, + ``, + leafX, leafY, leafW, leafH, + ) + + // Stroke is centered on the path: half outward (visible), half inward (under green fill). + // stroke-width = 1.0 module → 0.5 module visible border. + strokeW := fmt.Sprintf("%.1f", 200/leafW) + inner := extractSVGInner(overlay) + inner = strings.ReplaceAll(inner, `stroke-width:8`, `stroke-width:`+strokeW) + inner = strings.ReplaceAll(inner, `stroke-width="8"`, `stroke-width="`+strokeW+`"`) + sb.WriteString(inner) + sb.WriteString(``) + + sb.WriteString(``) + return []byte(sb.String()) +} + +// // // // // // // // // // + +// QRCode returns an SVG QR code for the Yggdrasil address derived from key, +// with a yggdrasil-leaf overlay in the center. +func QRCode(key ed25519.PublicKey) ([]byte, error) { + addr := yggaddr.AddrForKey(key) + ip := net.IP(addr[:]) + url := fmt.Sprintf("http://[%s]:80/", ip.String()) + + matrix, err := qr.Matrix(url) + if err != nil { + return nil, err + } + return renderOverlay(matrix, leafSVG), nil +} diff --git a/mod/html/img/qr/debug_test.go b/mod/html/img/qr/debug_test.go new file mode 100644 index 0000000..db0b722 --- /dev/null +++ b/mod/html/img/qr/debug_test.go @@ -0,0 +1,123 @@ +package qr + +import ( + "fmt" + "os/exec" + "strings" + "testing" +) + +// TestCompareWithQrencode generates a matrix for a given input and compares it +// module-by-module against the output of qrencode (reference implementation). +func TestCompareWithQrencode(t *testing.T) { + if _, err := exec.LookPath("qrencode"); err != nil { + t.Skip("qrencode not found") + } + + // Only byte-mode inputs where our mask selection matches qrencode. + // Differences in mode (numeric/alphanumeric) or mask choice produce + // completely different but equally valid matrices. + inputs := []string{ + "https://example.com", + } + + for _, input := range inputs { + t.Run(input, func(t *testing.T) { + // Generate reference matrix from qrencode + refMatrix := qrencodeMatrix(t, input) + if refMatrix == nil { + return + } + + // Generate our matrix + data := []byte(input) + version, err := selectVersion(data) + if err != nil { + t.Fatal(err) + } + ourMatrix := buildMatrix(data, version) + + refSize := len(refMatrix) + ourSize := len(ourMatrix) + if refSize != ourSize { + t.Fatalf("size mismatch: ref=%d, ours=%d (version ref=? ours=%d)", + refSize, ourSize, version) + return + } + + diffs := 0 + for r := 0; r < ourSize; r++ { + for c := 0; c < ourSize; c++ { + if ourMatrix[r][c] != refMatrix[r][c] { + diffs++ + if diffs <= 20 { + t.Errorf("diff at (%d,%d): ours=%v ref=%v", r, c, ourMatrix[r][c], refMatrix[r][c]) + } + } + } + } + if diffs > 20 { + t.Errorf("... and %d more diffs (total %d)", diffs-20, diffs) + } + if diffs > 0 { + t.Log("OUR matrix:") + t.Log(matrixASCII(ourMatrix)) + t.Log("REF matrix:") + t.Log(matrixASCII(refMatrix)) + } + }) + } +} + +// qrencodeMatrix calls qrencode and parses the ASCII output into a bool matrix. +func qrencodeMatrix(t *testing.T, input string) [][]bool { + t.Helper() + // qrencode -t ASCII outputs '#' for dark and ' ' for light, one row per line + cmd := exec.Command("qrencode", "-t", "ASCII", "-l", "Q", "-m", "0", input) + out, err := cmd.Output() + if err != nil { + t.Fatalf("qrencode failed: %v", err) + return nil + } + + lines := strings.Split(strings.TrimRight(string(out), "\n"), "\n") + // ASCII mode uses 2 chars per module (## for dark, " " for light) + size := len(lines) + if size == 0 { + t.Fatal("qrencode output empty") + return nil + } + // Detect char width per module + lineLen := len(lines[0]) + charsPerModule := lineLen / size + if charsPerModule < 1 { + charsPerModule = 1 + } + + m := make([][]bool, size) + for r, line := range lines { + m[r] = make([]bool, size) + for c := 0; c < size; c++ { + idx := c * charsPerModule + if idx < len(line) { + m[r][c] = line[idx] == '#' + } + } + } + return m +} + +func matrixASCII(m [][]bool) string { + var sb strings.Builder + for _, row := range m { + for _, cell := range row { + if cell { + fmt.Fprint(&sb, "██") + } else { + fmt.Fprint(&sb, " ") + } + } + sb.WriteByte('\n') + } + return sb.String() +} diff --git a/mod/html/img/qr/encode.go b/mod/html/img/qr/encode.go new file mode 100644 index 0000000..d8ee81f --- /dev/null +++ b/mod/html/img/qr/encode.go @@ -0,0 +1,136 @@ +package qr + +import "fmt" + +// // // // // // // // // // + +// bitWriterObj accumulates bits into a byte slice, MSB first. +type bitWriterObj struct { + buf []byte + cur byte // byte being assembled + pos int // next bit position within cur (0-7, MSB=0) + n int // total bits written +} + +func (w *bitWriterObj) write(val uint, bits int) { + for i := bits - 1; i >= 0; i-- { + bit := byte((val >> i) & 1) + w.cur |= bit << (7 - w.pos) + w.pos++ + w.n++ + if w.pos == 8 { + w.buf = append(w.buf, w.cur) + w.cur = 0 + w.pos = 0 + } + } +} + +func (w *bitWriterObj) bitLen() int { return w.n } + +func (w *bitWriterObj) bytes() []byte { + if w.pos > 0 { + return append(append([]byte{}, w.buf...), w.cur) + } + return append([]byte{}, w.buf...) +} + +// // // // // // // // // // + +// selectVersion returns the minimum QR version (1–10) that fits data at EC level Q. +func selectVersion(data []byte) (int, error) { + n := len(data) + for i, cap := range dataCapacityQ { + if n <= cap { + return i + 1, nil + } + } + return 0, fmt.Errorf("input too long: %d bytes exceeds version 10 Q capacity (151 bytes)", n) +} + +// // // // // // // // // // + +// encodePayload returns the final interleaved codeword sequence (data + EC). +func encodePayload(data []byte, version int) []byte { + p := ecParamsQ[version-1] + totalData := p.g1Count*p.g1Data + p.g2Count*p.g2Data + + // Build the data bit stream: mode | char count | bytes | terminator | padding + w := &bitWriterObj{} + w.write(0b0100, 4) // byte mode indicator + // Character count: 8 bits for versions 1–9, 16 bits for version 10+ (byte mode). + countBits := 8 + if version >= 10 { + countBits = 16 + } + w.write(uint(len(data)), countBits) + for _, b := range data { + w.write(uint(b), 8) + } + + // Terminator: up to 4 zero bits + rem := totalData*8 - w.bitLen() + if rem > 4 { + rem = 4 + } + w.write(0, rem) + + // Pad to byte boundary + for w.bitLen()%8 != 0 { + w.write(0, 1) + } + + // Pad bytes to fill capacity + padSeq := [2]byte{0xEC, 0x11} + for i := 0; w.bitLen()/8 < totalData; i++ { + w.write(uint(padSeq[i%2]), 8) + } + + raw := w.bytes() + + // Split raw data into blocks + type blockObj struct{ data []byte } + blocks := make([]blockObj, 0, p.g1Count+p.g2Count) + pos := 0 + for i := 0; i < p.g1Count; i++ { + blocks = append(blocks, blockObj{raw[pos : pos+p.g1Data]}) + pos += p.g1Data + } + for i := 0; i < p.g2Count; i++ { + blocks = append(blocks, blockObj{raw[pos : pos+p.g2Data]}) + pos += p.g2Data + } + + // Generate RS error correction for each block + type blockECObj struct { + data []byte + ec []byte + } + blocksEC := make([]blockECObj, len(blocks)) + for i, b := range blocks { + blocksEC[i] = blockECObj{b.data, rsEncode(b.data, p.ecPerBlock)} + } + + // Interleave data codewords + maxData := p.g1Data + if p.g2Count > 0 && p.g2Data > maxData { + maxData = p.g2Data + } + result := make([]byte, 0, totalData+len(blocks)*p.ecPerBlock) + for i := 0; i < maxData; i++ { + for _, b := range blocksEC { + if i < len(b.data) { + result = append(result, b.data[i]) + } + } + } + + // Interleave EC codewords + for i := 0; i < p.ecPerBlock; i++ { + for _, b := range blocksEC { + result = append(result, b.ec[i]) + } + } + + return result +} diff --git a/mod/html/img/qr/matrix.go b/mod/html/img/qr/matrix.go new file mode 100644 index 0000000..1c6185f --- /dev/null +++ b/mod/html/img/qr/matrix.go @@ -0,0 +1,380 @@ +package qr + +// // // // // // // // // // + +func make2D(size int) [][]bool { + m := make([][]bool, size) + for i := range m { + m[i] = make([]bool, size) + } + return m +} + +func copy2D(src [][]bool) [][]bool { + dst := make([][]bool, len(src)) + for i := range src { + dst[i] = make([]bool, len(src[i])) + copy(dst[i], src[i]) + } + return dst +} + +// // + +// setFinderPattern places a 7×7 finder pattern with top-left corner at (row, col). +func setFinderPattern(m, reserved [][]bool, row, col int) { + for r := 0; r < 7; r++ { + for c := 0; c < 7; c++ { + on := r == 0 || r == 6 || c == 0 || c == 6 || + (r >= 2 && r <= 4 && c >= 2 && c <= 4) + m[row+r][col+c] = on + reserved[row+r][col+c] = true + } + } +} + +func placeFinderPatterns(m, reserved [][]bool, size int) { + setFinderPattern(m, reserved, 0, 0) + setFinderPattern(m, reserved, 0, size-7) + setFinderPattern(m, reserved, size-7, 0) + // Separators: 1-module white border around each finder pattern + for i := 0; i < 8; i++ { + reserved[7][i] = true + reserved[i][7] = true + reserved[7][size-1-i] = true + reserved[i][size-8] = true + reserved[size-8][i] = true + reserved[size-1-i][7] = true + } +} + +func placeTimingPatterns(m, reserved [][]bool, size int) { + for i := 8; i < size-8; i++ { + on := i%2 == 0 + m[6][i] = on + m[i][6] = on + reserved[6][i] = true + reserved[i][6] = true + } + // Dark module: always on, position depends on version + m[size-8][8] = true + reserved[size-8][8] = true +} + +func placeAlignmentPatterns(m, reserved [][]bool, version, size int) { + pos := alignmentPos[version-1] + for _, r := range pos { + for _, c := range pos { + if reserved[r][c] { + continue + } + for dr := -2; dr <= 2; dr++ { + for dc := -2; dc <= 2; dc++ { + on := dr == -2 || dr == 2 || dc == -2 || dc == 2 || + (dr == 0 && dc == 0) + m[r+dr][c+dc] = on + reserved[r+dr][c+dc] = true + } + } + } + } +} + +// // + +func reserveFormatInfo(reserved [][]bool, size int) { + for i := 0; i <= 8; i++ { + reserved[8][i] = true + reserved[i][8] = true + } + for i := 0; i < 8; i++ { + reserved[8][size-1-i] = true + } + for i := 0; i < 7; i++ { + reserved[size-1-i][8] = true + } +} + +func reserveVersionInfo(reserved [][]bool, size int) { + for i := 0; i < 18; i++ { + reserved[5-i%6][size-9-i/6] = true + reserved[size-9-i/6][5-i%6] = true + } +} + +// // // // // // // // // // + +// placeData fills data modules in the QR zigzag pattern (right-to-left column pairs, +// alternating up/down). Reserved cells are skipped; remainder bits are left false. +func placeData(m, reserved [][]bool, codewords []byte, size int) { + bits := make([]bool, len(codewords)*8) + for i, cw := range codewords { + for b := 7; b >= 0; b-- { + bits[i*8+(7-b)] = (cw>>b)&1 == 1 + } + } + + bitIdx := 0 + goingUp := true + for col := size - 1; col > 0; col -= 2 { + if col == 6 { + col-- // skip timing column as right side of a pair + } + for rowOff := 0; rowOff < size; rowOff++ { + row := rowOff + if goingUp { + row = size - 1 - rowOff + } + for dc := 0; dc < 2; dc++ { + c := col - dc + if reserved[row][c] { + continue + } + if bitIdx < len(bits) { + m[row][c] = bits[bitIdx] + bitIdx++ + } + } + } + goingUp = !goingUp + } +} + +// // // // // // // // // // + +// bchFormat computes the 15-bit format information word for fmtData (5-bit value) +// using BCH(15,5) with generator 0x537, then XOR with the QR mask 0x5412. +func bchFormat(fmtData int) int { + d := fmtData << 10 + for i := 14; i >= 10; i-- { + if d&(1<>(14-i))&1 == 1 + } + + // Second copy: bottom-left (bits 0–6) and top-right (bits 7–14) + for i := 0; i < 7; i++ { + m[size-1-i][8] = (bits>>(14-i))&1 == 1 + } + for i := 7; i < 15; i++ { + m[8][size-15+i] = (bits>>(14-i))&1 == 1 + } +} + +// bchVersion computes the 18-bit version information word using BCH(18,6) +// with generator 0x1F25. +func bchVersion(version int) int { + d := version << 12 + for i := 17; i >= 12; i-- { + if d&(1<>i)&1 == 1 + m[5-i%6][size-9-i/6] = v // top-right block + m[size-9-i/6][5-i%6] = v // bottom-left block + } +} + +// // // // // // // // // // + +func maskCondition(maskID, r, c int) bool { + switch maskID { + case 0: + return (r+c)%2 == 0 + case 1: + return r%2 == 0 + case 2: + return c%3 == 0 + case 3: + return (r+c)%3 == 0 + case 4: + return (r/2+c/3)%2 == 0 + case 5: + return (r*c)%2+(r*c)%3 == 0 + case 6: + return ((r*c)%2+(r*c)%3)%2 == 0 + case 7: + return ((r+c)%2+(r*c)%3)%2 == 0 + } + return false +} + +func applyMask(m, reserved [][]bool, maskID, size int) [][]bool { + result := copy2D(m) + for r := 0; r < size; r++ { + for c := 0; c < size; c++ { + if !reserved[r][c] && maskCondition(maskID, r, c) { + result[r][c] = !result[r][c] + } + } + } + return result +} + +// // + +func penaltyRunScore(row []bool) int { + score := 0 + run := 1 + for i := 1; i < len(row); i++ { + if row[i] == row[i-1] { + run++ + } else { + if run >= 5 { + score += 3 + (run - 5) + } + run = 1 + } + } + if run >= 5 { + score += 3 + (run - 5) + } + return score +} + +var ( + penaltyPat1 = []bool{true, false, true, true, true, false, true, false, false, false, false} + penaltyPat2 = []bool{false, false, false, false, true, false, true, true, true, false, true} +) + +func matchPat(row []bool, pat []bool) bool { + for i := range pat { + if row[i] != pat[i] { + return false + } + } + return true +} + +func calcPenalty(m [][]bool, size int) int { + score := 0 + + // Rule 1: runs of 5+ in rows and columns + col := make([]bool, size) + for r := 0; r < size; r++ { + score += penaltyRunScore(m[r]) + for c := 0; c < size; c++ { + col[c] = m[c][r] + } + score += penaltyRunScore(col) + } + + // Rule 2: 2×2 blocks of same colour + for r := 0; r < size-1; r++ { + for c := 0; c < size-1; c++ { + v := m[r][c] + if m[r][c+1] == v && m[r+1][c] == v && m[r+1][c+1] == v { + score += 3 + } + } + } + + // Rule 3: finder-like patterns in rows and columns + for r := 0; r < size; r++ { + for c := 0; c <= size-11; c++ { + if matchPat(m[r][c:], penaltyPat1) || matchPat(m[r][c:], penaltyPat2) { + score += 40 + } + } + } + for c := 0; c < size; c++ { + for r := 0; r <= size-11; r++ { + match1, match2 := true, true + for k := 0; k < 11; k++ { + if m[r+k][c] != penaltyPat1[k] { + match1 = false + } + if m[r+k][c] != penaltyPat2[k] { + match2 = false + } + } + if match1 || match2 { + score += 40 + } + } + } + + // Rule 4: dark module proportion deviation from 50% + dark := 0 + for r := 0; r < size; r++ { + for c := 0; c < size; c++ { + if m[r][c] { + dark++ + } + } + } + pct := dark * 100 / (size * size) + diff := pct - 50 + if diff < 0 { + diff = -diff + } + score += (diff / 5) * 10 + + return score +} + +// // // // // // // // // // + +// buildMatrix constructs the complete QR code matrix for the given data and version. +func buildMatrix(data []byte, version int) [][]bool { + size := 21 + (version-1)*4 + codewords := encodePayload(data, version) + + m := make2D(size) + reserved := make2D(size) + + placeFinderPatterns(m, reserved, size) + placeTimingPatterns(m, reserved, size) + placeAlignmentPatterns(m, reserved, version, size) + reserveFormatInfo(reserved, size) + if version >= 7 { + reserveVersionInfo(reserved, size) + } + + placeData(m, reserved, codewords, size) + + // Evaluate all 8 masks and pick the one with lowest penalty score + bestMask := 0 + bestScore := -1 + var bestMatrix [][]bool + for maskID := 0; maskID < 8; maskID++ { + masked := applyMask(m, reserved, maskID, size) + placeFormatInfo(masked, maskID, size) + if version >= 7 { + placeVersionInfo(masked, version, size) + } + s := calcPenalty(masked, size) + if bestScore < 0 || s < bestScore { + bestScore = s + bestMask = maskID + bestMatrix = masked + } + } + _ = bestMask + return bestMatrix +} diff --git a/mod/html/img/qr/qr.go b/mod/html/img/qr/qr.go new file mode 100644 index 0000000..b6d12f8 --- /dev/null +++ b/mod/html/img/qr/qr.go @@ -0,0 +1,26 @@ +// Package qr generates QR codes as SVG without external dependencies. +// +// Encoding: byte mode, EC level Q (25% recovery), versions 1–10 (up to 151 bytes). +package qr + +// // // // // // // // // // + +// Generate returns a QR code SVG for url. +func Generate(url string) ([]byte, error) { + data := []byte(url) + version, err := selectVersion(data) + if err != nil { + return nil, err + } + return RenderSVG(buildMatrix(data, version)), nil +} + +// Matrix returns the raw QR boolean matrix for url (true = dark module). +func Matrix(url string) ([][]bool, error) { + data := []byte(url) + version, err := selectVersion(data) + if err != nil { + return nil, err + } + return buildMatrix(data, version), nil +} diff --git a/mod/html/img/qr/qr_test.go b/mod/html/img/qr/qr_test.go new file mode 100644 index 0000000..179a3c9 --- /dev/null +++ b/mod/html/img/qr/qr_test.go @@ -0,0 +1,507 @@ +package qr + +import ( + "strings" + "testing" +) + +// // // // // // // // // // +// GF256 + +func TestGF256Multiply(t *testing.T) { + gfInit() + // 2 * 0x80 = x * x^7 = x^8, which reduces via 0x11D: x^8 = x^4+x^3+x^2+1 = 0x1D + if got := gfMul(2, 0x80); got != 0x1D { + t.Errorf("gfMul(2, 0x80) = %#x, want 0x1D", got) + } + // identity + for x := byte(1); x != 0; x++ { + if got := gfMul(1, x); got != x { + t.Errorf("gfMul(1, %d) = %d, want %d", x, got, x) + } + } + // zero + for x := byte(0); x != 255; x++ { + if got := gfMul(0, x); got != 0 { + t.Errorf("gfMul(0, %d) = %d, want 0", x, got) + } + } + // commutative + if gfMul(3, 7) != gfMul(7, 3) { + t.Error("gfMul not commutative") + } +} + +// // // // // // // // // // +// Reed-Solomon self-check: data || rsEncode(data) must be divisible by the generator poly. + +func TestRSEncodeSelfConsistency(t *testing.T) { + gfInit() + // Version 1 Q: 13 data codewords, 13 EC codewords + // Encoding "1" (one byte, version 1 Q) + data := []byte{0x40, 0x13, 0x10, 0xEC, 0x11, 0xEC, 0x11, 0xEC, 0x11, 0xEC, 0x11, 0xEC, 0x11} + ec := rsEncode(data, 13) + if len(ec) != 13 { + t.Fatalf("rsEncode returned %d bytes, want 13", len(ec)) + } + + // Verify: the full codeword (data||ec) must be a multiple of the generator polynomial. + msg := append(append([]byte{}, data...), ec...) + gen := rsGeneratorPoly(13) + rem := make([]byte, len(msg)) + copy(rem, msg) + for i := 0; i < len(data); i++ { + coef := rem[i] + if coef == 0 { + continue + } + for j, g := range gen { + rem[i+j] ^= gfMul(coef, g) + } + } + for i, b := range rem[len(data):] { + if b != 0 { + t.Errorf("RS check failed at EC position %d: got %#x, want 0x00", i, b) + } + } +} + +// // // // // // // // // // +// Format information + +// readFormatInfo reads the 15 format info bits from the first copy in the matrix. +func readFormatInfo(m [][]bool) int { + firstCopy := [15][2]int{ + {8, 0}, {8, 1}, {8, 2}, {8, 3}, {8, 4}, {8, 5}, {8, 7}, {8, 8}, + {7, 8}, {5, 8}, {4, 8}, {3, 8}, {2, 8}, {1, 8}, {0, 8}, + } + bits := 0 + for i, pos := range firstCopy { + if m[pos[0]][pos[1]] { + bits |= 1 << (14 - i) + } + } + return bits +} + +// decodeFormatInfo returns (ecIndicator, maskID, valid). +// Strips the XOR mask, verifies BCH, and extracts the 5-bit data. +func decodeFormatInfo(rawBits int) (ecIndicator, maskID int, valid bool) { + unmasked := rawBits ^ 0x5412 + // BCH check: remainder after dividing unmasked by generator should be 0 + d := unmasked + for i := 14; i >= 10; i-- { + if d&(1<> 10) & 0x1F + return (fmtData >> 3) & 0x3, fmtData & 0x7, true +} + +func TestBchFormat(t *testing.T) { + // For EC level Q the bchFormat output must decode back to EC=3 (Q) and the given mask. + for maskID := 0; maskID < 8; maskID++ { + fmtData := (3 << 3) | maskID // Q=11binary=3 + bits := bchFormat(fmtData) + ec, mask, ok := decodeFormatInfo(bits) + if !ok { + t.Errorf("mask %d: BCH check failed (bits=%#x)", maskID, bits) + continue + } + if ec != 3 { + t.Errorf("mask %d: decoded EC=%d, want 3 (Q)", maskID, ec) + } + if mask != maskID { + t.Errorf("mask %d: decoded mask=%d", maskID, mask) + } + } +} + +// // // // // // // // // // +// Matrix structure + +func TestFinderPatterns(t *testing.T) { + m := make2D(21) + reserved := make2D(21) + placeFinderPatterns(m, reserved, 21) + + // Border of top-left finder: rows 0 and 6, cols 0-6; cols 0 and 6, rows 0-6 + for i := 0; i < 7; i++ { + if !m[0][i] { + t.Errorf("top-left finder border missing at (0,%d)", i) + } + if !m[6][i] { + t.Errorf("top-left finder border missing at (6,%d)", i) + } + if !m[i][0] { + t.Errorf("top-left finder border missing at (%d,0)", i) + } + if !m[i][6] { + t.Errorf("top-left finder border missing at (%d,6)", i) + } + } + // Interior of top-left finder: separator ring must be white + for r := 1; r <= 5; r++ { + for c := 1; c <= 5; c++ { + inCenter := r >= 2 && r <= 4 && c >= 2 && c <= 4 + inBorder := r == 1 || r == 5 || c == 1 || c == 5 + if inBorder && !inCenter { + if m[r][c] { + t.Errorf("top-left finder separator not white at (%d,%d)", r, c) + } + } + } + } + // Center 3×3 must be dark + for r := 2; r <= 4; r++ { + for c := 2; c <= 4; c++ { + if !m[r][c] { + t.Errorf("top-left finder center not dark at (%d,%d)", r, c) + } + } + } +} + +func TestTimingPatterns(t *testing.T) { + m := make2D(21) + reserved := make2D(21) + placeTimingPatterns(m, reserved, 21) + + // Row 6, cols 8-12: alternating dark/light starting with dark + for i := 8; i <= 12; i++ { + want := i%2 == 0 + if m[6][i] != want { + t.Errorf("timing row 6 col %d: got %v, want %v", i, m[6][i], want) + } + } + // Col 6, rows 8-12: same + for i := 8; i <= 12; i++ { + want := i%2 == 0 + if m[i][6] != want { + t.Errorf("timing col 6 row %d: got %v, want %v", i, m[i][6], want) + } + } + // Dark module + if !m[13][8] { + t.Error("dark module (13,8) not set") + } +} + +// // // // // // // // // // +// Integration + +func TestFormatInfoRoundTrip(t *testing.T) { + // Generate a QR for a short URL and verify format info decodes to EC=Q. + data := []byte("https://ygg.example.com/") + v, _ := selectVersion(data) + matrix := buildMatrix(data, v) + rawBits := readFormatInfo(matrix) + ec, _, ok := decodeFormatInfo(rawBits) + if !ok { + t.Fatalf("format info BCH check failed (bits=%#x)", rawBits) + } + if ec != 3 { + t.Errorf("format info EC indicator = %d (binary %02b), want 3 (Q=11)", ec, ec) + } +} + +func TestQROutputIsSVG(t *testing.T) { + svg, err := Generate("https://ygg.example.com/") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(svg), ": %q", string(svg[:min(len(svg), 20)])) + } + if !strings.Contains(string(svg), `viewBox=`) { + t.Error("SVG missing viewBox attribute") + } + if !strings.Contains(string(svg), `shape-rendering="crispEdges"`) { + t.Error("SVG missing shape-rendering=crispEdges (anti-aliasing would break QR scan)") + } + if !strings.Contains(string(svg), `color-scheme:light`) { + t.Error("SVG missing color-scheme protection") + } + if !strings.Contains(string(svg), `forced-color-adjust:none`) { + t.Error("SVG missing forced-color-adjust protection") + } + if !strings.Contains(string(svg), `!important`) { + t.Error("SVG missing !important color protection") + } +} + +func TestQRTooLong(t *testing.T) { + // 152 bytes should fail (version 10 Q max = 151) + _, err := Generate(strings.Repeat("x", 152)) + if err == nil { + t.Error("expected error for input > 151 bytes, got nil") + } +} + +func TestQRVersionSelection(t *testing.T) { + cases := []struct { + length int + version int + }{ + {11, 1}, + {12, 2}, + {20, 2}, + {21, 3}, + {32, 3}, + {33, 4}, + {46, 4}, + {47, 5}, + {151, 10}, + } + for _, tc := range cases { + data := []byte(strings.Repeat("a", tc.length)) + got, err := selectVersion(data) + if err != nil { + t.Errorf("len=%d: unexpected error: %v", tc.length, err) + continue + } + if got != tc.version { + t.Errorf("len=%d: version=%d, want %d", tc.length, got, tc.version) + } + } +} + +// // // // // // // // // // + +// // // // // // // // // // +// Decode round-trip: generate QR → read back bits → RS decode → verify original payload. + +// buildReserved reconstructs the reserved-cell map for a given version. +func buildReserved(version, size int) [][]bool { + m := make2D(size) + reserved := make2D(size) + placeFinderPatterns(m, reserved, size) + placeTimingPatterns(m, reserved, size) + placeAlignmentPatterns(m, reserved, version, size) + reserveFormatInfo(reserved, size) + if version >= 7 { + reserveVersionInfo(reserved, size) + } + return reserved +} + +// readZigzagBits mirrors placeData: reads data bits from the matrix in zigzag order. +func readZigzagBits(matrix [][]bool, reserved [][]bool, size int) []bool { + var bits []bool + goingUp := true + for col := size - 1; col > 0; col -= 2 { + if col == 6 { + col-- + } + for rowOff := 0; rowOff < size; rowOff++ { + row := rowOff + if goingUp { + row = size - 1 - rowOff + } + for dc := 0; dc < 2; dc++ { + c := col - dc + if !reserved[row][c] { + bits = append(bits, matrix[row][c]) + } + } + } + goingUp = !goingUp + } + return bits +} + +func TestDecodeRoundTrip(t *testing.T) { + inputs := []string{ + "A", + "https://ygg.example.com/", + "http://[200:abcd::1]:8080/", + // Realistic Yggdrasil URLs (full IPv6, typical ports) + "http://[200:dead:beef:1234:5678:9abc:def0:1234]:8443/", + "http://[200:1111:2222:3333:4444:5555:6666:7777]:80/some/page", + // Version 5+ (mixed block sizes) + "http://[200:aaaa:bbbb:cccc:dddd:eeee:ffff:0000]:8080/path/to/resource?q=1", + } + for _, input := range inputs { + t.Run(input, func(t *testing.T) { + data := []byte(input) + version, err := selectVersion(data) + if err != nil { + t.Fatal(err) + } + size := 21 + (version-1)*4 + matrix := buildMatrix(data, version) + + // Read format info to get the chosen mask + rawBits := readFormatInfo(matrix) + _, maskID, ok := decodeFormatInfo(rawBits) + if !ok { + t.Fatalf("format info BCH invalid (bits=%#x)", rawBits) + } + + // Apply inverse mask to the matrix + reserved := buildReserved(version, size) + unmasked := copy2D(matrix) + for r := 0; r < size; r++ { + for c := 0; c < size; c++ { + if !reserved[r][c] && maskCondition(maskID, r, c) { + unmasked[r][c] = !unmasked[r][c] + } + } + } + + // Read raw data bits in zigzag order + rawBits2 := readZigzagBits(unmasked, reserved, size) + + // Convert bits to codewords + p := ecParamsQ[version-1] + totalBlocks := p.g1Count + p.g2Count + totalDataCW := p.g1Count*p.g1Data + p.g2Count*p.g2Data + totalCW := totalDataCW + totalBlocks*p.ecPerBlock + + if len(rawBits2) < totalCW*8 { + t.Fatalf("not enough bits: got %d, need %d", len(rawBits2), totalCW*8) + } + + codewords := make([]byte, totalCW) + for i := range codewords { + for b := 0; b < 8; b++ { + if rawBits2[i*8+b] { + codewords[i] |= 1 << (7 - b) + } + } + } + + // De-interleave: extract per-block data codewords + // Build block sizes (mirrors encodePayload interleaving) + type blockSizeObj struct{ dataLen int } + blockSizes := make([]blockSizeObj, 0, totalBlocks) + for i := 0; i < p.g1Count; i++ { + blockSizes = append(blockSizes, blockSizeObj{p.g1Data}) + } + for i := 0; i < p.g2Count; i++ { + blockSizes = append(blockSizes, blockSizeObj{p.g2Data}) + } + maxData := p.g1Data + if p.g2Count > 0 && p.g2Data > maxData { + maxData = p.g2Data + } + + // De-interleave data codewords + blockData := make([][]byte, totalBlocks) + for i := range blockData { + blockData[i] = make([]byte, blockSizes[i].dataLen) + } + pos := 0 + for i := 0; i < maxData; i++ { + for b, bs := range blockSizes { + if i < bs.dataLen { + blockData[b][i] = codewords[pos] + pos++ + } + } + } + + // De-interleave EC codewords and RS-verify each block + blockEC := make([][]byte, totalBlocks) + for i := range blockEC { + blockEC[i] = make([]byte, p.ecPerBlock) + } + for i := 0; i < p.ecPerBlock; i++ { + for b := range blockSizes { + blockEC[b][i] = codewords[pos] + pos++ + } + } + + for b := range blockSizes { + full := append(append([]byte{}, blockData[b]...), blockEC[b]...) + gen := rsGeneratorPoly(p.ecPerBlock) + rem := make([]byte, len(full)) + copy(rem, full) + for i := 0; i < len(blockData[b]); i++ { + coef := rem[i] + if coef == 0 { + continue + } + for j, g := range gen { + rem[i+j] ^= gfMul(coef, g) + } + } + for i, bt := range rem[len(blockData[b]):] { + if bt != 0 { + t.Errorf("block %d RS check failed at EC pos %d: got %#x", b, i, bt) + } + } + } + + // Reconstruct full data sequence from de-interleaved blocks + var allData []byte + for _, bd := range blockData { + allData = append(allData, bd...) + } + + // Decode byte-mode payload: 4-bit mode + 8-bit count + data + // Build bitstream from allData + var msgBits []bool + for _, byt := range allData { + for b := 7; b >= 0; b-- { + msgBits = append(msgBits, (byt>>b)&1 == 1) + } + } + + // Read mode indicator (4 bits) + mode := 0 + for i := 0; i < 4; i++ { + if msgBits[i] { + mode |= 1 << (3 - i) + } + } + if mode != 0b0100 { + t.Errorf("mode indicator = %04b, want 0100 (byte mode)", mode) + } + + // Read character count (8 bits for v1-9, 16 for v10) + countBits := 8 + if version == 10 { + countBits = 16 + } + charCount := 0 + for i := 0; i < countBits; i++ { + if msgBits[4+i] { + charCount |= 1 << (countBits - 1 - i) + } + } + if charCount != len(data) { + t.Errorf("decoded char count = %d, want %d", charCount, len(data)) + } + + // Read data bytes + decoded := make([]byte, charCount) + base := 4 + countBits + for i := 0; i < charCount; i++ { + for b := 0; b < 8; b++ { + if msgBits[base+i*8+b] { + decoded[i] |= 1 << (7 - b) + } + } + } + if string(decoded) != input { + t.Errorf("decoded = %q, want %q", decoded, input) + } + }) + } +} + +// // // // // // // // // // + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/mod/html/img/qr/rs.go b/mod/html/img/qr/rs.go new file mode 100644 index 0000000..3d1d765 --- /dev/null +++ b/mod/html/img/qr/rs.go @@ -0,0 +1,85 @@ +package qr + +import "sync" + +// // // // // // // // // // + +var ( + gfOnce sync.Once + gfExp [512]byte // antilog table, doubled to avoid modulo in multiply + gfLog [256]int // log table; gfLog[0] is unused +) + +// gfInit initialises GF(256) tables using the QR primitive polynomial +// x^8 + x^4 + x^3 + x^2 + 1 = 0x11D. +func gfInit() { + gfOnce.Do(func() { + x := 1 + for i := 0; i < 255; i++ { + gfExp[i] = byte(x) + gfLog[x] = i + x <<= 1 + if x&0x100 != 0 { + x ^= 0x11D + } + } + for i := 255; i < 512; i++ { + gfExp[i] = gfExp[i-255] + } + }) +} + +// // + +func gfMul(a, b byte) byte { + if a == 0 || b == 0 { + return 0 + } + return gfExp[gfLog[a]+gfLog[b]] +} + +// // + +// polyMul multiplies two polynomials over GF(256). +// Coefficients are ordered from highest to lowest degree. +func polyMul(p, q []byte) []byte { + res := make([]byte, len(p)+len(q)-1) + for i, a := range p { + for j, b := range q { + res[i+j] ^= gfMul(a, b) + } + } + return res +} + +// rsGeneratorPoly returns the RS generator polynomial for ecN EC codewords: +// g(x) = ∏(x + α^i) for i = 0..ecN-1, coefficients high-to-low. +func rsGeneratorPoly(ecN int) []byte { + gfInit() + g := []byte{1} + for i := 0; i < ecN; i++ { + g = polyMul(g, []byte{1, gfExp[i]}) + } + return g +} + +// // // // // // // // // // + +// rsEncode computes ecN Reed-Solomon error correction codewords for data. +// Uses polynomial long division over GF(256). +func rsEncode(data []byte, ecN int) []byte { + gfInit() + gen := rsGeneratorPoly(ecN) + msg := make([]byte, len(data)+ecN) + copy(msg, data) + for i := range data { + coef := msg[i] + if coef == 0 { + continue + } + for j, g := range gen { + msg[i+j] ^= gfMul(coef, g) + } + } + return msg[len(data):] +} diff --git a/mod/html/img/qr/svg.go b/mod/html/img/qr/svg.go new file mode 100644 index 0000000..17368a6 --- /dev/null +++ b/mod/html/img/qr/svg.go @@ -0,0 +1,53 @@ +package qr + +import ( + "fmt" + "strings" +) + +// // // // // // // // // // + +const ( + QuietZone = 4 // mandatory 4-module quiet zone (ISO 18004) + ModulePixels = 8 // rendered pixel size per module; 8px gives ~200–500px total for v1–10 +) + +// RenderSVG converts a QR matrix to SVG bytes. +// +// Each dark module is a separate for maximum compatibility +// across mobile browsers and WebView engines. +// shape-rendering="crispEdges" disables anti-aliasing on module boundaries. +// Color protection prevents dark-mode inversion. +func RenderSVG(m [][]bool) []byte { + size := len(m) + total := size + 2*QuietZone + px := total * ModulePixels + + var sb strings.Builder + sb.Grow(size * size * 70) + + fmt.Fprintf(&sb, + ``, + total, total, px, px, + ) + fmt.Fprintf(&sb, + ``, + total, total, + ) + for r, row := range m { + for c, dark := range row { + if dark { + fmt.Fprintf(&sb, + ``, + c+QuietZone, r+QuietZone, + ) + } + } + } + sb.WriteString(``) + + return []byte(sb.String()) +} diff --git a/mod/html/img/qr/tables.go b/mod/html/img/qr/tables.go new file mode 100644 index 0000000..39ef433 --- /dev/null +++ b/mod/html/img/qr/tables.go @@ -0,0 +1,46 @@ +package qr + +// ecBlocksObj describes the RS block structure for one QR version at EC level Q. +type ecBlocksObj struct { + ecPerBlock int // EC codewords per block + g1Count int // number of blocks in group 1 + g1Data int // data codewords per block in group 1 + g2Count int // number of blocks in group 2 (0 = no group 2) + g2Data int // data codewords per block in group 2 +} + +// ecParamsQ holds EC block parameters for versions 1–10 at level Q. +// Index 0 = version 1. +var ecParamsQ = [10]ecBlocksObj{ + {13, 1, 13, 0, 0}, // v1: 13 data + {22, 1, 22, 0, 0}, // v2: 22 data + {18, 2, 17, 0, 0}, // v3: 34 data + {26, 2, 24, 0, 0}, // v4: 48 data + {18, 2, 15, 2, 16}, // v5: 62 data + {24, 4, 19, 0, 0}, // v6: 76 data + {18, 2, 14, 4, 15}, // v7: 88 data + {22, 4, 18, 2, 19}, // v8: 110 data + {20, 4, 16, 4, 17}, // v9: 132 data + {24, 6, 19, 2, 20}, // v10: 154 data +} + +// dataCapacityQ is the maximum input bytes (byte mode) for version i+1 at EC level Q. +// Derived from total data codewords minus overhead: +// v1-9: floor((codewords*8 - 12) / 8) (4-bit mode + 8-bit count) +// v10: floor((codewords*8 - 20) / 8) (4-bit mode + 16-bit count) +var dataCapacityQ = [10]int{11, 20, 32, 46, 60, 74, 86, 108, 130, 151} + +// alignmentPos lists alignment pattern center coordinates for version i+1. +// Version 1 has no alignment patterns. +var alignmentPos = [10][]int{ + {}, + {6, 18}, + {6, 22}, + {6, 26}, + {6, 30}, + {6, 34}, + {6, 22, 38}, + {6, 24, 42}, + {6, 26, 46}, + {6, 28, 50}, +} diff --git a/mod/html/img/qr/verify_test.go b/mod/html/img/qr/verify_test.go new file mode 100644 index 0000000..63b0914 --- /dev/null +++ b/mod/html/img/qr/verify_test.go @@ -0,0 +1,75 @@ +package qr + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestExternalDecode generates QR SVGs, converts to PNG via cairosvg, +// and decodes via pyzbar. This catches any deviation from the QR standard +// that the internal round-trip test would miss. +func TestExternalDecode(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not found") + } + + cases := []string{ + "https://example.com", + "http://[200:abcd::1]:8080/", + "http://[200:dead:beef:1234:5678:9abc:def0:1234]:8443/", + "HELLO WORLD", + "1234567890", + } + + dir := t.TempDir() + + for i, input := range cases { + svg, err := Generate(input) + if err != nil { + t.Fatalf("QR(%q): %v", input, err) + } + + svgPath := filepath.Join(dir, "qr.svg") + pngPath := filepath.Join(dir, "qr.png") + if err := os.WriteFile(svgPath, svg, 0o644); err != nil { + t.Fatal(err) + } + + script := ` +import sys, cairosvg +from PIL import Image +from pyzbar.pyzbar import decode + +cairosvg.svg2png(url=sys.argv[1], write_to=sys.argv[2], output_width=600, output_height=600) +img = Image.open(sys.argv[2]) +results = decode(img) +if not results: + print("DECODE_FAIL", file=sys.stderr) + sys.exit(1) +print(results[0].data.decode("utf-8")) +` + cmd := exec.Command("python3", "-c", script, svgPath, pngPath) + out, err := cmd.Output() + decoded := strings.TrimSpace(string(out)) + + if err != nil { + stderr := "" + if ee, ok := err.(*exec.ExitError); ok { + stderr = string(ee.Stderr) + } + t.Errorf("case %d %q: decode failed: %v\nstderr: %s", i, input, err, stderr) + // Save failing SVG for inspection + failPath := filepath.Join(dir, "fail.svg") + _ = os.WriteFile(failPath, svg, 0o644) + t.Logf("failing SVG saved to %s", failPath) + continue + } + + if decoded != input { + t.Errorf("case %d: decoded=%q, want=%q", i, decoded, input) + } + } +} diff --git a/mod/html/img/yggdrasil-leaf.svg b/mod/html/img/yggdrasil-leaf.svg new file mode 100644 index 0000000..589c6a3 --- /dev/null +++ b/mod/html/img/yggdrasil-leaf.svg @@ -0,0 +1,10 @@ + + + + + + + diff --git a/mod/html/sigils/render.go b/mod/html/sigils/render.go new file mode 100644 index 0000000..1f1e35e --- /dev/null +++ b/mod/html/sigils/render.go @@ -0,0 +1,203 @@ +package sigils + +import ( + "bytes" + "encoding/json" + "fmt" + "html" + "slices" + "strconv" + + "github.com/voluminor/ratatoskr/mod/ninfo" + coresigils "github.com/voluminor/ratatoskr/mod/sigils" + "github.com/voluminor/ratatoskr/mod/sigils/inet" + "github.com/voluminor/ratatoskr/mod/sigils/info" + "github.com/voluminor/ratatoskr/mod/sigils/public" + "github.com/voluminor/ratatoskr/mod/sigils/services" +) + +// // // // // // // // // // + +// Render produces HTML blocks from a parsed NodeInfo. +// Sigils contains one HTML block per recognized sigil. +// Extra contains one
per leftover key not claimed by any sigil. +func Render(parsed *ninfo.ParsedObj) (*ResultObj, error) { + if parsed == nil { + return nil, fmt.Errorf("nil parsed") + } + + result := &ResultObj{ + Sigils: make(map[string][]byte, len(parsed.Sigils)), + } + + for name, sg := range parsed.Sigils { + buf, err := renderSigil(sg) + if err != nil { + return nil, fmt.Errorf("sigil[%s]: %w", name, err) + } + result.Sigils[name] = buf + } + + if len(parsed.Extra) > 0 { + buf, err := renderExtra(parsed.Extra) + if err != nil { + return nil, fmt.Errorf("extra: %w", err) + } + result.Extra = buf + } + + return result, nil +} + +// RenderOne produces a single HTML block for one sigil. +func RenderOne(sg coresigils.Interface) ([]byte, error) { + if sg == nil { + return nil, fmt.Errorf("nil sigil") + } + return renderSigil(sg) +} + +// // + +func renderSigil(sg coresigils.Interface) ([]byte, error) { + // Typed renderers for known sigils. + switch o := sg.(type) { + case *info.Obj: + return RenderInfo(o), nil + case *inet.Obj: + return RenderInet(o), nil + case *public.Obj: + return RenderPublic(o), nil + case *services.Obj: + return RenderServices(o), nil + } + + // Fallback: generic render via Interface.Params(). + return renderGenericSigil(sg) +} + +func renderGenericSigil(sg coresigils.Interface) ([]byte, error) { + params := sg.Params() + if len(params) == 0 { + return nil, nil + } + + var buf bytes.Buffer + buf.WriteString(`
\n") + + buf.WriteString(`
`) + buf.WriteString(html.EscapeString(sg.GetName())) + buf.WriteString("
\n") + + keys := sortedKeys(params) + for _, k := range keys { + writeEntry(&buf, k, params[k], "sg") + } + + buf.WriteString("
\n") + return buf.Bytes(), nil +} + +func renderExtra(m map[string]any) ([]byte, error) { + if len(m) == 0 { + return nil, nil + } + + var buf bytes.Buffer + keys := sortedKeys(m) + + for _, k := range keys { + writeEntry(&buf, k, m[k], "ni") + } + + return buf.Bytes(), nil +} + +// // + +func writeRow(buf *bytes.Buffer, key, val string) { + buf.WriteString("
\n") + buf.WriteString(" ") + buf.WriteString(html.EscapeString(key)) + buf.WriteString("\n") + buf.WriteString(" ") + buf.WriteString(html.EscapeString(val)) + buf.WriteString("\n") + buf.WriteString("
\n") +} + +func writeEntry(buf *bytes.Buffer, key string, val any, prefix string) { + buf.WriteString(`
\n") + + buf.WriteString(`
`) + buf.WriteString(html.EscapeString(key)) + buf.WriteString("
\n") + + buf.WriteString(`
`) + buf.WriteString(formatVal(val)) + buf.WriteString("
\n") + + buf.WriteString("
\n") +} + +func formatVal(v any) string { + switch t := v.(type) { + case string: + return html.EscapeString(t) + case float64: + if t == float64(int64(t)) { + return strconv.FormatInt(int64(t), 10) + } + return strconv.FormatFloat(t, 'f', -1, 64) + case bool: + return strconv.FormatBool(t) + case nil: + return "" + default: + raw, err := json.Marshal(v) + if err != nil { + return html.EscapeString(fmt.Sprintf("%v", v)) + } + return html.EscapeString(string(raw)) + } +} + +func valType(v any) string { + switch v.(type) { + case string: + return "string" + case float64: + return "number" + case bool: + return "bool" + case nil: + return "null" + default: + return "json" + } +} + +func sortedKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + slices.Sort(keys) + return keys +} diff --git a/mod/html/sigils/render_inet.go b/mod/html/sigils/render_inet.go new file mode 100644 index 0000000..b055ff7 --- /dev/null +++ b/mod/html/sigils/render_inet.go @@ -0,0 +1,33 @@ +package sigils + +import ( + "bytes" + "html" + + "github.com/voluminor/ratatoskr/mod/sigils/inet" +) + +// // // // // // // // // // + +// RenderInet produces an HTML block from an inet sigil. +func RenderInet(o *inet.Obj) []byte { + addrs := o.Addrs() + if len(addrs) == 0 { + return nil + } + + var buf bytes.Buffer + buf.WriteString("
\n") + buf.WriteString("
inet
\n") + buf.WriteString("
\n") + + for _, addr := range addrs { + buf.WriteString("
") + buf.WriteString(html.EscapeString(addr)) + buf.WriteString("
\n") + } + + buf.WriteString("
\n") + buf.WriteString("
\n") + return buf.Bytes() +} diff --git a/mod/html/sigils/render_info.go b/mod/html/sigils/render_info.go new file mode 100644 index 0000000..8b7d5fa --- /dev/null +++ b/mod/html/sigils/render_info.go @@ -0,0 +1,62 @@ +package sigils + +import ( + "bytes" + "html" + "slices" + + "github.com/voluminor/ratatoskr/mod/sigils/info" +) + +// // // // // // // // // // + +// RenderInfo produces an HTML block from an info sigil. +func RenderInfo(o *info.Obj) []byte { + c := o.Info() + if c == nil { + return nil + } + + var buf bytes.Buffer + buf.WriteString("
\n") + buf.WriteString("
info
\n") + + writeRow(&buf, "name", c.Name) + writeRow(&buf, "type", c.Type) + + if c.Location != "" { + writeRow(&buf, "location", c.Location) + } + + if len(c.Contacts) > 0 { + buf.WriteString("
\n") + buf.WriteString("
contacts
\n") + + groups := make([]string, 0, len(c.Contacts)) + for g := range c.Contacts { + groups = append(groups, g) + } + slices.Sort(groups) + + for _, g := range groups { + buf.WriteString("
\n") + buf.WriteString("
") + buf.WriteString(html.EscapeString(g)) + buf.WriteString("
\n") + for _, addr := range c.Contacts[g] { + buf.WriteString("
") + buf.WriteString(html.EscapeString(addr)) + buf.WriteString("
\n") + } + buf.WriteString("
\n") + } + buf.WriteString("
\n") + } + + if c.Description != "" { + writeRow(&buf, "description", c.Description) + } + + buf.WriteString("
\n") + return buf.Bytes() +} diff --git a/mod/html/sigils/render_public.go b/mod/html/sigils/render_public.go new file mode 100644 index 0000000..b406f50 --- /dev/null +++ b/mod/html/sigils/render_public.go @@ -0,0 +1,45 @@ +package sigils + +import ( + "bytes" + "html" + "slices" + + "github.com/voluminor/ratatoskr/mod/sigils/public" +) + +// // // // // // // // // // + +// RenderPublic produces an HTML block from a public sigil. +func RenderPublic(o *public.Obj) []byte { + peers := o.Peers() + if len(peers) == 0 { + return nil + } + + var buf bytes.Buffer + buf.WriteString("
\n") + buf.WriteString("
public
\n") + + groups := make([]string, 0, len(peers)) + for g := range peers { + groups = append(groups, g) + } + slices.Sort(groups) + + for _, g := range groups { + buf.WriteString("
\n") + buf.WriteString("
") + buf.WriteString(html.EscapeString(g)) + buf.WriteString("
\n") + for _, uri := range peers[g] { + buf.WriteString("
") + buf.WriteString(html.EscapeString(uri)) + buf.WriteString("
\n") + } + buf.WriteString("
\n") + } + + buf.WriteString("
\n") + return buf.Bytes() +} diff --git a/mod/html/sigils/render_services.go b/mod/html/sigils/render_services.go new file mode 100644 index 0000000..bbcd327 --- /dev/null +++ b/mod/html/sigils/render_services.go @@ -0,0 +1,44 @@ +package sigils + +import ( + "bytes" + "html" + "slices" + "strconv" + + "github.com/voluminor/ratatoskr/mod/sigils/services" +) + +// // // // // // // // // // + +// RenderServices produces an HTML block from a services sigil. +func RenderServices(o *services.Obj) []byte { + svc := o.Services() + if len(svc) == 0 { + return nil + } + + var buf bytes.Buffer + buf.WriteString("
\n") + buf.WriteString("
services
\n") + + names := make([]string, 0, len(svc)) + for n := range svc { + names = append(names, n) + } + slices.Sort(names) + + for _, name := range names { + buf.WriteString("
") + buf.WriteString("") + buf.WriteString(html.EscapeString(name)) + buf.WriteString("") + buf.WriteString("") + buf.WriteString(strconv.FormatUint(uint64(svc[name]), 10)) + buf.WriteString("") + buf.WriteString("
\n") + } + + buf.WriteString("
\n") + return buf.Bytes() +} diff --git a/mod/html/sigils/result.go b/mod/html/sigils/result.go new file mode 100644 index 0000000..1cbfa61 --- /dev/null +++ b/mod/html/sigils/result.go @@ -0,0 +1,9 @@ +package sigils + +// // // // // // // // // // + +// ResultObj holds rendered HTML blocks from a parsed NodeInfo. +type ResultObj struct { + Extra []byte + Sigils map[string][]byte +} diff --git a/mod/html/sigils/style.go b/mod/html/sigils/style.go new file mode 100644 index 0000000..9bb9d7c --- /dev/null +++ b/mod/html/sigils/style.go @@ -0,0 +1,33 @@ +package sigils + +// // // // // // // // // // + +// CSS contains minimal styles for sigil and nodeinfo HTML blocks. +// Embed into a