0.3.1: never overwrite outputs (seconds + -N suffix); OpenOnComplete (folder|pdf|none) on Windows+macOS; hold jobs on missing/invalid key (401/403) with one native sign-in prompt and auto-retry; macOS pkg in-installer key flow (mac-key-setup); Windows release installer (no debug naming, errors-only log, options page); VERAE_LOG_LEVEL=debug switch
This commit is contained in:
parent
bfc7d4e485
commit
e960883a0a
29 changed files with 1804 additions and 378 deletions
10
cmd/verae-print/acquire_other.go
Normal file
10
cmd/verae-print/acquire_other.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
//go:build !windows && !darwin
|
||||
|
||||
package main
|
||||
|
||||
import "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
|
||||
|
||||
func acquireKeyInteractive(reason string) bool {
|
||||
vlog.Printf("sign-in needed: %s — run: verae-print setup", reason)
|
||||
return false
|
||||
}
|
||||
|
|
@ -3,6 +3,9 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
|
@ -14,68 +17,238 @@ import (
|
|||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
|
||||
)
|
||||
|
||||
// WinForms dialog (powershell, console hidden): masked key field + "Get a key"
|
||||
// button that opens the customer portal. Writes the key to the file in $env:VERAE_KEY_OUT.
|
||||
const keyDialogPS = `
|
||||
// Native WinForms dialogs (PowerShell, console hidden) shown in the user's
|
||||
// session by the helper. Results come back on stdout as base64 fields
|
||||
// ("action|b64|b64..."), so passwords/keys never touch disk, argv or logs.
|
||||
const formsPS = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Windows.Forms, System.Drawing
|
||||
[System.Windows.Forms.Application]::EnableVisualStyles()
|
||||
function B([string]$s) { [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($s)) }
|
||||
function U([string]$s) { if ($s) { [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($s)) } else { '' } }
|
||||
$kind = $env:VERAE_DLG_KIND; $msg = U $env:VERAE_DLG_MSG; $pre = U $env:VERAE_DLG_EMAIL
|
||||
$f = New-Object Windows.Forms.Form
|
||||
$f.Text = 'Verae TimeStamp PDF - signing key'; $f.Width = 520; $f.Height = 300; $f.StartPosition = 'CenterScreen'; $f.TopMost = $true
|
||||
$f.Text = 'Verae TimeStamp PDF'; $f.Width = 540; $f.StartPosition = 'CenterScreen'; $f.TopMost = $true
|
||||
$f.FormBorderStyle = 'FixedDialog'; $f.MaximizeBox = $false; $f.MinimizeBox = $false
|
||||
$l = New-Object Windows.Forms.Label; $l.Left = 15; $l.Top = 12; $l.Width = 480; $l.Height = 95
|
||||
$l.Text = $env:VERAE_KEY_MSG
|
||||
$f.Controls.Add($l)
|
||||
$b = New-Object Windows.Forms.Button; $b.Text = 'Get a Verae key (opens customer.verae.ai)'; $b.Left = 15; $b.Top = 110; $b.Width = 300
|
||||
$b.Add_Click({ Start-Process 'https://customer.verae.ai/portal/' }); $f.Controls.Add($b)
|
||||
$k = New-Object Windows.Forms.Label; $k.Text = 'Paste your xVerae- key:'; $k.Left = 15; $k.Top = 150; $k.Width = 300; $f.Controls.Add($k)
|
||||
$t = New-Object Windows.Forms.TextBox; $t.Left = 15; $t.Top = 172; $t.Width = 475; $t.UseSystemPasswordChar = $true; $f.Controls.Add($t)
|
||||
$ok = New-Object Windows.Forms.Button; $ok.Text = 'Save key'; $ok.Left = 300; $ok.Top = 215; $ok.Width = 90; $ok.DialogResult = 'OK'; $f.AcceptButton = $ok; $f.Controls.Add($ok)
|
||||
$c = New-Object Windows.Forms.Button; $c.Text = 'Not now'; $c.Left = 400; $c.Top = 215; $c.Width = 90; $c.DialogResult = 'Cancel'; $f.CancelButton = $c; $f.Controls.Add($c)
|
||||
if ($f.ShowDialog() -eq 'OK' -and $t.Text.Trim()) { [IO.File]::WriteAllText($env:VERAE_KEY_OUT, $t.Text.Trim()) ; exit 0 }
|
||||
exit 1
|
||||
$f.Font = New-Object Drawing.Font('Segoe UI', 9)
|
||||
$script:y = 12; $script:result = 'cancel'
|
||||
$l = New-Object Windows.Forms.Label; $l.Left = 15; $l.Top = $script:y; $l.Width = 495; $l.Height = 72; $l.Text = $msg; $f.Controls.Add($l); $script:y += 78
|
||||
function Field([string]$label, [bool]$hidden, [string]$val) {
|
||||
$a = New-Object Windows.Forms.Label; $a.Text = $label; $a.Left = 15; $a.Top = $script:y + 3; $a.Width = 150; $f.Controls.Add($a)
|
||||
$t = New-Object Windows.Forms.TextBox; $t.Left = 170; $t.Top = $script:y; $t.Width = 340; $t.UseSystemPasswordChar = $hidden; $t.Text = $val; $f.Controls.Add($t)
|
||||
$script:y += 30; return $t
|
||||
}
|
||||
function Btn([string]$text, [int]$x, [int]$w, [string]$res) {
|
||||
$b = New-Object Windows.Forms.Button; $b.Text = $text; $b.Left = $x; $b.Top = $script:y; $b.Width = $w; $b.Height = 28
|
||||
$b.Tag = $res; $b.Add_Click({ $script:result = $this.Tag; $f.Close() }); $f.Controls.Add($b); return $b
|
||||
}
|
||||
switch ($kind) {
|
||||
'login' {
|
||||
$e = Field 'Email' $false $pre; $p = Field 'Password' $true ''; $t = Field '2FA code (if enabled)' $false ''
|
||||
$script:y += 8
|
||||
$ok = Btn 'Sign in' 15 100 'login'; $f.AcceptButton = $ok
|
||||
Btn 'Create account' 125 120 'create' | Out-Null
|
||||
Btn 'Paste key' 255 100 'paste' | Out-Null
|
||||
$c = Btn 'Cancel' 410 100 'cancel'; $f.CancelButton = $c
|
||||
$f.Height = $script:y + 75
|
||||
if ($pre) { $f.Add_Shown({ $p.Focus() }) }
|
||||
[void]$f.ShowDialog()
|
||||
if ($script:result -eq 'login') { "login|$(B $e.Text)|$(B $p.Text)|$(B $t.Text)" } else { $script:result }
|
||||
}
|
||||
'create' {
|
||||
$n = Field 'Your name' $false ''; $e = Field 'Email' $false $pre; $p = Field 'Password' $true ''; $p2 = Field 'Confirm password' $true ''
|
||||
$script:y += 8
|
||||
$ok = Btn 'Create account' 15 130 'create'; $f.AcceptButton = $ok
|
||||
$c = Btn 'Back' 410 100 'cancel'; $f.CancelButton = $c
|
||||
$f.Height = $script:y + 75
|
||||
[void]$f.ShowDialog()
|
||||
if ($script:result -eq 'create') { "create|$(B $n.Text)|$(B $e.Text)|$(B $p.Text)|$(B $p2.Text)" } else { 'cancel' }
|
||||
}
|
||||
'code' {
|
||||
$k = Field 'Code from the email' $false ''
|
||||
$script:y += 8
|
||||
$ok = Btn 'Verify' 15 100 'verify'; $f.AcceptButton = $ok
|
||||
Btn 'Send a new code' 125 130 'resend' | Out-Null
|
||||
$c = Btn 'Cancel' 410 100 'cancel'; $f.CancelButton = $c
|
||||
$f.Height = $script:y + 75
|
||||
[void]$f.ShowDialog()
|
||||
if ($script:result -eq 'verify') { "verify|$(B $k.Text)" } else { $script:result }
|
||||
}
|
||||
'paste' {
|
||||
$g = New-Object Windows.Forms.Button; $g.Text = 'Open customer.verae.ai'; $g.Left = 15; $g.Top = $script:y; $g.Width = 200; $g.Height = 28
|
||||
$g.Add_Click({ Start-Process 'https://customer.verae.ai/portal/' }); $f.Controls.Add($g)
|
||||
$script:y += 40
|
||||
$k = Field 'xVerae- key' $true ''
|
||||
$script:y += 8
|
||||
$ok = Btn 'Save key' 15 100 'paste'; $f.AcceptButton = $ok
|
||||
$c = Btn 'Back' 410 100 'cancel'; $f.CancelButton = $c
|
||||
$f.Height = $script:y + 75
|
||||
[void]$f.ShowDialog()
|
||||
if ($script:result -eq 'paste') { "paste|$(B $k.Text)" } else { 'cancel' }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
func showKeyForm(msg string) (string, bool) {
|
||||
tmp, err := os.CreateTemp("", "verae-key-*.txt")
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
path := tmp.Name()
|
||||
tmp.Close()
|
||||
defer os.Remove(path)
|
||||
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", keyDialogPS)
|
||||
cmd.Env = append(os.Environ(), "VERAE_KEY_OUT="+path, "VERAE_KEY_MSG="+msg)
|
||||
var errDialog = errors.New("dialog failed")
|
||||
|
||||
func b64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
|
||||
|
||||
// winForm shows one form; returns the action and decoded fields.
|
||||
func winForm(kind, msg, email string) (string, []string, error) {
|
||||
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", formsPS)
|
||||
cmd.Env = append(os.Environ(), "VERAE_DLG_KIND="+kind, "VERAE_DLG_MSG="+b64(msg), "VERAE_DLG_EMAIL="+b64(email))
|
||||
hideWindow(cmd)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", false
|
||||
vlog.Errorf("dialog %s: %v", kind, err)
|
||||
return "", nil, errDialog
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
parts := strings.Split(strings.TrimSpace(out.String()), "|")
|
||||
var fields []string
|
||||
for _, p := range parts[1:] {
|
||||
d, _ := base64.StdEncoding.DecodeString(p)
|
||||
fields = append(fields, string(d))
|
||||
}
|
||||
return strings.TrimSpace(string(b)), true
|
||||
for len(fields) < 4 {
|
||||
fields = append(fields, "")
|
||||
}
|
||||
return parts[0], fields, nil
|
||||
}
|
||||
|
||||
// keyDialogFlow loops until a valid key is saved or the user cancels.
|
||||
func keyDialogFlow(reason string) bool {
|
||||
msg := reason + "\r\n\r\nClick 'Get a Verae key', sign in (or create an account), open Security and create an xVerae- token, then paste it below."
|
||||
for i := 0; i < 5; i++ {
|
||||
key, ok := showKeyForm(msg)
|
||||
if !ok {
|
||||
vlog.Printf("key-dialog: cancelled by user")
|
||||
// acquireKeyInteractive: sign in / create account / paste key, then save the
|
||||
// key for this user. True when a new key was saved.
|
||||
func acquireKeyInteractive(reason string) bool {
|
||||
caps := auth.GetCapabilities()
|
||||
msg, email := reason, ""
|
||||
for i := 0; i < 25; i++ {
|
||||
act, f, err := winForm("login", msg, email)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var key string
|
||||
switch act {
|
||||
case "login":
|
||||
email = strings.TrimSpace(f[0])
|
||||
if !auth.ValidEmail(email) || f[1] == "" {
|
||||
msg = "Please enter your email and password."
|
||||
continue
|
||||
}
|
||||
vlog.Printf("sign-in: login %s", auth.MaskEmail(email))
|
||||
key, err = auth.InstallLogin(caps, email, f[1], strings.TrimSpace(f[2]), tokenName())
|
||||
f[1] = ""
|
||||
if errors.Is(err, auth.ErrTOTPRequired) {
|
||||
msg = "This account uses two-factor authentication. Enter the 6-digit code from your authenticator app, then click Sign in."
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
vlog.Errorf("sign-in: login failed for %s: %v", auth.MaskEmail(email), err)
|
||||
msg = "Sign-in failed: " + err.Error()
|
||||
continue
|
||||
}
|
||||
case "create":
|
||||
k, ok := winCreateFlow(caps, email)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = k
|
||||
case "paste":
|
||||
if pasteFlow(reason) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
default:
|
||||
vlog.Printf("sign-in: cancelled by user")
|
||||
return false
|
||||
}
|
||||
if err := auth.SaveXVeraeToken(key); err != nil {
|
||||
msg = "The key could not be saved: " + err.Error()
|
||||
continue
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
vlog.Printf("sign-in: key %s saved to %s", auth.MaskKey(key), filepath.Join(home, ".verae", "xverae-token"))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func winCreateFlow(caps auth.Capabilities, email string) (string, bool) {
|
||||
msg := "Create a Verae account.\r\n" + caps.Policy.Describe()
|
||||
for i := 0; i < 10; i++ {
|
||||
act, f, err := winForm("create", msg, email)
|
||||
if err != nil || act != "create" {
|
||||
return "", false
|
||||
}
|
||||
name, em := strings.TrimSpace(f[0]), strings.TrimSpace(f[1])
|
||||
switch {
|
||||
case name == "":
|
||||
msg = "Please enter your name."
|
||||
continue
|
||||
case !auth.ValidEmail(em):
|
||||
msg = "Please enter a valid email address."
|
||||
continue
|
||||
case f[2] != f[3]:
|
||||
msg = "The passwords do not match."
|
||||
continue
|
||||
}
|
||||
if perr := caps.Policy.Check(f[2], em); perr != nil {
|
||||
msg = perr.Error() + "\r\n" + caps.Policy.Describe()
|
||||
continue
|
||||
}
|
||||
vlog.Printf("sign-in: register %s installApi=%v", auth.MaskEmail(em), caps.InstallAPI)
|
||||
rr, err := auth.InstallRegister(caps, name, em, f[2], tokenName())
|
||||
f[2], f[3] = "", ""
|
||||
if err != nil {
|
||||
msg = "Could not create the account: " + err.Error()
|
||||
continue
|
||||
}
|
||||
if rr.PendingID == "" {
|
||||
return rr.Key, true
|
||||
}
|
||||
cmsg := "We emailed a 6-digit code to " + auth.MaskEmail(em) + ". It may take a minute; check your spam folder."
|
||||
for j := 0; j < 10; j++ {
|
||||
act, cf, err := winForm("code", cmsg, "")
|
||||
if err != nil || act == "cancel" || act == "" {
|
||||
return "", false
|
||||
}
|
||||
if act == "resend" {
|
||||
if rerr := auth.InstallResend(rr.PendingID); rerr != nil {
|
||||
cmsg = "Could not send a new code: " + rerr.Error()
|
||||
} else {
|
||||
cmsg = "A new code was sent to " + auth.MaskEmail(em) + "."
|
||||
}
|
||||
continue
|
||||
}
|
||||
key, verr := auth.InstallVerify(rr.PendingID, cf[0], tokenName())
|
||||
if verr != nil {
|
||||
cmsg = verr.Error()
|
||||
continue
|
||||
}
|
||||
return key, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// pasteFlow: masked key field + portal button; validated before saving.
|
||||
func pasteFlow(reason string) bool {
|
||||
msg := "Open customer.verae.ai, sign in, open Security and create an xVerae- token (shown once). Paste it below."
|
||||
for i := 0; i < 10; i++ {
|
||||
act, f, err := winForm("paste", msg, "")
|
||||
if err != nil || act != "paste" {
|
||||
return false
|
||||
}
|
||||
key := strings.TrimSpace(f[0])
|
||||
st, det := auth.ValidateXVeraeKey(key)
|
||||
vlog.Printf("key-dialog: entered %s validate=%s %s", auth.MaskKey(key), st, det)
|
||||
vlog.Printf("sign-in: pasted %s validate=%s %s", auth.MaskKey(key), st, det)
|
||||
switch st {
|
||||
case "valid", "unreachable", "unknown":
|
||||
if err := auth.SaveXVeraeToken(key); err != nil {
|
||||
msg = "Could not save the key: " + err.Error()
|
||||
continue
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
vlog.Printf("key-dialog: saved %s to %s", auth.MaskKey(key), filepath.Join(home, ".verae", "xverae-token"))
|
||||
messageBox("Verae TimeStamp PDF", "Signing key saved ("+auth.MaskKey(key)+").", 0x40)
|
||||
vlog.Printf("sign-in: key %s saved", auth.MaskKey(key))
|
||||
return true
|
||||
case "invalid":
|
||||
msg = "Verae rejected that key (" + det + "). Please check it and paste again."
|
||||
|
|
@ -86,9 +259,19 @@ func keyDialogFlow(reason string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// keyDialogFlow is the Start-menu "Setup signing key" entry point.
|
||||
func keyDialogFlow(reason string) bool { return acquireKeyInteractive(reason) }
|
||||
|
||||
func runKeyDialog() int {
|
||||
vlog.Init("helper", false)
|
||||
if keyDialogFlow("Set up your Verae signing key for the 'Verae TimeStamp PDF' printer.") {
|
||||
release, ok := promptLock()
|
||||
if !ok {
|
||||
messageBox("Verae TimeStamp PDF", "A Verae sign-in window is already open.", 0x40)
|
||||
return 1
|
||||
}
|
||||
defer release()
|
||||
if keyDialogFlow("Sign in to Verae to set up the signing key for the 'Verae TimeStamp PDF' printer.") {
|
||||
messageBox("Verae TimeStamp PDF", "Your Verae signing key is saved. Waiting print jobs (if any) will be timestamped within a minute.", 0x40)
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
|
|
|
|||
370
cmd/verae-print/keysetup_darwin.go
Normal file
370
cmd/verae-print/keysetup_darwin.go
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/settings"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
|
||||
)
|
||||
|
||||
// macOS in-installer key registration, run by the pkg postinstall as the
|
||||
// console user (launchctl asuser + sudo -H -u). Native AppleScript dialogs;
|
||||
// passwords come back through osascript's stdout pipe only — never argv,
|
||||
// never files, never logs. Keys are logged masked only.
|
||||
|
||||
const dlgTitle = "Verae Print Stamp"
|
||||
|
||||
// AppleScript gets all user-visible text via argv (no string interpolation →
|
||||
// no injection). Output: "<button>\t<text returned>" or "CANCEL".
|
||||
const dialogScript = `on run argv
|
||||
set msg to item 1 of argv
|
||||
set defAns to item 2 of argv
|
||||
set hide to (item 3 of argv) is "1"
|
||||
set btns to paragraphs of (item 4 of argv)
|
||||
set defBtn to item 5 of argv
|
||||
set useAnswer to (item 6 of argv) is "1"
|
||||
tell me to activate
|
||||
try
|
||||
if useAnswer then
|
||||
if hide then
|
||||
set r to display dialog msg default answer defAns with hidden answer buttons btns default button defBtn with title "` + dlgTitle + `" giving up after 900
|
||||
else
|
||||
set r to display dialog msg default answer defAns buttons btns default button defBtn with title "` + dlgTitle + `" giving up after 900
|
||||
end if
|
||||
if gave up of r then return "CANCEL"
|
||||
return (button returned of r) & tab & (text returned of r)
|
||||
else
|
||||
set r to display dialog msg buttons btns default button defBtn with title "` + dlgTitle + `" giving up after 900
|
||||
if gave up of r then return "CANCEL"
|
||||
return (button returned of r) & tab
|
||||
end if
|
||||
on error number -128
|
||||
return "CANCEL"
|
||||
end try
|
||||
end run`
|
||||
|
||||
const chooseScript = `on run argv
|
||||
set msg to item 1 of argv
|
||||
set opts to paragraphs of (item 2 of argv)
|
||||
tell me to activate
|
||||
set r to choose from list opts with prompt msg default items {item 1 of opts} OK button name "Continue" cancel button name "Skip" with title "` + dlgTitle + `"
|
||||
if r is false then return "CANCEL"
|
||||
return item 1 of r
|
||||
end run`
|
||||
|
||||
var errCancel = errors.New("cancelled")
|
||||
|
||||
func osa(script string, args ...string) (string, error) {
|
||||
cmd := exec.Command("osascript", append([]string{"-e", script, "--"}, args...)...)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("osascript: %v", err)
|
||||
}
|
||||
return strings.TrimRight(string(out), "\n"), nil
|
||||
}
|
||||
|
||||
// ask shows a dialog; returns (button, answer).
|
||||
func ask(msg, def string, hidden, withAnswer bool, buttons []string, defBtn string) (string, string, error) {
|
||||
b2 := func(v bool) string {
|
||||
if v {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
out, err := osa(dialogScript, msg, def, b2(hidden), strings.Join(buttons, "\n"), defBtn, b2(withAnswer))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if out == "CANCEL" {
|
||||
return "", "", errCancel
|
||||
}
|
||||
btn, ans, _ := strings.Cut(out, "\t")
|
||||
return btn, ans, nil
|
||||
}
|
||||
|
||||
func say(msg string) { _, _, _ = ask(msg, "", false, false, []string{"OK"}, "OK") }
|
||||
|
||||
func choose(msg string, opts []string) (string, error) {
|
||||
out, err := osa(chooseScript, msg, strings.Join(opts, "\n"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out == "CANCEL" {
|
||||
return "", errCancel
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
const (
|
||||
optKeep = "Keep the existing key"
|
||||
optLogin = "Sign in to my Verae account (email + password)"
|
||||
optCreate = "Create a new Verae account"
|
||||
optPaste = "Paste an xVerae- key"
|
||||
optSkip = "Skip for now"
|
||||
)
|
||||
|
||||
func runMacKeySetup(args []string) int {
|
||||
fs := flag.NewFlagSet("mac-key-setup", flag.ContinueOnError)
|
||||
askOpen := fs.Bool("ask-open", true, "also ask what to open after each print")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
vlog.Init("installer", false)
|
||||
vlog.Printf("mac-key-setup start user=%s home=%s", os.Getenv("USER"), os.Getenv("HOME"))
|
||||
release, ok := promptLock()
|
||||
if !ok {
|
||||
say("A Verae sign-in window is already open.")
|
||||
return 1
|
||||
}
|
||||
result, saved := keyFlow("")
|
||||
release()
|
||||
vlog.Printf("mac-key-setup key result: %s", result)
|
||||
fmt.Println("key:", result)
|
||||
if saved || strings.HasPrefix(result, "kept") {
|
||||
if n := len(listPending()); n > 0 {
|
||||
done, remaining, _ := retryPending(stampFile)
|
||||
vlog.Printf("mac-key-setup: retried held print jobs: %d stamped, %d remaining", done, remaining)
|
||||
fmt.Printf("pending jobs: %d stamped, %d remaining\n", done, remaining)
|
||||
}
|
||||
}
|
||||
if *askOpen {
|
||||
mode := askOpenOnComplete()
|
||||
fmt.Println("openOnComplete:", mode)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// acquireKeyInteractive: native sign-in dialogs after a 401/403 or missing key.
|
||||
func acquireKeyInteractive(reason string) bool {
|
||||
_, saved := keyFlow(reason)
|
||||
return saved
|
||||
}
|
||||
|
||||
// keyFlow returns a masked result string and whether a NEW key was saved.
|
||||
func keyFlow(reason string) (string, bool) {
|
||||
existing, _ := auth.LoadXVeraeToken()
|
||||
caps := auth.GetCapabilities()
|
||||
vlog.Printf("mac-key-setup: installApi=%v emailVerification=%v existing=%v", caps.InstallAPI, caps.EmailVerification, existing != "")
|
||||
var opts []string
|
||||
intro := "No Verae signing key was found for this Mac user. You need one to timestamp documents."
|
||||
if existing != "" {
|
||||
st, det := auth.ValidateXVeraeKey(existing)
|
||||
vlog.Printf("mac-key-setup: existing key %s check=%s %s", auth.MaskKey(existing), st, det)
|
||||
switch st {
|
||||
case "valid":
|
||||
intro = "A Verae signing key is already saved: " + auth.MaskKey(existing) + " (verified with Verae)."
|
||||
opts = append(opts, optKeep)
|
||||
case "invalid":
|
||||
intro = "A saved key (" + auth.MaskKey(existing) + ") was REJECTED by Verae. Please sign in or paste a new key."
|
||||
default:
|
||||
intro = "A Verae signing key is saved: " + auth.MaskKey(existing) + " (could not verify right now)."
|
||||
opts = append(opts, optKeep)
|
||||
}
|
||||
}
|
||||
opts = append(opts, optLogin, optCreate, optPaste, optSkip)
|
||||
if reason != "" {
|
||||
intro = reason + "\n\n" + intro
|
||||
}
|
||||
prompt := intro + "\n\nYour password is sent only to customer.verae.ai over HTTPS and is never stored. Only the issued key is saved (Keychain + ~/.verae)."
|
||||
for {
|
||||
c, err := choose(prompt, opts)
|
||||
if err != nil {
|
||||
c = optSkip
|
||||
}
|
||||
var key string
|
||||
switch c {
|
||||
case optKeep:
|
||||
return "kept " + auth.MaskKey(existing), false
|
||||
case optLogin:
|
||||
key, err = macLogin(caps)
|
||||
case optCreate:
|
||||
key, err = macCreate(caps)
|
||||
case optPaste:
|
||||
key, err = macPaste()
|
||||
default:
|
||||
btn, _, _ := ask("Continue without a Verae key?\n\nThe printer is installed, but documents cannot be timestamped until you add a key. You will be asked again on your first print (or open Verae Print Stamp from Applications).", "", false, false, []string{"Go back", "Skip"}, "Go back")
|
||||
if btn == "Skip" {
|
||||
return "skipped", false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, errCancel) {
|
||||
continue // back to the menu
|
||||
}
|
||||
if err != nil {
|
||||
vlog.Errorf("mac-key-setup: %v", err)
|
||||
say("Could not get a key: " + err.Error())
|
||||
continue
|
||||
}
|
||||
if err := auth.SaveXVeraeToken(key); err != nil {
|
||||
vlog.Errorf("mac-key-setup: save failed: %v", err)
|
||||
say("The key could not be saved: " + err.Error())
|
||||
continue
|
||||
}
|
||||
vlog.Printf("mac-key-setup: key saved %s", auth.MaskKey(key))
|
||||
say("Your Verae signing key (" + auth.MaskKey(key) + ") is saved.\n\nPrint from any app and choose \"Verae Timestamping\".")
|
||||
return "saved " + auth.MaskKey(key), true
|
||||
}
|
||||
}
|
||||
|
||||
func macLogin(caps auth.Capabilities) (string, error) {
|
||||
email := ""
|
||||
for {
|
||||
btn, e, err := ask("Sign in to Verae\n\nEmail:", email, false, true, []string{"Back", "Next"}, "Next")
|
||||
if err != nil || btn == "Back" {
|
||||
return "", errCancel
|
||||
}
|
||||
email = strings.TrimSpace(e)
|
||||
if !auth.ValidEmail(email) {
|
||||
say("Please enter a valid email address.")
|
||||
continue
|
||||
}
|
||||
btn, pw, err := ask("Password for "+email+":", "", true, true, []string{"Back", "Sign in"}, "Sign in")
|
||||
if err != nil || btn == "Back" {
|
||||
continue
|
||||
}
|
||||
vlog.Printf("mac-key-setup: login %s", auth.MaskEmail(email))
|
||||
key, err := auth.InstallLogin(caps, email, pw, "", tokenName())
|
||||
if errors.Is(err, auth.ErrTOTPRequired) {
|
||||
btn, code, cerr := ask("This account uses two-factor authentication.\n\nEnter the 6-digit code from your authenticator app:", "", false, true, []string{"Back", "Verify"}, "Verify")
|
||||
if cerr != nil || btn == "Back" {
|
||||
continue
|
||||
}
|
||||
key, err = auth.InstallLogin(caps, email, pw, strings.TrimSpace(code), tokenName())
|
||||
}
|
||||
pw = ""
|
||||
if err != nil {
|
||||
vlog.Errorf("mac-key-setup: login failed for %s: %v", auth.MaskEmail(email), err)
|
||||
say("Sign-in failed: " + err.Error())
|
||||
continue
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
}
|
||||
|
||||
func macCreate(caps auth.Capabilities) (string, error) {
|
||||
var name, email string
|
||||
for {
|
||||
btn, n, err := ask("Create a Verae account\n\nYour name:", name, false, true, []string{"Back", "Next"}, "Next")
|
||||
if err != nil || btn == "Back" {
|
||||
return "", errCancel
|
||||
}
|
||||
name = strings.TrimSpace(n)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
btn, e, err := ask("Email address:", email, false, true, []string{"Back", "Next"}, "Next")
|
||||
if err != nil || btn == "Back" {
|
||||
continue
|
||||
}
|
||||
email = strings.TrimSpace(e)
|
||||
if !auth.ValidEmail(email) {
|
||||
say("Please enter a valid email address.")
|
||||
continue
|
||||
}
|
||||
policy := caps.Policy.Describe()
|
||||
btn, pw, err := ask("Choose a password.\n"+policy, "", true, true, []string{"Back", "Next"}, "Next")
|
||||
if err != nil || btn == "Back" {
|
||||
continue
|
||||
}
|
||||
if perr := caps.Policy.Check(pw, email); perr != nil {
|
||||
say(perr.Error())
|
||||
continue
|
||||
}
|
||||
btn, pw2, err := ask("Confirm the password:", "", true, true, []string{"Back", "Create account"}, "Create account")
|
||||
if err != nil || btn == "Back" {
|
||||
continue
|
||||
}
|
||||
if pw != pw2 {
|
||||
say("The passwords do not match.")
|
||||
continue
|
||||
}
|
||||
pw2 = ""
|
||||
vlog.Printf("mac-key-setup: register %s installApi=%v", auth.MaskEmail(email), caps.InstallAPI)
|
||||
rr, err := auth.InstallRegister(caps, name, email, pw, tokenName())
|
||||
pw = ""
|
||||
if err != nil {
|
||||
vlog.Errorf("mac-key-setup: register failed for %s: %v", auth.MaskEmail(email), err)
|
||||
say("Could not create the account: " + err.Error())
|
||||
continue
|
||||
}
|
||||
if rr.PendingID == "" {
|
||||
return rr.Key, nil // server without email verification (legacy signup)
|
||||
}
|
||||
vlog.Printf("mac-key-setup: verification code sent to %s", auth.MaskEmail(email))
|
||||
msg := "We emailed a 6-digit code to " + auth.MaskEmail(email) + ".\nIt may take a minute; check spam.\n\nCode:"
|
||||
for {
|
||||
btn, code, err := ask(msg, "", false, true, []string{"Cancel", "Send a new code", "Verify"}, "Verify")
|
||||
if err != nil || btn == "Cancel" {
|
||||
return "", errCancel
|
||||
}
|
||||
if btn == "Send a new code" {
|
||||
if rerr := auth.InstallResend(rr.PendingID); rerr != nil {
|
||||
say("Could not send a new code: " + rerr.Error())
|
||||
} else {
|
||||
say("A new code was sent.")
|
||||
}
|
||||
continue
|
||||
}
|
||||
key, verr := auth.InstallVerify(rr.PendingID, code, tokenName())
|
||||
if verr != nil {
|
||||
say(verr.Error())
|
||||
continue
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func macPaste() (string, error) {
|
||||
for {
|
||||
btn, k, err := ask("Paste your xVerae- key.\n\nNo key yet? Click \"Open portal\", sign in, open Security and create an xVerae- token (shown once).", "", true, true, []string{"Back", "Open portal", "Save"}, "Save")
|
||||
if err != nil || btn == "Back" {
|
||||
return "", errCancel
|
||||
}
|
||||
if btn == "Open portal" {
|
||||
_ = exec.Command("open", auth.PortalSignupURL).Start()
|
||||
continue
|
||||
}
|
||||
k = strings.TrimSpace(k)
|
||||
st, det := auth.ValidateXVeraeKey(k)
|
||||
vlog.Printf("mac-key-setup: pasted key %s: %s %s", auth.MaskKey(k), st, det)
|
||||
switch st {
|
||||
case "valid":
|
||||
return k, nil
|
||||
case "invalid":
|
||||
say("Verae rejected this key (" + det + "). Please check it and paste again.")
|
||||
case "badformat":
|
||||
say("That does not look like a Verae key: " + det)
|
||||
default:
|
||||
b, _, _ := ask("Could not reach Verae to check the key ("+det+").\nSave it anyway?", "", false, false, []string{"No", "Save anyway"}, "Save anyway")
|
||||
if b == "Save anyway" {
|
||||
return k, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func askOpenOnComplete() string {
|
||||
cur, _ := settings.Load()
|
||||
def := map[string]string{settings.OpenFolder: "Show in Finder", settings.OpenPDF: "Open the PDF", settings.OpenNone: "Do nothing"}[cur.OpenOnComplete]
|
||||
btn, _, err := ask("After each print is timestamped:\n\n• Show in Finder - reveal the new certificate PDF in Verae Timestamps (recommended)\n• Open the PDF - open the certificate in Preview\n• Do nothing", "", false, false, []string{"Do nothing", "Open the PDF", "Show in Finder"}, def)
|
||||
mode := cur.OpenOnComplete
|
||||
if err == nil {
|
||||
mode = map[string]string{"Show in Finder": settings.OpenFolder, "Open the PDF": settings.OpenPDF, "Do nothing": settings.OpenNone}[btn]
|
||||
}
|
||||
if err := settings.SetOpenOnComplete(settings.UserPath(""), mode); err != nil {
|
||||
vlog.Errorf("mac-key-setup: write config: %v", err)
|
||||
} else {
|
||||
vlog.Printf("mac-key-setup: openOnComplete=%s saved to %s", mode, settings.UserPath(""))
|
||||
}
|
||||
return mode
|
||||
}
|
||||
10
cmd/verae-print/keysetup_other.go
Normal file
10
cmd/verae-print/keysetup_other.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
//go:build !darwin
|
||||
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func runMacKeySetup(args []string) int {
|
||||
fmt.Println("mac-key-setup is macOS-only (Windows uses the installer pages / key-dialog)")
|
||||
return 1
|
||||
}
|
||||
|
|
@ -35,6 +35,10 @@ func main() {
|
|||
os.Exit(runInstallerAccount(os.Args[2:]))
|
||||
case "validate-key":
|
||||
os.Exit(runValidateKey(os.Args[2:]))
|
||||
case "config":
|
||||
os.Exit(runConfig(os.Args[2:]))
|
||||
case "mac-key-setup":
|
||||
os.Exit(runMacKeySetup(os.Args[2:]))
|
||||
case "key-dialog":
|
||||
os.Exit(runKeyDialog())
|
||||
case "setup", "login":
|
||||
|
|
@ -72,9 +76,13 @@ Usage:
|
|||
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 config [--open-on-complete folder|pdf|none] [--machine | --file PATH]
|
||||
# after each print: reveal the certificate (folder, default), open it (pdf), or nothing
|
||||
verae-print mac-key-setup # macOS: native dialogs to keep/sign in/create account/paste a key (run as the user)
|
||||
verae-print version
|
||||
|
||||
Debug: set VERAE_DEBUG=1 (or --debug) for full IPP request dumps. Logs: see "verae-print version -v".
|
||||
Debug logging is off by default; set VERAE_LOG_LEVEL=debug (or "logLevel": "debug" in
|
||||
~/.verae/config.json, or serve --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.
|
||||
|
|
@ -109,7 +117,7 @@ func runStamp(args []string) int {
|
|||
channel := fs.String("channel", string(auth.PreferredChannel()), "verae | zapier")
|
||||
outDir := fs.String("out-dir", "", "output directory")
|
||||
name := fs.String("name", "", "document stem override")
|
||||
openFolder := fs.Bool("open", false, "open output folder on success (macOS)")
|
||||
openFolder := fs.Bool("open", false, "on success, apply the OpenOnComplete setting (folder|pdf|none; see 'verae-print config')")
|
||||
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)
|
||||
|
|
@ -170,6 +178,24 @@ func runStamp(args []string) int {
|
|||
Channel: ch,
|
||||
Client: client,
|
||||
})
|
||||
if err != nil && isAuthFailure(err) {
|
||||
// Key invalid/expired: keep the job, ask the user to sign in (one prompt
|
||||
// at a time across processes), then stamp everything that is pending.
|
||||
title := *name
|
||||
if title == "" {
|
||||
title = filepath.Base(pdfPath)
|
||||
}
|
||||
m, herr := holdJob(pdfPath, title, "cli", *outDir, err, false)
|
||||
if herr != nil {
|
||||
lj.Errorf("stamp failed (%v) and could not hold job: %v", err, herr)
|
||||
fmt.Fprintf(os.Stderr, "stamp failed: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
lj.Printf("stamp: key rejected (%v); job HELD as pending %s", err, m.ID)
|
||||
fmt.Printf("Verae did not accept the signing key; the job is saved (pending %s) and will be timestamped after you sign in.\n", m.ID)
|
||||
handleAuthFailure(authReason(err), stampFile)
|
||||
return 0
|
||||
}
|
||||
if err != nil {
|
||||
lj.Errorf("stamp failed: %v", err)
|
||||
fmt.Fprintf(os.Stderr, "stamp failed: %v\n", err)
|
||||
|
|
@ -181,7 +207,12 @@ func runStamp(args []string) int {
|
|||
res.OriginalPDF, res.CertificatePDF, res.ReceiptJSON, res.ReceiptID, res.SHA256, res.Channel)
|
||||
|
||||
if *openFolder {
|
||||
openFolderPath(filepath.Dir(res.OriginalPDF))
|
||||
openOnComplete(lj, res.CertificatePDF)
|
||||
}
|
||||
// the key works: stamp anything held from earlier failures
|
||||
if len(listPending()) > 0 {
|
||||
done, remaining, _ := retryPending(stampFile)
|
||||
lj.Printf("pending: %d stamped, %d remaining", done, remaining)
|
||||
}
|
||||
// macOS notification (best-effort)
|
||||
notify(fmt.Sprintf("Verae timestamp %s", res.ReceiptID), res.CertificatePDF)
|
||||
|
|
|
|||
25
cmd/verae-print/open_other.go
Normal file
25
cmd/verae-print/open_other.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/settings"
|
||||
)
|
||||
|
||||
// openResult: macOS `open -R` reveals the file in Finder, `open` opens it.
|
||||
func openResult(mode, cert string) error {
|
||||
if runtime.GOOS == "darwin" {
|
||||
if mode == settings.OpenPDF {
|
||||
return exec.Command("open", cert).Run()
|
||||
}
|
||||
return exec.Command("open", "-R", cert).Run()
|
||||
}
|
||||
if mode == settings.OpenPDF {
|
||||
return exec.Command("xdg-open", cert).Start()
|
||||
}
|
||||
return exec.Command("xdg-open", filepath.Dir(cert)).Start()
|
||||
}
|
||||
46
cmd/verae-print/open_windows.go
Normal file
46
cmd/verae-print/open_windows.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/settings"
|
||||
)
|
||||
|
||||
var procShellExecuteW = syscall.NewLazyDLL("shell32.dll").NewProc("ShellExecuteW")
|
||||
|
||||
func shellOpen(path string) error {
|
||||
verb, _ := syscall.UTF16PtrFromString("open")
|
||||
file, _ := syscall.UTF16PtrFromString(path)
|
||||
r, _, _ := procShellExecuteW.Call(0, uintptr(unsafe.Pointer(verb)), uintptr(unsafe.Pointer(file)), 0, 0, 1 /*SW_SHOWNORMAL*/)
|
||||
if r <= 32 {
|
||||
return fmt.Errorf("ShellExecute returned %d", r)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openResult runs in the helper, which already lives in the logged-in user's
|
||||
// session (scheduled task, Interactive), so Explorer opens on their desktop.
|
||||
func openResult(mode, cert string) error {
|
||||
switch mode {
|
||||
case settings.OpenPDF:
|
||||
return shellOpen(cert)
|
||||
default:
|
||||
explorer := filepath.Join(os.Getenv("WINDIR"), "explorer.exe")
|
||||
cmd := exec.Command(explorer)
|
||||
// explorer needs exactly: /select,"C:\path with spaces\file.pdf" — Go's
|
||||
// default argument quoting breaks that, so set the raw command line.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: fmt.Sprintf(`"%s" /select,"%s"`, explorer, cert)}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
go cmd.Wait() // explorer exits 1 even on success; don't treat as error
|
||||
return nil
|
||||
}
|
||||
}
|
||||
59
cmd/verae-print/opencomplete.go
Normal file
59
cmd/verae-print/opencomplete.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/settings"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
|
||||
)
|
||||
|
||||
// openOnComplete applies the OpenOnComplete setting after a successful stamp.
|
||||
func openOnComplete(lj vlog.Job, certPDF string) {
|
||||
s, src := settings.Load()
|
||||
mode := s.OpenOnComplete
|
||||
if mode == settings.OpenNone {
|
||||
lj.Printf("open-on-complete: mode=none (from %s) — not opening", src)
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(certPDF); err != nil {
|
||||
lj.Errorf("open-on-complete: mode=%s target missing %q: %v", mode, certPDF, err)
|
||||
return
|
||||
}
|
||||
err := openResult(mode, certPDF)
|
||||
if err != nil {
|
||||
lj.Errorf("open-on-complete: mode=%s (from %s) target=%q FAILED: %v", mode, src, certPDF, err)
|
||||
return
|
||||
}
|
||||
lj.Printf("open-on-complete: mode=%s (from %s) target=%q OK", mode, src, certPDF)
|
||||
}
|
||||
|
||||
// runConfig: verae-print config [--show] [--open-on-complete folder|pdf|none] [--file PATH | --machine]
|
||||
func runConfig(args []string) int {
|
||||
fs := flag.NewFlagSet("config", flag.ContinueOnError)
|
||||
open := fs.String("open-on-complete", "", "folder | pdf | none")
|
||||
file := fs.String("file", "", "config file to update (default ~/.verae/config.json)")
|
||||
machine := fs.Bool("machine", false, "update the all-users default file instead")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
path := settings.UserPath("")
|
||||
if *machine {
|
||||
path = settings.MachinePath()
|
||||
}
|
||||
if *file != "" {
|
||||
path = *file
|
||||
}
|
||||
if *open != "" {
|
||||
if err := settings.SetOpenOnComplete(path, *open); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
vlog.Init("cli", false)
|
||||
vlog.Printf("config: openOnComplete=%s written to %s", settings.NormalizeOpen(*open), path)
|
||||
}
|
||||
s, src := settings.Load()
|
||||
fmt.Printf("openOnComplete=%s (from %s)\nuser file: %s\nmachine file: %s\n", s.OpenOnComplete, src, settings.UserPath(""), settings.MachinePath())
|
||||
return 0
|
||||
}
|
||||
256
cmd/verae-print/pending.go
Normal file
256
cmd/verae-print/pending.go
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
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"
|
||||
"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/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) }
|
||||
|
||||
type pendingMeta struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Source string `json:"source"`
|
||||
OutDir string `json:"outDir,omitempty"`
|
||||
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, title, source, outDir 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: title, Source: source, OutDir: outDir,
|
||||
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, title, outDir string, lj vlog.Job) (job.Result, error)
|
||||
|
||||
// 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(), m.Title, m.OutDir, lj)
|
||||
if err != nil {
|
||||
if isAuthFailure(err) {
|
||||
lj.Errorf("pending: %s still not authorised: %v", m.ID, err)
|
||||
return done, len(listPending()), true
|
||||
}
|
||||
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(fmt.Sprintf("Verae timestamp %s", res.ReceiptID), res.CertificatePDF)
|
||||
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
|
||||
}
|
||||
|
||||
// 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())
|
||||
notifyUser("Verae: print jobs waiting", fmt.Sprintf("%d job(s) will be timestamped after you sign in.", n))
|
||||
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
|
||||
}
|
||||
90
cmd/verae-print/pending_test.go
Normal file
90
cmd/verae-print/pending_test.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
|
||||
)
|
||||
|
||||
func pendingEnv(t *testing.T) string {
|
||||
d := t.TempDir()
|
||||
t.Setenv("VERAE_PENDING_DIR", filepath.Join(d, "pending"))
|
||||
t.Setenv("VERAE_NO_NOTIFY", "1")
|
||||
t.Setenv("VERAE_OPEN_ON_COMPLETE", "none")
|
||||
return d
|
||||
}
|
||||
|
||||
func TestHoldAndRetryAfterSignIn(t *testing.T) {
|
||||
d := pendingEnv(t)
|
||||
src := filepath.Join(d, "job.pdf")
|
||||
_ = os.WriteFile(src, []byte("%PDF-1.4 x"), 0o600)
|
||||
authErr := &veraeapi.HTTPError{Service: "zapier stamp", Status: 401, Body: "unauthorized"}
|
||||
if !isAuthFailure(authErr) || !isAuthFailure(errNoKey) || isAuthFailure(errors.New("timeout")) {
|
||||
t.Fatal("isAuthFailure")
|
||||
}
|
||||
m, err := holdJob(src, "Printed 2026-10-03 192901", "watch", "", authErr, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(src); !os.IsNotExist(err) {
|
||||
t.Fatal("source should have been moved")
|
||||
}
|
||||
if l := listPending(); len(l) != 1 || l[0].ID != m.ID {
|
||||
t.Fatalf("list %v", l)
|
||||
}
|
||||
keyOK := false
|
||||
stamp := func(pdf, title, outDir string, lj vlog.Job) (job.Result, error) {
|
||||
if !keyOK {
|
||||
return job.Result{}, authErr
|
||||
}
|
||||
return job.Result{ReceiptID: "R1", CertificatePDF: pdf}, nil
|
||||
}
|
||||
// cancelled prompt → job stays pending
|
||||
prompts := 0
|
||||
acquireKeyHook = func(string) bool { prompts++; return false }
|
||||
defer func() { acquireKeyHook = nil }()
|
||||
if handleAuthFailure("x", stamp) || len(listPending()) != 1 || prompts != 1 {
|
||||
t.Fatalf("cancel: prompts=%d pending=%d", prompts, len(listPending()))
|
||||
}
|
||||
// a second prompt while one is open is suppressed (single prompt)
|
||||
release, ok := promptLock()
|
||||
if !ok {
|
||||
t.Fatal("lock")
|
||||
}
|
||||
if handleAuthFailure("x", stamp) || prompts != 1 {
|
||||
t.Fatalf("second prompt shown while locked (prompts=%d)", prompts)
|
||||
}
|
||||
release()
|
||||
// successful sign-in → retried and removed
|
||||
acquireKeyHook = func(string) bool { prompts++; keyOK = true; return true }
|
||||
if !handleAuthFailure("x", stamp) || len(listPending()) != 0 {
|
||||
t.Fatalf("after sign-in pending=%d", len(listPending()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryKeepsNonAuthFailures(t *testing.T) {
|
||||
d := pendingEnv(t)
|
||||
for _, n := range []string{"a.pdf", "b.pdf"} {
|
||||
p := filepath.Join(d, n)
|
||||
_ = os.WriteFile(p, []byte("%PDF"), 0o600)
|
||||
if _, err := holdJob(p, n, "ipp", "", errNoKey, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
calls := 0
|
||||
done, remaining, authFail := retryPending(func(pdf, title, outDir string, lj vlog.Job) (job.Result, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return job.Result{}, errors.New("network down")
|
||||
}
|
||||
return job.Result{ReceiptID: "R"}, nil
|
||||
})
|
||||
if done != 1 || remaining != 1 || authFail {
|
||||
t.Fatalf("done=%d remaining=%d authFail=%v", done, remaining, authFail)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ package main
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
|
||||
|
|
@ -11,7 +12,7 @@ import (
|
|||
)
|
||||
|
||||
func notify(title, subtitle string) {
|
||||
if runtime.GOOS != "darwin" {
|
||||
if runtime.GOOS != "darwin" || os.Getenv("VERAE_NO_NOTIFY") == "1" {
|
||||
return
|
||||
}
|
||||
script := fmt.Sprintf(`display notification %q with title "Verae Timestamp" subtitle %q`, subtitle, title)
|
||||
|
|
@ -31,8 +32,6 @@ func openFolderPath(p string) {
|
|||
_ = 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 }
|
||||
|
|
|
|||
|
|
@ -37,19 +37,6 @@ func notify(title, subtitle string) { notifyUser(title, subtitle) }
|
|||
|
||||
func openFolderPath(p string) { _ = exec.Command("explorer.exe", p).Start() }
|
||||
|
||||
// launchSetupUI asks the user (GUI, no console) to set up a key, then
|
||||
// re-processes jobs that were held because no key was present.
|
||||
func launchSetupUI() {
|
||||
go func() {
|
||||
vlog.Printf("setup: no signing key — prompting user (GUI)")
|
||||
if keyDialogFlow("Your print job was saved but not yet timestamped because no Verae signing key is set up on this PC.") {
|
||||
retryHeldJobs()
|
||||
} else {
|
||||
notifyUser("Verae key not set", "Print jobs are kept and will be timestamped after you set up a key (Start > Verae > Setup signing key).")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func runInstallPrinterWindows() int {
|
||||
script := filepath.Join(exeDir(), "..", "scripts", "install-printer.ps1")
|
||||
ps := filepath.Join(os.Getenv("WINDIR"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
||||
|
|
|
|||
|
|
@ -9,14 +9,13 @@ import (
|
|||
"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/settings"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/winprint"
|
||||
)
|
||||
|
|
@ -61,7 +60,8 @@ func runServe(args []string) int {
|
|||
outDir := fs.String("out-dir", "", "output dir (default Documents\\Verae Timestamps)")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
vlog.SetDebug(*debug || debugFromRegistry())
|
||||
cfg, _ := settings.Load()
|
||||
vlog.SetDebug(*debug || debugFromRegistry() || cfg.LogLevel == "debug")
|
||||
logPath := vlog.Init("helper", !isHelperExe())
|
||||
host, _ := os.Hostname()
|
||||
home, _ := os.UserHomeDir()
|
||||
|
|
@ -71,6 +71,7 @@ func runServe(args []string) int {
|
|||
logAuthState()
|
||||
|
||||
proc := makeProcessor(*outDir)
|
||||
go pendingOnStart()
|
||||
errc := make(chan error, 2)
|
||||
if !*noIPP {
|
||||
srv := &winprint.IPPServer{Addr: *addr, Name: "Verae TimeStamp PDF", SpoolDir: filepath.Join(userWorkDir(), "ipp"), Process: proc}
|
||||
|
|
@ -102,90 +103,67 @@ func errState(err error) string {
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
var processor winprint.Processor
|
||||
|
||||
// makeProcessor: IPP/fallback jobs → stamp. Key missing/invalid/expired
|
||||
// (HTTP 401/403) → the PDF is held in the pending folder, ONE sign-in prompt
|
||||
// is shown, and all held jobs are retried after a new key is saved.
|
||||
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)
|
||||
if !strings.Contains(j.Format, "pdf") && !sniffPDF(j.Path) {
|
||||
return fmt.Errorf("received %s (not PDF); raster→PDF conversion is not implemented — file kept at %s. Set 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})
|
||||
res, err := stampFile(j.Path, j.Title, outDir, lj)
|
||||
if err != nil {
|
||||
if isAuthFailure(err) {
|
||||
m, herr := holdJob(j.Path, j.Title, j.Source, outDir, err, true)
|
||||
if herr != nil {
|
||||
lj.Errorf("process: could not hold job: %v (PDF left at %s)", herr, j.Path)
|
||||
return err
|
||||
}
|
||||
lj.Printf("process: HELD as pending %s (%v); prompting for sign-in", m.ID, err)
|
||||
go handleAuthFailure(authReason(err), stampFile)
|
||||
return nil // the job is safe; IPP reports success to Windows
|
||||
}
|
||||
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)
|
||||
openOnComplete(lj, res.CertificatePDF)
|
||||
// a successful stamp proves the key works: flush anything still pending
|
||||
if len(listPending()) > 0 {
|
||||
go retryPending(stampFile)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return processor
|
||||
}
|
||||
|
||||
// pendingOnStart retries held jobs at helper start, and whenever a key is saved
|
||||
// by another process (Start-menu "Setup signing key", installer).
|
||||
func pendingOnStart() {
|
||||
markKeySeen()
|
||||
if n := len(listPending()); n > 0 {
|
||||
vlog.Printf("pending: %d held job(s) found at start in %s", n, pendingDir())
|
||||
if auth.HasXVeraeToken() {
|
||||
if _, _, authFail := retryPending(stampFile); !authFail {
|
||||
goto watch
|
||||
}
|
||||
}
|
||||
handleAuthFailure("Print jobs from an earlier session are waiting for a valid Verae signing key.", stampFile)
|
||||
}
|
||||
watch:
|
||||
for range time.Tick(30 * time.Second) {
|
||||
if keyChangedSinceSeen() && len(listPending()) > 0 {
|
||||
vlog.Printf("pending: a new key was saved; retrying held jobs")
|
||||
retryPending(stampFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loggingDo(lj vlog.Job) func(*http.Request) (*http.Response, error) {
|
||||
return func(r *http.Request) (*http.Response, error) {
|
||||
start := time.Now()
|
||||
|
|
|
|||
61
cmd/verae-print/stampcore.go
Normal file
61
cmd/verae-print/stampcore.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
|
||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
|
||||
)
|
||||
|
||||
// clientForChannel builds an API client from the stored credentials.
|
||||
// Missing xVerae- key (and no JWT fallback) → errNoKey.
|
||||
func clientForChannel(ch auth.Channel, lj vlog.Job) (*veraeapi.Client, auth.Channel, error) {
|
||||
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("auth: no xVerae- token, using verae channel (auth.properties)")
|
||||
client.Channel, client.VeraeToken = auth.ChannelVerae, jwt
|
||||
return client, auth.ChannelVerae, nil
|
||||
}
|
||||
return nil, ch, fmt.Errorf("%w (%v)", errNoKey, err)
|
||||
}
|
||||
client.XVeraeToken = tok
|
||||
default:
|
||||
tok, err := auth.LoadVeraeJWT()
|
||||
if err != nil {
|
||||
return nil, ch, fmt.Errorf("verae channel: %v", err)
|
||||
}
|
||||
client.VeraeToken = tok
|
||||
}
|
||||
return client, ch, nil
|
||||
}
|
||||
|
||||
// stampFile stamps one PDF with the current key (no holding / prompting).
|
||||
func stampFile(pdf, title, outDir string, lj vlog.Job) (job.Result, error) {
|
||||
client, ch, err := clientForChannel(auth.PreferredChannel(), lj)
|
||||
if err != nil {
|
||||
return job.Result{}, err
|
||||
}
|
||||
client.HTTPDo = loggingDo(lj)
|
||||
start := time.Now()
|
||||
res, err := job.Run(job.Options{SourcePDF: pdf, OutDir: outDir, DocumentName: safeStem(title) + ".pdf", Channel: ch, Client: client})
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
lj.Printf("stamp: 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)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func authReason(err error) string {
|
||||
if veraeapi.IsAuthError(err) {
|
||||
return "Verae did not accept your signing key (it may have expired or been revoked). Please sign in again."
|
||||
}
|
||||
return "No Verae signing key is set up for this user yet. Please sign in or create an account."
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
Status: **proposal + client implementation + reference server pieces**
|
||||
(`internal/installapi` handler, `internal/mailer` SMTP mailer; §7). The client side is implemented in
|
||||
`internal/auth/portal_install.go` and driven by the Windows installer
|
||||
(`platform/windows/installer-debug.nsi` → `verae-print installer-account`).
|
||||
(`platform/windows/installer-windows.nsi`, macOS `verae-print mac-key-setup` from the pkg postinstall → `verae-print installer-account`).
|
||||
The server side does **not exist yet**. The portal backend source was not
|
||||
available, so nothing was changed or deployed on the server. Until the server
|
||||
implements it, the client automatically falls back to the endpoints the portal
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import (
|
|||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
|
||||
)
|
||||
|
||||
const HelperVersion = "0.1.2"
|
||||
const HelperVersion = "0.3.1"
|
||||
|
||||
// Result paths written for one print job.
|
||||
type Result struct {
|
||||
|
|
@ -111,14 +111,17 @@ func Run(opt Options) (Result, error) {
|
|||
if origBase == "" || origBase == "." {
|
||||
origBase = stem + ".pdf"
|
||||
}
|
||||
origPath := filepath.Join(outDir, origBase)
|
||||
srcBytes, err := os.ReadFile(opt.SourcePDF)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if err := os.WriteFile(origPath, srcBytes, 0o600); err != nil {
|
||||
// Reserve a name that collides with nothing already there: the original,
|
||||
// its .verae.json sidecar. Never overwrite; add -2, -3, ... instead.
|
||||
origPath, stem, err := reserveOriginal(outDir, origBase, stem, srcBytes)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
origBase = filepath.Base(origPath)
|
||||
|
||||
digest, err := hash.FileSHA256(origPath)
|
||||
if err != nil {
|
||||
|
|
@ -136,7 +139,7 @@ func Run(opt Options) (Result, error) {
|
|||
}
|
||||
|
||||
safeID := SanitizeReceiptID(rec.ReceiptID)
|
||||
certPath := filepath.Join(outDir, fmt.Sprintf("%s-verae-timestamp-%s.pdf", stem, safeID))
|
||||
certStem := fmt.Sprintf("%s-verae-timestamp-%s", stem, safeID)
|
||||
|
||||
// Always prefer the branded Registration Document (digests-only wording,
|
||||
// no mcp.verae.ai). Edge attachPdf is optional and may still say MCP.
|
||||
|
|
@ -154,7 +157,8 @@ func Run(opt Options) (Result, error) {
|
|||
return Result{}, err
|
||||
}
|
||||
_ = rec.PDF // upstream PDF ignored in favor of branded local certificate
|
||||
if err := os.WriteFile(certPath, pdfBytes, 0o600); err != nil {
|
||||
certPath, err := writeExclusive(outDir, certStem, ".pdf", pdfBytes)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
|
|
@ -173,12 +177,12 @@ func Run(opt Options) (Result, error) {
|
|||
"status": "ok",
|
||||
"saved_at": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
jsonPath := filepath.Join(outDir, stem+".verae.json")
|
||||
jb, err := json.MarshalIndent(sidecar, "", " ")
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if err := os.WriteFile(jsonPath, jb, 0o600); err != nil {
|
||||
jsonPath, err := writeExclusive(outDir, stem, ".verae.json", jb)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
|
|
@ -191,3 +195,72 @@ func Run(opt Options) (Result, error) {
|
|||
Channel: opt.Channel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
const maxSuffix = 10000
|
||||
|
||||
func suffixed(base string, n int) string {
|
||||
if n <= 1 {
|
||||
return base
|
||||
}
|
||||
return fmt.Sprintf("%s-%d", base, n)
|
||||
}
|
||||
|
||||
func exists(p string) bool {
|
||||
_, err := os.Lstat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// createNew writes data to path only if path does not exist yet (O_EXCL).
|
||||
func createNew(path string, data []byte) error {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
f.Close()
|
||||
os.Remove(path)
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
// writeExclusive writes dir/<stem><ext>, or <stem>-2<ext>, -3 ... if taken.
|
||||
func writeExclusive(dir, stem, ext string, data []byte) (string, error) {
|
||||
for n := 1; n < maxSuffix; n++ {
|
||||
p := filepath.Join(dir, suffixed(stem, n)+ext)
|
||||
err := createNew(p, data)
|
||||
if err == nil {
|
||||
return p, nil
|
||||
}
|
||||
if !os.IsExist(err) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no free file name for %s%s in %s", stem, ext, dir)
|
||||
}
|
||||
|
||||
// reserveOriginal saves the original under origBase (or origBase-N) such that
|
||||
// neither it nor the matching <stem>.verae.json already exists. Returns the
|
||||
// saved path and the stem to use for the certificate and sidecar.
|
||||
func reserveOriginal(dir, origBase, stem string, data []byte) (string, string, error) {
|
||||
ext := filepath.Ext(origBase)
|
||||
base := strings.TrimSuffix(origBase, ext)
|
||||
if base == "" {
|
||||
base = stem
|
||||
}
|
||||
for n := 1; n < maxSuffix; n++ {
|
||||
st := suffixed(stem, n)
|
||||
if exists(filepath.Join(dir, st+".verae.json")) {
|
||||
continue
|
||||
}
|
||||
p := filepath.Join(dir, suffixed(base, n)+ext)
|
||||
err := createNew(p, data)
|
||||
if err == nil {
|
||||
return p, st, nil
|
||||
}
|
||||
if !os.IsExist(err) {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("no free file name for %s in %s", origBase, dir)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,3 +103,46 @@ func TestRunVeraeRendersLocalCert(t *testing.T) {
|
|||
t.Fatal("not a pdf")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNeverOverwritesSameName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "out")
|
||||
n := 0
|
||||
client := &veraeapi.Client{
|
||||
Channel: auth.ChannelZapier,
|
||||
ZapierBase: "https://zap.test",
|
||||
XVeraeToken: "xVerae-x",
|
||||
HTTPDo: func(req *http.Request) (*http.Response, error) {
|
||||
n++
|
||||
// same receipt id every time, to also exercise cert-name collisions
|
||||
body := `{"receipt_id":"bafySame"}`
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
|
||||
},
|
||||
}
|
||||
var results []Result
|
||||
for i, content := range []string{"%PDF-1.4 first", "%PDF-1.4 second", "%PDF-1.4 third"} {
|
||||
src := filepath.Join(dir, "src"+string(rune('a'+i))+".pdf")
|
||||
_ = os.WriteFile(src, []byte(content), 0o600)
|
||||
res, err := Run(Options{SourcePDF: src, OutDir: out, DocumentName: "Printed 2026-10-03 192901.pdf", Channel: auth.ChannelZapier, Client: client})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
results = append(results, res)
|
||||
}
|
||||
wantOrig := []string{"Printed 2026-10-03 192901.pdf", "Printed 2026-10-03 192901-2.pdf", "Printed 2026-10-03 192901-3.pdf"}
|
||||
wantJSON := []string{"Printed-2026-10-03-192901.verae.json", "Printed-2026-10-03-192901-2.verae.json", "Printed-2026-10-03-192901-3.verae.json"}
|
||||
wantCert := []string{"Printed-2026-10-03-192901-verae-timestamp-bafySame.pdf", "Printed-2026-10-03-192901-2-verae-timestamp-bafySame.pdf", "Printed-2026-10-03-192901-3-verae-timestamp-bafySame.pdf"}
|
||||
for i, r := range results {
|
||||
if filepath.Base(r.OriginalPDF) != wantOrig[i] || filepath.Base(r.ReceiptJSON) != wantJSON[i] || filepath.Base(r.CertificatePDF) != wantCert[i] {
|
||||
t.Fatalf("job %d: %s | %s | %s", i, filepath.Base(r.OriginalPDF), filepath.Base(r.ReceiptJSON), filepath.Base(r.CertificatePDF))
|
||||
}
|
||||
}
|
||||
if b, _ := os.ReadFile(results[0].OriginalPDF); string(b) != "%PDF-1.4 first" {
|
||||
t.Fatalf("first original overwritten: %q", b)
|
||||
}
|
||||
// a pre-existing cert with the target name is never overwritten
|
||||
p, err := writeExclusive(out, "Printed-2026-10-03-192901-verae-timestamp-bafySame", ".pdf", []byte("x"))
|
||||
if err != nil || filepath.Base(p) != "Printed-2026-10-03-192901-verae-timestamp-bafySame-2.pdf" {
|
||||
t.Fatal(p, err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
128
internal/settings/settings.go
Normal file
128
internal/settings/settings.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// Package settings holds user preferences for the print helper.
|
||||
//
|
||||
// Lookup order (first hit wins per field):
|
||||
// 1. env VERAE_OPEN_ON_COMPLETE
|
||||
// 2. user file ~/.verae/config.json (written by the installers for the console/logged-in user)
|
||||
// 3. machine file %ProgramData%\Verae\PrintStamp\config.json (Windows)
|
||||
// or /Library/Application Support/Verae/PrintStamp/config.json (macOS)
|
||||
// 4. defaults (OpenOnComplete = "folder", LogLevel = info)
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OpenOnComplete values.
|
||||
const (
|
||||
OpenFolder = "folder" // reveal the certificate PDF in Explorer/Finder
|
||||
OpenPDF = "pdf" // open the certificate PDF in the default viewer
|
||||
OpenNone = "none"
|
||||
)
|
||||
|
||||
// Settings is the on-disk JSON shape.
|
||||
type Settings struct {
|
||||
OpenOnComplete string `json:"openOnComplete,omitempty"`
|
||||
// LogLevel "debug" enables verbose helper logs (same as VERAE_LOG_LEVEL=debug).
|
||||
LogLevel string `json:"logLevel,omitempty"`
|
||||
}
|
||||
|
||||
// Default settings.
|
||||
var Default = Settings{OpenOnComplete: OpenFolder}
|
||||
|
||||
// NormalizeOpen maps user input to a valid value ("" if invalid).
|
||||
func NormalizeOpen(v string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "folder", "reveal", "finder", "explorer", "show":
|
||||
return OpenFolder
|
||||
case "pdf", "open", "file":
|
||||
return OpenPDF
|
||||
case "none", "off", "no", "false", "0":
|
||||
return OpenNone
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// UserPath is ~/.verae/config.json for the given home ("" = current user).
|
||||
func UserPath(home string) string {
|
||||
if home == "" {
|
||||
home, _ = os.UserHomeDir()
|
||||
}
|
||||
return filepath.Join(home, ".verae", "config.json")
|
||||
}
|
||||
|
||||
// MachinePath is the all-users default file.
|
||||
func MachinePath() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
pd := os.Getenv("ProgramData")
|
||||
if pd == "" {
|
||||
pd = `C:\ProgramData`
|
||||
}
|
||||
return filepath.Join(pd, "Verae", "PrintStamp", "config.json")
|
||||
}
|
||||
return "/Library/Application Support/Verae/PrintStamp/config.json"
|
||||
}
|
||||
|
||||
func readFile(p string) (Settings, bool) {
|
||||
var s Settings
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return s, false
|
||||
}
|
||||
b = []byte(strings.TrimPrefix(string(b), "\uFEFF"))
|
||||
if json.Unmarshal(b, &s) != nil {
|
||||
return Settings{}, false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
|
||||
// Load resolves the effective settings and a short description of where each came from.
|
||||
func Load() (Settings, string) {
|
||||
eff := Default
|
||||
src := "default"
|
||||
if s, ok := readFile(MachinePath()); ok {
|
||||
if v := NormalizeOpen(s.OpenOnComplete); v != "" {
|
||||
eff.OpenOnComplete, src = v, MachinePath()
|
||||
}
|
||||
}
|
||||
if s, ok := readFile(UserPath("")); ok {
|
||||
if v := NormalizeOpen(s.OpenOnComplete); v != "" {
|
||||
eff.OpenOnComplete, src = v, UserPath("")
|
||||
}
|
||||
}
|
||||
for _, p := range []string{MachinePath(), UserPath("")} {
|
||||
if s, ok := readFile(p); ok && s.LogLevel != "" {
|
||||
eff.LogLevel = strings.ToLower(strings.TrimSpace(s.LogLevel))
|
||||
}
|
||||
}
|
||||
if v := NormalizeOpen(os.Getenv("VERAE_OPEN_ON_COMPLETE")); v != "" {
|
||||
eff.OpenOnComplete, src = v, "env VERAE_OPEN_ON_COMPLETE"
|
||||
}
|
||||
return eff, src
|
||||
}
|
||||
|
||||
// SetOpenOnComplete updates (merges) the JSON file at path, preserving unknown keys.
|
||||
func SetOpenOnComplete(path, value string) error {
|
||||
v := NormalizeOpen(value)
|
||||
if v == "" {
|
||||
return fmt.Errorf("openOnComplete must be folder, pdf or none (got %q)", value)
|
||||
}
|
||||
m := map[string]any{}
|
||||
if b, err := os.ReadFile(path); err == nil {
|
||||
_ = json.Unmarshal([]byte(strings.TrimPrefix(string(b), "\uFEFF")), &m)
|
||||
}
|
||||
m["openOnComplete"] = v
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
b, _ := json.MarshalIndent(m, "", " ")
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
48
internal/settings/settings_test.go
Normal file
48
internal/settings/settings_test.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package settings
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadOrderAndMerge(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("VERAE_OPEN_ON_COMPLETE", "")
|
||||
if s, src := Load(); s.OpenOnComplete != OpenFolder || (src != "default" && src != MachinePath()) {
|
||||
t.Fatalf("default: %v %s", s, src)
|
||||
}
|
||||
p := UserPath("")
|
||||
_ = os.MkdirAll(filepath.Dir(p), 0o700)
|
||||
_ = os.WriteFile(p, []byte(`{"other":1}`), 0o600)
|
||||
if err := SetOpenOnComplete(p, "PDF"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := os.ReadFile(p)
|
||||
if s, _ := Load(); s.OpenOnComplete != OpenPDF {
|
||||
t.Fatalf("user: %s", b)
|
||||
}
|
||||
if string(b) == "" || !contains(string(b), `"other": 1`) {
|
||||
t.Fatalf("unknown key lost: %s", b)
|
||||
}
|
||||
t.Setenv("VERAE_OPEN_ON_COMPLETE", "none")
|
||||
if s, src := Load(); s.OpenOnComplete != OpenNone || src != "env VERAE_OPEN_ON_COMPLETE" {
|
||||
t.Fatal(s, src)
|
||||
}
|
||||
if SetOpenOnComplete(p, "bogus") == nil {
|
||||
t.Fatal("want error")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool { return indexOf(s, sub) >= 0 }
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
|
@ -105,7 +106,7 @@ func (c *Client) stampVerae(digest string) (Receipt, error) {
|
|||
_ = json.Unmarshal(raw, &parsed)
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
return Receipt{}, fmt.Errorf("verae stamp HTTP %d: %s", res.StatusCode, truncate(raw, 400))
|
||||
return Receipt{}, &HTTPError{Service: "verae stamp", Status: res.StatusCode, Body: truncate(raw, 400)}
|
||||
}
|
||||
|
||||
cid, created, merkle := extractVeraeReceipt(parsed)
|
||||
|
|
@ -311,7 +312,7 @@ func (c *Client) stampZapier(digest, documentName string) (Receipt, error) {
|
|||
return Receipt{}, err
|
||||
}
|
||||
if res.StatusCode >= 300 {
|
||||
return Receipt{}, fmt.Errorf("zapier stamp HTTP %d: %s", res.StatusCode, truncate(raw, 400))
|
||||
return Receipt{}, &HTTPError{Service: "zapier stamp", Status: res.StatusCode, Body: truncate(raw, 400)}
|
||||
}
|
||||
parsed := map[string]any{}
|
||||
_ = json.Unmarshal(raw, &parsed)
|
||||
|
|
@ -377,3 +378,18 @@ func truncate(b []byte, n int) string {
|
|||
func decodeBase64(s string) ([]byte, error) {
|
||||
return base64.StdEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
// HTTPError is a non-2xx answer from a stamp endpoint.
|
||||
type HTTPError struct {
|
||||
Service string
|
||||
Status int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *HTTPError) Error() string { return fmt.Sprintf("%s HTTP %d: %s", e.Service, e.Status, e.Body) }
|
||||
|
||||
// IsAuthError reports whether err is an HTTP 401/403 (invalid, revoked or expired key).
|
||||
func IsAuthError(err error) bool {
|
||||
var he *HTTPError
|
||||
return errors.As(err, &he) && (he.Status == 401 || he.Status == 403)
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue