// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package store import ( "cmp" "errors" "fmt" "io/fs" "log/slog" "os" "path" "path/filepath" "slices" "strings" "time" "sourcedock.dev/petrbalvin/volumen/internal/post" ) // CleanupStaleTombstones removes tombstones whose slug has a live post // again, so a future undelete cannot clobber it. It writes inside the // content directory, which is why it is a call of its own rather than a // side effect of New: a read-only command must not mutate the tree it // inspects. func (s *Store) CleanupStaleTombstones() { root, err := s.openRoot() if err != nil { return } for _, slugDir := range s.revisionDirs() { live := s.restoreTargetPath(slugDir) if live == "" { continue } for _, entry := range readDirIn(root, slugDir) { name := entry.Name() if !strings.HasPrefix(name, ".deleted-") || !strings.HasSuffix(name, ".md") { continue } rel := path.Join(slugDir, name) if err := root.Remove(rel); err == nil { slog.Info("store: removed stale tombstone", "tombstone", filepath.Join(s.ContentDir, rel), "live", filepath.Join(s.ContentDir, live)) } } } } // TombstonePath returns the newest .deleted-.md for slug, or "". // Tombstones are ordered by their delete stamp, falling back to the file // modification time, so a delete that follows another within the same // second still resolves to the later one. func (s *Store) TombstonePath(slug string) string { root, err := s.openRoot() if err != nil { return "" } revDir := revisionsName(slug) type named struct { name string mtime time.Time } var found []named for _, entry := range readDirIn(root, revDir) { name := entry.Name() if !strings.HasPrefix(name, ".deleted-") || !strings.HasSuffix(name, ".md") { continue } info, ok := statIn(root, revDir, name) if !ok { continue } found = append(found, named{name: name, mtime: info.ModTime()}) } if len(found) == 0 { return "" } slices.SortFunc(found, func(a, b named) int { if c := b.mtime.Compare(a.mtime); c != 0 { return c } return strings.Compare(b.name, a.name) }) return filepath.Join(s.ContentDir, revDir, found[0].name) } // Undelete restores a soft-deleted post from its newest tombstone. func (s *Store) Undelete(slug string) *post.Post { tombstone := s.TombstonePath(slug) if tombstone == "" { return nil } root, err := s.openRoot() if err != nil { slog.Warn("store: cannot restore", "slug", slug, "error", err) return nil } rel, err := filepath.Rel(s.ContentDir, tombstone) if err != nil { return nil } content, err := root.ReadFile(rel) if err != nil { slog.Warn("store: failed to read tombstone", "path", tombstone, "error", err) return nil } p, err := post.Parse(string(content)) if err != nil { slog.Warn("store: failed to parse tombstone", "path", tombstone, "error", err) return nil } if p.Slug() == "" { p.Metadata.Set("slug", slug) } name, err := s.defaultNameFor(p) if err != nil { slog.Warn("store: cannot restore", "slug", slug, "error", err) return nil } unlock := s.targetLock(filepath.Join(s.ContentDir, name)) defer unlock() if _, err := root.Stat(name); err == nil { slog.Warn("store: cannot undelete, target exists", "slug", slug, "path", name) return nil } if err := root.MkdirAll(path.Dir(name), 0o755); err != nil { slog.Warn("store: failed to restore", "path", name, "error", err) return nil } if err := atomicWriteIn(root, name, content); err != nil { slog.Warn("store: failed to restore", "path", name, "error", err) return nil } p.Path = filepath.Join(s.ContentDir, name) if err := root.Remove(rel); err != nil { slog.Warn("store: restored post but could not remove the tombstone", "path", tombstone, "error", err) } s.pruneRevisions(slug) s.InvalidateCache() return p } // writeTombstone moves the post into the revision archive as a deleted // marker. The copy carries an explicit lang so that undeleting a post // that lived in a language subdirectory puts it back where it came from. func (s *Store) writeTombstone(p *post.Post) error { if p.Path == "" { return fmt.Errorf("post has no path") } root, err := s.openRoot() if err != nil { return err } live, err := filepath.Rel(s.ContentDir, p.Path) if err != nil { return fmt.Errorf("locate %s: %w", p.Path, err) } // Two deletes of one slug can race between Find and the lock; the // second must not archive again, and the caller reports the post as // already gone rather than as a failure. if _, err := root.Stat(live); err != nil { if errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("post %s: %w", p.Slug(), fs.ErrNotExist) } return fmt.Errorf("stat %s: %w", p.Path, err) } slug := p.Slug() if slug == "" { slug = strings.TrimSuffix(filepath.Base(p.Path), ".md") } if s.revisionLimit <= 0 { if err := root.Remove(live); err != nil { return fmt.Errorf("delete %s: %w", slug, err) } return nil } revDir := revisionsName(slug) if err := root.MkdirAll(revDir, 0o755); err != nil { return fmt.Errorf("create revision directory: %w", err) } stored := p.Clone() if lang := p.Lang(); lang != "" { if existing, present := stored.Metadata.Get("lang"); !present || existing == nil { stored.Metadata.Set("lang", lang) } } dumped, err := stored.ToFile() if err != nil { return fmt.Errorf("serialise %s: %w", slug, err) } // The archive directory is shared by every language variant of the // slug, so writes into it serialise on it rather than on the live // file, whose per-language locks would let two variants race for the // same stamp. unlock := s.targetLock(filepath.Join(s.ContentDir, revDir)) defer unlock() stamp := time.Now().UTC().Format("20060102T150405Z") dest := path.Join(revDir, ".deleted-"+stamp+".md") for suffix := 1; ; suffix++ { if _, err := root.Stat(dest); err != nil { break } dest = path.Join(revDir, fmt.Sprintf(".deleted-%s-%d.md", stamp, suffix)) } if err := atomicWriteIn(root, dest, []byte(dumped)); err != nil { return fmt.Errorf("tombstone %s: %w", slug, err) } s.pruneTombstones(revDir, dest) // The copy is durable, so the original can go. A crash between the // two leaves a live post and a tombstone, which CleanupStaleTombstones // resolves in favour of the live post. if err := root.Remove(live); err != nil { return fmt.Errorf("remove %s after tombstoning: %w", p.Path, err) } return nil } // pruneTombstones removes the tombstones an older delete left behind: // only the newest is ever undeleted from, so keeping one bounds the // archive against a create/delete cycle that would otherwise grow it // forever (revision_limit deliberately does not count tombstones). func (s *Store) pruneTombstones(revDir, keep string) { root, err := s.openRoot() if err != nil { return } for _, entry := range readDirIn(root, revDir) { name := entry.Name() if !strings.HasPrefix(name, ".deleted-") || !strings.HasSuffix(name, ".md") { continue } rel := path.Join(revDir, name) if rel == keep { continue } if err := root.Remove(rel); err != nil { slog.Warn("store: could not prune an old tombstone", "path", rel, "error", err) } } } // --- read path internals --------------------------------------------------- // revisionsName returns the archive directory of a slug, relative to the // content directory. func revisionsName(slug string) string { safe := safeSlugRe.ReplaceAllString(slug, "-") // A slug of ".", "..", or similar must never become a directory path // component that walks out of the revisions tree. if safe == "" || strings.Trim(safe, ".") == "" { safe = "post" } return path.Join(revisionsDirname, safe) } // revisionDirs lists the archive directories, relative to the content // directory. func (s *Store) revisionDirs() []string { root, err := s.openRoot() if err != nil { return nil } var dirs []string for _, entry := range readDirIn(root, revisionsDirname) { if entry.IsDir() { dirs = append(dirs, path.Join(revisionsDirname, entry.Name())) } } return dirs } // restoreTargetPath finds a live post whose file name matches the encoded // slug directory of a tombstone, as a path relative to the content // directory. func (s *Store) restoreTargetPath(slugDir string) string { root, err := s.openRoot() if err != nil { return "" } encoded := path.Base(slugDir) candidates := []string{encoded + ".md"} var names []string for _, entry := range readDirIn(root, ".") { if !entry.IsDir() { continue } name := entry.Name() if name == MediaDirName || name == revisionsDirname { continue } names = append(names, name) } slices.Sort(names) for _, name := range names { candidates = append(candidates, path.Join(name, encoded+".md")) } for _, candidate := range candidates { if _, err := root.Stat(candidate); err == nil { return candidate } } return "" } func (s *Store) archiveRevision(name, slug string) { if s.revisionLimit <= 0 { return } root, err := s.openRoot() if err != nil { slog.Warn("store: could not archive revision", "slug", slug, "error", err) return } existing, err := root.ReadFile(name) if err != nil { // A missing file is the ordinary first save; anything else // (permissions, I/O) would silently drop the previous version, // so it is logged rather than swallowed. if !errors.Is(err, fs.ErrNotExist) { slog.Warn("store: could not read the previous version to archive it", "slug", slug, "path", name, "error", err) } return } revDir := revisionsName(slug) if err := root.MkdirAll(revDir, 0o755); err != nil { slog.Warn("store: could not archive revision", "slug", slug, "error", err) return } // The archive directory is shared by every language variant of the // slug, so the write serialises on it; two variants saving in the // same second would otherwise pick the same stamp and write through // the same temp file. unlock := s.targetLock(filepath.Join(s.ContentDir, revDir)) defer unlock() stamp := time.Now().UTC().Format("20060102T150405Z") dest := path.Join(revDir, stamp+".md") for suffix := 1; ; suffix++ { if _, err := root.Stat(dest); err != nil { break } dest = path.Join(revDir, fmt.Sprintf("%s-%d.md", stamp, suffix)) } // The revision is a full copy of the previous file, so it goes // through the same atomic write as the post itself: a crash must not // leave a half-written revision in the history. if err := atomicWriteIn(root, dest, existing); err != nil { slog.Warn("store: could not archive revision", "slug", slug, "error", err) return } s.pruneRevisions(slug) } func (s *Store) pruneRevisions(slug string) { if s.revisionLimit <= 0 { return } entries := s.revisionEntries(slug) if len(entries) <= s.revisionLimit { return } root, err := s.openRoot() if err != nil { return } for _, e := range entries[:len(entries)-s.revisionLimit] { if err := root.Remove(path.Join(revisionsName(slug), e.name)); err != nil { slog.Warn("store: could not prune revision", "slug", slug, "name", e.name, "error", err) } } } type revisionEntry struct { name string mtime int64 } // revisionEntries lists revision files for slug (excluding delete // tombstones), oldest first. func (s *Store) revisionEntries(slug string) []revisionEntry { root, err := s.openRoot() if err != nil { return nil } revDir := revisionsName(slug) var out []revisionEntry for _, entry := range readDirIn(root, revDir) { name := entry.Name() if !strings.HasSuffix(name, ".md") || strings.HasPrefix(name, ".deleted-") { continue } info, ok := statIn(root, revDir, name) if !ok { continue } out = append(out, revisionEntry{name: name, mtime: info.ModTime().UnixNano()}) } slices.SortFunc(out, func(a, b revisionEntry) int { if c := cmp.Compare(a.mtime, b.mtime); c != 0 { return c } return strings.Compare(a.name, b.name) }) return out } // Revision is one archived revision as shown in the admin UI. type Revision struct { Name string Size int64 When string } // Revisions lists archived revisions for slug, newest first. func (s *Store) Revisions(slug string) []Revision { entries := s.revisionEntries(slug) var out []Revision root, err := s.openRoot() if err != nil { return nil } revDir := revisionsName(slug) for _, rev := range slices.Backward(entries) { info, err := root.Stat(path.Join(revDir, rev.name)) if err != nil { continue } out = append(out, Revision{ Name: rev.name, Size: info.Size(), When: time.Unix(0, rev.mtime).UTC().Format("2006-01-02 15:04 UTC"), }) } return out } func (s *Store) revisionPath(slug, name string) string { safeName := filepath.Base(name) if !strings.HasSuffix(safeName, ".md") { return "" } root, err := s.openRoot() if err != nil { return "" } rel := path.Join(revisionsName(slug), safeName) if _, err := root.Stat(rel); err != nil { return "" } return filepath.Join(s.ContentDir, rel) } // RevisionContent returns the raw Markdown of one archived revision. func (s *Store) RevisionContent(slug, name string) string { path := s.revisionPath(slug, name) if path == "" { return "" } data, err := os.ReadFile(path) if err != nil { slog.Warn("store: cannot read revision", "slug", slug, "name", name, "error", err) return "" } return string(data) } // RestoreRevision replaces the post's content with an archived // revision; the current state is archived first, so restoring is // itself reversible. func (s *Store) RestoreRevision(p *post.Post, name string) *post.Post { content := s.RevisionContent(p.Slug(), name) if content == "" { return nil } restored, err := post.Parse(content) if err != nil { slog.Warn("store: cannot restore revision", "slug", p.Slug(), "error", err) return nil } restored.Metadata.Set("slug", p.Slug()) restored.Path = p.Path saved, err := s.Save(restored) if err != nil { slog.Warn("store: cannot restore revision", "slug", p.Slug(), "error", err) return nil } return saved }