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 }