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
7 changed files with 1804 additions and 378 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Package vlog is the verbose debug logger used by the CLI, the print helper
|
||||
// Package vlog is the logger used by the CLI, the print helper
|
||||
// and the IPP/watch servers. On Windows logs go to %ProgramData%\Verae\logs
|
||||
// (shared, ACL'd by the installer so Users can write); elsewhere to the user
|
||||
// cache dir. Every print job carries a job ID so one job can be followed from
|
||||
|
|
@ -24,9 +24,15 @@ var (
|
|||
file *os.File
|
||||
comp = "verae"
|
||||
jobSeq uint64
|
||||
debugOn = os.Getenv("VERAE_DEBUG") == "1"
|
||||
debugOn = envDebug()
|
||||
)
|
||||
|
||||
// envDebug: DEBUG level is off by default; VERAE_LOG_LEVEL=debug (or the
|
||||
// legacy VERAE_DEBUG=1) turns it on. INFO/ERROR are always written.
|
||||
func envDebug() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(os.Getenv("VERAE_LOG_LEVEL")), "debug") || os.Getenv("VERAE_DEBUG") == "1"
|
||||
}
|
||||
|
||||
// Dir returns the log directory for this platform.
|
||||
func Dir() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ func (w *Watcher) claim(p string, size int64) {
|
|||
}
|
||||
}
|
||||
lj.Printf("watch: claimed %s (%d bytes, magic=%q) → %s", p, size, sniff(dst), dst)
|
||||
j := Job{ID: id, Source: "watch", Title: "Printed " + time.Now().Format("2006-01-02 1504"), Format: "application/pdf", Path: dst, Bytes: size, Received: time.Now()}
|
||||
j := Job{ID: id, Source: "watch", Title: "Printed " + time.Now().Format("2006-01-02 150405"), Format: "application/pdf", Path: dst, Bytes: size, Received: time.Now()}
|
||||
start := time.Now()
|
||||
err := func() (err error) {
|
||||
defer func() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue