Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
494 lines
14 KiB
Go
494 lines
14 KiB
Go
// 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
|
|
}
|