Some checks failed
windows-msi / full-msi-unsigned (push) Has been cancelled
On HTTP 402/429 (and credit/billing error bodies), hold the print job and open https://zap.verae.ai/portal/?tab=billing so the user can reload prepaid balance. Throttle browser opens to once per 10 minutes. Auth failures still use the sign-in flow unchanged.
471 lines
16 KiB
Go
471 lines
16 KiB
Go
package main
|
|
|
|
// Held ("pending") print jobs: when a stamp fails because the key is missing,
|
|
// invalid or expired (HTTP 401/403), the PDF is moved into a per-user pending
|
|
// folder instead of being lost, ONE login prompt is shown (cross-process lock),
|
|
// and after a new key is saved every pending job is retried automatically.
|
|
// Cancelled → jobs stay pending and are retried on the next helper start,
|
|
// the next successful login/key save, or the next successful print.
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
|
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
|
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/statuslang"
|
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
|
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
|
|
)
|
|
|
|
// errNoKey: no usable signing key is stored.
|
|
var errNoKey = errors.New("no Verae signing key is set up")
|
|
|
|
// isAuthFailure: the job can succeed after the user signs in again.
|
|
func isAuthFailure(err error) bool { return errors.Is(err, errNoKey) || veraeapi.IsAuthError(err) }
|
|
|
|
// isBillingFailure: the job can succeed after the user adds funds on Zap Billing.
|
|
func isBillingFailure(err error) bool { return veraeapi.IsBillingError(err) }
|
|
|
|
type pendingMeta struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Source string `json:"source"`
|
|
OutDir string `json:"outDir,omitempty"`
|
|
OutPath string `json:"outPath,omitempty"` // file chosen in the Save As dialog
|
|
Reason string `json:"reason"`
|
|
Created string `json:"created"`
|
|
}
|
|
|
|
func pendingDir() string {
|
|
if d := os.Getenv("VERAE_PENDING_DIR"); d != "" {
|
|
return d
|
|
}
|
|
base, err := os.UserConfigDir() // %APPDATA% / ~/Library/Application Support
|
|
if err != nil {
|
|
base = os.TempDir()
|
|
}
|
|
return filepath.Join(base, "Verae", "PrintStamp", "pending")
|
|
}
|
|
|
|
func (m pendingMeta) pdf() string { return filepath.Join(pendingDir(), m.ID+".pdf") }
|
|
func (m pendingMeta) meta() string { return filepath.Join(pendingDir(), m.ID+".json") }
|
|
|
|
func moveOrCopy(src, dst string, move bool) error {
|
|
if move {
|
|
if err := os.Rename(src, dst); err == nil {
|
|
return nil
|
|
}
|
|
}
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(out, in); err != nil {
|
|
out.Close()
|
|
os.Remove(dst)
|
|
return err
|
|
}
|
|
if err := out.Close(); err != nil {
|
|
return err
|
|
}
|
|
if move {
|
|
_ = os.Remove(src)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// holdJob stores the PDF + metadata in the pending folder.
|
|
func holdJob(pdfPath string, t stampTarget, source string, reason error, move bool) (pendingMeta, error) {
|
|
if err := os.MkdirAll(pendingDir(), 0o700); err != nil {
|
|
return pendingMeta{}, err
|
|
}
|
|
rb := make([]byte, 3)
|
|
_, _ = rand.Read(rb)
|
|
m := pendingMeta{ID: time.Now().Format("20060102-150405") + "-" + hex.EncodeToString(rb), Title: t.Title, Source: source, OutDir: t.OutDir, OutPath: t.OutPath,
|
|
Reason: reason.Error(), Created: time.Now().Format(time.RFC3339)}
|
|
if err := moveOrCopy(pdfPath, m.pdf(), move); err != nil {
|
|
return pendingMeta{}, err
|
|
}
|
|
b, _ := json.MarshalIndent(m, "", " ")
|
|
if err := os.WriteFile(m.meta(), b, 0o600); err != nil {
|
|
return pendingMeta{}, err
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func listPending() []pendingMeta {
|
|
ents, _ := os.ReadDir(pendingDir())
|
|
var out []pendingMeta
|
|
for _, e := range ents {
|
|
if !strings.HasSuffix(e.Name(), ".json") {
|
|
continue
|
|
}
|
|
b, err := os.ReadFile(filepath.Join(pendingDir(), e.Name()))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var m pendingMeta
|
|
if json.Unmarshal(b, &m) != nil || m.ID == "" {
|
|
continue
|
|
}
|
|
if _, err := os.Stat(m.pdf()); err != nil {
|
|
continue
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
|
return out
|
|
}
|
|
|
|
func removePending(m pendingMeta) {
|
|
_ = os.Remove(m.pdf())
|
|
_ = os.Remove(m.meta())
|
|
}
|
|
|
|
// stampFn stamps one PDF with the CURRENT stored key.
|
|
type stampFn func(pdf string, t stampTarget, lj vlog.Job) (job.Result, error)
|
|
|
|
// stampTarget says where results go: OutPath (Save As choice) or OutDir+Title.
|
|
type stampTarget struct{ Title, OutDir, OutPath string }
|
|
|
|
// acquireKeyHook replaces the interactive prompt in tests.
|
|
var acquireKeyHook func(reason string) bool
|
|
|
|
var retryMu sync.Mutex // one retry pass at a time within a process
|
|
|
|
// retryPending re-stamps every pending job. Stops at the first auth failure.
|
|
func retryPending(stamp stampFn) (done, remaining int, authFail bool) {
|
|
retryMu.Lock()
|
|
defer retryMu.Unlock()
|
|
for _, m := range listPending() {
|
|
lj := vlog.Job(vlog.NewJobID("pending"))
|
|
lj.Printf("pending: retrying held job %s title=%q (held because: %s)", m.ID, m.Title, m.Reason)
|
|
res, err := stamp(m.pdf(), stampTarget{Title: m.Title, OutDir: m.OutDir, OutPath: m.OutPath}, lj)
|
|
if err != nil {
|
|
if isAuthFailure(err) {
|
|
lj.Errorf("pending: %s still not authorised: %v", m.ID, err)
|
|
return done, len(listPending()), true
|
|
}
|
|
if isBillingFailure(err) {
|
|
lj.Errorf("pending: %s still needs funds: %v", m.ID, err)
|
|
offerBillingCheckout(billingReason(err))
|
|
return done, len(listPending()), false
|
|
}
|
|
lj.Errorf("pending: %s failed (kept for next retry): %v", m.ID, err)
|
|
continue
|
|
}
|
|
removePending(m)
|
|
done++
|
|
lj.Printf("pending: %s OK receipt=%s cert=%q", m.ID, res.ReceiptID, res.CertificatePDF)
|
|
notifyUser(statuslang.NotifyTitle("final"), statuslang.NotifyBody("final", m.Title, res.ReceiptID))
|
|
openOnComplete(lj, res.CertificatePDF)
|
|
}
|
|
return done, len(listPending()), false
|
|
}
|
|
|
|
// promptLock guarantees a single login prompt across processes (macOS runs one
|
|
// process per print job; Windows has the helper plus the Start-menu dialog).
|
|
func promptLock() (func(), bool) {
|
|
_ = os.MkdirAll(pendingDir(), 0o700)
|
|
p := filepath.Join(pendingDir(), ".prompt.lock")
|
|
for i := 0; i < 2; i++ {
|
|
f, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
|
if err == nil {
|
|
fmt.Fprintf(f, "%d %s\n", os.Getpid(), time.Now().Format(time.RFC3339))
|
|
f.Close()
|
|
return func() { _ = os.Remove(p) }, true
|
|
}
|
|
st, serr := os.Stat(p)
|
|
if serr == nil && time.Since(st.ModTime()) > 20*time.Minute {
|
|
_ = os.Remove(p) // stale (crashed prompt)
|
|
continue
|
|
}
|
|
return func() {}, false
|
|
}
|
|
return func() {}, false
|
|
}
|
|
|
|
// billingOpenThrottle is how long we suppress re-opening the Billing tab
|
|
// after a credit failure (avoids a browser storm while jobs stay held).
|
|
const billingOpenThrottle = 10 * time.Minute
|
|
|
|
// offerBillingCheckout opens the Zap Billing reload page at most once per
|
|
// billingOpenThrottle. Safe across processes via a lock file in pendingDir.
|
|
func offerBillingCheckout(reason string) {
|
|
_ = os.MkdirAll(pendingDir(), 0o700)
|
|
marker := filepath.Join(pendingDir(), ".billing.open")
|
|
if st, err := os.Stat(marker); err == nil && time.Since(st.ModTime()) < billingOpenThrottle {
|
|
vlog.Printf("billing: checkout already offered %s ago; not re-opening (%d pending)",
|
|
time.Since(st.ModTime()).Round(time.Second), len(listPending()))
|
|
return
|
|
}
|
|
_ = os.WriteFile(marker, []byte(time.Now().Format(time.RFC3339)+"\n"+reason+"\n"), 0o600)
|
|
vlog.Printf("billing: opening %s (%d pending)", auth.ZapBillingURL, len(listPending()))
|
|
notifyUser(statuslang.NotifyTitle("billing"), notifyQueueHint(reason))
|
|
if err := openBrowser(auth.ZapBillingURL); err != nil {
|
|
vlog.Errorf("billing: could not open browser: %v — open %s", err, auth.ZapBillingURL)
|
|
fmt.Fprintf(os.Stderr, "Out of credits. Open Billing to add funds:\n %s\n", auth.ZapBillingURL)
|
|
}
|
|
}
|
|
|
|
// handleBillingFailure holds the print job narrative for the user and opens
|
|
// Zap Billing. Does not prompt for a new signing key.
|
|
func handleBillingFailure(reason string) {
|
|
offerBillingCheckout(reason)
|
|
}
|
|
|
|
// handleAuthFailure shows ONE login prompt (if none is open), then retries all
|
|
// pending jobs. Returns true when everything pending was stamped.
|
|
func handleAuthFailure(reason string, stamp stampFn) bool {
|
|
release, ok := promptLock()
|
|
if !ok {
|
|
vlog.Printf("pending: a Verae sign-in prompt is already open; job queued (%d pending)", len(listPending()))
|
|
return false
|
|
}
|
|
defer release()
|
|
for attempt := 0; attempt < 3; attempt++ {
|
|
n := len(listPending())
|
|
if n == 0 {
|
|
return true
|
|
}
|
|
msg := reason + "\n\n" + strconv.Itoa(n) + " print job(s) are saved and will be timestamped as soon as you sign in."
|
|
vlog.Printf("pending: prompting for sign-in (%d job(s) held)", n)
|
|
acquire := acquireKeyInteractive
|
|
if acquireKeyHook != nil {
|
|
acquire = acquireKeyHook
|
|
}
|
|
if !acquire(msg) {
|
|
vlog.Printf("pending: sign-in cancelled; %d job(s) kept in %s", n, pendingDir())
|
|
ht, hb := statuslang.NotifyHeldJobs(n)
|
|
notifyUser(ht, notifyQueueHint(hb))
|
|
return false
|
|
}
|
|
markKeySeen()
|
|
done, remaining, authFail := retryPending(stamp)
|
|
vlog.Printf("pending: after sign-in: %d stamped, %d remaining", done, remaining)
|
|
if !authFail {
|
|
return remaining == 0
|
|
}
|
|
reason = "Verae did not accept the new key either."
|
|
}
|
|
return false
|
|
}
|
|
|
|
// keyFingerprint identifies the stored key without revealing it.
|
|
func keyFingerprint() string {
|
|
k, err := auth.LoadXVeraeToken()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
h := sha256.Sum256([]byte(k))
|
|
return hex.EncodeToString(h[:6])
|
|
}
|
|
|
|
var (
|
|
seenMu sync.Mutex
|
|
seenKey string
|
|
)
|
|
|
|
func markKeySeen() { seenMu.Lock(); seenKey = keyFingerprint(); seenMu.Unlock() }
|
|
|
|
// keyChangedSinceSeen: a key was saved elsewhere (Start-menu dialog, installer).
|
|
func keyChangedSinceSeen() bool {
|
|
fp := keyFingerprint()
|
|
seenMu.Lock()
|
|
defer seenMu.Unlock()
|
|
if fp != "" && fp != seenKey {
|
|
seenKey = fp
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// runPendingList prints the held job queue (PT-04 / PT-W4 style) using brand status strings.
|
|
// Flags: -json (machine-readable), -html (self-contained local queue page).
|
|
func runPendingList(args []string) int {
|
|
fs := flag.NewFlagSet("pending", flag.ContinueOnError)
|
|
fs.SetOutput(os.Stderr)
|
|
asJSON := fs.Bool("json", false, "machine-readable JSON (held jobs only; hold id is not evidence)")
|
|
asHTML := fs.Bool("html", false, "self-contained HTML job queue (PT-04/W4 labels)")
|
|
if err := fs.Parse(args); err != nil {
|
|
return 2
|
|
}
|
|
list := listPending()
|
|
if *asJSON {
|
|
b, err := encodePendingJSON(list, traySnapshot(""))
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
return 1
|
|
}
|
|
_, _ = os.Stdout.Write(b)
|
|
return 0
|
|
}
|
|
if *asHTML {
|
|
_, err := io.WriteString(os.Stdout, renderPendingHTML(list, traySnapshot("")))
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
fmt.Printf("Pending print jobs · %s\n", statuslang.Pending)
|
|
fmt.Printf("Folder: %s\n", pendingDir())
|
|
if len(list) == 0 {
|
|
fmt.Println("(empty) — nothing waiting for a signing key")
|
|
fmt.Println("Note:", statuslang.EvidenceNote)
|
|
return 0
|
|
}
|
|
fmt.Printf("%-22s %-28s %-16s %s\n", "HOLD_ID", "TITLE", "STATE", "CREATED")
|
|
for _, m := range list {
|
|
title := m.Title
|
|
if title == "" {
|
|
title = "(untitled)"
|
|
}
|
|
if len(title) > 28 {
|
|
title = title[:25] + "…"
|
|
}
|
|
fmt.Printf("%-22s %-28s %-16s %s\n", m.ID, title, statuslang.Pending, m.Created)
|
|
}
|
|
fmt.Printf("\n%d held · sign in / setup key to stamp\n", len(list))
|
|
fmt.Println(statuslang.EvidenceNote)
|
|
return 0
|
|
}
|
|
|
|
type pendingJSONRow struct {
|
|
HoldID string `json:"holdId"`
|
|
Title string `json:"title"`
|
|
Source string `json:"source,omitempty"`
|
|
State string `json:"state"`
|
|
Reason string `json:"reason,omitempty"`
|
|
Created string `json:"created"`
|
|
}
|
|
|
|
type pendingJSONOut struct {
|
|
Headline string `json:"headline"`
|
|
Detail string `json:"detail"`
|
|
State string `json:"state"`
|
|
Folder string `json:"folder"`
|
|
Count int `json:"count"`
|
|
KeyOK bool `json:"keyOk"`
|
|
HelperUp bool `json:"helperUp"`
|
|
QueueURL string `json:"queueUrl,omitempty"`
|
|
Note string `json:"note"`
|
|
Jobs []pendingJSONRow `json:"jobs"`
|
|
}
|
|
|
|
func encodePendingJSON(list []pendingMeta, snap traySnap) ([]byte, error) {
|
|
out := pendingJSONOut{
|
|
Headline: snap.Headline,
|
|
Detail: snap.Detail,
|
|
State: statuslang.Pending,
|
|
Folder: pendingDir(),
|
|
Count: len(list),
|
|
KeyOK: snap.KeyOK,
|
|
HelperUp: snap.HelperUp,
|
|
QueueURL: snap.QueueURL,
|
|
Note: statuslang.EvidenceNote,
|
|
Jobs: make([]pendingJSONRow, 0, len(list)),
|
|
}
|
|
if len(list) == 0 && snap.KeyOK {
|
|
out.State = statuslang.PrinterReady
|
|
}
|
|
for _, m := range list {
|
|
title := m.Title
|
|
if title == "" {
|
|
title = "(untitled)"
|
|
}
|
|
out.Jobs = append(out.Jobs, pendingJSONRow{
|
|
HoldID: m.ID, Title: title, Source: m.Source,
|
|
State: statuslang.Pending, Reason: m.Reason, Created: m.Created,
|
|
})
|
|
}
|
|
return json.MarshalIndent(out, "", " ")
|
|
}
|
|
|
|
func htmlEsc(s string) string {
|
|
return strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """).Replace(s)
|
|
}
|
|
|
|
// renderPendingHTML is the PT-04/W4 queue page (also served at helper GET /queue).
|
|
func renderPendingHTML(list []pendingMeta, snap traySnap) string {
|
|
var rows strings.Builder
|
|
if len(list) == 0 {
|
|
rows.WriteString(`<tr><td colspan="4" class="empty">No held jobs — print with Destination → Verae Timestamping</td></tr>`)
|
|
} else {
|
|
for _, m := range list {
|
|
title := m.Title
|
|
if title == "" {
|
|
title = "(untitled)"
|
|
}
|
|
rows.WriteString(fmt.Sprintf(
|
|
`<tr><td class="mono">%s</td><td>%s</td><td><span class="badge">%s</span></td><td class="muted">%s</td></tr>`,
|
|
htmlEsc(m.ID), htmlEsc(title), htmlEsc(statuslang.Pending), htmlEsc(m.Created),
|
|
))
|
|
}
|
|
}
|
|
meta := fmt.Sprintf("Folder: %s · %d held", htmlEsc(pendingDir()), len(list))
|
|
if snap.QueueURL != "" {
|
|
meta += fmt.Sprintf(` · live: <a class="mono" href="%s">%s</a>`, htmlEsc(snap.QueueURL), htmlEsc(snap.QueueURL))
|
|
} else {
|
|
meta += ` · refresh: <span class="mono">verae-print pending --html</span>`
|
|
}
|
|
meta += fmt.Sprintf(` · <span class="mono">verae-print tray-status</span>`)
|
|
return fmt.Sprintf(`<!doctype html>
|
|
<html lang="en"><head>
|
|
<meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
<meta http-equiv="refresh" content="15"/>
|
|
<title>Print job queue · Verae Timestamping</title>
|
|
<style>
|
|
:root{--plum:#531B59;--gold:#C4A85A;--ink:#1D1534;--muted:#5E5470;--bg:#FBFAFD;--card:#fff;--line:#E3DEF0;--surface:#F4F1F8;--pend-bg:#EEF0F3;--pend:#475467;--ok:#0E6B3A;--ok-bg:#E3F5EA}
|
|
*{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:var(--bg);color:var(--ink)}
|
|
main{max-width:820px;margin:0 auto;padding:28px 16px}
|
|
h1{font-size:1.35rem;margin:0 0 6px} .lede{color:var(--muted);font-size:.95rem;margin:0 0 18px;max-width:40rem}
|
|
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px 18px;box-shadow:0 1px 2px rgba(29,21,52,.04)}
|
|
table{width:100%%;border-collapse:collapse;font-size:.9rem} th,td{text-align:left;padding:10px 8px;border-bottom:1px solid var(--line)}
|
|
th{font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);font-weight:600}
|
|
.mono{font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:.8rem}
|
|
.muted{color:var(--muted)}.empty{color:var(--muted);text-align:center;padding:28px 8px}
|
|
.badge{display:inline-block;background:var(--pend-bg);color:var(--pend);border-radius:999px;padding:2px 10px;font-size:.78rem;font-weight:600}
|
|
.pill{display:inline-block;border-radius:999px;padding:3px 12px;font-size:.8rem;font-weight:600;background:var(--ok-bg);color:var(--ok)}
|
|
.pill.warn{background:var(--pend-bg);color:var(--pend)}
|
|
.note{margin-top:14px;padding:12px 14px;background:var(--surface);border-left:3px solid var(--gold);border-radius:0 8px 8px 0;font-size:.85rem;color:var(--muted)}
|
|
.note b{color:var(--plum)} .meta{margin-top:12px;font-size:.8rem;color:var(--muted)}
|
|
a{color:var(--plum)} .status{margin:0 0 14px;display:flex;gap:10px;flex-wrap:wrap;align-items:center}
|
|
</style></head><body><main>
|
|
<h1>Print job queue</h1>
|
|
<p class="lede">PT-04 / PT-W4 · PT-W3 tray substitute · Held jobs wait for a valid xVerae- key. <b style="color:var(--plum)">%s</b></p>
|
|
<div class="status"><span class="pill%s">%s</span><span class="muted">%s</span></div>
|
|
<div class="card">
|
|
<table><thead><tr><th>Hold id</th><th>Document</th><th>State</th><th>Created</th></tr></thead>
|
|
<tbody>%s</tbody></table>
|
|
<div class="note"><b>Not proof:</b> %s Wait for <b>%s</b>.</div>
|
|
<p class="meta">%s</p>
|
|
</div></main></body></html>
|
|
`, htmlEsc(statuslang.EvidenceNote), pillClass(snap), htmlEsc(snap.Headline), htmlEsc(snap.Detail), rows.String(), htmlEsc(statuslang.EvidenceNote), htmlEsc(statuslang.FinalReceiptReady), meta)
|
|
}
|
|
|
|
func pillClass(snap traySnap) string {
|
|
if snap.Held > 0 || !snap.KeyOK {
|
|
return " warn"
|
|
}
|
|
return ""
|
|
}
|