The menu-bar app, not the root daemon, opens the AirDrop share sheet for each finished job when the setting is on. The unsigned arm64 package installs the daemon and LaunchAgent, and Darwin DNSServiceRegister advertises the _universal and _print subtypes. Co-authored-by: George Lambert <marchon@gmail.com>
259 lines
6.8 KiB
Go
259 lines
6.8 KiB
Go
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.afterJob)
|
|
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
|
|
}
|