package handler import ( "archive/zip" "encoding/json" "io" "net/http" "os" "path/filepath" "sort" "strings" "sync" "time" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) // Edit-in-place for the .zddc.zip config bundle. A zip is a random-access // container (unlike a streamed .tgz), so a member can be rewritten without // re-encoding the operator's intent — we read the whole archive, mutate one // member, snapshot the old version into an in-zip .history/, and atomically // replace the file. Gated to active admins (dispatch already 404s the bundle // to everyone else) and to the .zddc.zip bundle specifically; content zips stay // read-only. // // History layout INSIDE the bundle (so edits travel with it): // // .history// the prior bytes // .history//log.jsonl append-only audit (ts, email, op) // // Writes serialize on one mutex — admin bundle edits are infrequent, and a // whole-archive rewrite must not interleave. var zipWriteMu sync.Mutex const zipHistoryDir = ".history" // ServeZipWrite handles PUT (write/create a member) and DELETE (remove a // member) inside a .zddc.zip bundle. member is the path within the zip. func ServeZipWrite(cfg config.Config, w http.ResponseWriter, r *http.Request, zipAbs, member string) { member = strings.TrimLeft(member, "/") if member == "" || strings.HasSuffix(member, "/") { http.Error(w, "Bad Request — a zip member path is required", http.StatusBadRequest) return } if member == zipHistoryDir || strings.HasPrefix(member, zipHistoryDir+"/") { http.Error(w, "Forbidden — the in-zip .history/ store is append-only", http.StatusForbidden) return } switch r.Method { case http.MethodPut: body, ok := readBodyCapped(cfg, w, r) if !ok { return } zipMutate(cfg, w, r, zipAbs, member, body, false) case http.MethodDelete: zipMutate(cfg, w, r, zipAbs, member, nil, true) default: w.Header().Set("Allow", "PUT, DELETE") http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) } } // zipLogLine is one append-only audit record in an in-zip log.jsonl. type zipLogLine struct { TS string `json:"ts"` Email string `json:"email"` Op string `json:"op"` Bytes int `json:"bytes"` } func zipMutate(cfg config.Config, w http.ResponseWriter, r *http.Request, zipAbs, member string, body []byte, del bool) { zipWriteMu.Lock() defer zipWriteMu.Unlock() members, order, err := readZipMembers(zipAbs) if err != nil { http.Error(w, "Internal Server Error — read bundle: "+err.Error(), http.StatusInternalServerError) return } old, existed := members[member] if del && !existed { http.Error(w, "Not Found", http.StatusNotFound) return } // Snapshot the prior bytes + append an audit line BEFORE mutating. if existed { ts := time.Now().UTC().Format("2006-01-02T15:04:05.000000000Z") histPrefix := zipHistoryDir + "/" + member + "/" addMember(members, &order, histPrefix+ts, old) op := "put" if del { op = "delete" } line, _ := json.Marshal(zipLogLine{TS: ts, Email: EmailFromContext(r), Op: op, Bytes: len(body)}) logKey := histPrefix + "log.jsonl" members[logKey] = append(append(append([]byte{}, members[logKey]...), line...), '\n') // log.jsonl may be newly created here. ensureInOrder(&order, logKey) } if del { delete(members, member) order = removeFromOrder(order, member) } else { addMember(members, &order, member, body) } if err := writeZipAtomic(zipAbs, members, order); err != nil { auditFile(r, zipOp(del), r.URL.Path, http.StatusInternalServerError, 0, err) http.Error(w, "Internal Server Error — write bundle: "+err.Error(), http.StatusInternalServerError) return } // A .zddc.zip change can alter both policy (its .zddc members feed the // cascade) and tool HTML (apps.Bundle, which hot-reloads on mtime). Clear // the policy cache so the next decision re-reads the bundle. zddc.InvalidateCache(cfg.Root) if del { w.Header().Set("X-ZDDC-Source", "zip:delete") w.WriteHeader(http.StatusNoContent) auditFile(r, "zip-delete", r.URL.Path, http.StatusNoContent, 0, nil) return } status := http.StatusOK if !existed { status = http.StatusCreated } w.Header().Set("ETag", `"`+fileETag(body)+`"`) w.Header().Set("X-ZDDC-Source", "zip:put") w.WriteHeader(status) auditFile(r, "zip-put", r.URL.Path, status, len(body), nil) } func zipOp(del bool) string { if del { return "zip-delete" } return "zip-put" } // readZipMembers loads every member of the zip at zipAbs into a name→bytes map // plus an order slice (insertion order, for stable rewrites). func readZipMembers(zipAbs string) (map[string][]byte, []string, error) { zr, err := zip.OpenReader(zipAbs) if err != nil { return nil, nil, err } defer zr.Close() members := make(map[string][]byte, len(zr.File)) order := make([]string, 0, len(zr.File)) for _, f := range zr.File { if f.FileInfo().IsDir() { continue } rc, err := f.Open() if err != nil { return nil, nil, err } data, err := io.ReadAll(rc) rc.Close() if err != nil { return nil, nil, err } if _, dup := members[f.Name]; !dup { order = append(order, f.Name) } members[f.Name] = data } return members, order, nil } func addMember(members map[string][]byte, order *[]string, name string, data []byte) { if _, ok := members[name]; !ok { *order = append(*order, name) } members[name] = data } func ensureInOrder(order *[]string, name string) { for _, n := range *order { if n == name { return } } *order = append(*order, name) } func removeFromOrder(order []string, name string) []string { out := order[:0] for _, n := range order { if n != name { out = append(out, n) } } return out } // writeZipAtomic writes members to a fresh zip in the same directory and renames // it over zipAbs. Members are emitted in `order` (sorted as a tiebreak for any // not in order) so rewrites are deterministic. func writeZipAtomic(zipAbs string, members map[string][]byte, order []string) error { // Any member not captured in order (defensive) goes last, sorted. seen := make(map[string]bool, len(order)) for _, n := range order { seen[n] = true } var extra []string for n := range members { if !seen[n] { extra = append(extra, n) } } sort.Strings(extra) names := append(append([]string{}, order...), extra...) tmp, err := os.CreateTemp(filepath.Dir(zipAbs), ".zddc.zip.tmp-*") if err != nil { return err } tmpName := tmp.Name() defer os.Remove(tmpName) // no-op after a successful rename zw := zip.NewWriter(tmp) for _, name := range names { data, ok := members[name] if !ok { continue } fw, err := zw.Create(name) if err != nil { zw.Close() tmp.Close() return err } if _, err := fw.Write(data); err != nil { zw.Close() tmp.Close() return err } } if err := zw.Close(); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } return os.Rename(tmpName, zipAbs) }