diff --git a/server/go.mod b/server/go.mod index f64a134..f96c4d7 100644 --- a/server/go.mod +++ b/server/go.mod @@ -1,3 +1,5 @@ module echo-lot.app/server go 1.24 + +require github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..e99b5b9 --- /dev/null +++ b/server/go.sum @@ -0,0 +1,2 @@ +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= diff --git a/server/internal/adminui/pages.go b/server/internal/adminui/pages.go index d2f8902..357404a 100644 --- a/server/internal/adminui/pages.go +++ b/server/internal/adminui/pages.go @@ -138,7 +138,8 @@ func (s *Server) devices(w http.ResponseWriter, r *http.Request, sess *adminauth } s.render(w, r, "devices", map[string]any{ "Session": sess, "CSRF": s.csrfToken(sess), "Rows": rows, - "Link": link, "LinkHref": href, "Admin": sess.Admin, + // Rendered from the same validated value as the href, so a rejected link produces neither. + "Link": link, "LinkHref": href, "LinkQR": qrSVG(string(href)), "Admin": sess.Admin, }) } diff --git a/server/internal/adminui/qr.go b/server/internal/adminui/qr.go new file mode 100644 index 0000000..3577a2f --- /dev/null +++ b/server/internal/adminui/qr.go @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package adminui + +import ( + "fmt" + "html/template" + "strings" + + qrcode "github.com/skip2/go-qrcode" +) + +// qrSVG renders text as an inline SVG QR code, or empty if it will not encode. +// +// Inline SVG rather than a PNG data: URI because the page's CSP is `default-src 'none'` and means +// it. A data: image would need img-src opened up; markup needs nothing, and the QR is generated +// here from a boolean matrix, so nothing a user supplied reaches the output. +// +// Drawn as one path rather than a rect per module: a link of this length encodes to roughly 60x60 +// modules, and two thousand elements is a lot of DOM for a picture of a square. +func qrSVG(text string) template.HTML { + if text == "" { + return "" + } + // Medium recovery: a phone camera reading a screen has no dirt or creases to survive, and + // lower recovery keeps the module count down, which keeps it scannable on a small display. + q, err := qrcode.New(text, qrcode.Medium) + if err != nil { + return "" // too long to encode; the link text below it still works + } + bitmap := q.Bitmap() + n := len(bitmap) + if n == 0 { + return "" + } + + var path strings.Builder + for y, row := range bitmap { + for x, dark := range row { + if dark { + fmt.Fprintf(&path, "M%d %dh1v1h-1z", x, y) + } + } + } + + // A quiet zone is part of the spec, not decoration: without it a scanner cannot find the + // symbol's edges against whatever is next to it on the page. + var out strings.Builder + fmt.Fprintf(&out, + ``+ + ``+ + ``, + n, n, n, n, path.String()) + return template.HTML(out.String()) +} diff --git a/server/internal/adminui/qr_test.go b/server/internal/adminui/qr_test.go new file mode 100644 index 0000000..c4c1058 --- /dev/null +++ b/server/internal/adminui/qr_test.go @@ -0,0 +1,24 @@ +package adminui + +import ( + "strings" + "testing" +) + +func TestQrSVGEncodesAnEnrolmentLink(t *testing.T) { + link := "echolot://enroll?v=1&u=https%3A%2F%2Ffmr.echo-lot.app&p=pin-sha256%3AzRV9qkiLnRexAeh4RrSfJzbPWO%2BU%2F2Oj2%2FNVM%2FKfXlg%3D&t=20e6ccaa2a028dc0aab16442c258d1b8eadb5794682905fe" + out := string(qrSVG(link)) + if !strings.HasPrefix(out, " .narrow{max-width:27rem} .panel{background:var(--hull);border:1px solid var(--rule);border-radius:3px; padding:.95rem 1rem;margin:.9rem 0;min-width:0} + /* White plate behind the code: a QR needs the light modules to actually be light, and this + page is dark. */ + .qr{display:inline-block;background:#fff;padding:8px;border-radius:4px;margin:.2rem 0;line-height:0} + .qr svg{display:block;width:min(240px,60vw);height:auto} .empty{border:1px dashed var(--rule);border-radius:3px;padding:1.4rem 1rem; color:var(--dim);font-size:.9rem} code,pre,.mono{font-family:var(--mono);font-size:.82rem} @@ -328,8 +332,9 @@ const baseHTML = ` app, so following the link hands it the token directly. Copying a 200-character string between two devices is the step that goes wrong, and it does not have to happen at all. --> {{with $.LinkHref}}

Open in the Echolot app

{{end}} -

Only works on the phone you are enrolling. From anywhere else, copy the link - into the app's enrolment field, or deliver it over adb.

+

Works on the phone you are enrolling. From another device, scan this:

+ {{with $.LinkQR}}
{{.}}
{{end}} +

Or copy the link into the app's enrolment field, or deliver it over adb.

{{.}}

adb shell am start -a android.intent.action.VIEW -d "{{.}}"