PrintToVeraeTimeStamp/cmd/verae-print/serve.go

281 lines
8.4 KiB
Go

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 (
setupMu sync.Mutex
setupActive bool
heldMu sync.Mutex
heldJobs []winprint.Job
processor winprint.Processor
)
// promptSetup shows the key dialog at most once at a time.
func promptSetup() {
setupMu.Lock()
defer setupMu.Unlock()
if setupActive {
return
}
setupActive = true
launchSetupUI()
}
func retryHeldJobs() {
setupMu.Lock()
setupActive = false
setupMu.Unlock()
heldMu.Lock()
jobs := heldJobs
heldJobs = nil
heldMu.Unlock()
for _, j := range jobs {
vlog.Job(j.ID).Printf("retry: re-processing held job after key setup")
if err := processor(j); err != nil {
vlog.Job(j.ID).Errorf("retry failed: %v", err)
}
}
}
func makeProcessor(outDir string) winprint.Processor {
processor = 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
}
heldMu.Lock()
heldJobs = append(heldJobs, j)
heldMu.Unlock()
promptSetup()
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
}
return processor
}
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
}