Parse completedAt/certificate/result from Verae API; enrich merkle via POST /api/verify (blockIndex). Generate certificate with the same jsPDF layout as register.html export. Preserve original filename (e.g. .html).
379 lines
10 KiB
Go
379 lines
10 KiB
Go
package veraeapi
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
|
|
)
|
|
|
|
const (
|
|
defaultVeraeBase = "https://api.veraetime.net"
|
|
defaultZapierBase = "https://zap.verae.ai"
|
|
defaultPoll = 250 * time.Millisecond
|
|
defaultMaxPolls = 80
|
|
)
|
|
|
|
// Receipt is the stamp result used by the print helper.
|
|
type Receipt struct {
|
|
ReceiptID string
|
|
SHA256 string
|
|
CreatedAt string
|
|
Merkle string
|
|
Channel auth.Channel
|
|
BaseURL string
|
|
PDF []byte // registration / certificate PDF when available
|
|
PDFFilename string
|
|
RawResponse json.RawMessage
|
|
}
|
|
|
|
// Client stamps digests on Verae Time or the Zapier edge.
|
|
type Client struct {
|
|
Channel auth.Channel
|
|
VeraeBase string
|
|
ZapierBase string
|
|
VeraeToken string // JWT
|
|
XVeraeToken string
|
|
HTTPDo func(*http.Request) (*http.Response, error)
|
|
PollInterval time.Duration
|
|
MaxPolls int
|
|
}
|
|
|
|
func (c *Client) do(req *http.Request) (*http.Response, error) {
|
|
if c.HTTPDo != nil {
|
|
return c.HTTPDo(req)
|
|
}
|
|
return http.DefaultClient.Do(req)
|
|
}
|
|
|
|
func (c *Client) veraeBase() string {
|
|
if c.VeraeBase != "" {
|
|
return strings.TrimRight(c.VeraeBase, "/")
|
|
}
|
|
return defaultVeraeBase
|
|
}
|
|
|
|
func (c *Client) zapierBase() string {
|
|
if c.ZapierBase != "" {
|
|
return strings.TrimRight(c.ZapierBase, "/")
|
|
}
|
|
return defaultZapierBase
|
|
}
|
|
|
|
// Stamp submits digest and waits for a receipt. documentName is for PDF metadata.
|
|
func (c *Client) Stamp(digest, documentName string) (Receipt, error) {
|
|
switch c.Channel {
|
|
case auth.ChannelZapier:
|
|
return c.stampZapier(digest, documentName)
|
|
default:
|
|
return c.stampVerae(digest)
|
|
}
|
|
}
|
|
|
|
func (c *Client) stampVerae(digest string) (Receipt, error) {
|
|
base := c.veraeBase()
|
|
body, _ := json.Marshal(map[string]any{
|
|
"data": digest,
|
|
"digest": digest,
|
|
"hashAlg": "SHA256",
|
|
"restamp": false,
|
|
})
|
|
req, err := http.NewRequest(http.MethodPost, base+"/api/timestamp", bytes.NewReader(body))
|
|
if err != nil {
|
|
return Receipt{}, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if c.VeraeToken != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.VeraeToken)
|
|
}
|
|
res, err := c.do(req)
|
|
if err != nil {
|
|
return Receipt{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
raw, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return Receipt{}, err
|
|
}
|
|
parsed := map[string]any{}
|
|
_ = json.Unmarshal(raw, &parsed)
|
|
|
|
if res.StatusCode >= 300 {
|
|
return Receipt{}, fmt.Errorf("verae stamp HTTP %d: %s", res.StatusCode, truncate(raw, 400))
|
|
}
|
|
|
|
cid, created, merkle := extractVeraeReceipt(parsed)
|
|
jobID := str(parsed["jobId"])
|
|
|
|
// Existing digest (or immediate certificate): prefer receipts[].completedAt
|
|
if cid != "" && (truthy(parsed["existing"]) || str(parsed["certificate"]) != "") {
|
|
rec := Receipt{
|
|
ReceiptID: cid,
|
|
SHA256: digest,
|
|
CreatedAt: created,
|
|
Merkle: merkle,
|
|
Channel: auth.ChannelVerae,
|
|
BaseURL: base,
|
|
RawResponse: raw,
|
|
}
|
|
return c.enrichFromHash(base, rec)
|
|
}
|
|
|
|
if jobID == "" {
|
|
return Receipt{}, fmt.Errorf("verae stamp: missing jobId in %s", truncate(raw, 400))
|
|
}
|
|
return c.pollVerae(base, digest, jobID, raw)
|
|
}
|
|
|
|
func (c *Client) pollVerae(base, digest, jobID string, firstRaw []byte) (Receipt, error) {
|
|
interval := c.PollInterval
|
|
if interval <= 0 {
|
|
interval = defaultPoll
|
|
}
|
|
max := c.MaxPolls
|
|
if max <= 0 {
|
|
max = defaultMaxPolls
|
|
}
|
|
statusURL := base + "/api/status/" + jobID
|
|
var last []byte = firstRaw
|
|
for i := 0; i < max; i++ {
|
|
time.Sleep(interval)
|
|
req, err := http.NewRequest(http.MethodGet, statusURL, nil)
|
|
if err != nil {
|
|
return Receipt{}, err
|
|
}
|
|
if c.VeraeToken != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.VeraeToken)
|
|
}
|
|
res, err := c.do(req)
|
|
if err != nil {
|
|
return Receipt{}, err
|
|
}
|
|
raw, err := io.ReadAll(res.Body)
|
|
res.Body.Close()
|
|
if err != nil {
|
|
return Receipt{}, err
|
|
}
|
|
last = raw
|
|
parsed := map[string]any{}
|
|
_ = json.Unmarshal(raw, &parsed)
|
|
st := strings.ToLower(str(parsed["status"]))
|
|
if st == "failed" || st == "error" {
|
|
return Receipt{}, fmt.Errorf("verae job %s failed: %s", jobID, truncate(raw, 400))
|
|
}
|
|
// Status shape: { status:"completed", result:"Qm…", completedAt:"…" }
|
|
cid := str(parsed["result"])
|
|
if cid == "" {
|
|
cid, _, _ = extractVeraeReceipt(parsed)
|
|
}
|
|
if (cid != "" && (st == "completed" || st == "done")) || (st == "completed" && cid != "") {
|
|
created := firstNonEmpty(str(parsed["completedAt"]), str(parsed["timestamp"]), str(parsed["created_at"]))
|
|
merkle := firstNonEmpty(str(parsed["blockHash"]), str(parsed["merkle"]), str(parsed["blockIndex"]))
|
|
if meta, ok := parsed["metadata"].(map[string]any); ok {
|
|
created = firstNonEmpty(str(meta["timestamp"]), str(meta["completedAt"]), created)
|
|
merkle = firstNonEmpty(str(meta["blockIndex"]), str(meta["blockHash"]), merkle)
|
|
}
|
|
if cid == "" {
|
|
cid = jobID
|
|
}
|
|
rec := Receipt{
|
|
ReceiptID: cid,
|
|
SHA256: digest,
|
|
CreatedAt: created,
|
|
Merkle: merkle,
|
|
Channel: auth.ChannelVerae,
|
|
BaseURL: base,
|
|
RawResponse: last,
|
|
}
|
|
return c.enrichFromHash(base, rec)
|
|
}
|
|
if st == "completed" || st == "done" {
|
|
// completed but empty result — still try hash lookup
|
|
rec := Receipt{SHA256: digest, Channel: auth.ChannelVerae, BaseURL: base, RawResponse: last, CreatedAt: str(parsed["completedAt"])}
|
|
rec, err := c.enrichFromHash(base, rec)
|
|
if err == nil && rec.ReceiptID != "" {
|
|
return rec, nil
|
|
}
|
|
}
|
|
}
|
|
return Receipt{}, fmt.Errorf("verae job %s timed out; last=%s", jobID, truncate(last, 400))
|
|
}
|
|
|
|
// extractVeraeReceipt pulls CID + completedAt from stamp / receipts[] payloads.
|
|
func extractVeraeReceipt(parsed map[string]any) (cid, created, merkle string) {
|
|
cid = firstNonEmpty(str(parsed["certificate"]), str(parsed["receipt_id"]))
|
|
created = firstNonEmpty(str(parsed["completedAt"]), str(parsed["timestamp"]), str(parsed["created_at"]))
|
|
merkle = firstNonEmpty(str(parsed["blockHash"]), str(parsed["merkle"]), str(parsed["blockIndex"]))
|
|
for _, item := range asSlice(parsed["receipts"]) {
|
|
m, ok := item.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
cid = firstNonEmpty(cid, str(m["certificate"]), str(m["receipt_id"]))
|
|
created = firstNonEmpty(created, str(m["completedAt"]), str(m["created_at"]), str(m["timestamp"]))
|
|
merkle = firstNonEmpty(merkle, str(m["blockHash"]), str(m["merkle"]), str(m["blockIndex"]))
|
|
}
|
|
return cid, created, merkle
|
|
}
|
|
|
|
func asSlice(v any) []any {
|
|
switch t := v.(type) {
|
|
case []any:
|
|
return t
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// enrichFromHash fills missing created_at / merkle / receipt via GET /api/hash/{digest}
|
|
// and POST /api/verify {certificate} (blockIndex → merkle).
|
|
func (c *Client) enrichFromHash(base string, rec Receipt) (Receipt, error) {
|
|
if rec.CreatedAt != "" && rec.Merkle != "" && rec.ReceiptID != "" {
|
|
return rec, nil
|
|
}
|
|
req, err := http.NewRequest(http.MethodGet, base+"/api/hash/"+rec.SHA256, nil)
|
|
if err == nil {
|
|
if c.VeraeToken != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.VeraeToken)
|
|
}
|
|
if res, err := c.do(req); err == nil {
|
|
raw, _ := io.ReadAll(res.Body)
|
|
res.Body.Close()
|
|
if res.StatusCode < 300 {
|
|
parsed := map[string]any{}
|
|
_ = json.Unmarshal(raw, &parsed)
|
|
cid, created, merkle := extractVeraeReceipt(parsed)
|
|
rec.ReceiptID = firstNonEmpty(rec.ReceiptID, cid)
|
|
rec.CreatedAt = firstNonEmpty(rec.CreatedAt, created)
|
|
rec.Merkle = firstNonEmpty(rec.Merkle, merkle)
|
|
}
|
|
}
|
|
}
|
|
if rec.ReceiptID != "" && rec.Merkle == "" {
|
|
body, _ := json.Marshal(map[string]string{"certificate": rec.ReceiptID})
|
|
vreq, err := http.NewRequest(http.MethodPost, base+"/api/verify", bytes.NewReader(body))
|
|
if err == nil {
|
|
vreq.Header.Set("Content-Type", "application/json")
|
|
if c.VeraeToken != "" {
|
|
vreq.Header.Set("Authorization", "Bearer "+c.VeraeToken)
|
|
}
|
|
if res, err := c.do(vreq); err == nil {
|
|
raw, _ := io.ReadAll(res.Body)
|
|
res.Body.Close()
|
|
if res.StatusCode < 300 {
|
|
parsed := map[string]any{}
|
|
_ = json.Unmarshal(raw, &parsed)
|
|
rec.CreatedAt = firstNonEmpty(rec.CreatedAt, str(parsed["timestamp"]), str(parsed["completedAt"]))
|
|
// blockIndex may be a number
|
|
rec.Merkle = firstNonEmpty(rec.Merkle, str(parsed["blockIndex"]), str(parsed["blockHash"]), str(parsed["merkle"]))
|
|
if rec.Merkle == "" {
|
|
if n, ok := parsed["blockIndex"].(float64); ok {
|
|
rec.Merkle = fmt.Sprintf("%.0f", n)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return rec, nil
|
|
}
|
|
|
|
func (c *Client) stampZapier(digest, documentName string) (Receipt, error) {
|
|
base := c.zapierBase()
|
|
body, _ := json.Marshal(map[string]any{
|
|
"sha256": digest,
|
|
"attachPdf": true,
|
|
"wait": true,
|
|
"documentName": documentName,
|
|
})
|
|
req, err := http.NewRequest(http.MethodPost, base+"/v1/timestamp", bytes.NewReader(body))
|
|
if err != nil {
|
|
return Receipt{}, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if c.XVeraeToken != "" {
|
|
req.Header.Set("x-api-key", c.XVeraeToken)
|
|
}
|
|
req.Header.Set("x-billing-channel", "api")
|
|
res, err := c.do(req)
|
|
if err != nil {
|
|
return Receipt{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
raw, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return Receipt{}, err
|
|
}
|
|
if res.StatusCode >= 300 {
|
|
return Receipt{}, fmt.Errorf("zapier stamp HTTP %d: %s", res.StatusCode, truncate(raw, 400))
|
|
}
|
|
parsed := map[string]any{}
|
|
_ = json.Unmarshal(raw, &parsed)
|
|
cid := firstNonEmpty(str(parsed["receipt_id"]), str(parsed["certificate"]))
|
|
var pdf []byte
|
|
if b64 := str(parsed["pdfBase64"]); b64 != "" {
|
|
pdf, err = decodeBase64(b64)
|
|
if err != nil {
|
|
return Receipt{}, fmt.Errorf("pdfBase64: %w", err)
|
|
}
|
|
}
|
|
return Receipt{
|
|
ReceiptID: cid,
|
|
SHA256: digest,
|
|
CreatedAt: firstNonEmpty(str(parsed["created_at"]), str(parsed["timestamp"]), time.Now().UTC().Format(time.RFC3339)),
|
|
Merkle: firstNonEmpty(str(parsed["merkle"]), str(parsed["blockHash"])),
|
|
Channel: auth.ChannelZapier,
|
|
BaseURL: base,
|
|
PDF: pdf,
|
|
PDFFilename: str(parsed["pdfFilename"]),
|
|
RawResponse: raw,
|
|
}, nil
|
|
}
|
|
|
|
func str(v any) string {
|
|
switch t := v.(type) {
|
|
case string:
|
|
return t
|
|
case fmt.Stringer:
|
|
return t.String()
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func truthy(v any) bool {
|
|
switch t := v.(type) {
|
|
case bool:
|
|
return t
|
|
case string:
|
|
return t != "" && t != "false" && t != "0"
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func firstNonEmpty(ss ...string) string {
|
|
for _, s := range ss {
|
|
if s != "" {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func truncate(b []byte, n int) string {
|
|
if len(b) <= n {
|
|
return string(b)
|
|
}
|
|
return string(b[:n]) + "…"
|
|
}
|
|
|
|
func decodeBase64(s string) ([]byte, error) {
|
|
return base64.StdEncoding.DecodeString(s)
|
|
}
|