From dc37fa76dc243e632ffe26822d51ba670acb5815 Mon Sep 17 00:00:00 2001 From: "Grok Bot (for George)" Date: Sat, 3 Oct 2026 18:49:56 -0400 Subject: [PATCH] windows-debug: IPP loopback printer helper, Print-to-PDF fallback, debug installer, diag script --- cmd/verae-print/hide_windows.go | 12 + cmd/verae-print/main.go | 38 +- cmd/verae-print/platform_other.go | 38 ++ cmd/verae-print/platform_windows.go | 62 ++ cmd/verae-print/serve.go | 244 +++++++ go.mod | 1 + go.sum | 2 + internal/auth/auth.go | 7 + internal/job/job.go | 12 +- internal/pdfcert/pdfcert.go | 16 +- internal/veraeapi/client.go | 18 +- internal/vlog/vlog.go | 144 ++++ internal/winprint/ipp.go | 615 ++++++++++++++++++ internal/winprint/ipp_test.go | 88 +++ internal/winprint/watch.go | 108 +++ platform/windows/README-TEST.txt | 71 ++ platform/windows/build-windows-debug.ps1 | 27 + platform/windows/installer-debug.nsi | 128 ++++ platform/windows/scripts/install-printer.ps1 | 154 +++++ .../windows/scripts/uninstall-printer.ps1 | 13 + platform/windows/scripts/verae-diag.ps1 | 74 +++ 21 files changed, 1843 insertions(+), 29 deletions(-) create mode 100644 cmd/verae-print/hide_windows.go create mode 100644 cmd/verae-print/platform_other.go create mode 100644 cmd/verae-print/platform_windows.go create mode 100644 cmd/verae-print/serve.go create mode 100644 internal/vlog/vlog.go create mode 100644 internal/winprint/ipp.go create mode 100644 internal/winprint/ipp_test.go create mode 100644 internal/winprint/watch.go create mode 100644 platform/windows/README-TEST.txt create mode 100644 platform/windows/build-windows-debug.ps1 create mode 100644 platform/windows/installer-debug.nsi create mode 100644 platform/windows/scripts/install-printer.ps1 create mode 100644 platform/windows/scripts/uninstall-printer.ps1 create mode 100644 platform/windows/scripts/verae-diag.ps1 diff --git a/cmd/verae-print/hide_windows.go b/cmd/verae-print/hide_windows.go new file mode 100644 index 0000000..28de07b --- /dev/null +++ b/cmd/verae-print/hide_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package main + +import ( + "os/exec" + "syscall" +) + +func hideWindow(c *exec.Cmd) { + c.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW +} diff --git a/cmd/verae-print/main.go b/cmd/verae-print/main.go index 07dce1b..ca971b2 100644 --- a/cmd/verae-print/main.go +++ b/cmd/verae-print/main.go @@ -6,14 +6,20 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog" ) func main() { + // verae-printhelper.exe (GUI subsystem, no console) defaults to "serve". + if len(os.Args) < 2 && isHelperExe() { + os.Args = append(os.Args, "serve") + } if len(os.Args) < 2 { usage() os.Exit(2) @@ -21,6 +27,10 @@ func main() { switch os.Args[1] { case "stamp": os.Exit(runStamp(os.Args[2:])) + case "serve": + os.Exit(runServe(os.Args[2:])) + case "ipp-selftest": + os.Exit(runIPPSelftest(os.Args[2:])) case "setup", "login": os.Exit(runSetup()) case "install-macos": @@ -29,6 +39,9 @@ func main() { os.Exit(runInstallPrinter()) case "version", "-version", "--version": fmt.Println(job.HelperVersion) + if len(os.Args) > 2 { + fmt.Println("logs:", vlog.Dir()) + } case "help", "-h", "--help": usage() default: @@ -50,8 +63,13 @@ Usage: verae-print stamp [--channel=verae|zapier] [--out-dir DIR] [--name STEM] [--open] verae-print install-printer # Destination list: Verae Timestamping (recommended) verae-print install-macos # also install PDF Services menu items (legacy) + verae-print serve [--addr 127.0.0.1:8631] [--no-ipp] [--no-watch] [--debug] + # Windows print helper: IPP loopback printer + fallback watcher + verae-print ipp-selftest [--addr 127.0.0.1:8631] # probe the helper like Windows does verae-print version +Debug: set VERAE_DEBUG=1 (or --debug) for full IPP request dumps. Logs: see "verae-print version -v". + First install opens https://customer.verae.ai/portal/ so you can create an account and mint an xVerae- signing key (Security tab). The key is stored in Keychain. @@ -87,12 +105,20 @@ func runStamp(args []string) int { name := fs.String("name", "", "document stem override") openFolder := fs.Bool("open", false, "open output folder on success (macOS)") skipSetup := fs.Bool("skip-setup", false, "do not open first-run account dialog") + jobFlag := fs.String("job-id", "", "debug job id (for log correlation)") _ = fs.Parse(args) if fs.NArg() < 1 { fmt.Fprintln(os.Stderr, "stamp: PDF path required") return 2 } pdfPath := fs.Arg(0) + jobID := *jobFlag + if jobID == "" { + jobID = vlog.NewJobID("cli") + } + vlog.Init("cli", false) + lj := vlog.Job(jobID) + lj.Printf("stamp start pdf=%q channel=%s out=%q name=%q", pdfPath, *channel, *outDir, *name) ch, err := auth.ParseChannel(*channel) if err != nil { @@ -139,15 +165,17 @@ func runStamp(args []string) int { Client: client, }) if err != nil { + lj.Errorf("stamp failed: %v", err) fmt.Fprintf(os.Stderr, "stamp failed: %v\n", err) return 1 } + lj.Printf("stamp ok receipt=%s sha256=%s cert=%q", res.ReceiptID, res.SHA256, res.CertificatePDF) fmt.Printf("Saved:\n %s\n %s\n %s\nReceipt: %s\nSHA-256: %s\nChannel: %s\n", res.OriginalPDF, res.CertificatePDF, res.ReceiptJSON, res.ReceiptID, res.SHA256, res.Channel) if *openFolder { - _ = exec.Command("open", filepath.Dir(res.OriginalPDF)).Run() + openFolderPath(filepath.Dir(res.OriginalPDF)) } // macOS notification (best-effort) notify(fmt.Sprintf("Verae timestamp %s", res.ReceiptID), res.CertificatePDF) @@ -155,6 +183,9 @@ func runStamp(args []string) int { } func runInstallPrinter() int { + if runtime.GOOS == "windows" { + return runInstallPrinterWindows() + } script := filepath.Join(repoRootHint(), "platform", "macos", "ipp", "install-ipp-printer.sh") if _, err := os.Stat(script); err != nil { // fall back next to executable @@ -276,11 +307,6 @@ fi return 0 } -func notify(title, subtitle string) { - script := fmt.Sprintf(`display notification %q with title "Verae Timestamp" subtitle %q`, subtitle, title) - _ = exec.Command("osascript", "-e", script).Run() -} - func envOr(k, def string) string { if v := os.Getenv(k); v != "" { return v diff --git a/cmd/verae-print/platform_other.go b/cmd/verae-print/platform_other.go new file mode 100644 index 0000000..c0cf56c --- /dev/null +++ b/cmd/verae-print/platform_other.go @@ -0,0 +1,38 @@ +//go:build !windows + +package main + +import ( + "fmt" + "os/exec" + "runtime" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog" +) + +func notify(title, subtitle string) { + if runtime.GOOS != "darwin" { + return + } + script := fmt.Sprintf(`display notification %q with title "Verae Timestamp" subtitle %q`, subtitle, title) + _ = exec.Command("osascript", "-e", script).Run() +} + +func notifyUser(title, body string) { + vlog.Printf("notify: %s — %s", title, body) + notify(title, body) +} + +func openFolderPath(p string) { + if runtime.GOOS == "darwin" { + _ = exec.Command("open", p).Run() + return + } + _ = exec.Command("xdg-open", p).Start() +} + +func launchSetupUI() { vlog.Printf("setup: no signing key — run: verae-print setup") } + +func runInstallPrinterWindows() int { return 1 } + +func debugFromRegistry() bool { return false } diff --git a/cmd/verae-print/platform_windows.go b/cmd/verae-print/platform_windows.go new file mode 100644 index 0000000..cd2b45b --- /dev/null +++ b/cmd/verae-print/platform_windows.go @@ -0,0 +1,62 @@ +//go:build windows + +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog" +) + +func exeDir() string { + exe, err := os.Executable() + if err != nil { + return "." + } + return filepath.Dir(exe) +} + +// notifyUser: best-effort balloon via PowerShell (no extra deps). Always logs. +func notifyUser(title, body string) { + vlog.Printf("notify: %s — %s", title, body) + if os.Getenv("VERAE_NO_NOTIFY") == "1" { + return + } + esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") } + ps := fmt.Sprintf(`Add-Type -AssemblyName System.Windows.Forms;$n=New-Object System.Windows.Forms.NotifyIcon;$n.Icon=[System.Drawing.SystemIcons]::Information;$n.Visible=$true;$n.ShowBalloonTip(8000,'%s','%s',[System.Windows.Forms.ToolTipIcon]::Info);Start-Sleep 9;$n.Dispose()`, esc(title), esc(body)) + cmd := exec.Command("powershell.exe", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps) + hideWindow(cmd) + _ = cmd.Start() +} + +func notify(title, subtitle string) { notifyUser(title, subtitle) } + +func openFolderPath(p string) { _ = exec.Command("explorer.exe", p).Start() } + +// launchSetupUI opens a console running "verae-print setup" for the user. +func launchSetupUI() { + cli := filepath.Join(exeDir(), "verae-print.exe") + vlog.Printf("setup: no signing key — launching %q setup in a new console", cli) + _ = exec.Command("cmd.exe", "/c", "start", "Verae Print Stamp setup", cli, "setup").Start() +} + +func runInstallPrinterWindows() int { + script := filepath.Join(exeDir(), "..", "scripts", "install-printer.ps1") + ps := filepath.Join(os.Getenv("WINDIR"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe") + cmd := exec.Command(ps, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script) + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "install-printer failed: %v (run elevated; see %s)\n", err, vlog.Dir()) + return 1 + } + return 0 +} + +func debugFromRegistry() bool { + out, err := exec.Command("reg.exe", "query", `HKLM\Software\Verae\PrintStamp`, "/v", "Debug").Output() + return err == nil && strings.Contains(string(out), "0x1") +} diff --git a/cmd/verae-print/serve.go b/cmd/verae-print/serve.go new file mode 100644 index 0000000..564778f --- /dev/null +++ b/cmd/verae-print/serve.go @@ -0,0 +1,244 @@ +package main + +import ( + "bytes" + "flag" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "github.com/OpenPrinting/goipp" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/winprint" +) + +func isHelperExe() bool { + exe, err := os.Executable() + if err != nil { + return false + } + return strings.Contains(strings.ToLower(filepath.Base(exe)), "printhelper") +} + +// fallbackDropDir is the folder the "Verae TimeStamp PDF (Local Port)" queue writes into. +func fallbackDropDir() string { + if v := os.Getenv("VERAE_FALLBACK_DIR"); v != "" { + return v + } + if runtime.GOOS == "windows" { + pd := os.Getenv("ProgramData") + if pd == "" { + pd = `C:\ProgramData` + } + return filepath.Join(pd, "Verae", "spool", "fallback") + } + return filepath.Join(os.TempDir(), "verae-fallback") +} + +func userWorkDir() string { + base, err := os.UserCacheDir() // %LOCALAPPDATA% on Windows + if err != nil { + base = os.TempDir() + } + return filepath.Join(base, "Verae", "PrintStamp", "spool") +} + +func runServe(args []string) int { + fs := flag.NewFlagSet("serve", flag.ExitOnError) + addr := fs.String("addr", envOr("VERAE_IPP_ADDR", "127.0.0.1:8631"), "IPP listen address") + noIPP := fs.Bool("no-ipp", false, "disable IPP loopback printer") + noWatch := fs.Bool("no-watch", false, "disable fallback folder watcher") + debug := fs.Bool("debug", false, "verbose logging (also VERAE_DEBUG=1)") + outDir := fs.String("out-dir", "", "output dir (default Documents\\Verae Timestamps)") + _ = fs.Parse(args) + + vlog.SetDebug(*debug || debugFromRegistry()) + logPath := vlog.Init("helper", !isHelperExe()) + host, _ := os.Hostname() + home, _ := os.UserHomeDir() + exe, _ := os.Executable() + vlog.Printf("==== verae-print helper %s start exe=%q os=%s/%s host=%s home=%q log=%q debug=%v", + job.HelperVersion, exe, runtime.GOOS, runtime.GOARCH, host, home, logPath, vlog.DebugEnabled()) + logAuthState() + + proc := makeProcessor(*outDir) + errc := make(chan error, 2) + if !*noIPP { + srv := &winprint.IPPServer{Addr: *addr, Name: "Verae TimeStamp PDF", SpoolDir: filepath.Join(userWorkDir(), "ipp"), Process: proc} + go func() { errc <- srv.ListenAndServe() }() + } + if !*noWatch { + w := &winprint.Watcher{Dir: fallbackDropDir(), WorkDir: filepath.Join(userWorkDir(), "fallback"), Process: proc} + go w.Run() + } + if *noIPP { + select {} + } + err := <-errc + vlog.Errorf("helper exiting: %v", err) + fmt.Fprintln(os.Stderr, err) + return 1 +} + +func logAuthState() { + _, xerr := auth.LoadXVeraeToken() + _, jerr := auth.LoadVeraeJWT() + vlog.Printf("auth: preferred=%s xVerae-token=%v jwt(auth.properties)=%v", auth.PreferredChannel(), errState(xerr), errState(jerr)) +} + +func errState(err error) string { + if err == nil { + return "present" + } + return "missing (" + err.Error() + ")" +} + +var setupOnce sync.Once + +func makeProcessor(outDir string) winprint.Processor { + return func(j winprint.Job) error { + lj := vlog.Job(j.ID) + lj.Printf("process: source=%s title=%q user=%q format=%s bytes=%d path=%q", j.Source, j.Title, j.User, j.Format, j.Bytes, j.Path) + if !strings.Contains(j.Format, "pdf") && sniffPDF(j.Path) == false { + return fmt.Errorf("received %s (not PDF); raster→PDF conversion not implemented in this debug build — file kept at %s. Re-test with VERAE_IPP_FORMATS=application/pdf", j.Format, j.Path) + } + ch := auth.PreferredChannel() + client := &veraeapi.Client{Channel: ch} + switch ch { + case auth.ChannelZapier: + tok, err := auth.LoadXVeraeToken() + if err != nil { + if jwt, jerr := auth.LoadVeraeJWT(); jerr == nil { + lj.Printf("process: no xVerae- token, falling back to verae channel (auth.properties)") + ch, client.Channel, client.VeraeToken = auth.ChannelVerae, auth.ChannelVerae, jwt + break + } + setupOnce.Do(launchSetupUI) + return fmt.Errorf("no signing key: %v — run 'verae-print setup' then reprint (PDF kept at %s)", err, j.Path) + } + client.XVeraeToken = tok + default: + tok, err := auth.LoadVeraeJWT() + if err != nil { + return fmt.Errorf("verae channel: %v", err) + } + client.VeraeToken = tok + } + client.HTTPDo = loggingDo(lj) + name := safeStem(j.Title) + start := time.Now() + res, err := job.Run(job.Options{SourcePDF: j.Path, OutDir: outDir, DocumentName: name + ".pdf", Channel: ch, Client: client}) + if err != nil { + notifyUser("Verae timestamp FAILED", err.Error()) + return err + } + lj.Printf("process: OK in %s receipt=%s sha256=%s\n original=%q\n cert=%q\n json=%q", + time.Since(start).Round(time.Millisecond), res.ReceiptID, res.SHA256, res.OriginalPDF, res.CertificatePDF, res.ReceiptJSON) + _ = os.Remove(j.Path) + notifyUser(fmt.Sprintf("Verae timestamp %s", res.ReceiptID), res.CertificatePDF) + return nil + } +} + +func loggingDo(lj vlog.Job) func(*http.Request) (*http.Response, error) { + return func(r *http.Request) (*http.Response, error) { + start := time.Now() + res, err := http.DefaultClient.Do(r) + if err != nil { + lj.Errorf("api: %s %s → error after %s: %v", r.Method, r.URL.Redacted(), time.Since(start).Round(time.Millisecond), err) + return res, err + } + lj.Printf("api: %s %s → HTTP %d in %s", r.Method, r.URL.Redacted(), res.StatusCode, time.Since(start).Round(time.Millisecond)) + return res, nil + } +} + +func sniffPDF(p string) bool { + f, err := os.Open(p) + if err != nil { + return false + } + defer f.Close() + b := make([]byte, 5) + n, _ := f.Read(b) + return n == 5 && string(b) == "%PDF-" +} + +func safeStem(title string) string { + t := filepath.Base(strings.ReplaceAll(title, `\`, "/")) + if ext := filepath.Ext(t); ext != "" && len(ext) <= 5 { + t = strings.TrimSuffix(t, ext) + } + var b strings.Builder + for _, r := range t { + switch { + case r == ' ' || r == '-' || r == '_' || r == '.' || (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z'): + b.WriteRune(r) + } + if b.Len() >= 80 { + break + } + } + s := strings.TrimSpace(b.String()) + if s == "" { + s = "document-" + time.Now().Format("20060102-150405") + } + return s +} + +// runIPPSelftest sends Get-Printer-Attributes + Validate-Job to the helper. +func runIPPSelftest(args []string) int { + fs := flag.NewFlagSet("ipp-selftest", flag.ExitOnError) + addr := fs.String("addr", "127.0.0.1:8631", "helper address") + _ = fs.Parse(args) + uri := "ipp://" + *addr + "/ipp/print" + ok := true + for _, op := range []goipp.Op{goipp.OpGetPrinterAttributes, goipp.OpValidateJob} { + m := goipp.NewRequest(goipp.DefaultVersion, op, 1) + m.Operation.Add(goipp.MakeAttribute("attributes-charset", goipp.TagCharset, goipp.String("utf-8"))) + m.Operation.Add(goipp.MakeAttribute("attributes-natural-language", goipp.TagLanguage, goipp.String("en"))) + m.Operation.Add(goipp.MakeAttribute("printer-uri", goipp.TagURI, goipp.String(uri))) + if op == goipp.OpValidateJob { + m.Operation.Add(goipp.MakeAttribute("document-format", goipp.TagMimeType, goipp.String("application/pdf"))) + } + b, _ := m.EncodeBytes() + res, err := http.Post("http://"+*addr+"/ipp/print", goipp.ContentType, bytes.NewReader(b)) + if err != nil { + fmt.Printf("FAIL %s: %v\n", op, err) + return 1 + } + var out goipp.Message + err = out.Decode(res.Body) + res.Body.Close() + if err != nil { + fmt.Printf("FAIL %s: decode: %v\n", op, err) + return 1 + } + st := goipp.Status(out.Code) + fmt.Printf("%s → %s (%d printer attrs)\n", op, st, len(out.Printer)) + if st != goipp.StatusOk { + ok = false + } + if op == goipp.OpGetPrinterAttributes { + for _, a := range out.Printer { + if a.Name == "document-format-supported" || a.Name == "printer-name" || a.Name == "printer-uri-supported" { + fmt.Printf(" %s = %s\n", a.Name, a.Values) + } + } + } + } + if !ok { + return 1 + } + fmt.Println("IPP helper OK:", uri) + return 0 +} diff --git a/go.mod b/go.mod index 77c9992..9c22511 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/VeraeLLC/PrintToVeraeTimeStamp go 1.26.1 require ( + github.com/OpenPrinting/goipp v1.2.0 // indirect github.com/phpdave11/gofpdf v1.4.2 // indirect github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect ) diff --git a/go.sum b/go.sum index ec56742..bad4a04 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/OpenPrinting/goipp v1.2.0 h1:qeB3GyhhB7NM16quwyl51CsTEHFb9chZXAprt+00NKo= +github.com/OpenPrinting/goipp v1.2.0/go.mod h1:ot2iw+QF7fVLaX+55JUNlF5YSDNiXVo2LRAv21iGcQI= github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index f696296..c6ffa9c 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" ) @@ -83,6 +84,9 @@ func LoadXVeraeToken() (string, error) { } } // macOS Keychain (generic password, account optional) + if runtime.GOOS != "darwin" { + return "", fmt.Errorf("no xVerae- token (set XVERAE_TOKEN or create %s)", filepath.Join(home, ".verae", "xverae-token")) + } out, err := exec.Command("security", "find-generic-password", "-s", "verae.xVeraeToken", "-w").Output() if err == nil { t := strings.TrimSpace(string(out)) @@ -144,6 +148,9 @@ func SaveXVeraeToken(token string) error { return err } // Keychain (best-effort): delete old, add new + if runtime.GOOS != "darwin" { + return nil + } account := os.Getenv("USER") if account == "" { account = "verae" diff --git a/internal/job/job.go b/internal/job/job.go index 47de8a5..c460366 100644 --- a/internal/job/job.go +++ b/internal/job/job.go @@ -19,12 +19,12 @@ const HelperVersion = "0.1.2" // Result paths written for one print job. type Result struct { - OriginalPDF string - CertificatePDF string - ReceiptJSON string - ReceiptID string - SHA256 string - Channel auth.Channel + OriginalPDF string + CertificatePDF string + ReceiptJSON string + ReceiptID string + SHA256 string + Channel auth.Channel } // Options for processing a captured print PDF. diff --git a/internal/pdfcert/pdfcert.go b/internal/pdfcert/pdfcert.go index 571c5c8..655062c 100644 --- a/internal/pdfcert/pdfcert.go +++ b/internal/pdfcert/pdfcert.go @@ -73,14 +73,14 @@ func Build(in Input) ([]byte, error) { } row := map[string]any{ - "file": docName, - "sha256": in.SHA256, - "scope": scope, - "receipt_id": in.ReceiptID, - "created_at": created, - "merkle": merkle, - "lookup_url": lookup, - "size_bytes": in.ByteLength, + "file": docName, + "sha256": in.SHA256, + "scope": scope, + "receipt_id": in.ReceiptID, + "created_at": created, + "merkle": merkle, + "lookup_url": lookup, + "size_bytes": in.ByteLength, } if b, err := buildWithNode(row); err == nil && len(b) > 1000 { diff --git a/internal/veraeapi/client.go b/internal/veraeapi/client.go index 789803a..9853c05 100644 --- a/internal/veraeapi/client.go +++ b/internal/veraeapi/client.go @@ -22,15 +22,15 @@ const ( // Receipt is the stamp result used by the print helper. type Receipt struct { - ReceiptID string - SHA256 string - CreatedAt string - Merkle string - Channel auth.Channel - BaseURL string - PDF []byte // registration / certificate PDF when available - PDFFilename string - RawResponse json.RawMessage + ReceiptID string + SHA256 string + CreatedAt string + Merkle string + Channel auth.Channel + BaseURL string + PDF []byte // registration / certificate PDF when available + PDFFilename string + RawResponse json.RawMessage } // Client stamps digests on Verae Time or the Zapier edge. diff --git a/internal/vlog/vlog.go b/internal/vlog/vlog.go new file mode 100644 index 0000000..eb517ae --- /dev/null +++ b/internal/vlog/vlog.go @@ -0,0 +1,144 @@ +// Package vlog is the verbose debug logger used by the CLI, the print helper +// and the IPP/watch servers. On Windows logs go to %ProgramData%\Verae\logs +// (shared, ACL'd by the installer so Users can write); elsewhere to the user +// cache dir. Every print job carries a job ID so one job can be followed from +// IPP receive → save → hash → API → certificate. +package vlog + +import ( + "fmt" + "io" + "os" + "os/user" + "path/filepath" + "runtime" + "strings" + "sync" + "sync/atomic" + "time" +) + +var ( + mu sync.Mutex + out io.Writer = os.Stderr + file *os.File + comp = "verae" + jobSeq uint64 + debugOn = os.Getenv("VERAE_DEBUG") == "1" +) + +// Dir returns the log directory for this platform. +func Dir() string { + if runtime.GOOS == "windows" { + pd := os.Getenv("ProgramData") + if pd == "" { + pd = `C:\ProgramData` + } + return filepath.Join(pd, "Verae", "logs") + } + if d, err := os.UserCacheDir(); err == nil { + return filepath.Join(d, "Verae", "logs") + } + return filepath.Join(os.TempDir(), "verae-logs") +} + +func userTag() string { + if u, err := user.Current(); err == nil { + n := u.Username + if i := strings.LastIndexAny(n, `\/`); i >= 0 { + n = n[i+1:] + } + return sanitize(n) + } + return "unknown" +} + +func sanitize(s string) string { + var b strings.Builder + for _, r := range s { + if r < 128 && (r == '-' || r == '_' || r == '.' || (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')) { + b.WriteRune(r) + } + } + if b.Len() == 0 { + return "x" + } + return b.String() +} + +// Init opens /-.log (append). alsoStderr tees output. +// Falls back to %TEMP% if the shared dir is not writable. Returns the path. +func Init(component string, alsoStderr bool) string { + mu.Lock() + defer mu.Unlock() + comp = component + name := fmt.Sprintf("%s-%s.log", component, userTag()) + var path string + for _, d := range []string{Dir(), filepath.Join(os.TempDir(), "Verae", "logs")} { + if err := os.MkdirAll(d, 0o775); err != nil { + continue + } + p := filepath.Join(d, name) + f, err := os.OpenFile(p, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o664) + if err != nil { + continue + } + file, path = f, p + break + } + switch { + case file != nil && alsoStderr: + out = io.MultiWriter(file, os.Stderr) + case file != nil: + out = file + default: + out = os.Stderr + } + return path +} + +// SetDebug enables verbose (Debugf) output. +func SetDebug(on bool) { debugOn = on || debugOn } + +// DebugEnabled reports whether verbose output is on. +func DebugEnabled() bool { return debugOn } + +func write(level, job, msg string) { + mu.Lock() + defer mu.Unlock() + ts := time.Now().Format("2006-01-02T15:04:05.000-07:00") + if job == "" { + job = "-" + } + fmt.Fprintf(out, "%s %s %s pid=%d job=%s %s\n", ts, level, comp, os.Getpid(), job, strings.TrimRight(msg, "\n")) +} + +// Printf logs at INFO with no job. +func Printf(format string, a ...any) { write("INFO", "", fmt.Sprintf(format, a...)) } + +// Errorf logs at ERROR with no job. +func Errorf(format string, a ...any) { write("ERROR", "", fmt.Sprintf(format, a...)) } + +// Debugf logs at DEBUG when enabled. +func Debugf(format string, a ...any) { + if debugOn { + write("DEBUG", "", fmt.Sprintf(format, a...)) + } +} + +// NewJobID returns J--. +func NewJobID(src string) string { + n := atomic.AddUint64(&jobSeq, 1) + return fmt.Sprintf("J%s-%s-%d", time.Now().Format("20060102-150405"), sanitize(src), n) +} + +// Job is a logger bound to a job ID. +type Job string + +func (j Job) Printf(format string, a ...any) { write("INFO", string(j), fmt.Sprintf(format, a...)) } +func (j Job) Errorf(format string, a ...any) { write("ERROR", string(j), fmt.Sprintf(format, a...)) } +func (j Job) Debugf(format string, a ...any) { + if debugOn { + write("DEBUG", string(j), fmt.Sprintf(format, a...)) + } +} diff --git a/internal/winprint/ipp.go b/internal/winprint/ipp.go new file mode 100644 index 0000000..b927f2f --- /dev/null +++ b/internal/winprint/ipp.go @@ -0,0 +1,615 @@ +// Package winprint implements the Windows "Verae TimeStamp PDF" virtual +// printer receivers: a minimal IPP Everywhere loopback server (primary path, +// used with the inbox Microsoft IPP Class Driver) and a folder watcher for the +// Microsoft Print To PDF + Local file port fallback. Pure Go, no cgo, so it +// cross-compiles from macOS and is unit-testable there. +package winprint + +import ( + "bytes" + "crypto/sha1" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/OpenPrinting/goipp" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog" +) + +// Job is a captured print job handed to the processor. +type Job struct { + ID string // vlog job id + IPPJobID int + Source string // "ipp" or "watch" + Title string + User string + Format string + Path string // spooled document on disk + Bytes int64 + Received time.Time +} + +// Processor turns a captured job into a stamp. Return error to mark aborted. +type Processor func(Job) error + +// IPPServer is a tiny IPP/2.0 printer. +type IPPServer struct { + Addr string // e.g. 127.0.0.1:8631 + Name string // printer-name / info + SpoolDir string // where documents are written + Formats []string // document-format-supported + Process Processor + started time.Time + mu sync.Mutex + nextID int + jobs map[int]*ippJob + queue chan Job +} + +type ippJob struct { + id int + logID string + name string + user string + format string + state int // 3 pending,5 processing,7 canceled,8 aborted,9 completed + reasons string + message string + created time.Time + done time.Time +} + +const ( + jsPending = 3 + jsProcessing = 5 + jsCanceled = 7 + jsAborted = 8 + jsCompleted = 9 +) + +// DefaultFormats are advertised unless VERAE_IPP_FORMATS overrides. +var DefaultFormats = []string{"application/pdf", "image/pwg-raster"} + +// ListenAndServe binds and serves until error. Jobs are processed serially. +func (s *IPPServer) ListenAndServe() error { + if s.Addr == "" { + s.Addr = "127.0.0.1:8631" + } + if s.Name == "" { + s.Name = "Verae TimeStamp PDF" + } + if len(s.Formats) == 0 { + s.Formats = DefaultFormats + if env := strings.TrimSpace(os.Getenv("VERAE_IPP_FORMATS")); env != "" { + s.Formats = strings.Split(env, ",") + } + } + if err := os.MkdirAll(s.SpoolDir, 0o700); err != nil { + return fmt.Errorf("spool dir: %w", err) + } + s.started = time.Now() + s.jobs = map[int]*ippJob{} + s.queue = make(chan Job, 64) + go s.worker() + ln, err := net.Listen("tcp", s.Addr) + if err != nil { + return fmt.Errorf("listen %s: %w (is another helper / user session already running?)", s.Addr, err) + } + vlog.Printf("ipp: listening on http://%s/ipp/print formats=%v spool=%s", s.Addr, s.Formats, s.SpoolDir) + srv := &http.Server{Handler: s, ReadHeaderTimeout: 30 * time.Second} + return srv.Serve(ln) +} + +func (s *IPPServer) worker() { + for j := range s.queue { + lj := vlog.Job(j.ID) + s.setState(j.IPPJobID, jsProcessing, "job-printing", "stamping") + start := time.Now() + err := func() (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("panic: %v", r) + } + }() + if s.Process == nil { + return fmt.Errorf("no processor configured") + } + return s.Process(j) + }() + if err != nil { + lj.Errorf("ipp: job %d FAILED after %s: %v", j.IPPJobID, time.Since(start).Round(time.Millisecond), err) + s.setState(j.IPPJobID, jsAborted, "aborted-by-system", truncate(err.Error(), 200)) + continue + } + lj.Printf("ipp: job %d completed in %s", j.IPPJobID, time.Since(start).Round(time.Millisecond)) + s.setState(j.IPPJobID, jsCompleted, "job-completed-successfully", "timestamped") + } +} + +func (s *IPPServer) setState(id, st int, reasons, msg string) { + s.mu.Lock() + defer s.mu.Unlock() + if j := s.jobs[id]; j != nil { + j.state, j.reasons, j.message = st, reasons, msg + if st >= jsCanceled { + j.done = time.Now() + } + } +} + +// ServeHTTP handles IPP POSTs (any path) and a plain-text GET status page. +func (s *IPPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + vlog.Debugf("http: %s %s from=%s ct=%q ua=%q len=%d te=%v", r.Method, r.URL.Path, r.RemoteAddr, r.Header.Get("Content-Type"), r.UserAgent(), r.ContentLength, r.TransferEncoding) + if r.Method == http.MethodGet || r.Method == http.MethodHead { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + s.mu.Lock() + n := len(s.jobs) + s.mu.Unlock() + fmt.Fprintf(w, "Verae TimeStamp PDF IPP helper\nprinter-uri: ipp://%s/ipp/print\nformats: %s\njobs-seen: %d\nup-since: %s\npid: %d\n", + s.Addr, strings.Join(s.Formats, ","), n, s.started.Format(time.RFC3339), os.Getpid()) + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req goipp.Message + if err := req.Decode(r.Body); err != nil { + vlog.Errorf("ipp: decode error from %s: %v", r.RemoteAddr, err) + http.Error(w, "bad ipp", http.StatusBadRequest) + return + } + op := goipp.Op(req.Code) + if vlog.DebugEnabled() { + var b bytes.Buffer + req.Print(&b, true) + vlog.Debugf("ipp: request %s id=%d\n%s", op, req.RequestID, b.String()) + } else { + vlog.Printf("ipp: request %s id=%d user=%q", op, req.RequestID, attrString(req.Operation, "requesting-user-name")) + } + resp := s.dispatch(&req, r) + if vlog.DebugEnabled() { + var b bytes.Buffer + resp.Print(&b, false) + vlog.Debugf("ipp: response %s id=%d\n%s", goipp.Status(resp.Code), req.RequestID, b.String()) + } + // drain anything left so keep-alive works + _, _ = io.Copy(io.Discard, r.Body) + w.Header().Set("Content-Type", goipp.ContentType) + if err := resp.Encode(w); err != nil { + vlog.Errorf("ipp: encode response: %v", err) + } +} + +func (s *IPPServer) newResp(req *goipp.Message, st goipp.Status, msg string) *goipp.Message { + resp := goipp.NewResponse(req.Version, st, req.RequestID) + resp.Operation.Add(goipp.MakeAttribute("attributes-charset", goipp.TagCharset, goipp.String("utf-8"))) + resp.Operation.Add(goipp.MakeAttribute("attributes-natural-language", goipp.TagLanguage, goipp.String("en"))) + if msg != "" { + resp.Operation.Add(goipp.MakeAttribute("status-message", goipp.TagText, goipp.String(msg))) + } + return resp +} + +func (s *IPPServer) dispatch(req *goipp.Message, r *http.Request) *goipp.Message { + switch goipp.Op(req.Code) { + case goipp.OpGetPrinterAttributes: + resp := s.newResp(req, goipp.StatusOk, "") + resp.Printer = s.printerAttrs(req, r) + return resp + case goipp.OpValidateJob: + if st, msg := s.checkFormat(req); st != goipp.StatusOk { + return s.newResp(req, st, msg) + } + return s.newResp(req, goipp.StatusOk, "") + case goipp.OpPrintJob: + if st, msg := s.checkFormat(req); st != goipp.StatusOk { + return s.newResp(req, st, msg) + } + j := s.createJob(req) + return s.receiveDocument(req, r, j) + case goipp.OpCreateJob: + j := s.createJob(req) + resp := s.newResp(req, goipp.StatusOk, "") + resp.Job = s.jobAttrs(j, r) + return resp + case goipp.OpSendDocument: + id := int(attrInt(req.Operation, "job-id")) + s.mu.Lock() + j := s.jobs[id] + s.mu.Unlock() + if j == nil { + return s.newResp(req, goipp.StatusErrorNotFound, "no such job") + } + if f := attrString(req.Operation, "document-format"); f != "" { + j.format = f + } + return s.receiveDocument(req, r, j) + case goipp.OpCloseJob: + return s.newResp(req, goipp.StatusOk, "") + case goipp.OpGetJobAttributes: + id := int(attrInt(req.Operation, "job-id")) + s.mu.Lock() + j := s.jobs[id] + s.mu.Unlock() + if j == nil { + return s.newResp(req, goipp.StatusErrorNotFound, "no such job") + } + resp := s.newResp(req, goipp.StatusOk, "") + resp.Job = s.jobAttrs(j, r) + return resp + case goipp.OpGetJobs: + resp := s.newResp(req, goipp.StatusOk, "") + which := attrString(req.Operation, "which-jobs") + s.mu.Lock() + var groups goipp.Groups + groups.Add(goipp.Group{Tag: goipp.TagOperationGroup, Attrs: resp.Operation}) + for _, j := range s.jobs { + done := j.state >= jsCanceled + if (which == "completed") != done { + continue + } + groups.Add(goipp.Group{Tag: goipp.TagJobGroup, Attrs: s.jobAttrsLocked(j, r)}) + } + s.mu.Unlock() + resp.Groups = groups + return resp + case goipp.OpCancelJob: + id := int(attrInt(req.Operation, "job-id")) + s.setState(id, jsCanceled, "job-canceled-by-user", "canceled") + return s.newResp(req, goipp.StatusOk, "") + default: + vlog.Printf("ipp: unsupported operation %s (0x%04x)", goipp.Op(req.Code), req.Code) + return s.newResp(req, goipp.StatusErrorOperationNotSupported, "operation not supported") + } +} + +func (s *IPPServer) checkFormat(req *goipp.Message) (goipp.Status, string) { + f := attrString(req.Operation, "document-format") + if f == "" || f == "application/octet-stream" { + return goipp.StatusOk, "" + } + for _, x := range s.Formats { + if strings.EqualFold(strings.TrimSpace(x), f) { + return goipp.StatusOk, "" + } + } + vlog.Errorf("ipp: client asked for unsupported document-format %q", f) + return goipp.StatusErrorDocumentFormatNotSupported, "document-format not supported: " + f +} + +func (s *IPPServer) createJob(req *goipp.Message) *ippJob { + s.mu.Lock() + defer s.mu.Unlock() + s.nextID++ + j := &ippJob{ + id: s.nextID, + logID: vlog.NewJobID("ipp"), + name: firstNonEmpty(attrString(req.Operation, "job-name"), attrString(req.Operation, "document-name"), "document"), + user: attrString(req.Operation, "requesting-user-name"), + format: firstNonEmpty(attrString(req.Operation, "document-format"), "application/octet-stream"), + state: jsPending, + reasons: "none", + created: time.Now(), + } + s.jobs[j.id] = j + vlog.Job(j.logID).Printf("ipp: created job %d name=%q user=%q format=%s", j.id, j.name, j.user, j.format) + return j +} + +func (s *IPPServer) receiveDocument(req *goipp.Message, r *http.Request, j *ippJob) *goipp.Message { + lj := vlog.Job(j.logID) + ext := ".bin" + switch { + case strings.Contains(j.format, "pdf"): + ext = ".pdf" + case strings.Contains(j.format, "pwg-raster"): + ext = ".pwg" + case strings.Contains(j.format, "urf"): + ext = ".urf" + case strings.Contains(j.format, "postscript"): + ext = ".ps" + } + path := filepath.Join(s.SpoolDir, fmt.Sprintf("%s%s", j.logID, ext)) + f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + lj.Errorf("ipp: create spool file: %v", err) + s.setState(j.id, jsAborted, "aborted-by-system", err.Error()) + return s.newResp(req, goipp.StatusErrorInternal, "spool failed") + } + n, err := io.Copy(f, r.Body) + cerr := f.Close() + if err == nil { + err = cerr + } + if err != nil { + lj.Errorf("ipp: read document after %d bytes: %v", n, err) + s.setState(j.id, jsAborted, "aborted-by-system", err.Error()) + return s.newResp(req, goipp.StatusErrorInternal, "read failed") + } + magic := sniff(path) + lj.Printf("ipp: received %d bytes format=%s magic=%q → %s", n, j.format, magic, path) + if n == 0 { + s.setState(j.id, jsAborted, "aborted-by-system", "empty document") + resp := s.newResp(req, goipp.StatusOk, "empty document") + resp.Job = s.jobAttrs(j, r) + return resp + } + s.queue <- Job{ + ID: j.logID, IPPJobID: j.id, Source: "ipp", Title: j.name, User: j.user, + Format: j.format, Path: path, Bytes: n, Received: time.Now(), + } + resp := s.newResp(req, goipp.StatusOk, "") + resp.Job = s.jobAttrs(j, r) + return resp +} + +func sniff(path string) string { + f, err := os.Open(path) + if err != nil { + return "" + } + defer f.Close() + b := make([]byte, 8) + n, _ := f.Read(b) + return strings.Map(func(r rune) rune { + if r < 32 || r > 126 { + return '.' + } + return r + }, string(b[:n])) +} + +func (s *IPPServer) printerURI(req *goipp.Message, r *http.Request) string { + if u := attrString(req.Operation, "printer-uri"); u != "" { + return u + } + host := r.Host + if host == "" { + host = s.Addr + } + return "ipp://" + host + "/ipp/print" +} + +func (s *IPPServer) jobAttrs(j *ippJob, r *http.Request) goipp.Attributes { + s.mu.Lock() + defer s.mu.Unlock() + return s.jobAttrsLocked(j, r) +} + +func (s *IPPServer) jobAttrsLocked(j *ippJob, r *http.Request) goipp.Attributes { + host := r.Host + if host == "" { + host = s.Addr + } + var a goipp.Attributes + a.Add(goipp.MakeAttribute("job-id", goipp.TagInteger, goipp.Integer(j.id))) + a.Add(goipp.MakeAttribute("job-uri", goipp.TagURI, goipp.String(fmt.Sprintf("ipp://%s/ipp/print/%d", host, j.id)))) + a.Add(goipp.MakeAttribute("job-printer-uri", goipp.TagURI, goipp.String("ipp://"+host+"/ipp/print"))) + a.Add(goipp.MakeAttribute("job-name", goipp.TagName, goipp.String(j.name))) + a.Add(goipp.MakeAttribute("job-originating-user-name", goipp.TagName, goipp.String(firstNonEmpty(j.user, "anonymous")))) + a.Add(goipp.MakeAttribute("job-state", goipp.TagEnum, goipp.Integer(j.state))) + a.Add(goipp.MakeAttribute("job-state-reasons", goipp.TagKeyword, goipp.String(firstNonEmpty(j.reasons, "none")))) + if j.message != "" { + a.Add(goipp.MakeAttribute("job-state-message", goipp.TagText, goipp.String(j.message))) + } + a.Add(goipp.MakeAttribute("time-at-creation", goipp.TagInteger, goipp.Integer(int(j.created.Sub(s.started).Seconds())+1))) + if !j.done.IsZero() { + a.Add(goipp.MakeAttribute("time-at-completed", goipp.TagInteger, goipp.Integer(int(j.done.Sub(s.started).Seconds())+1))) + } + a.Add(goipp.MakeAttribute("job-impressions-completed", goipp.TagInteger, goipp.Integer(0))) + return a +} + +func kw(name string, vals ...string) goipp.Attribute { return multi(name, goipp.TagKeyword, vals...) } + +func multi(name string, tag goipp.Tag, vals ...string) goipp.Attribute { + a := goipp.Attribute{Name: name} + for _, v := range vals { + a.Values.Add(tag, goipp.String(v)) + } + return a +} + +func ints(name string, tag goipp.Tag, vals ...int) goipp.Attribute { + a := goipp.Attribute{Name: name} + for _, v := range vals { + a.Values.Add(tag, goipp.Integer(v)) + } + return a +} + +func mediaCol(w, h int, margins int) goipp.Attribute { + size := goipp.Collection{} + size.Add(goipp.MakeAttribute("x-dimension", goipp.TagInteger, goipp.Integer(w))) + size.Add(goipp.MakeAttribute("y-dimension", goipp.TagInteger, goipp.Integer(h))) + col := goipp.Collection{} + col.Add(goipp.MakeAttribute("media-size", goipp.TagBeginCollection, size)) + for _, m := range []string{"media-bottom-margin", "media-left-margin", "media-right-margin", "media-top-margin"} { + col.Add(goipp.MakeAttribute(m, goipp.TagInteger, goipp.Integer(margins))) + } + col.Add(goipp.MakeAttribute("media-source", goipp.TagKeyword, goipp.String("auto"))) + col.Add(goipp.MakeAttribute("media-type", goipp.TagKeyword, goipp.String("stationery"))) + return goipp.MakeAttribute("media-col", goipp.TagBeginCollection, col) +} + +func (s *IPPServer) uuid() string { + h := sha1.Sum([]byte("verae-timestamp-pdf|" + s.Addr)) + return fmt.Sprintf("urn:uuid:%x-%x-%x-%x-%x", h[0:4], h[4:6], h[6:8], h[8:10], h[10:16]) +} + +func (s *IPPServer) printerAttrs(req *goipp.Message, r *http.Request) goipp.Attributes { + uri := s.printerURI(req, r) + sec := "none" + if strings.HasPrefix(uri, "ipps:") || strings.HasPrefix(uri, "https:") { + sec = "tls" + } + s.mu.Lock() + queued := 0 + busy := false + for _, j := range s.jobs { + if j.state < jsCanceled { + queued++ + busy = busy || j.state == jsProcessing + } + } + s.mu.Unlock() + state := 3 + if busy { + state = 4 + } + up := int(time.Since(s.started).Seconds()) + 1 + + var a goipp.Attributes + add := func(x goipp.Attribute) { a.Add(x) } + add(goipp.MakeAttribute("printer-uri-supported", goipp.TagURI, goipp.String(uri))) + add(kw("uri-security-supported", sec)) + add(kw("uri-authentication-supported", "none")) + add(goipp.MakeAttribute("printer-name", goipp.TagName, goipp.String(s.Name))) + add(goipp.MakeAttribute("printer-info", goipp.TagText, goipp.String(s.Name))) + add(goipp.MakeAttribute("printer-make-and-model", goipp.TagText, goipp.String("Verae TimeStamp PDF"))) + add(goipp.MakeAttribute("printer-location", goipp.TagText, goipp.String("This PC (loopback)"))) + add(goipp.MakeAttribute("printer-more-info", goipp.TagURI, goipp.String("http://"+s.Addr+"/"))) + add(goipp.MakeAttribute("printer-uuid", goipp.TagURI, goipp.String(s.uuid()))) + add(goipp.MakeAttribute("printer-device-id", goipp.TagText, goipp.String("MFG:Verae;MDL:TimeStamp PDF;CMD:PDF,PWGRaster;CLS:PRINTER;DES:Verae TimeStamp PDF;"))) + add(ints("printer-state", goipp.TagEnum, state)) + add(kw("printer-state-reasons", "none")) + add(goipp.MakeAttribute("printer-state-message", goipp.TagText, goipp.String("ready"))) + add(goipp.MakeAttribute("printer-is-accepting-jobs", goipp.TagBoolean, goipp.Boolean(true))) + add(ints("queued-job-count", goipp.TagInteger, queued)) + add(ints("printer-up-time", goipp.TagInteger, up)) + add(ints("printer-state-change-time", goipp.TagInteger, 1)) + add(ints("printer-config-change-time", goipp.TagInteger, 1)) + add(goipp.MakeAttribute("printer-current-time", goipp.TagDateTime, goipp.Time{Time: time.Now()})) + add(kw("ipp-versions-supported", "1.1", "2.0")) + add(kw("ipp-features-supported", "ipp-everywhere")) + add(ints("operations-supported", goipp.TagEnum, + int(goipp.OpPrintJob), int(goipp.OpValidateJob), int(goipp.OpCreateJob), int(goipp.OpSendDocument), + int(goipp.OpCancelJob), int(goipp.OpGetJobAttributes), int(goipp.OpGetJobs), int(goipp.OpGetPrinterAttributes), + int(goipp.OpCloseJob))) + add(multi("charset-configured", goipp.TagCharset, "utf-8")) + add(multi("charset-supported", goipp.TagCharset, "utf-8")) + add(multi("natural-language-configured", goipp.TagLanguage, "en")) + add(multi("generated-natural-language-supported", goipp.TagLanguage, "en")) + add(multi("document-format-supported", goipp.TagMimeType, s.Formats...)) + add(multi("document-format-default", goipp.TagMimeType, s.Formats[0])) + add(multi("document-format-preferred", goipp.TagMimeType, "application/pdf")) + add(kw("pdf-versions-supported", "adobe-1.3", "adobe-1.4", "adobe-1.5", "adobe-1.6", "adobe-1.7", "iso-32000-1_2008")) + add(kw("compression-supported", "none")) + add(kw("pdl-override-supported", "attempted")) + add(goipp.MakeAttribute("multiple-document-jobs-supported", goipp.TagBoolean, goipp.Boolean(false))) + add(kw("multiple-document-handling-supported", "separate-documents-uncollated-copies", "separate-documents-collated-copies")) + add(ints("multiple-operation-time-out", goipp.TagInteger, 120)) + add(kw("multiple-operation-time-out-action", "process-job")) + add(kw("which-jobs-supported", "completed", "not-completed")) + add(kw("job-ids-supported", "true")) + add(kw("printer-kind", "document")) + add(goipp.MakeAttribute("color-supported", goipp.TagBoolean, goipp.Boolean(true))) + add(goipp.MakeAttribute("copies-default", goipp.TagInteger, goipp.Integer(1))) + add(goipp.MakeAttribute("copies-supported", goipp.TagRange, goipp.Range{Lower: 1, Upper: 1})) + add(ints("finishings-default", goipp.TagEnum, 3)) + add(ints("finishings-supported", goipp.TagEnum, 3)) + add(ints("orientation-requested-default", goipp.TagEnum, 3)) + add(ints("orientation-requested-supported", goipp.TagEnum, 3, 4, 5, 6)) + add(kw("sides-default", "one-sided")) + add(kw("sides-supported", "one-sided")) + add(kw("print-color-mode-default", "color")) + add(kw("print-color-mode-supported", "auto", "color", "monochrome")) + add(ints("print-quality-default", goipp.TagEnum, 4)) + add(ints("print-quality-supported", goipp.TagEnum, 3, 4, 5)) + add(kw("output-bin-default", "face-up")) + add(kw("output-bin-supported", "face-up")) + add(kw("print-scaling-default", "auto")) + add(kw("print-scaling-supported", "auto", "auto-fit", "fill", "fit", "none")) + res := goipp.Resolution{Xres: 600, Yres: 600, Units: goipp.UnitsDpi} + res3 := goipp.Resolution{Xres: 300, Yres: 300, Units: goipp.UnitsDpi} + add(goipp.MakeAttribute("printer-resolution-default", goipp.TagResolution, res)) + pr := goipp.Attribute{Name: "printer-resolution-supported"} + pr.Values.Add(goipp.TagResolution, res3) + pr.Values.Add(goipp.TagResolution, res) + add(pr) + pw := goipp.Attribute{Name: "pwg-raster-document-resolution-supported"} + pw.Values.Add(goipp.TagResolution, res3) + pw.Values.Add(goipp.TagResolution, res) + add(pw) + add(kw("pwg-raster-document-type-supported", "sgray_8", "srgb_8")) + add(kw("pwg-raster-document-sheet-back", "normal")) + // media (hundredths of mm) + add(kw("media-default", "na_letter_8.5x11in")) + add(kw("media-supported", "na_letter_8.5x11in", "na_legal_8.5x14in", "iso_a4_210x297mm")) + add(kw("media-ready", "na_letter_8.5x11in", "iso_a4_210x297mm")) + add(kw("media-type-supported", "stationery")) + add(kw("media-source-supported", "auto")) + add(ints("media-bottom-margin-supported", goipp.TagInteger, 0)) + add(ints("media-left-margin-supported", goipp.TagInteger, 0)) + add(ints("media-right-margin-supported", goipp.TagInteger, 0)) + add(ints("media-top-margin-supported", goipp.TagInteger, 0)) + letter, legal, a4 := mediaCol(21590, 27940, 0), mediaCol(21590, 35560, 0), mediaCol(21000, 29700, 0) + def := letter + def.Name = "media-col-default" + add(def) + db := goipp.Attribute{Name: "media-col-database"} + for _, m := range []goipp.Attribute{letter, legal, a4} { + db.Values = append(db.Values, m.Values...) + } + add(db) + rd := goipp.Attribute{Name: "media-col-ready"} + rd.Values = append(rd.Values, letter.Values...) + rd.Values = append(rd.Values, a4.Values...) + add(rd) + ms := goipp.Attribute{Name: "media-size-supported"} + for _, wh := range [][2]int{{21590, 27940}, {21590, 35560}, {21000, 29700}} { + c := goipp.Collection{} + c.Add(goipp.MakeAttribute("x-dimension", goipp.TagInteger, goipp.Integer(wh[0]))) + c.Add(goipp.MakeAttribute("y-dimension", goipp.TagInteger, goipp.Integer(wh[1]))) + ms.Values.Add(goipp.TagBeginCollection, c) + } + add(ms) + add(kw("media-col-supported", "media-size", "media-type", "media-source", "media-bottom-margin", "media-left-margin", "media-right-margin", "media-top-margin")) + add(kw("job-creation-attributes-supported", "copies", "media", "media-col", "orientation-requested", "print-color-mode", "print-quality", "sides", "output-bin", "print-scaling", "printer-resolution")) + add(goipp.MakeAttribute("job-k-octets-supported", goipp.TagRange, goipp.Range{Lower: 0, Upper: 2000000})) + return a +} + +func attrString(attrs goipp.Attributes, name string) string { + for _, a := range attrs { + if a.Name == name && len(a.Values) > 0 { + return a.Values[0].V.String() + } + } + return "" +} + +func attrInt(attrs goipp.Attributes, name string) int32 { + for _, a := range attrs { + if a.Name == name && len(a.Values) > 0 { + if v, ok := a.Values[0].V.(goipp.Integer); ok { + return int32(v) + } + } + } + return 0 +} + +func firstNonEmpty(ss ...string) string { + for _, s := range ss { + if strings.TrimSpace(s) != "" { + return s + } + } + return "" +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/internal/winprint/ipp_test.go b/internal/winprint/ipp_test.go new file mode 100644 index 0000000..a9bac28 --- /dev/null +++ b/internal/winprint/ipp_test.go @@ -0,0 +1,88 @@ +package winprint + +import ( + "bytes" + "net" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/OpenPrinting/goipp" +) + +func freeAddr(t *testing.T) string { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + a := l.Addr().String() + l.Close() + return a +} + +func call(t *testing.T, addr string, m *goipp.Message, doc []byte) *goipp.Message { + b, err := m.EncodeBytes() + if err != nil { + t.Fatal(err) + } + b = append(b, doc...) + res, err := http.Post("http://"+addr+"/ipp/print", goipp.ContentType, bytes.NewReader(b)) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + var out goipp.Message + if err := out.Decode(res.Body); err != nil { + t.Fatal(err) + } + return &out +} + +func req(op goipp.Op, addr string) *goipp.Message { + m := goipp.NewRequest(goipp.DefaultVersion, op, 1) + m.Operation.Add(goipp.MakeAttribute("attributes-charset", goipp.TagCharset, goipp.String("utf-8"))) + m.Operation.Add(goipp.MakeAttribute("attributes-natural-language", goipp.TagLanguage, goipp.String("en"))) + m.Operation.Add(goipp.MakeAttribute("printer-uri", goipp.TagURI, goipp.String("ipp://"+addr+"/ipp/print"))) + return m +} + +func TestIPPPrintJobFlow(t *testing.T) { + addr := freeAddr(t) + got := make(chan Job, 1) + s := &IPPServer{Addr: addr, SpoolDir: t.TempDir(), Process: func(j Job) error { got <- j; return nil }} + go s.ListenAndServe() + time.Sleep(200 * time.Millisecond) + + r := call(t, addr, req(goipp.OpGetPrinterAttributes, addr), nil) + if goipp.Status(r.Code) != goipp.StatusOk || len(r.Printer) < 40 { + t.Fatalf("gpa status=%v attrs=%d", goipp.Status(r.Code), len(r.Printer)) + } + + pj := req(goipp.OpPrintJob, addr) + pj.Operation.Add(goipp.MakeAttribute("requesting-user-name", goipp.TagName, goipp.String("george"))) + pj.Operation.Add(goipp.MakeAttribute("job-name", goipp.TagName, goipp.String("Contract.docx"))) + pj.Operation.Add(goipp.MakeAttribute("document-format", goipp.TagMimeType, goipp.String("application/pdf"))) + doc := []byte("%PDF-1.4\n%fake\n") + r = call(t, addr, pj, doc) + if goipp.Status(r.Code) != goipp.StatusOk { + t.Fatalf("print-job status %v", goipp.Status(r.Code)) + } + select { + case j := <-got: + b, _ := os.ReadFile(j.Path) + if !bytes.Equal(b, doc) || j.Title != "Contract.docx" || j.User != "george" || filepath.Ext(j.Path) != ".pdf" { + t.Fatalf("bad job %+v body=%q", j, b) + } + case <-time.After(3 * time.Second): + t.Fatal("job not processed") + } + + bad := req(goipp.OpPrintJob, addr) + bad.Operation.Add(goipp.MakeAttribute("document-format", goipp.TagMimeType, goipp.String("image/urf"))) + r = call(t, addr, bad, []byte("x")) + if goipp.Status(r.Code) != goipp.StatusErrorDocumentFormatNotSupported { + t.Fatalf("expected format error, got %v", goipp.Status(r.Code)) + } +} diff --git a/internal/winprint/watch.go b/internal/winprint/watch.go new file mode 100644 index 0000000..7ba960d --- /dev/null +++ b/internal/winprint/watch.go @@ -0,0 +1,108 @@ +package winprint + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog" +) + +// Watcher polls a drop folder written by the spooler through a Local file +// port (Microsoft Print To PDF → C:\ProgramData\Verae\spool\fallback\verae-job.pdf). +// When a file is stable and can be opened exclusively it is moved into the +// per-user work dir under a unique name and handed to Process. +type Watcher struct { + Dir string + WorkDir string + Interval time.Duration + Process Processor +} + +// Run blocks forever. +func (w *Watcher) Run() { + if w.Interval <= 0 { + w.Interval = time.Second + } + _ = os.MkdirAll(w.WorkDir, 0o700) + vlog.Printf("watch: polling %s every %s (work=%s)", w.Dir, w.Interval, w.WorkDir) + sizes := map[string]int64{} + warned := false + for { + ents, err := os.ReadDir(w.Dir) + if err != nil { + if !warned { + vlog.Errorf("watch: read %s: %v (fallback printer disabled until it exists)", w.Dir, err) + warned = true + } + time.Sleep(5 * time.Second) + continue + } + warned = false + seen := map[string]bool{} + for _, e := range ents { + if e.IsDir() || strings.HasPrefix(e.Name(), ".") || strings.HasSuffix(strings.ToLower(e.Name()), ".tmp") { + continue + } + p := filepath.Join(w.Dir, e.Name()) + seen[p] = true + st, err := os.Stat(p) + if err != nil || st.Size() == 0 { + continue + } + prev, ok := sizes[p] + sizes[p] = st.Size() + if !ok || prev != st.Size() || time.Since(st.ModTime()) < 2*time.Second { + continue // still growing + } + w.claim(p, st.Size()) + delete(sizes, p) + } + for p := range sizes { + if !seen[p] { + delete(sizes, p) + } + } + time.Sleep(w.Interval) + } +} + +func (w *Watcher) claim(p string, size int64) { + id := vlog.NewJobID("watch") + lj := vlog.Job(id) + dst := filepath.Join(w.WorkDir, id+".pdf") + // Rename fails while the spooler still holds the file open (Windows sharing rules). + if err := os.Rename(p, dst); err != nil { + // cross-volume or locked: try copy+remove + b, rerr := os.ReadFile(p) + if rerr != nil { + lj.Debugf("watch: %s not ready: %v / %v", p, err, rerr) + return + } + if werr := os.WriteFile(dst, b, 0o600); werr != nil { + lj.Errorf("watch: copy to %s: %v", dst, werr) + return + } + if derr := os.Remove(p); derr != nil { + lj.Errorf("watch: copied but could not remove %s: %v (will re-detect; check ACLs)", p, derr) + } + } + lj.Printf("watch: claimed %s (%d bytes, magic=%q) → %s", p, size, sniff(dst), dst) + j := Job{ID: id, Source: "watch", Title: "Printed " + time.Now().Format("2006-01-02 1504"), Format: "application/pdf", Path: dst, Bytes: size, Received: time.Now()} + start := time.Now() + err := func() (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("panic: %v", r) + } + }() + return w.Process(j) + }() + if err != nil { + lj.Errorf("watch: job FAILED after %s: %v", time.Since(start).Round(time.Millisecond), err) + return + } + lj.Printf("watch: job completed in %s", time.Since(start).Round(time.Millisecond)) +} diff --git a/platform/windows/README-TEST.txt b/platform/windows/README-TEST.txt new file mode 100644 index 0000000..b191aeb --- /dev/null +++ b/platform/windows/README-TEST.txt @@ -0,0 +1,71 @@ +Verae TimeStamp PDF - Windows DEBUG test build +============================================== + +What this build adds +-------------------- +* "Verae TimeStamp PDF" printer (PRIMARY). It uses the built-in Microsoft IPP + Class Driver and sends jobs to a local helper (verae-printhelper.exe) at + http://127.0.0.1:8631/ipp/print. The helper runs as you, saves the PDF, + timestamps it with Verae, and writes the certificate. +* "Verae TimeStamp PDF (Local Port)" printer (FALLBACK). It uses the built-in + Microsoft Print To PDF driver and writes to + C:\ProgramData\Verae\spool\fallback\verae-job.pdf. The same helper picks the + file up. +* Heavy logging in C:\ProgramData\Verae\logs: + install-