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, "