281 lines
8.4 KiB
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
|
|
}
|