From a7e42e60348fe665078f1201855ed516efe62f94 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 01:07:27 +0000 Subject: [PATCH] Return the stamped PDF to the device that sent it. POST /api/stamp runs the print pipeline and responds with the certificate, or a zip of the original, the certificate, and the receipt. The receipts page links an iPhone shortcut, and mDNS advertises _verae._tcp without a second IPP name. Co-authored-by: George Lambert --- docs/ios-shortcut/README.md | 36 ++++ docs/ios-shortcut/share-extension.md | 47 +++++ docs/network-printer.md | 10 +- internal/netprint/advertise.go | 12 +- internal/netprint/dnsservice.go | 4 +- internal/netprint/mdns.go | 79 ++++++++ internal/netprint/receipts.go | 10 + internal/netprint/server.go | 17 ++ internal/netprint/shortcut_page.go | 39 ++++ internal/netprint/stamp_api.go | 259 +++++++++++++++++++++++++ internal/netprint/stamp_api_test.go | 276 +++++++++++++++++++++++++++ 11 files changed, 785 insertions(+), 4 deletions(-) create mode 100644 docs/ios-shortcut/README.md create mode 100644 docs/ios-shortcut/share-extension.md create mode 100644 internal/netprint/shortcut_page.go create mode 100644 internal/netprint/stamp_api.go create mode 100644 internal/netprint/stamp_api_test.go diff --git a/docs/ios-shortcut/README.md b/docs/ios-shortcut/README.md new file mode 100644 index 0000000..5c30eb1 --- /dev/null +++ b/docs/ios-shortcut/README.md @@ -0,0 +1,36 @@ +# Verae Timestamp iPhone shortcut + +Build this in the Shortcuts app. It shows up in the Share sheet, sends the PDF or photo to the Verae printer on your Wi-Fi, and saves the stamped certificate. A signed `.shortcut` file is not included: Apple only accepts shortcuts signed on a Mac or iPhone, and this tree is built on Linux. + +Open the receipts page on a computer (`http://:631/receipts`, or 8632 if CUPS is using 631). The QR code opens `http://:/ios-shortcut` on the phone, which repeats these steps and shows the stamp address for this machine. + +## Build it + +1. Open **Shortcuts** and tap **+**. Name the shortcut **Verae Timestamp**. +2. Tap the shortcut’s **(i)** or settings and turn on **Show in Share Sheet**. +3. Under share sheet types, leave **PDFs** and **Images** selected. You can clear the other types. +4. Add **Get Contents of URL**. + - URL: `http://:/api/stamp` + If the receipts page has a PIN, use `http://:/api/stamp?access=YOURPIN`. + - Method: **POST**. + - Request Body: **File**. + - File: **Shortcut Input**. + - Add a header `X-Filename` if you want the job title to be the file name. The server also accepts `Content-Disposition: attachment; filename="Contract.pdf"`. PDF and JPEG are recognized from the file bytes, so a photo from the share sheet works. +5. Add **Quick Look** and pass the contents of the URL. That is the stamped certificate PDF. +6. Add **Save File**. Leave **Ask Where to Save** on and pick iCloud Drive or On My iPhone when it asks. +7. Show the receipt id: + - Add **Get Dictionary Value**. + - Dictionary: the **Headers** of the Get Contents of URL result. + - Key: `X-Verae-Receipt-Id`. + - Add **Show Result** (or **Show Notification**) with that value. + - `X-Verae-SHA256` is the document hash. `X-Verae-Receipt-JSON` is the URL of the `.verae.json` summary on the printer. + +To get a zip instead, change the URL to end in `?format=zip` (use `&access=` if you already have `?access=`). **Save File** then receives `original.pdf`, `certificate.pdf`, and `receipt.json`. + +## Use it + +In Files or Photos, open a PDF or picture, tap **Share**, and choose **Verae Timestamp**. If it is missing, tap **More** and enable it. + +The printer records the request as a job, lists it on the receipts page, and emails the stamped PDF when **Email stamped PDFs** is on. The shortcut does not wait for the email. The PDF in the shortcut result is the certificate. + +The stamp address is only for Private and Domain networks, same as the printer. A wrong or missing PIN returns `401` and no PDF. diff --git a/docs/ios-shortcut/share-extension.md b/docs/ios-shortcut/share-extension.md new file mode 100644 index 0000000..a240bbd --- /dev/null +++ b/docs/ios-shortcut/share-extension.md @@ -0,0 +1,47 @@ +# Share extension for the Verae Timestamping iOS app + +The app (`com.flightlookup.verae-timestamping`) is not in this repository. This note is the design for a **Verae Timestamp** row in the iOS Share sheet that stamps a PDF or photo on the LAN print server and keeps the result in Files and in the app. + +The shortcut in `README.md` is the path that works before that extension ships. The server side it needs is already here: `POST /api/stamp` and a `_verae._tcp` Bonjour record. + +## Extension + +- Extension point: `com.apple.share-services`. +- Display name: **Verae Timestamp**. +- Activation rule: the first attachment conforms to `com.adobe.pdf` or `public.image` (which covers JPEG and HEIC). Ignore other share types. +- Process the attachment in the extension, then call `completeRequestReturningItems` with an item provider for the stamped PDF so the system share sheet can hand it back. Also write a copy into the app group so the main app can list it without waiting for Files. + +Suggested app group: `group.com.flightlookup.verae-timestamping`. Put the certificate, the receipt id, the SHA-256, and the receipt JSON in that container. The main app reads the same folder on launch. + +## Finding the printer + +Browse Bonjour in `local.` with `NWBrowser`: + +1. Prefer `_verae._tcp`. The TXT record is `txtvers=1`, `path=/api/stamp`, `ver=1`, and `url=http://:/api/stamp`. The SRV record is the port (631, or 8632 when CUPS owns 631). Resolve the host and POST to `http://:` plus `path`. +2. If that browse is empty, browse `_ipp._tcp` and keep the instance whose TXT `ty` or `product` is **Verae Timestamp**. Use the host and port from that service and POST to `/api/stamp`. The AirPrint `adminurl` ends in `/receipts`; the stamp URL is the same origin with `/api/stamp`. + +This repository advertises `_verae._tcp` from the print server’s mDNS socket. It is a separate service name from `._ipp._tcp.local`, so it does not create a second IPP advertisement. On Windows, `DnsServiceRegister` still owns only the IPP name. The shared UDP 5353 socket answers AirPrint subtype PTRs for IPP and, separately, the `_verae._tcp` PTR, SRV, and TXT. It does not add IPP SRV, TXT, or address records while DNS-SD is up. + +Stop the browse when the extension finishes. Cache the last host and port in the app group so a later share can try it first and fall back to a browse if the POST fails. + +## Upload + +`POST /api/stamp` with the file bytes as the body, or as multipart field `file`. + +- Header `X-Filename`: the document title. +- Header `X-Verae-User`: optional, shown on the receipts page. Default is `stamp`. +- The response body is `application/pdf` (the certificate). +- Headers: `X-Verae-Receipt-Id`, `X-Verae-SHA256`, `X-Verae-Receipt-JSON`. +- `?format=zip` returns `original.pdf`, `certificate.pdf`, and `receipt.json`. + +The job is stored like a print, appears on `/receipts`, and is emailed when email is enabled. The extension should treat the HTTP response as the copy to save, not wait for the email. + +## PIN and network + +The route uses the same Private/Domain check and optional receipts PIN as `/receipts`. Send the PIN as `X-Verae-Access`, `Authorization: Bearer `, or `?access=`. Store the PIN in the Keychain (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`), not in the app group. A `401` means the PIN is missing or wrong. A `403` means the computer is on a Public network and Public access is off. Show that text; do not save an empty file. + +## Saving + +- Write the certificate into the app group and refresh the main app’s receipt list (a Darwin notification on the app group is enough). +- Offer **Save to Files** with a document picker, or by returning the PDF from the extension so the user can pick a folder. +- Keep the receipt id and SHA-256 next to the file. The JSON URL in `X-Verae-Receipt-JSON` is on the LAN and needs the same PIN. diff --git a/docs/network-printer.md b/docs/network-printer.md index a45e810..a7a836c 100644 --- a/docs/network-printer.md +++ b/docs/network-printer.md @@ -35,7 +35,15 @@ The SMTP password and the optional receipts PIN are not stored in `netprint.json ## Receipts on the phone -`http://:/receipts` is served on the same port as IPP, so the existing Private and Domain firewall rules cover it. Public networks stay blocked unless that option is on. An optional PIN (or `?access=` token) can be required on the LAN. The loopback control page does not ask for the PIN, so the tray on this computer still opens the list. The status JSON field is `receiptsUrl`. +`http://:/receipts` is served on the same port as IPP, so the existing Private and Domain firewall rules cover it. Public networks stay blocked unless that option is on. An optional PIN (or `?access=` token) can be required on the LAN. The loopback control page does not ask for the PIN, so the tray on this computer still opens the list. The status JSON field is `receiptsUrl`. The page links to the iPhone shortcut and shows a QR code for `http://:/ios-shortcut`. + +## Stamp and return + +`POST http://:/api/stamp` accepts a PDF or JPEG, either as the raw body (set `X-Filename` or `Content-Disposition` for the title) or as multipart with a `file` field. It runs the same pipeline as a print job: the job is stored, it shows on the receipts page, and it is emailed when email is on. The response is the stamped certificate PDF. Headers `X-Verae-Receipt-Id`, `X-Verae-SHA256`, and `X-Verae-Receipt-JSON` identify the receipt. `?format=zip` returns `original.pdf`, `certificate.pdf`, and `receipt.json`. The same Private and Domain rule and the same PIN apply. Send the PIN as `?access=`, the `X-Verae-Access` header, or `Authorization: Bearer`. A failure does not return a PDF. + +The server also advertises `_verae._tcp` (PTR, SRV, and TXT `path=/api/stamp`) beside the IPP printer. That record is a different service name, so it does not add a second `_ipp._tcp` advertisement. While Windows DNS-SD owns the printer, the shared UDP socket still answers only the AirPrint subtype PTRs for `_ipp._tcp` and does not add IPP SRV, TXT, or address records. The `_verae._tcp` SRV and TXT go out on that socket because DNS-SD does not register that name. Address records stay with the printer advertisement. + +The Share sheet steps are in `docs/ios-shortcut/README.md`. A signed `.shortcut` file is not in this tree: signing needs the Shortcuts app on a Mac or iPhone. The design for a native share extension in the Verae Timestamping iOS app (`com.flightlookup.verae-timestamping`) is `docs/ios-shortcut/share-extension.md`. Forward-to is optional: a local queue name, or an `ipp://` URI. A failed forward does not fail the timestamp. diff --git a/internal/netprint/advertise.go b/internal/netprint/advertise.go index 2c78d6e..1ce79b6 100644 --- a/internal/netprint/advertise.go +++ b/internal/netprint/advertise.go @@ -191,6 +191,7 @@ func advertiseUDP(ctx context.Context, current func() Service, sink mdnsSink) er go func() { <-ctx.Done() _, _ = writeMDNS(conn, current().Announce(true)) + _, _ = writeMDNS(conn, current().VeraeAnnounce(true)) _ = conn.Close() }() sent := 0 @@ -204,6 +205,11 @@ func advertiseUDP(ctx context.Context, current func() Service, sink mdnsSink) er return } sent++ + if v := current().VeraeAnnounce(false); len(v) > 0 { + if _, verr := writeMDNS(conn, v); verr != nil { + log.Printf("netprint: mDNS _verae._tcp announcement failed: %v", verr) + } + } log.Printf("netprint: mDNS announcement sent (%d bytes) to 224.0.0.251 and ff02::fb port 5353", n) if sink != nil { sink(mdnsReport{ @@ -237,7 +243,11 @@ func advertiseUDP(ctx context.Context, current func() Service, sink mdnsSink) er continue } query := append([]byte(nil), buf[:n]...) - resp := current().Reply(query) + svc := current() + resp := svc.Reply(query) + if resp == nil { + resp = svc.ReplyVerae(query) + } if resp == nil { continue } diff --git a/internal/netprint/dnsservice.go b/internal/netprint/dnsservice.go index 2ff0530..62966f8 100644 --- a/internal/netprint/dnsservice.go +++ b/internal/netprint/dnsservice.go @@ -111,7 +111,7 @@ func advertiseForOS(goos string, ctx context.Context, current func() Service, ap sink(mdnsReport{ Advertising: true, Mode: "dns-sd", - Detail: st.dnsDetail + "; shared UDP 5353 subtype PTR _universal._sub and _print._sub", + Detail: st.dnsDetail + "; shared UDP 5353 subtype PTR _universal._sub and _print._sub; _verae._tcp", LastSent: time.Now(), }) case st.dnsOK: @@ -129,7 +129,7 @@ func advertiseForOS(goos string, ctx context.Context, current func() Service, ap sink(mdnsReport{ Advertising: true, Mode: "udp", - Detail: "shared UDP 5353 PTR _ipp._tcp, _universal._sub, _print._sub", + Detail: "shared UDP 5353 PTR _ipp._tcp, _universal._sub, _print._sub, _verae._tcp", Err: st.dnsErr, LastSent: time.Now(), }) diff --git a/internal/netprint/mdns.go b/internal/netprint/mdns.go index 1f84974..ab5202e 100644 --- a/internal/netprint/mdns.go +++ b/internal/netprint/mdns.go @@ -30,6 +30,8 @@ type Service struct { Port int IPs []net.IP TXT []string + // StampURL is the LAN stamp-and-return address advertised on _verae._tcp. + StampURL string // SubtypesOnly answers _universal._sub and _print._sub PTRs and nothing // else. Windows uses that mode while DnsServiceRegister owns the primary // _ipp._tcp name: a second SRV or TXT for the same instance is a conflict, @@ -192,6 +194,83 @@ func (s Service) Announce(goodbye bool) []byte { return s.encodeResponse(0, qs, ttl) } +func (s Service) veraeLabels() []string { + return []string{sanitizeLabel(s.Instance), "_verae", "_tcp", "local"} +} + +func (s Service) veraeTXT() []string { + txt := []string{"txtvers=1", "path=/api/stamp", "ver=1"} + if u := strings.TrimSpace(s.StampURL); u != "" { + txt = append(txt, "url="+trimTXT(u, 200)) + } + return txt +} + +// VeraeAnnounce advertises _verae._tcp. It is a different service name from +// _ipp._tcp, so it does not create a second copy of the printer. While +// SubtypesOnly is set, address records stay with DnsServiceRegister. +func (s Service) VeraeAnnounce(goodbye bool) []byte { + ttl := uint32(mdnsTTL) + if goodbye { + ttl = 0 + } + inst := s.veraeLabels() + ans := []dnsRR{{ + name: []string{"_verae", "_tcp", "local"}, typ: dnsTypePTR, class: dnsClassIN, ttl: ttl, ptr: inst, + }} + extra := []dnsRR{ + {name: inst, typ: dnsTypeSRV, class: dnsClassIN | dnsCacheFlush, ttl: ttl, srvPort: s.Port, srvTarget: s.hostLabels()}, + {name: inst, typ: dnsTypeTXT, class: dnsClassIN | dnsCacheFlush, ttl: ttl, txt: s.veraeTXT()}, + } + if !s.SubtypesOnly { + extra = append(extra, s.addressRRs(ttl)...) + } + return encodeMessage(0, 0x8400, nil, ans, extra) +} + +func (s Service) addressRRs(ttl uint32) []dnsRR { + var out []dnsRR + for _, ip := range s.IPs { + if v4 := ip.To4(); v4 != nil { + out = append(out, dnsRR{name: s.hostLabels(), typ: dnsTypeA, class: dnsClassIN | dnsCacheFlush, ttl: ttl, ip: v4}) + continue + } + if v6 := ip.To16(); v6 != nil { + out = append(out, dnsRR{name: s.hostLabels(), typ: dnsTypeAAAA, class: dnsClassIN | dnsCacheFlush, ttl: ttl, ip: v6}) + } + } + return out +} + +// ReplyVerae answers a browse or resolve for _verae._tcp. +func (s Service) ReplyVerae(query []byte) []byte { + if len(query) < 12 { + return nil + } + flags := binary.BigEndian.Uint16(query[2:4]) + if flags&0x8000 != 0 { + return nil + } + qs, err := parseQuestions(query) + if err != nil || len(qs) == 0 { + return nil + } + var matched []dnsQuestion + for _, q := range qs { + n := lowerJoin(q.name) + switch { + case n == "_verae._tcp.local" && (q.typ == dnsTypePTR || q.typ == dnsTypeANY): + matched = append(matched, q) + case n == lowerJoin(s.veraeLabels()) && (q.typ == dnsTypePTR || q.typ == dnsTypeSRV || q.typ == dnsTypeTXT || q.typ == dnsTypeANY): + matched = append(matched, q) + } + } + if len(matched) == 0 { + return nil + } + return s.VeraeAnnounce(false) +} + func (s Service) encodeResponse(id uint16, qs []dnsQuestion, ttl uint32) []byte { var answers []byte var add []byte diff --git a/internal/netprint/receipts.go b/internal/netprint/receipts.go index 357e0fe..b32167d 100644 --- a/internal/netprint/receipts.go +++ b/internal/netprint/receipts.go @@ -4,6 +4,7 @@ import ( "crypto/hmac" "crypto/sha256" "crypto/subtle" + "encoding/base64" "encoding/hex" "fmt" "html" @@ -12,6 +13,8 @@ import ( "net/http" "strconv" "strings" + + "github.com/skip2/go-qrcode" ) // currentNetworkPublic is the Windows network category, replaced in tests. @@ -156,6 +159,13 @@ p{margin:4px 0} a.btn{display:inline-block;min-height:44px;line-height:44px;padding:0 14px;margin:8px 8px 0 0;background:#111;color:#fff;text-decoration:none;border-radius:8px} `) fmt.Fprintf(&b, "

