Files
mailgosend/internal/caldav/server.go
T
2026-05-22 06:06:44 +00:00

731 lines
21 KiB
Go

// Package caldav implements a CalDAV server (RFC 4791 over WebDAV RFC 4918).
// Authentication: HTTP Basic Auth against the user DB.
// Events are stored as AES-256-GCM encrypted iCalendar blobs.
package caldav
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/xml"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
appCrypto "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/db"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
)
const (
nsDAV = "DAV:"
nsCalDAV = "urn:ietf:params:xml:ns:caldav"
nsCS = "http://calendarserver.org/ns/"
)
// Deps holds CalDAV server dependencies.
type Deps struct {
DB *db.DB
Crypt *appCrypto.Crypto
}
// Server is the CalDAV HTTP handler.
type Server struct {
deps *Deps
mux *http.ServeMux
}
// New creates a CalDAV server and registers handlers on the given mux prefix.
func New(deps *Deps) *Server {
s := &Server{deps: deps, mux: http.NewServeMux()}
s.setup()
return s
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(w, r)
}
func (s *Server) setup() {
// Well-known discovery redirects.
s.mux.HandleFunc("/.well-known/caldav", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/caldav/", http.StatusMovedPermanently)
})
// All CalDAV routes use a catch-all; we route internally by path/method.
s.mux.HandleFunc("/caldav/", s.withAuth(s.route))
}
// withAuth wraps a handler requiring HTTP Basic Auth.
func (s *Server) withAuth(next func(http.ResponseWriter, *http.Request, *models.User)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, err := s.authenticate(r)
if err != nil || user == nil {
w.Header().Set("WWW-Authenticate", `Basic realm="mailgosend CalDAV", charset="UTF-8"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if !user.Enabled {
http.Error(w, "Account disabled", http.StatusForbidden)
return
}
next(w, r, user)
}
}
func (s *Server) authenticate(r *http.Request) (*models.User, error) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Basic ") {
return nil, nil
}
decoded, err := base64.StdEncoding.DecodeString(authHeader[6:])
if err != nil {
return nil, fmt.Errorf("basic auth decode: %w", err)
}
parts := strings.SplitN(string(decoded), ":", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("basic auth format")
}
email := strings.TrimSpace(parts[0])
password := parts[1]
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
user, err := s.deps.DB.GetUserByEmail(ctx, email)
if err != nil || user == nil {
return nil, err
}
if err := appCrypto.CheckPassword(user.PasswordHash, password); err != nil {
return nil, nil
}
return user, nil
}
// route dispatches CalDAV requests by path structure and HTTP method.
// Path patterns under /caldav/:
// /caldav/ → root (discovery)
// /caldav/p/{userID} → principal
// /caldav/{userID}/ → calendar home
// /caldav/{userID}/{calID}/ → calendar collection
// /caldav/{userID}/{calID}/{uid}.ics → event resource
func (s *Server) route(w http.ResponseWriter, r *http.Request, user *models.User) {
path := strings.TrimPrefix(r.URL.Path, "/caldav")
path = strings.TrimSuffix(path, "/")
segments := splitPath(path)
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
r = r.WithContext(ctx)
switch len(segments) {
case 0:
// /caldav/ — root
s.handleOptions(w, r, "caldav")
if r.Method == "PROPFIND" {
s.propfindRoot(w, r, user)
}
case 1:
if segments[0] == "p" || strings.HasPrefix(segments[0], "p") {
// /caldav/p — principal without user ID (redirect to user principal)
http.Redirect(w, r, fmt.Sprintf("/caldav/p/%d", user.ID), http.StatusMovedPermanently)
return
}
// /caldav/{userID}/ — calendar home
ownerID, err := strconv.ParseInt(segments[0], 10, 64)
if err != nil || ownerID != user.ID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
switch r.Method {
case "OPTIONS":
s.handleOptions(w, r, "collection")
case "PROPFIND":
s.propfindHome(w, r, user)
case "MKCOL":
s.mkcalendarHome(w, r, user)
default:
w.Header().Set("Allow", davAllow)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case 2:
// /caldav/p/{userID} — principal
if segments[0] == "p" {
switch r.Method {
case "OPTIONS":
s.handleOptions(w, r, "principal")
case "PROPFIND":
s.propfindPrincipal(w, r, user)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
return
}
// /caldav/{userID}/{calID}/ — calendar collection
ownerID, _ := strconv.ParseInt(segments[0], 10, 64)
if ownerID != user.ID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
calID, err := strconv.ParseInt(segments[1], 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
cal, err := s.deps.DB.GetCalendarByID(r.Context(), calID)
if err != nil || cal == nil || cal.UserID != user.ID {
http.NotFound(w, r)
return
}
switch r.Method {
case "OPTIONS":
s.handleOptions(w, r, "calendar")
case "PROPFIND":
s.propfindCalendar(w, r, user, cal)
case "REPORT":
s.reportCalendar(w, r, user, cal)
case "DELETE":
s.deps.DB.DeleteCalendar(r.Context(), calID) //nolint:errcheck
w.WriteHeader(http.StatusNoContent)
default:
w.Header().Set("Allow", davAllow)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case 3:
// /caldav/{userID}/{calID}/{uid}.ics — event resource
ownerID, _ := strconv.ParseInt(segments[0], 10, 64)
if ownerID != user.ID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
calID, err := strconv.ParseInt(segments[1], 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
cal, err := s.deps.DB.GetCalendarByID(r.Context(), calID)
if err != nil || cal == nil || cal.UserID != user.ID {
http.NotFound(w, r)
return
}
uid := strings.TrimSuffix(segments[2], ".ics")
switch r.Method {
case "GET", "HEAD":
s.getEvent(w, r, user, cal, uid)
case "PUT":
s.putEvent(w, r, user, cal, uid)
case "DELETE":
s.deleteEvent(w, r, user, cal, uid)
case "OPTIONS":
s.handleOptions(w, r, "event")
default:
w.Header().Set("Allow", davAllow)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
default:
http.NotFound(w, r)
}
}
const davAllow = "OPTIONS, GET, PUT, DELETE, PROPFIND, MKCOL, REPORT"
func (s *Server) handleOptions(w http.ResponseWriter, r *http.Request, resType string) {
w.Header().Set("DAV", "1, 2, 3, calendar-access")
w.Header().Set("Allow", davAllow)
w.Header().Set("Ms-Author-Via", "DAV")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
}
}
// ---- PROPFIND handlers ----
func (s *Server) propfindRoot(w http.ResponseWriter, r *http.Request, user *models.User) {
depth := r.Header.Get("Depth")
if depth == "" {
depth = "0"
}
responses := []davResponse{
{
Href: "/caldav/",
Props: []davProp{
{Name: "resourcetype", NS: nsDAV, Value: "<D:collection/>"},
{Name: "displayname", NS: nsDAV, Value: "CalDAV"},
{Name: "current-user-principal", NS: nsDAV,
Value: fmt.Sprintf("<D:href>/caldav/p/%d</D:href>", user.ID)},
},
},
}
writeMultiStatus(w, responses)
}
func (s *Server) propfindPrincipal(w http.ResponseWriter, r *http.Request, user *models.User) {
responses := []davResponse{
{
Href: fmt.Sprintf("/caldav/p/%d", user.ID),
Props: []davProp{
{Name: "resourcetype", NS: nsDAV, Value: "<D:principal/>"},
{Name: "displayname", NS: nsDAV, Value: xmlEscape(user.DisplayName)},
{Name: "current-user-principal", NS: nsDAV,
Value: fmt.Sprintf("<D:href>/caldav/p/%d</D:href>", user.ID)},
{Name: "calendar-home-set", NS: nsCalDAV,
Value: fmt.Sprintf("<D:href>/caldav/%d/</D:href>", user.ID)},
},
},
}
writeMultiStatus(w, responses)
}
func (s *Server) propfindHome(w http.ResponseWriter, r *http.Request, user *models.User) {
depth := r.Header.Get("Depth")
cals, err := s.deps.DB.ListCalendars(r.Context(), user.ID)
if err != nil {
log.Printf("[caldav] list calendars: %v", err)
}
responses := []davResponse{
{
Href: fmt.Sprintf("/caldav/%d/", user.ID),
Props: []davProp{
{Name: "resourcetype", NS: nsDAV, Value: "<D:collection/>"},
{Name: "displayname", NS: nsDAV, Value: "Calendars"},
{Name: "current-user-principal", NS: nsDAV,
Value: fmt.Sprintf("<D:href>/caldav/p/%d</D:href>", user.ID)},
{Name: "calendar-home-set", NS: nsCalDAV,
Value: fmt.Sprintf("<D:href>/caldav/%d/</D:href>", user.ID)},
},
},
}
if depth != "0" {
for _, cal := range cals {
responses = append(responses, calendarResponse(user.ID, cal))
}
}
writeMultiStatus(w, responses)
}
func (s *Server) propfindCalendar(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar) {
depth := r.Header.Get("Depth")
responses := []davResponse{calendarResponse(user.ID, cal)}
if depth != "0" {
events, err := s.deps.DB.ListCalendarEvents(r.Context(), cal.ID)
if err != nil {
log.Printf("[caldav] list events: %v", err)
}
for _, ev := range events {
responses = append(responses, eventResponse(user.ID, cal.ID, ev))
}
}
writeMultiStatus(w, responses)
}
func calendarResponse(userID int64, cal *models.Calendar) davResponse {
ctag := strconv.FormatInt(cal.SyncToken, 10)
syncToken := fmt.Sprintf("https://example.com/ns/sync/%d", cal.SyncToken)
return davResponse{
Href: fmt.Sprintf("/caldav/%d/%d/", userID, cal.ID),
Props: []davProp{
{Name: "resourcetype", NS: nsDAV,
Value: `<D:collection/><C:calendar xmlns:C="` + nsCalDAV + `"/>`},
{Name: "displayname", NS: nsDAV, Value: xmlEscape(cal.Name)},
{Name: "calendar-description", NS: nsCalDAV, Value: xmlEscape(cal.Description)},
{Name: "calendar-color", NS: "http://apple.com/ns/ical/", Value: xmlEscape(cal.Color)},
{Name: "supported-calendar-component-set", NS: nsCalDAV,
Value: `<C:comp xmlns:C="` + nsCalDAV + `" name="VEVENT"/><C:comp xmlns:C="` + nsCalDAV + `" name="VTODO"/>`},
{Name: "getctag", NS: nsCS, Value: ctag},
{Name: "sync-token", NS: nsDAV, Value: xmlEscape(syncToken)},
},
}
}
func eventResponse(userID, calID int64, ev *models.CalendarEvent) davResponse {
return davResponse{
Href: fmt.Sprintf("/caldav/%d/%d/%s.ics", userID, calID, ev.UID),
Props: []davProp{
{Name: "resourcetype", NS: nsDAV, Value: ""},
{Name: "getetag", NS: nsDAV, Value: `"` + ev.ETag + `"`},
{Name: "getcontenttype", NS: nsDAV, Value: "text/calendar; charset=utf-8"},
{Name: "getlastmodified", NS: nsDAV, Value: ev.UpdatedAt.UTC().Format(http.TimeFormat)},
},
}
}
// ---- REPORT ----
func (s *Server) reportCalendar(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar) {
body, err := io.ReadAll(io.LimitReader(r.Body, 256*1024))
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
var req struct {
XMLName xml.Name `xml:""`
SyncToken string `xml:"sync-token"`
Hrefs []string `xml:"href"`
}
_ = xml.Unmarshal(body, &req)
localName := ""
if req.XMLName.Local != "" {
localName = req.XMLName.Local
}
switch localName {
case "sync-collection":
s.syncCollection(w, r, user, cal, req.SyncToken)
case "calendar-multiget":
s.calendarMultiget(w, r, user, cal, req.Hrefs)
default:
// calendar-query or unknown: return all events.
s.propfindCalendar(w, r, user, cal)
}
}
func (s *Server) syncCollection(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar, clientToken string) {
// Minimal sync: if token matches current, return 0 changes; else return all.
curToken := fmt.Sprintf("https://example.com/ns/sync/%d", cal.SyncToken)
if clientToken == curToken {
// No changes since last sync.
writeMultiStatus(w, []davResponse{})
return
}
// Full sync: return all events.
events, err := s.deps.DB.ListCalendarEvents(r.Context(), cal.ID)
if err != nil {
log.Printf("[caldav] sync events: %v", err)
}
var responses []davResponse
for _, ev := range events {
responses = append(responses, eventResponse(user.ID, cal.ID, ev))
}
writeMultiStatus(w, responses)
}
func (s *Server) calendarMultiget(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar, hrefs []string) {
var responses []davResponse
for _, href := range hrefs {
// Extract UID from href like /caldav/{uid}/{calid}/{uid}.ics
parts := splitPath(strings.TrimPrefix(href, "/caldav"))
if len(parts) < 3 {
continue
}
uid := strings.TrimSuffix(parts[len(parts)-1], ".ics")
ev, err := s.deps.DB.GetCalendarEvent(r.Context(), cal.ID, uid)
if err != nil || ev == nil {
responses = append(responses, davResponse{
Href: href,
Status: "HTTP/1.1 404 Not Found",
})
continue
}
// Return event with ical data.
raw, err := s.decryptICal(user.ID, ev.ICalEnc)
if err != nil {
continue
}
res := eventResponse(user.ID, cal.ID, ev)
res.Props = append(res.Props, davProp{
Name: "calendar-data", NS: nsCalDAV, Value: xmlEscape(string(raw)), CData: true,
})
responses = append(responses, res)
}
writeMultiStatus(w, responses)
}
// ---- Event GET/PUT/DELETE ----
func (s *Server) getEvent(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar, uid string) {
ev, err := s.deps.DB.GetCalendarEvent(r.Context(), cal.ID, uid)
if err != nil || ev == nil {
http.NotFound(w, r)
return
}
// Conditional GET.
if match := r.Header.Get("If-None-Match"); match != "" {
if match == `"`+ev.ETag+`"` {
w.WriteHeader(http.StatusNotModified)
return
}
}
raw, err := s.decryptICal(user.ID, ev.ICalEnc)
if err != nil {
http.Error(w, "decrypt error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
w.Header().Set("ETag", `"`+ev.ETag+`"`)
w.Header().Set("Last-Modified", ev.UpdatedAt.UTC().Format(http.TimeFormat))
if r.Method == "HEAD" {
w.Header().Set("Content-Length", strconv.Itoa(len(raw)))
return
}
w.Write(raw) //nolint:errcheck
}
func (s *Server) putEvent(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar, uid string) {
if r.ContentLength > 1024*1024 {
http.Error(w, "Request too large", http.StatusRequestEntityTooLarge)
return
}
raw, err := io.ReadAll(io.LimitReader(r.Body, 1024*1024))
if err != nil || len(raw) == 0 {
http.Error(w, "read error", http.StatusBadRequest)
return
}
// Validate it's iCalendar data.
rawStr := string(raw)
if !strings.Contains(rawStr, "BEGIN:VCALENDAR") {
http.Error(w, "Invalid iCalendar data", http.StatusBadRequest)
return
}
// Conditional check: If-Match must match existing ETag.
existing, err := s.deps.DB.GetCalendarEvent(r.Context(), cal.ID, uid)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
if ifMatch := r.Header.Get("If-Match"); ifMatch != "" && ifMatch != "*" {
if existing == nil || `"`+existing.ETag+`"` != ifMatch {
http.Error(w, "Precondition Failed", http.StatusPreconditionFailed)
return
}
}
if r.Header.Get("If-None-Match") == "*" && existing != nil {
http.Error(w, "Precondition Failed", http.StatusPreconditionFailed)
return
}
// Parse minimal fields from iCalendar for DB index.
dtStart, dtEnd, summary, recurring := parseICal(rawStr)
// Encrypt and store.
icalEnc, err := s.encryptICal(user.ID, raw)
if err != nil {
http.Error(w, "encrypt error", http.StatusInternalServerError)
return
}
etag := sha256Hex(raw)
if err := s.deps.DB.UpsertCalendarEvent(r.Context(), cal.ID, uid, etag, icalEnc, dtStart, dtEnd, summary, recurring); err != nil {
log.Printf("[caldav] upsert event: %v", err)
http.Error(w, "db error", http.StatusInternalServerError)
return
}
if _, err := s.deps.DB.BumpCalendarSyncToken(r.Context(), cal.ID); err != nil {
log.Printf("[caldav] bump token: %v", err)
}
w.Header().Set("ETag", `"`+etag+`"`)
if existing == nil {
w.WriteHeader(http.StatusCreated)
} else {
w.WriteHeader(http.StatusNoContent)
}
}
func (s *Server) deleteEvent(w http.ResponseWriter, r *http.Request, user *models.User, cal *models.Calendar, uid string) {
ev, err := s.deps.DB.GetCalendarEvent(r.Context(), cal.ID, uid)
if err != nil || ev == nil {
http.NotFound(w, r)
return
}
if ifMatch := r.Header.Get("If-Match"); ifMatch != "" && ifMatch != "*" {
if `"`+ev.ETag+`"` != ifMatch {
http.Error(w, "Precondition Failed", http.StatusPreconditionFailed)
return
}
}
if err := s.deps.DB.DeleteCalendarEvent(r.Context(), cal.ID, uid); err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
if _, err := s.deps.DB.BumpCalendarSyncToken(r.Context(), cal.ID); err != nil {
log.Printf("[caldav] bump token delete: %v", err)
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) mkcalendarHome(w http.ResponseWriter, r *http.Request, user *models.User) {
// MKCOL on calendar home: ensure default calendar exists.
if _, err := s.deps.DB.EnsureDefaultCalendar(r.Context(), user.ID); err != nil {
http.Error(w, "error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
// ---- Encryption helpers ----
func (s *Server) encryptICal(userID int64, plain []byte) ([]byte, error) {
key, err := s.deps.Crypt.DeriveKey("ical", userID)
if err != nil {
return nil, err
}
return appCrypto.Encrypt(key, plain)
}
func (s *Server) decryptICal(userID int64, enc []byte) ([]byte, error) {
key, err := s.deps.Crypt.DeriveKey("ical", userID)
if err != nil {
return nil, err
}
return appCrypto.Decrypt(key, enc)
}
// ---- iCalendar parsing (minimal, no third-party lib) ----
// parseICal extracts DTSTART, DTEND, SUMMARY, RRULE presence from raw iCal text.
func parseICal(raw string) (dtStart, dtEnd time.Time, summary string, recurring bool) {
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimRight(line, "\r")
k, v, ok := strings.Cut(line, ":")
if !ok {
continue
}
// Strip parameters from key (e.g., DTSTART;TZID=UTC → DTSTART)
k = strings.SplitN(k, ";", 2)[0]
switch strings.ToUpper(k) {
case "DTSTART":
dtStart = parseICalTime(v)
case "DTEND":
dtEnd = parseICalTime(v)
case "SUMMARY":
summary = icalUnfold(v)
case "RRULE":
recurring = true
}
}
return
}
func parseICalTime(s string) time.Time {
s = strings.TrimSuffix(s, "Z")
formats := []string{"20060102T150405", "20060102"}
for _, f := range formats {
if t, err := time.Parse(f, s); err == nil {
return t.UTC()
}
}
return time.Time{}
}
func icalUnfold(s string) string {
return strings.ReplaceAll(s, `\n`, "\n")
}
// ---- XML multi-status response ----
type davProp struct {
Name string
NS string
Value string
CData bool // wrap Value in CDATA
}
type davResponse struct {
Href string
Status string // if empty, use 200 OK
Props []davProp
}
func writeMultiStatus(w http.ResponseWriter, responses []davResponse) {
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
w.WriteHeader(http.StatusMultiStatus)
fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?>`+"\n")
fmt.Fprintf(w, `<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">`+"\n")
for _, resp := range responses {
fmt.Fprintf(w, " <D:response>\n")
fmt.Fprintf(w, " <D:href>%s</D:href>\n", xmlEscape(resp.Href))
if resp.Status != "" {
fmt.Fprintf(w, " <D:status>%s</D:status>\n", xmlEscape(resp.Status))
} else if len(resp.Props) > 0 {
fmt.Fprintf(w, " <D:propstat>\n <D:prop>\n")
for _, p := range resp.Props {
writeDAVProp(w, p)
}
fmt.Fprintf(w, " </D:prop>\n <D:status>HTTP/1.1 200 OK</D:status>\n </D:propstat>\n")
} else {
fmt.Fprintf(w, " <D:status>HTTP/1.1 200 OK</D:status>\n")
}
fmt.Fprintf(w, " </D:response>\n")
}
fmt.Fprintf(w, "</D:multistatus>\n")
}
func writeDAVProp(w http.ResponseWriter, p davProp) {
ns := ""
switch p.NS {
case nsDAV:
ns = "D"
case nsCalDAV:
ns = "C"
case nsCS:
ns = "CS"
case "http://apple.com/ns/ical/":
ns = "ICAL"
default:
ns = "D"
}
if p.Value == "" {
fmt.Fprintf(w, " <%s:%s/>\n", ns, p.Name)
return
}
if p.CData {
fmt.Fprintf(w, " <%s:%s><![CDATA[%s]]></%s:%s>\n", ns, p.Name, p.Value, ns, p.Name)
} else {
fmt.Fprintf(w, " <%s:%s>%s</%s:%s>\n", ns, p.Name, p.Value, ns, p.Name)
}
}
// ---- helpers ----
func splitPath(path string) []string {
var out []string
for _, s := range strings.Split(strings.Trim(path, "/"), "/") {
if s != "" {
out = append(out, s)
}
}
return out
}
func xmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
return s
}
func sha256Hex(data []byte) string {
h := sha256.Sum256(data)
return hex.EncodeToString(h[:])
}