Files

494 lines
14 KiB
Go
Raw Permalink Normal View History

2026-09-18 12:03:35 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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-<stamp>.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
}