%s

Recent timestamped jobs, newest first.

", html.EscapeString(name)) + if r.Host != "" { + shortcut := "http://" + r.Host + "/ios-shortcut" + fmt.Fprintf(&b, `

iPhone Shortcut

`) + if png, err := qrcode.Encode(shortcut, qrcode.Medium, 256); err == nil { + fmt.Fprintf(&b, `

Scan to build the Share sheet shortcut.

QR code for the iPhone shortcut

`, base64.StdEncoding.EncodeToString(png)) + } + } list := a.Store.List() shown := 0 for i := len(list) - 1; i >= 0 && shown < 100; i-- { diff --git a/internal/netprint/server.go b/internal/netprint/server.go index 3879510..4b79ec9 100644 --- a/internal/netprint/server.go +++ b/internal/netprint/server.go @@ -104,6 +104,17 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) s.printer.ServeHTTP(w, r) return } + if r.URL.Path == "/api/stamp" || r.URL.Path == "/api/stamp/" { + if !receiptsNetworkAllowed(currentNetworkPublic(), s.snapshot().PublicNetworks()) { + http.Error(w, "the stamp API is available on Private and Domain networks", http.StatusForbidden) + return + } + if !s.authorizeStamp(w, r) { + return + } + s.handleStamp(w, r) + return + } if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/settings" { http.NotFound(w, r) return @@ -115,6 +126,10 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) if !s.allowReceipts(w, r) { return } + if r.URL.Path == "/ios-shortcut" || r.URL.Path == "/ios-shortcut/" { + s.serveShortcutGuide(w, r) + return + } admin.ServeHTTP(w, r) }) s.ippSrv = &http.Server{Handler: pub} @@ -156,12 +171,14 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) c := s.snapshot() ips := lanIPs() adminURL := s.printer.AdminURL + stampURL := strings.TrimSuffix(adminURL, "/receipts") + "/api/stamp" return Service{ Instance: c.Name, Host: hostLabel(c.Hostname), Port: s.port, IPs: ips, TXT: AirPrintTXT(c.Name, uuid, adminURL, "", ""), + StampURL: stampURL, } }, s.setMDNS) if err != nil && ctx.Err() == nil { diff --git a/internal/netprint/shortcut_page.go b/internal/netprint/shortcut_page.go new file mode 100644 index 0000000..4494bd6 --- /dev/null +++ b/internal/netprint/shortcut_page.go @@ -0,0 +1,39 @@ +package netprint + +import ( + "fmt" + "html" + "net/http" + "strings" +) + +func (s *Server) serveShortcutGuide(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + stamp := "http://" + r.Host + "/api/stamp" + if r.Host == "" { + stamp = strings.TrimSuffix(s.receiptsURL(), "/receipts") + "/api/stamp" + } + page := fmt.Sprintf(`iPhone shortcut + + +

