package main import ( "encoding/json" "errors" "io" "net/http" "os" "path/filepath" "strconv" "time" ) type DiagramRevision struct { Diagram SavedAt string `json:"saved_at"` Action string `json:"action"` RestoredFrom int `json:"restored_from,omitempty"` } type diagramDocument struct { Diagram History []DiagramRevision `json:"history,omitempty"` Approvals []DiagramApproval `json:"approvals,omitempty"` } var errDiagramRevisionNotFound = errors.New("diagram revision not found") func (ds *diagramStore) readDocument(id string) (diagramDocument, error) { var doc diagramDocument b, err := os.ReadFile(filepath.Join(ds.dir, "diagrams", id+".json")) if err != nil { return doc, err } if err = json.Unmarshal(b, &doc); err != nil { return doc, err } if err = validateDiagram(doc.Diagram); err != nil { return doc, err } seen := map[int]bool{} for _, h := range doc.History { if h.Revision <= 0 || h.Revision > doc.Revision || seen[h.Revision] { return doc, errors.New("invalid diagram history") } if err = validateDiagram(h.Diagram); err != nil { return doc, err } seen[h.Revision] = true } if doc.Revision > 0 && !seen[doc.Revision] { doc.History = append(doc.History, DiagramRevision{Diagram: doc.Diagram, Action: "existing"}) } return doc, nil } func (ds *diagramStore) writeDocument(id string, doc diagramDocument) error { dir := filepath.Join(ds.dir, "diagrams") if err := os.MkdirAll(dir, 0755); err != nil { return err } b, err := json.MarshalIndent(doc, "", " ") if err != nil { return err } f, err := os.CreateTemp(dir, ".diagram-*") if err != nil { return err } defer os.Remove(f.Name()) if _, err = f.Write(append(b, '\n')); err != nil { f.Close() return err } if err = f.Sync(); err != nil { f.Close() return err } if err = f.Close(); err != nil { return err } return os.Rename(f.Name(), filepath.Join(dir, id+".json")) } // Caller holds ds.mu. Snapshot and current graph are committed in one atomic file. func (ds *diagramStore) writeRevision(id string, doc diagramDocument, d Diagram, action string, from int) (Diagram, error) { d.Revision = doc.Revision + 1 if err := validateDiagram(d); err != nil { return d, err } doc.Diagram = d doc.History = append(doc.History, DiagramRevision{Diagram: d, SavedAt: time.Now().In(ict).Format(time.RFC3339), Action: action, RestoredFrom: from}) return d, ds.writeDocument(id, doc) } func (ds *diagramStore) revisions(id string) ([]DiagramRevision, error) { ds.mu.Lock() defer ds.mu.Unlock() doc, err := ds.readDocument(id) if errors.Is(err, os.ErrNotExist) { return []DiagramRevision{}, nil } return doc.History, err } func (ds *diagramStore) revision(id string, revision int) (DiagramRevision, error) { ds.mu.Lock() defer ds.mu.Unlock() doc, err := ds.readDocument(id) if err != nil { if errors.Is(err, os.ErrNotExist) { return DiagramRevision{}, errDiagramRevisionNotFound } return DiagramRevision{}, err } for _, h := range doc.History { if h.Revision == revision { return h, nil } } return DiagramRevision{}, errDiagramRevisionNotFound } func (ds *diagramStore) restore(id string, expected, target int) (Diagram, error) { ds.mu.Lock() defer ds.mu.Unlock() doc, err := ds.readDocument(id) if errors.Is(err, os.ErrNotExist) { return Diagram{}, errDiagramRevisionNotFound } if err != nil { return Diagram{}, err } if doc.Revision != expected { return Diagram{}, errDiagramConflict } for _, h := range doc.History { if h.Revision == target { return ds.writeRevision(id, doc, h.Diagram, "restore", target) } } return Diagram{}, errDiagramRevisionNotFound } func decodeDiagramRequest(w http.ResponseWriter, r *http.Request, v any) bool { r.Body = http.MaxBytesReader(w, r.Body, 64<<10) dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() if err := dec.Decode(v); err != nil { http.Error(w, "Invalid request", 400) return false } var extra any if err := dec.Decode(&extra); err != io.EOF { http.Error(w, "Invalid request", 400) return false } return true } func diagramResponse(w http.ResponseWriter, value any, err error) { switch { case errors.Is(err, errDiagramConflict): http.Error(w, "A newer revision exists. Reload before retrying.", 409) case errors.Is(err, errDiagramRevisionNotFound): http.Error(w, "Revision not found", 404) case err != nil: http.Error(w, "Unable to read or save diagram", 500) default: w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") json.NewEncoder(w).Encode(value) } } func useCaseForRequest(w http.ResponseWriter, r *http.Request) (UseCase, bool) { c, err := loadUseCases(repoRoot) if err != nil { http.Error(w, "Unable to read use cases", 500) return UseCase{}, false } u, ok := c.UC[r.PathValue("uid")] if !ok { http.NotFound(w, r) } return u, ok } func registerDiagramHistory(mux *http.ServeMux, ds *diagramStore) { mux.HandleFunc("GET /api/use-cases/{uid}/revisions", func(w http.ResponseWriter, r *http.Request) { if _, ok := useCaseForRequest(w, r); !ok { return } snapshots, err := ds.revisions(r.PathValue("uid")) items := []map[string]any{} for _, s := range snapshots { items = append(items, map[string]any{"revision": s.Revision, "saved_at": s.SavedAt, "action": s.Action, "restored_from": s.RestoredFrom, "node_count": len(s.Nodes), "edge_count": len(s.Edges)}) } diagramResponse(w, items, err) }) mux.HandleFunc("GET /api/use-cases/{uid}/revisions/{revision}", func(w http.ResponseWriter, r *http.Request) { if _, ok := useCaseForRequest(w, r); !ok { return } rev, err := strconv.Atoi(r.PathValue("revision")) if err != nil { http.NotFound(w, r) return } d, err := ds.revision(r.PathValue("uid"), rev) diagramResponse(w, d, err) }) mux.HandleFunc("POST /api/use-cases/{uid}/restore", func(w http.ResponseWriter, r *http.Request) { if !sameOrigin(r) { http.Error(w, "Forbidden", 403) return } if _, ok := useCaseForRequest(w, r); !ok { return } var sub struct { Revision int `json:"revision"` Target int `json:"target_revision"` } if !decodeDiagramRequest(w, r, &sub) { return } if sub.Revision < 0 || sub.Target <= 0 { http.Error(w, "Invalid revision", 400) return } d, err := ds.restore(r.PathValue("uid"), sub.Revision, sub.Target) diagramResponse(w, d, err) }) }