The tray and AirDrop helper now live in Verae NetPrint.app so notifications can start, /api/stamp uses the same client as Print-Job and refuses mock receipts, and the default printer name includes the hostname with an mDNS rename when that name is taken. Co-authored-by: George Lambert <marchon@gmail.com>
320 lines
8.8 KiB
Go
320 lines
8.8 KiB
Go
package job
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
|
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/hash"
|
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/pdfcert"
|
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi"
|
|
)
|
|
|
|
const HelperVersion = "0.3.4"
|
|
|
|
// Result paths written for one print job.
|
|
type Result struct {
|
|
OriginalPDF string
|
|
CertificatePDF string
|
|
ReceiptJSON string
|
|
ReceiptID string
|
|
SHA256 string
|
|
Channel auth.Channel
|
|
}
|
|
|
|
// Options for processing a captured print PDF.
|
|
type Options struct {
|
|
SourcePDF string // temp PDF from macOS PDF Services
|
|
OutDir string
|
|
DocumentName string // optional override for stem; default from source basename
|
|
// OutPath, when set, is the exact path chosen by the user in a Save As
|
|
// dialog (which already asked before replacing an existing file). The
|
|
// original is written there; certificate and sidecar go next to it using
|
|
// the chosen base name, and those are still never overwritten.
|
|
OutPath string
|
|
Channel auth.Channel
|
|
Client *veraeapi.Client
|
|
}
|
|
|
|
var unsafeName = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
|
|
|
|
// RefuseMockReceipt rejects a test double. Print-Job and /api/stamp both
|
|
// call this. A receipt id with the mock-cert prefix is not a Verae receipt,
|
|
// and it is refused outside test builds that opt into a real id.
|
|
func RefuseMockReceipt(id, merkle string) error {
|
|
lid := strings.ToLower(strings.TrimSpace(id))
|
|
if strings.HasPrefix(lid, "mock-cert") || strings.HasPrefix(lid, "mock-") {
|
|
return fmt.Errorf("mock receipt %s is not used outside test builds", strings.TrimSpace(id))
|
|
}
|
|
if strings.TrimSpace(merkle) == "42" && strings.Contains(lid, "mock") {
|
|
return fmt.Errorf("mock receipt %s is not used outside test builds", strings.TrimSpace(id))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SanitizeReceiptID makes a receipt id safe for filenames.
|
|
func SanitizeReceiptID(id string) string {
|
|
id = strings.TrimSpace(id)
|
|
id = unsafeName.ReplaceAllString(id, "-")
|
|
id = strings.Trim(id, "-.")
|
|
if id == "" {
|
|
id = "unknown"
|
|
}
|
|
// Keep under common NAME_MAX budget with prefix
|
|
const max = 180
|
|
if len(id) > max {
|
|
id = id[:max]
|
|
}
|
|
return id
|
|
}
|
|
|
|
// StemFromPath returns the document stem without .pdf.
|
|
func StemFromPath(path, override string) string {
|
|
if override != "" {
|
|
s := strings.TrimSuffix(override, filepath.Ext(override))
|
|
return unsafeName.ReplaceAllString(s, "-")
|
|
}
|
|
base := filepath.Base(path)
|
|
stem := strings.TrimSuffix(base, filepath.Ext(base))
|
|
stem = unsafeName.ReplaceAllString(stem, "-")
|
|
stem = strings.Trim(stem, "-.")
|
|
if stem == "" {
|
|
stem = "document"
|
|
}
|
|
return stem
|
|
}
|
|
|
|
// DefaultOutDir is ~/Documents/Verae Timestamps.
|
|
func DefaultOutDir() (string, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(home, "Documents", "Verae Timestamps"), nil
|
|
}
|
|
|
|
// Run copies the source PDF, stamps digests-only, writes certificate + JSON.
|
|
func Run(opt Options) (Result, error) {
|
|
if opt.SourcePDF == "" {
|
|
return Result{}, fmt.Errorf("source PDF required")
|
|
}
|
|
if opt.Client == nil {
|
|
return Result{}, fmt.Errorf("client required")
|
|
}
|
|
outDir := opt.OutDir
|
|
if opt.OutPath != "" {
|
|
outDir = filepath.Dir(opt.OutPath)
|
|
}
|
|
if outDir == "" {
|
|
d, err := DefaultOutDir()
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
outDir = d
|
|
}
|
|
if err := os.MkdirAll(outDir, 0o700); err != nil {
|
|
return Result{}, err
|
|
}
|
|
|
|
stem := StemFromPath(opt.SourcePDF, opt.DocumentName)
|
|
// Preserve original basename (e.g. .html) for the proven artifact + certificate label.
|
|
origBase := filepath.Base(opt.SourcePDF)
|
|
if opt.DocumentName != "" {
|
|
origBase = filepath.Base(opt.DocumentName)
|
|
if filepath.Ext(origBase) == "" {
|
|
origBase = origBase + filepath.Ext(opt.SourcePDF)
|
|
}
|
|
}
|
|
if origBase == "" || origBase == "." {
|
|
origBase = stem + ".pdf"
|
|
}
|
|
srcBytes, err := os.ReadFile(opt.SourcePDF)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
// Reserve a name that collides with nothing already there: the original,
|
|
// its .verae.json sidecar. Never overwrite; add -2, -3, ... instead.
|
|
var origPath string
|
|
if opt.OutPath != "" {
|
|
origPath = opt.OutPath
|
|
if !strings.EqualFold(filepath.Ext(origPath), ".pdf") {
|
|
origPath += ".pdf"
|
|
}
|
|
stem = userStem(filepath.Base(origPath))
|
|
if err := os.WriteFile(origPath, srcBytes, 0o600); err != nil { // replace confirmed by the user
|
|
return Result{}, err
|
|
}
|
|
} else {
|
|
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 {
|
|
return Result{}, err
|
|
}
|
|
|
|
client := *opt.Client
|
|
client.Channel = opt.Channel
|
|
rec, err := client.Stamp(digest, origBase)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
if err := RefuseMockReceipt(rec.ReceiptID, rec.Merkle); err != nil {
|
|
return Result{}, err
|
|
}
|
|
if rec.ReceiptID == "" {
|
|
return Result{}, fmt.Errorf("stamp returned empty receipt id")
|
|
}
|
|
|
|
safeID := SanitizeReceiptID(rec.ReceiptID)
|
|
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.
|
|
pdfBytes, err := pdfcert.Build(pdfcert.Input{
|
|
DocumentName: origBase,
|
|
SHA256: digest,
|
|
ReceiptID: rec.ReceiptID,
|
|
CreatedAt: rec.CreatedAt,
|
|
Merkle: rec.Merkle,
|
|
Scope: "global",
|
|
Channel: string(opt.Channel),
|
|
ByteLength: int64(len(srcBytes)),
|
|
})
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
_ = rec.PDF // upstream PDF ignored in favor of branded local certificate
|
|
certPath, err := writeExclusive(outDir, certStem, ".pdf", pdfBytes)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
|
|
sidecar := map[string]any{
|
|
"helper_version": HelperVersion,
|
|
"document_name": origBase,
|
|
"sha256": digest,
|
|
"receipt_id": rec.ReceiptID,
|
|
"created_at": rec.CreatedAt,
|
|
"merkle": rec.Merkle,
|
|
"channel": string(opt.Channel),
|
|
"base_url": rec.BaseURL,
|
|
"original_file": filepath.Base(origPath),
|
|
"certificate_pdf": filepath.Base(certPath),
|
|
"byte_length": len(srcBytes),
|
|
"status": "ok",
|
|
"saved_at": time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
jb, err := json.MarshalIndent(sidecar, "", " ")
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
jsonPath, err := writeExclusive(outDir, stem, ".verae.json", jb)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
|
|
return Result{
|
|
OriginalPDF: origPath,
|
|
CertificatePDF: certPath,
|
|
ReceiptJSON: jsonPath,
|
|
ReceiptID: rec.ReceiptID,
|
|
SHA256: digest,
|
|
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)
|
|
}
|
|
|
|
// userStem keeps a user-chosen name as typed (spaces etc.), only removing the
|
|
// extension and characters that are invalid in file names on any platform.
|
|
func userStem(base string) string {
|
|
st := strings.TrimSuffix(base, filepath.Ext(base))
|
|
st = strings.Map(func(r rune) rune {
|
|
if r < 32 || strings.ContainsRune(`<>:"/\\|?*`, r) {
|
|
return '-'
|
|
}
|
|
return r
|
|
}, st)
|
|
st = strings.TrimRight(strings.TrimSpace(st), ".")
|
|
if st == "" {
|
|
st = "document"
|
|
}
|
|
return st
|
|
}
|