Receipts

+

Verae Timestamp shortcut

+

Build this in the Shortcuts app so the Share sheet sends a PDF or photo here and returns the stamped certificate. A signed shortcut file is not served: Apple signs those on a Mac or iPhone.

+
    +
  1. Open Shortcuts, tap +, and name it Verae Timestamp.
  2. +
  3. Turn on Show in Share Sheet. Keep PDFs and Images selected.
  4. +
  5. Add Get Contents of URL. URL %s. Method POST. Request Body: File. File: Shortcut Input. If a PIN is set, add ?access= and the PIN.
  6. +
  7. Add a header X-Filename when you want that name as the job title. PDF and JPEG are recognized from the file itself.
  8. +
  9. Add Quick Look on the result, then Save File, and leave Ask Where to Save on.
  10. +
  11. Add Get Dictionary Value. Dictionary: Headers of the URL result. Key X-Verae-Receipt-Id. Show Result. X-Verae-SHA256 is the hash and X-Verae-Receipt-JSON is the receipt summary.
  12. +
  13. For a zip of the original, the stamped PDF, and the receipt JSON, use %s?format=zip.
  14. +
+

From Files or Photos, tap Share and choose Verae Timestamp. The printer lists the job and emails it when email is on.

+`, html.EscapeString(stamp), html.EscapeString(stamp)) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write([]byte(page)) +} diff --git a/internal/netprint/stamp_api.go b/internal/netprint/stamp_api.go new file mode 100644 index 0000000..070e1d1 --- /dev/null +++ b/internal/netprint/stamp_api.go @@ -0,0 +1,259 @@ +package netprint + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "log" + "mime" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" +) + +// handleStamp runs the print pipeline and returns the stamped PDF, or a zip +// of the original, the certificate, and the receipt JSON when format=zip. +func (s *Server) handleStamp(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + doc, title, format, err := readStampBody(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if len(doc) == 0 { + http.Error(w, "empty document", http.StatusBadRequest) + return + } + if len(doc) > maxDocument { + http.Error(w, "document is too large", http.StatusRequestEntityTooLarge) + return + } + id, err := s.store.AllocateID() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + user := stampUser(r.Header.Get("X-Verae-User")) + log.Printf("netprint: stamp api job %d title=%q bytes=%d", id, title, len(doc)) + ProcessJob(s.store, s.snapshot(), s.printer, id, format, title, user, doc, s.stamp, s.forward, s.queueReceiptMail) + rec, ok := s.store.Get(id) + if !ok || rec.State != "completed" || rec.CertificatePDF == "" { + msg := "stamp failed" + if rec.Error != "" { + msg = rec.Error + } + http.Error(w, msg, http.StatusUnprocessableEntity) + return + } + cert, err := readStampFile(rec.CertificatePDF) + if err != nil { + http.Error(w, "stamped PDF is missing", http.StatusInternalServerError) + return + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Verae-Receipt-Id", rec.ReceiptID) + w.Header().Set("X-Verae-SHA256", rec.SHA256) + w.Header().Set("X-Verae-Receipt-JSON", s.receiptJSONURL(r, id)) + if strings.EqualFold(r.URL.Query().Get("format"), "zip") { + payload, err := stampZip(rec, cert) + if err != nil { + http.Error(w, "could not build zip", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", `attachment; filename="verae-stamp.zip"`) + _, _ = w.Write(payload) + return + } + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", `attachment; filename="certificate.pdf"`) + _, _ = w.Write(cert) +} + +func (s *Server) receiptJSONURL(r *http.Request, id int) string { + host := r.Host + if host == "" { + host = strings.TrimPrefix(strings.TrimSuffix(s.receiptsURL(), "/receipts"), "http://") + } + return fmt.Sprintf("http://%s/jobs/%d/receipt.json", host, id) +} + +func (s *Server) authorizeStamp(w http.ResponseWriter, r *http.Request) bool { + pin, err := s.receiptsPIN() + if err != nil { + log.Printf("netprint: stamp lock: %v", err) + http.Error(w, "stamp unavailable", http.StatusForbidden) + return false + } + if pin == "" || stampTokenOK(r, pin) { + return true + } + w.Header().Set("WWW-Authenticate", `Bearer realm="verae-stamp"`) + http.Error(w, "PIN required", http.StatusUnauthorized) + return false +} + +func stampTokenOK(r *http.Request, pin string) bool { + if c, err := r.Cookie(receiptsCookie); err == nil && cookieMatches(c.Value, pin) { + return true + } + if access := strings.TrimSpace(r.URL.Query().Get("access")); access != "" && pinMatch(access, pin) { + return true + } + if h := strings.TrimSpace(r.Header.Get("X-Verae-Access")); h != "" && pinMatch(h, pin) { + return true + } + authz := strings.TrimSpace(r.Header.Get("Authorization")) + if len(authz) > 7 && strings.EqualFold(authz[:7], "bearer ") && pinMatch(strings.TrimSpace(authz[7:]), pin) { + return true + } + return false +} + +func readStampBody(r *http.Request) (doc []byte, title, format string, err error) { + ct := r.Header.Get("Content-Type") + mediaType, _, _ := mime.ParseMediaType(ct) + if strings.HasPrefix(mediaType, "multipart/") { + r.Body = http.MaxBytesReader(nil, r.Body, maxDocument+1<<20) + mr, err := r.MultipartReader() + if err != nil { + return nil, "", "", fmt.Errorf("bad multipart body") + } + var part *multipart.Part + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + return nil, "", "", fmt.Errorf("bad multipart body") + } + name := p.FormName() + if part == nil || name == "file" || name == "document" || name == "pdf" { + if part != nil { + part.Close() + } + part = p + if name == "file" || name == "document" || name == "pdf" { + break + } + } else { + p.Close() + } + } + if part == nil { + return nil, "", "", fmt.Errorf("multipart body has no file") + } + defer part.Close() + doc, err = io.ReadAll(io.LimitReader(part, maxDocument+1)) + if err != nil { + return nil, "", "", fmt.Errorf("read document") + } + title = stampTitle(part.FileName()) + format = part.Header.Get("Content-Type") + return doc, title, format, nil + } + doc, err = io.ReadAll(io.LimitReader(http.MaxBytesReader(nil, r.Body, maxDocument+1), maxDocument+1)) + if err != nil { + return nil, "", "", fmt.Errorf("read document") + } + title = stampTitle(firstNonEmpty(r.Header.Get("X-Filename"), r.URL.Query().Get("filename"), filenameParam(r.Header.Get("Content-Disposition")))) + format = mediaType + return doc, title, format, nil +} + +func filenameParam(v string) string { + _, params, err := mime.ParseMediaType(v) + if err != nil { + return "" + } + return params["filename"] +} + +func stampTitle(name string) string { + name = strings.ReplaceAll(name, "\\", "/") + name = filepath.Base(name) + name = strings.Map(func(r rune) rune { + if r < 32 || r == '"' { + return -1 + } + return r + }, name) + name = strings.TrimSpace(name) + if name == "" || name == "." || name == "/" { + return "Stamp" + } + return name +} + +func stampUser(user string) string { + user = strings.TrimSpace(strings.Map(func(r rune) rune { + if r < 32 { + return -1 + } + return r + }, user)) + if user == "" { + return "stamp" + } + if len(user) > 80 { + user = user[:80] + } + return user +} + +func readStampFile(path string) ([]byte, error) { + if strings.TrimSpace(path) == "" { + return nil, fmt.Errorf("missing") + } + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return io.ReadAll(io.LimitReader(f, maxDocument+1)) +} + +func stampZip(rec Record, cert []byte) ([]byte, error) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + add := func(name string, b []byte) error { + if len(b) == 0 { + return nil + } + w, err := zw.Create(name) + if err != nil { + return err + } + _, err = w.Write(b) + return err + } + origPath := rec.OriginalPDF + if origPath == "" { + origPath = filepath.Join(filepath.Dir(rec.CertificatePDF), "document.pdf") + } + orig, _ := readStampFile(origPath) + if err := add("original.pdf", orig); err != nil { + _ = zw.Close() + return nil, err + } + if err := add("certificate.pdf", cert); err != nil { + _ = zw.Close() + return nil, err + } + js, _ := readStampFile(rec.ReceiptJSON) + if err := add("receipt.json", js); err != nil { + _ = zw.Close() + return nil, err + } + if err := zw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/internal/netprint/stamp_api_test.go b/internal/netprint/stamp_api_test.go new file mode 100644 index 0000000..c646883 --- /dev/null +++ b/internal/netprint/stamp_api_test.go @@ -0,0 +1,276 @@ +package netprint + +import ( + "archive/zip" + "bytes" + "context" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/mailer" +) + +func TestStampAPIReturnsCertificate(t *testing.T) { + cfg, path := testConfig(t) + s, err := Start(cfg, path, localStamp(), &fakeCtrl{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + on := true + s.mu.Lock() + s.cfg.EmailEnabled = &on + s.cfg.EmailTo = DefaultEmailTo + s.mu.Unlock() + if err := saveSecrets(s.secretsFile(), Secrets{SMTPPassword: "pw"}); err != nil { + t.Fatal(err) + } + prev := sendMail + var mu sync.Mutex + var mailed mailer.Message + sendMail = func(c mailer.Config, m mailer.Message) error { + mu.Lock() + mailed = m + mu.Unlock() + return nil + } + t.Cleanup(func() { sendMail = prev }) + + req, err := http.NewRequest(http.MethodPost, ippURL(s, "/api/stamp"), strings.NewReader("%PDF-1.4 contract")) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/pdf") + req.Header.Set("X-Filename", "Contract.pdf") + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(res.Body) + res.Body.Close() + if res.StatusCode != http.StatusOK || !bytes.HasPrefix(body, []byte("%PDF-cert")) { + t.Fatalf("%d %q", res.StatusCode, body) + } + if res.Header.Get("X-Verae-Receipt-Id") != "QmabStamp" || res.Header.Get("X-Verae-SHA256") == "" || !strings.Contains(res.Header.Get("X-Verae-Receipt-JSON"), "/receipt.json") { + t.Fatalf("headers %+v", res.Header) + } + page := getBody(t, ippURL(s, "/receipts")) + if !strings.Contains(page, "Contract.pdf") || !strings.Contains(page, "QmabStamp") || !strings.Contains(page, "iPhone Shortcut") || !strings.Contains(page, "data:image/png;base64,") { + t.Fatal(page) + } + guide := getBody(t, ippURL(s, "/ios-shortcut")) + if !strings.Contains(guide, "Share Sheet") || !strings.Contains(guide, "/api/stamp") || !strings.Contains(guide, "X-Verae-Receipt-Id") { + t.Fatal(guide) + } + other := httpStatus(t, ippURL(s, "/api/v1/status")) + if other != http.StatusNotFound { + t.Fatalf("control route on the LAN listener: %d", other) + } + deadline := time.Now().Add(2 * time.Second) + var subject, text string + for time.Now().Before(deadline) { + mu.Lock() + subject, text = mailed.Subject, mailed.Text + mu.Unlock() + if strings.Contains(text, "QmabStamp") { + break + } + time.Sleep(15 * time.Millisecond) + } + if !strings.Contains(subject, "Verae timestamp: Contract.pdf") || !strings.Contains(text, "QmabStamp") { + t.Fatalf("mail %q %q", subject, text) + } + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + part, err := mw.CreateFormFile("file", "Photo.jpg") + if err != nil { + t.Fatal(err) + } + _, _ = part.Write([]byte("%PDF-1.4 note")) + _ = mw.Close() + mreq, err := http.NewRequest(http.MethodPost, ippURL(s, "/api/stamp?format=zip"), &buf) + if err != nil { + t.Fatal(err) + } + mreq.Header.Set("Content-Type", mw.FormDataContentType()) + mres, err := http.DefaultClient.Do(mreq) + if err != nil { + t.Fatal(err) + } + zbody, _ := io.ReadAll(mres.Body) + mres.Body.Close() + if mres.StatusCode != http.StatusOK || mres.Header.Get("Content-Type") != "application/zip" || mres.Header.Get("X-Verae-Receipt-Id") != "QmabStamp" { + t.Fatalf("zip %d %s %q", mres.StatusCode, mres.Header.Get("Content-Type"), zbody) + } + zr, err := zip.NewReader(bytes.NewReader(zbody), int64(len(zbody))) + if err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, f := range zr.File { + names[f.Name] = true + } + for _, name := range []string{"original.pdf", "certificate.pdf", "receipt.json"} { + if !names[name] { + t.Fatalf("zip names %+v", names) + } + } +} + +func TestStampAPIUsesPINAndNetwork(t *testing.T) { + cfg, path := testConfig(t) + s, err := Start(cfg, path, localStamp(), &fakeCtrl{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + if err := saveSecrets(s.secretsFile(), Secrets{ReceiptsPIN: "2468"}); err != nil { + t.Fatal(err) + } + res, err := http.Post(ippURL(s, "/api/stamp"), "application/pdf", strings.NewReader("%PDF-1.4")) + if err != nil { + t.Fatal(err) + } + locked, _ := io.ReadAll(res.Body) + res.Body.Close() + if res.StatusCode != http.StatusUnauthorized || bytes.Contains(locked, []byte("%PDF")) { + t.Fatalf("%d %q", res.StatusCode, locked) + } + ok, err := http.Post(ippURL(s, "/api/stamp?access=2468"), "application/pdf", strings.NewReader("%PDF-1.4")) + if err != nil { + t.Fatal(err) + } + got, _ := io.ReadAll(ok.Body) + ok.Body.Close() + if ok.StatusCode != http.StatusOK || !bytes.HasPrefix(got, []byte("%PDF-cert")) { + t.Fatalf("access %d %q", ok.StatusCode, got) + } + req, err := http.NewRequest(http.MethodPost, ippURL(s, "/api/stamp"), strings.NewReader("%PDF-1.4")) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/pdf") + req.Header.Set("X-Verae-Access", "2468") + hres, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + hbody, _ := io.ReadAll(hres.Body) + hres.Body.Close() + if hres.StatusCode != http.StatusOK || !bytes.HasPrefix(hbody, []byte("%PDF-cert")) { + t.Fatalf("header %d %q", hres.StatusCode, hbody) + } + + prev := currentNetworkPublic + currentNetworkPublic = func() bool { return true } + t.Cleanup(func() { currentNetworkPublic = prev }) + blocked, err := http.Post(ippURL(s, "/api/stamp?access=2468"), "application/pdf", strings.NewReader("%PDF-1.4")) + if err != nil { + t.Fatal(err) + } + bb, _ := io.ReadAll(blocked.Body) + blocked.Body.Close() + if blocked.StatusCode != http.StatusForbidden || bytes.Contains(bb, []byte("%PDF-cert")) { + t.Fatalf("public %d %q", blocked.StatusCode, bb) + } +} + +func TestVeraeAnnounceStaysOffIPPRecords(t *testing.T) { + svc := sampleService() + svc.SubtypesOnly = true + svc.StampURL = "http://192.0.2.10:631/api/stamp" + if err := assertSubtypePTRsOnly(svc.Announce(false), "Verae Timestamp"); err != nil { + t.Fatal(err) + } + ans, extra, _, err := parsePacket(svc.VeraeAnnounce(false)) + if err != nil { + t.Fatal(err) + } + var sawPTR, sawSRV, sawTXT, sawAddr bool + for _, rr := range append(ans, extra...) { + switch rr.typ { + case dnsTypePTR: + if rr.name == "_verae._tcp.local" && rr.ptr == "verae timestamp._verae._tcp.local" { + sawPTR = true + } + if rr.name == "_ipp._tcp.local" { + t.Fatal("verae announcement included _ipp._tcp") + } + case dnsTypeSRV: + sawSRV = rr.port == 631 + case dnsTypeTXT: + if strings.Contains(strings.Join(rr.txt, "\n"), "path=/api/stamp") && strings.Contains(strings.Join(rr.txt, "\n"), "http://192.0.2.10:631/api/stamp") { + sawTXT = true + } + case dnsTypeA, dnsTypeAAAA: + sawAddr = true + } + } + if !sawPTR || !sawSRV || !sawTXT || sawAddr { + t.Fatalf("ptr %v srv %v txt %v addr %v", sawPTR, sawSRV, sawTXT, sawAddr) + } + if svc.ReplyVerae(Query(1, []string{"_verae", "_tcp", "local"}, dnsTypePTR)) == nil { + t.Fatal("did not answer _verae._tcp") + } + full := sampleService() + full.StampURL = svc.StampURL + _, extra, _, err = parsePacket(full.VeraeAnnounce(false)) + if err != nil { + t.Fatal(err) + } + var sawA bool + for _, rr := range extra { + if rr.typ == dnsTypeA { + sawA = true + } + } + if !sawA { + t.Fatal("full _verae announcement omitted the address") + } +} + +func localStamp() StampFunc { + return func(pdf, title, user string) (job.Result, error) { + dir := filepath.Dir(pdf) + cert := filepath.Join(dir, "certificate.pdf") + js := filepath.Join(dir, title+".verae.json") + if err := os.WriteFile(cert, []byte("%PDF-cert"), 0o600); err != nil { + return job.Result{}, err + } + if err := os.WriteFile(js, []byte("{\"receipt_id\":\"QmabStamp\"}\n"), 0o600); err != nil { + return job.Result{}, err + } + return job.Result{ + SHA256: strings.Repeat("ab", 32), ReceiptID: "QmabStamp", + CertificatePDF: cert, OriginalPDF: pdf, ReceiptJSON: js, + }, nil + } +} + +func httpStatus(t *testing.T, url string) int { + t.Helper() + res, err := http.Get(url) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + _, _ = io.Copy(io.Discard, res.Body) + return res.StatusCode +}