Initial commit
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
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
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// atomicWriteIn replaces name inside the root: a temporary file in the
|
||||
// same directory, fsync, rename, and a directory fsync.
|
||||
func atomicWriteIn(root *os.Root, name string, data []byte) error {
|
||||
dir := path.Dir(name)
|
||||
base := path.Base(name)
|
||||
handle, err := root.OpenFile(path.Join(dir, "."+base+".tmp"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
if _, err := handle.Write(data); err != nil {
|
||||
handle.Close()
|
||||
root.Remove(path.Join(dir, "."+base+".tmp"))
|
||||
return fmt.Errorf("write: %w", err)
|
||||
}
|
||||
if err := handle.Sync(); err != nil {
|
||||
handle.Close()
|
||||
root.Remove(path.Join(dir, "."+base+".tmp"))
|
||||
return fmt.Errorf("sync: %w", err)
|
||||
}
|
||||
if err := handle.Close(); err != nil {
|
||||
root.Remove(path.Join(dir, "."+base+".tmp"))
|
||||
return fmt.Errorf("close: %w", err)
|
||||
}
|
||||
if err := root.Rename(path.Join(dir, "."+base+".tmp"), name); err != nil {
|
||||
root.Remove(path.Join(dir, "."+base+".tmp"))
|
||||
return fmt.Errorf("replace: %w", err)
|
||||
}
|
||||
syncDirIn(root, dir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncDirIn flushes a directory entry inside the root, so the rename is
|
||||
// durable.
|
||||
func syncDirIn(root *os.Root, dir string) {
|
||||
handle, err := root.Open(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer handle.Close()
|
||||
_ = handle.Sync()
|
||||
}
|
||||
|
||||
func absPath(path string) string {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
return abs
|
||||
}
|
||||
|
||||
// within reports whether path is inside directory, comparing resolved
|
||||
// paths so that a symlinked content directory is not mistaken for an
|
||||
// escape. A path that does not exist yet is resolved through its
|
||||
// longest existing ancestor, which is what a new post file is.
|
||||
func within(directory, path string) bool {
|
||||
dirResolved := resolveExisting(directory)
|
||||
pathResolved := resolveExisting(path)
|
||||
if pathResolved == dirResolved {
|
||||
return true
|
||||
}
|
||||
rel, err := filepath.Rel(dirResolved, pathResolved)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||
}
|
||||
|
||||
// resolveExisting resolves symlinks in the longest existing prefix of
|
||||
// path and appends the remainder unchanged.
|
||||
func resolveExisting(path string) string {
|
||||
if resolved, err := filepath.EvalSymlinks(path); err == nil {
|
||||
return resolved
|
||||
}
|
||||
parent := filepath.Dir(path)
|
||||
if parent == path || parent == "." {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(resolveExisting(parent), filepath.Base(path))
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"uuid"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/imagefile"
|
||||
)
|
||||
|
||||
// MediaPath returns the absolute path of a media file, for a caller that
|
||||
// serves it. The name must carry an allowed image extension, and the file
|
||||
// is opened through the store's root, so a name that would escape the
|
||||
// content directory is refused rather than checked for.
|
||||
func (s *Store) MediaPath(name string) (string, error) {
|
||||
if !imagefile.Allowed(name) {
|
||||
return "", fmt.Errorf("%q is not an allowed image name", name)
|
||||
}
|
||||
base := filepath.Base(name)
|
||||
root, err := s.openRoot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
handle, err := root.Open(path.Join(MediaDirName, base))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer handle.Close()
|
||||
info, err := handle.Stat()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", fmt.Errorf("%q is not a regular file", base)
|
||||
}
|
||||
return filepath.Join(s.ContentDir, MediaDirName, base), nil
|
||||
}
|
||||
|
||||
// StoreUpload persists an uploaded image and returns its public URL. The
|
||||
// extension comes from the byte signature, so a stored file always
|
||||
// matches the type it is served as; data that carries no image signature
|
||||
// is refused. The caller is responsible for size validation.
|
||||
func (s *Store) StoreUpload(originalName string, data []byte) (string, error) {
|
||||
ext := imagefile.Detect(data)
|
||||
if ext == "" {
|
||||
return "", fmt.Errorf("unsupported image format in %q", filepath.Base(originalName))
|
||||
}
|
||||
root, err := s.openRoot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := root.MkdirAll(MediaDirName, 0o755); err != nil {
|
||||
return "", fmt.Errorf("create media directory: %w", err)
|
||||
}
|
||||
name := uuid.NewV4().String() + ext
|
||||
if err := atomicWriteIn(root, path.Join(MediaDirName, name), data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "/media/" + name, nil
|
||||
}
|
||||
|
||||
// DeleteMedia removes a media file by its public URL and reports whether a
|
||||
// file was removed.
|
||||
func (s *Store) DeleteMedia(url string) bool {
|
||||
if url == "" || !strings.HasPrefix(url, "/media/") {
|
||||
return false
|
||||
}
|
||||
root, err := s.openRoot()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return root.Remove(path.Join(MediaDirName, filepath.Base(url))) == nil
|
||||
}
|
||||
|
||||
// Media is one file in the media library.
|
||||
type Media struct {
|
||||
Name string
|
||||
URL string
|
||||
Size int64
|
||||
MTime time.Time
|
||||
|
||||
// Width and Height are the pixel dimensions the image header
|
||||
// carries, or 0 when the header does not yield them. Only a short
|
||||
// prefix of the file is read to learn them.
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
// headerPrefix is how much of a media file is read to find the headers
|
||||
// that carry the pixel dimensions: the WebP chunks, the AVIF `ispe` box,
|
||||
// or the size attributes of an SVG root element.
|
||||
const headerPrefix = 64 << 10
|
||||
|
||||
// ListMedia returns metadata for every file in the media directory,
|
||||
// newest first, with the pixel dimensions the headers carry.
|
||||
func (s *Store) ListMedia() []Media {
|
||||
root, err := s.openRoot()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var names []string
|
||||
for _, entry := range readDirIn(root, MediaDirName) {
|
||||
if !entry.IsDir() {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
}
|
||||
slices.Sort(names)
|
||||
out := make([]Media, 0, len(names))
|
||||
for _, name := range names {
|
||||
info, ok := statIn(root, MediaDirName, name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item := Media{
|
||||
Name: name,
|
||||
URL: "/media/" + name,
|
||||
Size: info.Size(),
|
||||
MTime: info.ModTime(),
|
||||
}
|
||||
// A header that cannot be read or parsed leaves the dimensions
|
||||
// at zero; the tile simply shows no size then.
|
||||
if handle, err := root.Open(path.Join(MediaDirName, name)); err == nil {
|
||||
head := make([]byte, min(headerPrefix, info.Size()))
|
||||
if n, err := io.ReadFull(handle, head); n > 0 && (err == nil || err == io.ErrUnexpectedEOF) {
|
||||
item.Width, item.Height, _ = imagefile.Dimensions(head[:n])
|
||||
}
|
||||
handle.Close()
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
slices.SortStableFunc(out, func(a, b Media) int { return b.MTime.Compare(a.MTime) })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
// Package store reads and writes Markdown posts and media in a content
|
||||
// directory: an mtime-snapshot cache, atomic writes, per-target locks,
|
||||
// revision archives, and rename-based soft deletes.
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/identifiers"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/post"
|
||||
)
|
||||
|
||||
const revisionsDirname = ".revisions"
|
||||
|
||||
// maxPostFileBytes bounds one content file the cache will read. A saved
|
||||
// post cannot come near it (the body is capped at 1 MiB before it is
|
||||
// ever written); the bound exists so an abandoned file dropped into the
|
||||
// content directory cannot be pulled into memory whole on every scan.
|
||||
const maxPostFileBytes = 8 << 20
|
||||
|
||||
var safeSlugRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
||||
|
||||
// MediaDirName is the uploads directory inside the content directory.
|
||||
const MediaDirName = "media"
|
||||
|
||||
// Store manages posts on disk in a flat or language-subdivided
|
||||
// directory.
|
||||
type Store struct {
|
||||
ContentDir string
|
||||
|
||||
defaultLang string
|
||||
followSymlinks bool
|
||||
revisionLimit int
|
||||
|
||||
mu sync.Mutex
|
||||
cached []*post.Post
|
||||
byPath map[string]*post.Post
|
||||
snapshot []snapshotEntry
|
||||
unreadable []Unreadable
|
||||
haveCache bool
|
||||
slugIndex map[string][]string
|
||||
aliasIndex map[string]string
|
||||
|
||||
locksGuard sync.Mutex
|
||||
saveLocks map[string]*lockEntry
|
||||
|
||||
// rootOnce guards root, a handle confined to the content directory.
|
||||
// Every path the store touches outside the post walk is opened
|
||||
// through it, so a name that would escape the tree through ".." or a
|
||||
// symlink is refused by the kernel rather than checked for in Go.
|
||||
rootOnce sync.Once
|
||||
root *os.Root
|
||||
rootErr error
|
||||
}
|
||||
|
||||
// openRoot returns the handle confined to the content directory, opening
|
||||
// it once per store.
|
||||
func (s *Store) openRoot() (*os.Root, error) {
|
||||
s.rootOnce.Do(func() {
|
||||
if err := os.MkdirAll(s.ContentDir, 0o755); err != nil {
|
||||
s.rootErr = fmt.Errorf("create the content directory: %w", err)
|
||||
return
|
||||
}
|
||||
s.root, s.rootErr = os.OpenRoot(s.ContentDir)
|
||||
})
|
||||
return s.root, s.rootErr
|
||||
}
|
||||
|
||||
// readDirIn lists a directory through the root, and returns nil when it
|
||||
// does not exist.
|
||||
func readDirIn(root *os.Root, dir string) []os.DirEntry {
|
||||
handle, err := root.Open(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer handle.Close()
|
||||
entries, err := handle.ReadDir(-1)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// statIn reports one entry of a directory listed through the root.
|
||||
func statIn(root *os.Root, dir, name string) (os.FileInfo, bool) {
|
||||
info, err := root.Stat(path.Join(dir, name))
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return info, true
|
||||
}
|
||||
|
||||
// lockEntry is a per-target write lock plus the number of callers
|
||||
// holding or waiting for it, so an entry can be dropped once it is idle.
|
||||
type lockEntry struct {
|
||||
mu sync.Mutex
|
||||
refs int
|
||||
}
|
||||
|
||||
type snapshotEntry struct {
|
||||
path string
|
||||
mtime int64
|
||||
size int64
|
||||
}
|
||||
|
||||
// Options configure a Store.
|
||||
type Options struct {
|
||||
// ContentDir is the directory holding the posts, and the media and
|
||||
// revision directories inside it.
|
||||
ContentDir string
|
||||
// DefaultLang is the language of a post that has none and that does
|
||||
// not sit in a language subdirectory.
|
||||
DefaultLang string
|
||||
// RevisionLimit is how many previous versions of a post to keep; zero
|
||||
// or less disables archiving, which also makes a delete permanent.
|
||||
RevisionLimit int
|
||||
// FollowSymlinks reads a symlinked post file instead of skipping it.
|
||||
FollowSymlinks bool
|
||||
}
|
||||
|
||||
// New opens a store over Options.ContentDir. The path is resolved through
|
||||
// any symlinks, so a content directory reached by a symlinked path walks
|
||||
// and compares consistently.
|
||||
func New(opts Options) *Store {
|
||||
abs, err := filepath.Abs(opts.ContentDir)
|
||||
if err != nil {
|
||||
abs = opts.ContentDir
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
||||
abs = resolved
|
||||
}
|
||||
return &Store{
|
||||
ContentDir: abs,
|
||||
defaultLang: opts.DefaultLang,
|
||||
followSymlinks: opts.FollowSymlinks,
|
||||
revisionLimit: opts.RevisionLimit,
|
||||
byPath: map[string]*post.Post{},
|
||||
slugIndex: map[string][]string{},
|
||||
aliasIndex: map[string]string{},
|
||||
saveLocks: map[string]*lockEntry{},
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupStaleTombstones lives in revision.go: a stale tombstone is a
|
||||
// hazard only once the server serves the tree, so the read-only commands
|
||||
// do not trigger it.
|
||||
|
||||
// InvalidateCache drops the cached posts so the next read re-scans the
|
||||
// disk.
|
||||
func (s *Store) InvalidateCache() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cached = nil
|
||||
s.byPath = map[string]*post.Post{}
|
||||
s.unreadable = nil
|
||||
s.snapshot = nil
|
||||
s.slugIndex = map[string][]string{}
|
||||
s.aliasIndex = map[string]string{}
|
||||
s.haveCache = false
|
||||
}
|
||||
|
||||
// All returns every loadable post, using the mtime snapshot cache.
|
||||
func (s *Store) All() []*post.Post {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.refresh()
|
||||
return slices.Clone(s.cached)
|
||||
}
|
||||
|
||||
// refresh rebuilds the cache when the (path, mtime, size) snapshot has
|
||||
// changed, and does nothing on a hit. The caller must hold s.mu.
|
||||
func (s *Store) refresh() {
|
||||
snapshot := s.buildSnapshot()
|
||||
if s.haveCache && slices.Equal(snapshot, s.snapshot) {
|
||||
return
|
||||
}
|
||||
s.snapshot = snapshot
|
||||
posts := make([]*post.Post, 0, len(snapshot))
|
||||
byPath := make(map[string]*post.Post, len(snapshot))
|
||||
var unreadable []Unreadable
|
||||
for _, entry := range snapshot {
|
||||
p, err := s.loadPost(entry.path)
|
||||
if err != nil {
|
||||
slog.Warn("store: skipping post", "path", entry.path, "error", err)
|
||||
unreadable = append(unreadable, Unreadable{Path: entry.path, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
posts = append(posts, p)
|
||||
byPath[entry.path] = p
|
||||
}
|
||||
s.cached = posts
|
||||
s.byPath = byPath
|
||||
s.unreadable = unreadable
|
||||
s.buildIndex()
|
||||
s.buildLinkIndex()
|
||||
s.haveCache = true
|
||||
}
|
||||
|
||||
// buildLinkIndex maps every valid frontmatter DOI to the slug of the
|
||||
// post published under it, and hands the map to the freshly loaded
|
||||
// posts, so a reference citing one of those DOIs links to its post
|
||||
// inside the instance instead of leaving for the resolver. The first
|
||||
// post published under a DOI wins; the map is shared read-only.
|
||||
func (s *Store) buildLinkIndex() {
|
||||
index := map[string]string{}
|
||||
for _, p := range s.cached {
|
||||
doi := identifiers.NormalizeDOI(p.DOI())
|
||||
if !identifiers.ValidDOI(doi) || p.Slug() == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(doi)
|
||||
if _, seen := index[key]; !seen {
|
||||
index[key] = p.Slug()
|
||||
}
|
||||
}
|
||||
for _, p := range s.cached {
|
||||
p.SetLinkIndex(index)
|
||||
}
|
||||
}
|
||||
|
||||
// Unreadable is a content file the store could not load.
|
||||
type Unreadable struct {
|
||||
Path string
|
||||
Error string
|
||||
}
|
||||
|
||||
// Unreadable lists the content files the current scan could not read or
|
||||
// parse. Every read path skips them, so a diagnostic has to ask for
|
||||
// them explicitly.
|
||||
func (s *Store) Unreadable() []Unreadable {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.refresh()
|
||||
return slices.Clone(s.unreadable)
|
||||
}
|
||||
|
||||
// Find returns the post with the given slug, optionally filtered by
|
||||
// language (posts flagged all_langs match any language). The returned
|
||||
// post is shared with the cache: clone it before mutating.
|
||||
func (s *Store) Find(slug, lang string) *post.Post {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.refresh()
|
||||
for _, path := range s.slugIndex[slug] {
|
||||
if p := s.byPath[path]; p != nil && p.Slug() == slug && languageMatches(p, lang) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
for _, p := range s.cached {
|
||||
if p.Slug() == slug && languageMatches(p, lang) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func languageMatches(p *post.Post, lang string) bool {
|
||||
return lang == "" || p.Lang() == lang || p.AllLangs()
|
||||
}
|
||||
|
||||
// ResolveAlias returns the canonical slug for an alias, or "".
|
||||
func (s *Store) ResolveAlias(alias string) string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.refresh()
|
||||
return s.aliasIndex[alias]
|
||||
}
|
||||
|
||||
// CacheKey returns a string that changes whenever the content directory
|
||||
// changes, for a caller that memoises rendering of the whole post set.
|
||||
// It is derived from the same (path, mtime, size) snapshot the read
|
||||
// cache uses, so an edit that leaves the slug and date alone still
|
||||
// changes it.
|
||||
func (s *Store) CacheKey() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.refresh()
|
||||
var b strings.Builder
|
||||
for _, entry := range s.snapshot {
|
||||
fmt.Fprintf(&b, "%s|%d|%d;", entry.path, entry.mtime, entry.size)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Save writes the post atomically, archiving the previous version as a
|
||||
// revision first. It returns the post with Path set, and it sets Path on
|
||||
// the argument it is given, so a caller passing a post obtained from the
|
||||
// cache must clone it first.
|
||||
func (s *Store) Save(p *post.Post) (*post.Post, error) {
|
||||
target := p.Path
|
||||
if target == "" {
|
||||
var err error
|
||||
target, err = s.defaultPathFor(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
root, err := s.openRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := filepath.Rel(s.ContentDir, target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("locate %s: %w", target, err)
|
||||
}
|
||||
if err := root.MkdirAll(path.Dir(name), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create post directory: %w", err)
|
||||
}
|
||||
unlock := s.targetLock(target)
|
||||
defer unlock()
|
||||
|
||||
slug := p.Slug()
|
||||
if slug == "" {
|
||||
slug = strings.TrimSuffix(filepath.Base(target), ".md")
|
||||
}
|
||||
s.archiveRevision(name, slug)
|
||||
|
||||
content, err := p.ToFile()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := atomicWriteIn(root, name, []byte(content)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Path = target
|
||||
s.InvalidateCache()
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Delete removes the post. With revisions enabled the file is moved into
|
||||
// the revision archive as a tombstone and undoable is true; with a
|
||||
// non-positive revision limit it is removed outright and undoable is
|
||||
// false. A nil post with a nil error means no such post.
|
||||
func (s *Store) Delete(slug, lang string) (p *post.Post, undoable bool, err error) {
|
||||
p = s.Find(slug, lang)
|
||||
if p == nil || p.Path == "" {
|
||||
return p, false, nil
|
||||
}
|
||||
unlock := s.targetLock(p.Path)
|
||||
defer unlock()
|
||||
|
||||
if err := s.writeTombstone(p); err != nil {
|
||||
// The file vanished between Find and the lock: the delete the
|
||||
// caller asked for has already happened, so it is "no such post"
|
||||
// rather than a failure.
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
s.InvalidateCache()
|
||||
return p, s.revisionLimit > 0, nil
|
||||
}
|
||||
|
||||
// mdFiles lists the .md files in the content directory, relative to it.
|
||||
func (s *Store) mdFiles() []string {
|
||||
var results []string
|
||||
root := s.ContentDir
|
||||
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// A walk that cannot read a subtree still lists the rest, but
|
||||
// an unreadable root must not pass silently as an empty site.
|
||||
slog.Error("store: cannot walk the content directory", "path", path, "error", err)
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
name := d.Name()
|
||||
if path != root && (name == MediaDirName || name == revisionsDirname) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(d.Name(), ".md") {
|
||||
return nil
|
||||
}
|
||||
if !s.followSymlinks {
|
||||
if info, err := d.Info(); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
results = append(results, path)
|
||||
return nil
|
||||
})
|
||||
return results
|
||||
}
|
||||
|
||||
func (s *Store) buildSnapshot() []snapshotEntry {
|
||||
files := s.mdFiles()
|
||||
rows := make([]snapshotEntry, 0, len(files))
|
||||
for _, f := range files {
|
||||
info, err := os.Stat(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, snapshotEntry{path: f, mtime: info.ModTime().UnixNano(), size: info.Size()})
|
||||
}
|
||||
slices.SortFunc(rows, func(a, b snapshotEntry) int { return strings.Compare(a.path, b.path) })
|
||||
return rows
|
||||
}
|
||||
|
||||
func (s *Store) buildIndex() {
|
||||
s.slugIndex = map[string][]string{}
|
||||
s.aliasIndex = map[string]string{}
|
||||
for _, p := range s.cached {
|
||||
if p.Path == "" || p.Slug() == "" {
|
||||
continue
|
||||
}
|
||||
s.slugIndex[p.Slug()] = append(s.slugIndex[p.Slug()], p.Path)
|
||||
for _, alias := range p.Aliases() {
|
||||
s.aliasIndex[alias] = p.Slug()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) loadPost(path string) (*post.Post, error) {
|
||||
if info, err := os.Stat(path); err == nil && info.Size() > maxPostFileBytes {
|
||||
return nil, fmt.Errorf("file exceeds %d bytes and is not a post this engine could render", maxPostFileBytes)
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsed, err := post.Parse(string(content))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := parsed
|
||||
// Both values are derived here and held outside the metadata, so a
|
||||
// later save writes the file back as it was read.
|
||||
slug := p.Slug()
|
||||
if slug == "" {
|
||||
slug = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
}
|
||||
lang := p.Lang()
|
||||
if lang == "" {
|
||||
lang = s.langFromPath(path)
|
||||
if lang == "" {
|
||||
lang = s.defaultLang
|
||||
}
|
||||
}
|
||||
p.SetFileLocation(slug, lang)
|
||||
p.Path = path
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Store) langFromPath(path string) string {
|
||||
parent := filepath.Dir(absPath(path))
|
||||
if parent == s.ContentDir {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(parent)
|
||||
}
|
||||
|
||||
// defaultNameFor returns the path of a post relative to the content
|
||||
// directory: the language subdirectory when the post has one, then its
|
||||
// slug.
|
||||
func (s *Store) defaultNameFor(p *post.Post) (string, error) {
|
||||
name := p.Slug() + ".md"
|
||||
if p.Lang() != "" && p.Lang() != s.defaultLang {
|
||||
name = path.Join(p.Lang(), name)
|
||||
}
|
||||
if !within(s.ContentDir, filepath.Join(s.ContentDir, name)) {
|
||||
return "", fmt.Errorf("slug escapes content directory")
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// defaultPathFor returns the absolute path a post is written to.
|
||||
func (s *Store) defaultPathFor(p *post.Post) (string, error) {
|
||||
name, err := s.defaultNameFor(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(s.ContentDir, name), nil
|
||||
}
|
||||
|
||||
func (s *Store) targetLock(target string) func() {
|
||||
s.locksGuard.Lock()
|
||||
entry, ok := s.saveLocks[target]
|
||||
if !ok {
|
||||
entry = &lockEntry{}
|
||||
s.saveLocks[target] = entry
|
||||
}
|
||||
entry.refs++
|
||||
s.locksGuard.Unlock()
|
||||
|
||||
entry.mu.Lock()
|
||||
return func() {
|
||||
entry.mu.Unlock()
|
||||
s.locksGuard.Lock()
|
||||
entry.refs--
|
||||
if entry.refs == 0 {
|
||||
delete(s.saveLocks, target)
|
||||
}
|
||||
s.locksGuard.Unlock()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,846 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/post"
|
||||
)
|
||||
|
||||
func newStore(t *testing.T) (*Store, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
content := filepath.Join(dir, "posts")
|
||||
if err := os.MkdirAll(content, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
return New(Options{ContentDir: content, DefaultLang: "en", RevisionLimit: 10}), content
|
||||
}
|
||||
|
||||
func writePost(t *testing.T, content, name, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(content, name)
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write post: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
const helloFile = `+++
|
||||
title = "Hello"
|
||||
slug = "hello"
|
||||
lang = "cs"
|
||||
tags = ["a"]
|
||||
|
||||
[translations]
|
||||
en = "hello-en"
|
||||
+++
|
||||
|
||||
Body text.
|
||||
`
|
||||
|
||||
func TestAllAndFind(t *testing.T) {
|
||||
s, content := newStore(t)
|
||||
writePost(t, content, "hello.md", helloFile)
|
||||
writePost(t, content, "draft.md", "+++\ntitle = \"D\"\nslug = \"draft\"\ndraft = true\n+++\nx\n")
|
||||
|
||||
all := s.All()
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("All() = %d posts, want 2", len(all))
|
||||
}
|
||||
p := s.Find("hello", "")
|
||||
if p == nil {
|
||||
t.Fatal("Find(hello) = nil")
|
||||
}
|
||||
if p.Title() != "Hello" || p.Lang() != "cs" {
|
||||
t.Fatalf("post wrong: %q %q", p.Title(), p.Lang())
|
||||
}
|
||||
if s.Find("hello", "en") != nil {
|
||||
t.Fatal("lang filter ignored")
|
||||
}
|
||||
if s.Find("missing", "") != nil {
|
||||
t.Fatal("Find(missing) found something")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheInvalidatesOnChange(t *testing.T) {
|
||||
s, content := newStore(t)
|
||||
writePost(t, content, "hello.md", helloFile)
|
||||
if len(s.All()) != 1 {
|
||||
t.Fatal("want 1 post")
|
||||
}
|
||||
// Cached read.
|
||||
if len(s.All()) != 1 {
|
||||
t.Fatal("cached read broken")
|
||||
}
|
||||
writePost(t, content, "second.md", "+++\nslug = \"second\"\n+++\nx\n")
|
||||
if got := len(s.All()); got != 2 {
|
||||
t.Fatalf("cache not invalidated: %d posts", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlugFallbacksFromFilename(t *testing.T) {
|
||||
s, content := newStore(t)
|
||||
writePost(t, content, "from-name.md", "+++\ntitle = \"T\"\n+++\nx\n")
|
||||
p := s.Find("from-name", "")
|
||||
if p == nil {
|
||||
t.Fatal("post not found via filename slug")
|
||||
}
|
||||
if p.Path != filepath.Join(content, "from-name.md") {
|
||||
t.Fatalf("path = %q", p.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLangFromSubdirectory(t *testing.T) {
|
||||
s, content := newStore(t)
|
||||
if err := os.MkdirAll(filepath.Join(content, "fr"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
writePost(t, content, filepath.Join("fr", "bonjour.md"), "+++\nslug = \"bonjour\"\n+++\nx\n")
|
||||
p := s.Find("bonjour", "fr")
|
||||
if p == nil {
|
||||
t.Fatal("post not found")
|
||||
}
|
||||
if p.Lang() != "fr" {
|
||||
t.Fatalf("lang = %q, want fr", p.Lang())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipsMediaAndRevisionsDirs(t *testing.T) {
|
||||
s, content := newStore(t)
|
||||
writePost(t, content, "real.md", "+++\nslug = \"real\"\n+++\nx\n")
|
||||
for _, dir := range []string{MediaDirName, revisionsDirname} {
|
||||
if err := os.MkdirAll(filepath.Join(content, dir), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
}
|
||||
writePost(t, content, filepath.Join(MediaDirName, "x.md"), "+++\nslug = \"media-post\"\n+++\nx\n")
|
||||
writePost(t, content, filepath.Join(revisionsDirname, "y.md"), "+++\nslug = \"rev-post\"\n+++\nx\n")
|
||||
if got := len(s.All()); got != 1 {
|
||||
t.Fatalf("All() = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipsBrokenFiles(t *testing.T) {
|
||||
s, content := newStore(t)
|
||||
writePost(t, content, "ok.md", "+++\nslug = \"ok\"\n+++\nx\n")
|
||||
writePost(t, content, "broken.md", "+++\nthis is not = valid = toml\n+++\nx\n")
|
||||
if got := len(s.All()); got != 1 {
|
||||
t.Fatalf("All() = %d, want 1 (broken file skipped)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndRevisions(t *testing.T) {
|
||||
s, content := newStore(t)
|
||||
p := mustParsePost(t, "+++\ntitle = \"V1\"\nslug = \"rev\"\n+++\nversion one\n")
|
||||
saved, err := s.Save(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
if saved.Path != filepath.Join(content, "rev.md") {
|
||||
t.Fatalf("path = %q", saved.Path)
|
||||
}
|
||||
|
||||
p2 := mustParsePost(t, "+++\ntitle = \"V2\"\nslug = \"rev\"\n+++\nversion two\n")
|
||||
p2.Path = saved.Path
|
||||
if _, err := s.Save(p2); err != nil {
|
||||
t.Fatalf("Save 2: %v", err)
|
||||
}
|
||||
|
||||
revs := s.Revisions("rev")
|
||||
if len(revs) != 1 {
|
||||
t.Fatalf("revisions = %v, want 1", revs)
|
||||
}
|
||||
if revs[0].When == "" || revs[0].Size == 0 {
|
||||
t.Fatalf("revision entry incomplete: %+v", revs[0])
|
||||
}
|
||||
body := s.RevisionContent("rev", revs[0].Name)
|
||||
if !strings.Contains(body, "version one") {
|
||||
t.Fatalf("revision content = %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevisionLimitPruning(t *testing.T) {
|
||||
s, _ := newStore(t)
|
||||
s.revisionLimit = 2
|
||||
p := mustParsePost(t, "+++\nslug = \"p\"\n+++\nv0\n")
|
||||
saved, err := s.Save(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
for i := 1; i <= 4; i++ {
|
||||
next := mustParsePost(t, "+++\nslug = \"p\"\n+++\nv"+strings.Repeat("x", i)+"\n")
|
||||
next.Path = saved.Path
|
||||
if _, err := s.Save(next); err != nil {
|
||||
t.Fatalf("Save %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if got := len(s.Revisions("p")); got != 2 {
|
||||
t.Fatalf("revisions = %d, want pruned to 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAndUndelete(t *testing.T) {
|
||||
s, content := newStore(t)
|
||||
writePost(t, content, "bye.md", "+++\ntitle = \"Bye\"\nslug = \"bye\"\n+++\ncontent\n")
|
||||
|
||||
deleted, undoable, err := s.Delete("bye", "")
|
||||
if err != nil || !undoable {
|
||||
t.Fatalf("Delete: %v, undoable=%v", err, undoable)
|
||||
}
|
||||
if deleted == nil {
|
||||
t.Fatal("Delete returned nil")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(content, "bye.md")); err == nil {
|
||||
t.Fatal("live file still exists after delete")
|
||||
}
|
||||
if s.Find("bye", "") != nil {
|
||||
t.Fatal("deleted post still findable")
|
||||
}
|
||||
if s.TombstonePath("bye") == "" {
|
||||
t.Fatal("no tombstone")
|
||||
}
|
||||
|
||||
restored := s.Undelete("bye")
|
||||
if restored == nil {
|
||||
t.Fatal("Undelete returned nil")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(content, "bye.md")); err != nil {
|
||||
t.Fatalf("file not restored: %v", err)
|
||||
}
|
||||
if s.TombstonePath("bye") != "" {
|
||||
t.Fatal("tombstone not removed")
|
||||
}
|
||||
if p := s.Find("bye", ""); p == nil || p.Title() != "Bye" {
|
||||
t.Fatalf("restored post wrong: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndeleteRefusesClobber(t *testing.T) {
|
||||
s, content := newStore(t)
|
||||
writePost(t, content, "dup.md", "+++\nslug = \"dup\"\ntitle = \"old\"\n+++\nx\n")
|
||||
if deleted, _, _ := s.Delete("dup", ""); deleted == nil {
|
||||
t.Fatal("Delete failed")
|
||||
}
|
||||
writePost(t, content, "dup.md", "+++\nslug = \"dup\"\ntitle = \"new\"\n+++\ny\n")
|
||||
if restored := s.Undelete("dup"); restored != nil {
|
||||
t.Fatal("Undelete clobbered an existing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndeleteWithoutTombstone(t *testing.T) {
|
||||
s, _ := newStore(t)
|
||||
if p := s.Undelete("nothing"); p != nil {
|
||||
t.Fatal("want nil without tombstone")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleTombstoneCleanup(t *testing.T) {
|
||||
s, content := newStore(t)
|
||||
writePost(t, content, "gone.md", "+++\nslug = \"gone\"\n+++\nx\n")
|
||||
if deleted, _, _ := s.Delete("gone", ""); deleted == nil {
|
||||
t.Fatal("Delete failed")
|
||||
}
|
||||
// Recreate the live post, then run the cleanup the serving path runs:
|
||||
// the tombstone is now stale and must go.
|
||||
writePost(t, content, "gone.md", "+++\nslug = \"gone\"\n+++\ny\n")
|
||||
reopened := New(Options{ContentDir: content, DefaultLang: "en", RevisionLimit: 10})
|
||||
if reopened.TombstonePath("gone") == "" {
|
||||
t.Fatal("tombstone vanished before the cleanup ran")
|
||||
}
|
||||
reopened.CleanupStaleTombstones()
|
||||
if reopened.TombstonePath("gone") != "" {
|
||||
t.Fatal("stale tombstone survived the cleanup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteWithRevisionsDisabled(t *testing.T) {
|
||||
s, content := newStore(t)
|
||||
s.revisionLimit = 0
|
||||
writePost(t, content, "hard.md", "+++\nslug = \"hard\"\n+++\nx\n")
|
||||
deleted, undoable, err := s.Delete("hard", "")
|
||||
if err != nil || deleted == nil || undoable {
|
||||
t.Fatal("hard delete failed")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(content, "hard.md")); err == nil {
|
||||
t.Fatal("file still exists")
|
||||
}
|
||||
if s.TombstonePath("hard") != "" {
|
||||
t.Fatal("tombstone created despite revisions disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRejectsSlugEscape(t *testing.T) {
|
||||
s, _ := newStore(t)
|
||||
p := mustParsePost(t, "+++\nslug = \"../escape\"\n+++\nx\n")
|
||||
if _, err := s.Save(p); err == nil {
|
||||
t.Fatal("want error for escaping slug")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreRevision(t *testing.T) {
|
||||
s, _ := newStore(t)
|
||||
p := mustParsePost(t, "+++\nslug = \"r\"\ntitle = \"one\"\n+++\nfirst\n")
|
||||
saved, err := s.Save(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
p2 := mustParsePost(t, "+++\nslug = \"r\"\ntitle = \"two\"\n+++\nsecond\n")
|
||||
p2.Path = saved.Path
|
||||
if _, err := s.Save(p2); err != nil {
|
||||
t.Fatalf("Save 2: %v", err)
|
||||
}
|
||||
revs := s.Revisions("r")
|
||||
if len(revs) == 0 {
|
||||
t.Fatal("no revisions")
|
||||
}
|
||||
restored := s.RestoreRevision(p2, revs[0].Name)
|
||||
if restored == nil {
|
||||
t.Fatal("RestoreRevision returned nil")
|
||||
}
|
||||
if !strings.Contains(string(mustRead(t, saved.Path)), "first") {
|
||||
t.Fatal("content not restored")
|
||||
}
|
||||
if restored.Slug() != "r" {
|
||||
t.Fatalf("slug = %q", restored.Slug())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreRevisionMissing(t *testing.T) {
|
||||
s, _ := newStore(t)
|
||||
p := mustParsePost(t, "+++\nslug = \"r\"\n+++\nx\n")
|
||||
if restored := s.RestoreRevision(p, "nope.md"); restored != nil {
|
||||
t.Fatal("want nil for missing revision")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAlias(t *testing.T) {
|
||||
s, content := newStore(t)
|
||||
writePost(t, content, "aliased.md", "+++\nslug = \"new\"\naliases = [\"old\", \"older\"]\n+++\nx\n")
|
||||
if got := s.ResolveAlias("old"); got != "new" {
|
||||
t.Fatalf("alias = %q, want new", got)
|
||||
}
|
||||
if got := s.ResolveAlias("unknown"); got != "" {
|
||||
t.Fatalf("alias = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateCache(t *testing.T) {
|
||||
s, content := newStore(t)
|
||||
writePost(t, content, "a.md", "+++\nslug = \"a\"\n+++\nx\n")
|
||||
s.All()
|
||||
s.InvalidateCache()
|
||||
if got := len(s.All()); got != 1 {
|
||||
t.Fatalf("All after invalidate = %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// webpBytes is a minimal RIFF/WEBP header, enough for the signature
|
||||
// check.
|
||||
func webpBytes() []byte {
|
||||
data := append([]byte("RIFF"), 0, 0, 0, 0)
|
||||
return append(data, []byte("WEBPVP8 ")...)
|
||||
}
|
||||
|
||||
func TestStoreUploadAndListMedia(t *testing.T) {
|
||||
s, _ := newStore(t)
|
||||
webpData := append([]byte("RIFF"), 0, 0, 0, 0)
|
||||
webpData = append(webpData, []byte("WEBPVP8 ")...)
|
||||
url, err := s.StoreUpload("pic.png", webpData)
|
||||
if err != nil {
|
||||
t.Fatalf("StoreUpload: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(url, "/media/") || !strings.HasSuffix(url, ".webp") {
|
||||
t.Fatalf("url = %q", url)
|
||||
}
|
||||
name := strings.TrimPrefix(url, "/media/")
|
||||
if _, err := s.MediaPath(name); err != nil {
|
||||
t.Fatalf("MediaPath cannot find the upload: %v", err)
|
||||
}
|
||||
if _, err := s.MediaPath("../" + name); err != nil {
|
||||
t.Fatalf("a base name should still resolve: %v", err)
|
||||
}
|
||||
if _, err := s.MediaPath("notes.txt"); err == nil {
|
||||
t.Fatal("MediaPath accepted a name that is not an image")
|
||||
}
|
||||
media := s.ListMedia()
|
||||
if len(media) != 1 || media[0].Name != name {
|
||||
t.Fatalf("ListMedia = %v", media)
|
||||
}
|
||||
if !s.DeleteMedia(url) {
|
||||
t.Fatal("DeleteMedia failed")
|
||||
}
|
||||
if s.DeleteMedia(url) {
|
||||
t.Fatal("DeleteMedia succeeded twice")
|
||||
}
|
||||
if s.DeleteMedia("/etc/passwd") {
|
||||
t.Fatal("DeleteMedia accepted non-media URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreUploadSVGAndDimensions(t *testing.T) {
|
||||
s, _ := newStore(t)
|
||||
svg := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900"><rect width="10" height="10"/></svg>`)
|
||||
url, err := s.StoreUpload("figure.png", svg)
|
||||
if err != nil {
|
||||
t.Fatalf("StoreUpload(svg): %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(url, ".svg") {
|
||||
t.Fatalf("url = %q, want .svg from the bytes", url)
|
||||
}
|
||||
name := strings.TrimPrefix(url, "/media/")
|
||||
if _, err := s.MediaPath(name); err != nil {
|
||||
t.Fatalf("MediaPath refuses the svg: %v", err)
|
||||
}
|
||||
media := s.ListMedia()
|
||||
if len(media) != 1 {
|
||||
t.Fatalf("ListMedia = %v", media)
|
||||
}
|
||||
if media[0].Width != 1200 || media[0].Height != 900 {
|
||||
t.Fatalf("svg dimensions = %d×%d, want 1200×900", media[0].Width, media[0].Height)
|
||||
}
|
||||
// A name that is not an allowed image extension is still refused.
|
||||
if _, err := s.MediaPath("notes.svg.txt"); err == nil {
|
||||
t.Fatal("MediaPath accepted a double-extension name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreUploadRequiresASignature(t *testing.T) {
|
||||
s, _ := newStore(t)
|
||||
// The extension comes from the bytes, never from the filename, so a
|
||||
// file whose bytes are not an image is refused whatever it is called.
|
||||
for _, name := range []string{"photo.avif", "photo.gif", "photo.webp"} {
|
||||
if _, err := s.StoreUpload(name, []byte("not an image but long enough")); err == nil {
|
||||
t.Fatalf("StoreUpload accepted %q without a signature", name)
|
||||
}
|
||||
}
|
||||
// The signature decides the stored extension even when the name says
|
||||
// something else.
|
||||
url, err := s.StoreUpload("photo.gif", webpBytes())
|
||||
if err != nil {
|
||||
t.Fatalf("StoreUpload: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(url, ".webp") {
|
||||
t.Fatalf("url = %q, want .webp", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaPathMissing(t *testing.T) {
|
||||
s, _ := newStore(t)
|
||||
if _, err := s.MediaPath("nope.webp"); err == nil {
|
||||
t.Fatal("want an error for missing media")
|
||||
}
|
||||
if got := s.ListMedia(); len(got) != 0 {
|
||||
t.Fatalf("ListMedia = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndeleteSetsSlugFromRequest(t *testing.T) {
|
||||
s, content := newStore(t)
|
||||
// Post without explicit slug: filename provides it after load.
|
||||
writePost(t, content, "implicit.md", "+++\ntitle = \"I\"\n+++\nx\n")
|
||||
if deleted, _, _ := s.Delete("implicit", ""); deleted == nil {
|
||||
t.Fatal("Delete failed")
|
||||
}
|
||||
restored := s.Undelete("implicit")
|
||||
if restored == nil {
|
||||
t.Fatal("Undelete failed")
|
||||
}
|
||||
if restored.Slug() != "implicit" {
|
||||
t.Fatalf("slug = %q", restored.Slug())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultLangApplied(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
content := filepath.Join(dir, "posts")
|
||||
if err := os.MkdirAll(content, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
writePost(t, content, "x.md", "+++\nslug = \"x\"\n+++\nbody\n")
|
||||
s := New(Options{ContentDir: content, DefaultLang: "cs", RevisionLimit: 10})
|
||||
p := s.Find("x", "")
|
||||
if p == nil {
|
||||
t.Fatal("post not found")
|
||||
}
|
||||
if p.Lang() != "cs" {
|
||||
t.Fatalf("lang = %q, want default cs", p.Lang())
|
||||
}
|
||||
}
|
||||
|
||||
func mustRead(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestFrontmatterMetaUsedBySave(t *testing.T) {
|
||||
// Guard against accidental nil-metadata saves.
|
||||
s, _ := newStore(t)
|
||||
meta := frontmatter.NewMeta()
|
||||
meta.Set("slug", "meta-only")
|
||||
p := post.New(meta, "body")
|
||||
if _, err := s.Save(p); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
if s.Find("meta-only", "") == nil {
|
||||
t.Fatal("post not found after save")
|
||||
}
|
||||
}
|
||||
|
||||
func mustParsePost(t *testing.T, content string) *post.Post {
|
||||
t.Helper()
|
||||
p, err := post.Parse(content)
|
||||
if err != nil {
|
||||
t.Fatalf("post.Parse: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// The snapshot cache is what keeps a read from re-parsing every file, so
|
||||
// a cache hit must be observable: a second All() must not re-read.
|
||||
func TestCacheIsActuallyUsed(t *testing.T) {
|
||||
s, content := newStore(t)
|
||||
writePost(t, content, "a.md", "+++\nslug = \"a\"\n+++\nbody\n")
|
||||
if len(s.All()) != 1 {
|
||||
t.Fatal("first read failed")
|
||||
}
|
||||
// Rewrite the file with the same size and mtime: only a cache hit can
|
||||
// explain the stale answer.
|
||||
path := filepath.Join(content, "a.md")
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
original := "+++\nslug = \"a\"\n+++\nbody\n"
|
||||
replacement := "+++\nslug = \"b\"\n+++\nbody\n"
|
||||
if len(replacement) != len(original) {
|
||||
t.Fatal("fixture sizes differ, the test cannot prove a cache hit")
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(replacement), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.Chtimes(path, info.ModTime(), info.ModTime()); err != nil {
|
||||
t.Fatalf("chtimes: %v", err)
|
||||
}
|
||||
if got := s.All(); len(got) != 1 || got[0].Slug() != "a" {
|
||||
t.Fatalf("the cache was bypassed: %v", got)
|
||||
}
|
||||
// An explicit invalidation must see the change.
|
||||
s.InvalidateCache()
|
||||
if got := s.All(); len(got) != 1 || got[0].Slug() != "b" {
|
||||
t.Fatalf("invalidation did not re-read: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Find serves single-post requests from the same cache, so it must not
|
||||
// re-read the file either.
|
||||
func TestFindUsesTheCache(t *testing.T) {
|
||||
s, content := newStore(t)
|
||||
writePost(t, content, "a.md", "+++\nslug = \"a\"\n+++\nbody\n")
|
||||
first := s.Find("a", "")
|
||||
if first == nil {
|
||||
t.Fatal("Find returned nothing")
|
||||
}
|
||||
second := s.Find("a", "")
|
||||
if second == nil {
|
||||
t.Fatal("second Find returned nothing")
|
||||
}
|
||||
if first != second {
|
||||
t.Fatal("Find rebuilt the post instead of using the cache")
|
||||
}
|
||||
// A write invalidates, so the next read is fresh.
|
||||
first.Metadata.Set("title", "changed")
|
||||
if _, err := s.Save(first.Clone()); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
if again := s.Find("a", ""); again == nil || again.Title() != "changed" {
|
||||
t.Fatalf("the cache survived a save: %+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnreadableFilesAreReported(t *testing.T) {
|
||||
s, content := newStore(t)
|
||||
writePost(t, content, "good.md", "+++\nslug = \"good\"\n+++\nbody\n")
|
||||
writePost(t, content, "broken.md", "+++\nthis is not = valid = toml\n+++\nx\n")
|
||||
if len(s.All()) != 1 {
|
||||
t.Fatalf("All = %d posts, want only the parsable one", len(s.All()))
|
||||
}
|
||||
broken := s.Unreadable()
|
||||
if len(broken) != 1 || !strings.HasSuffix(broken[0].Path, "broken.md") || broken[0].Error == "" {
|
||||
t.Fatalf("Unreadable = %+v", broken)
|
||||
}
|
||||
}
|
||||
|
||||
// A post deleted from a language subdirectory must come back to the
|
||||
// directory it came from, not to the root under the default language.
|
||||
func TestUndeleteKeepsTheLanguage(t *testing.T) {
|
||||
s, content := newStore(t)
|
||||
if err := os.MkdirAll(filepath.Join(content, "cs"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
writePost(t, content, "cs/ahoj.md", "+++\nslug = \"ahoj\"\n+++\ntělo\n")
|
||||
if p := s.Find("ahoj", ""); p == nil || p.Lang() != "cs" {
|
||||
t.Fatalf("fixture lang = %v", p)
|
||||
}
|
||||
if deleted, undoable, err := s.Delete("ahoj", ""); err != nil || deleted == nil || !undoable {
|
||||
t.Fatalf("Delete = %v, %v, %v", deleted, undoable, err)
|
||||
}
|
||||
restored := s.Undelete("ahoj")
|
||||
if restored == nil {
|
||||
t.Fatal("Undelete returned nothing")
|
||||
}
|
||||
if restored.Lang() != "cs" {
|
||||
t.Fatalf("restored lang = %q, want cs", restored.Lang())
|
||||
}
|
||||
if want := filepath.Join(content, "cs", "ahoj.md"); restored.Path != want {
|
||||
t.Fatalf("restored path = %q, want %q", restored.Path, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Two deletes inside the same second must resolve to the later one, not
|
||||
// to whichever the filesystem happened to order first.
|
||||
func TestNewestTombstoneWins(t *testing.T) {
|
||||
s, content := newStore(t)
|
||||
writePost(t, content, "a.md", "+++\nslug = \"x\"\n+++\nfirst\n")
|
||||
if deleted, _, err := s.Delete("x", ""); err != nil || deleted == nil {
|
||||
t.Fatalf("first delete: %v", err)
|
||||
}
|
||||
writePost(t, content, "a.md", "+++\nslug = \"x\"\n+++\nsecond\n")
|
||||
if deleted, _, err := s.Delete("x", ""); err != nil || deleted == nil {
|
||||
t.Fatalf("second delete: %v", err)
|
||||
}
|
||||
restored := s.Undelete("x")
|
||||
if restored == nil {
|
||||
t.Fatal("Undelete returned nothing")
|
||||
}
|
||||
if !strings.Contains(restored.Body, "second") {
|
||||
t.Fatalf("restored the earlier tombstone: %q", restored.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// A content directory reached through a symlink must work for reads and
|
||||
// for writes: many deployments point one at a data volume.
|
||||
func TestSymlinkedContentDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
real := filepath.Join(dir, "data", "posts")
|
||||
if err := os.MkdirAll(real, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
link := filepath.Join(dir, "posts")
|
||||
if err := os.Symlink(real, link); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
s := New(Options{ContentDir: link, DefaultLang: "en", RevisionLimit: 10})
|
||||
writePost(t, real, "a.md", "+++\nslug = \"a\"\n+++\nbody\n")
|
||||
if len(s.All()) != 1 {
|
||||
t.Fatal("a symlinked content directory hid the posts")
|
||||
}
|
||||
p, err := post.Parse("+++\nslug = \"new\"\ntitle = \"New\"\n+++\nbody\n")
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
saved, err := s.Save(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Save through a symlinked directory: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(saved.Path); err != nil {
|
||||
t.Fatalf("saved file missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A delete whose file vanished between Find and the lock (another
|
||||
// request deleted it) reports "no such post", not a failure, and leaves
|
||||
// no second tombstone behind.
|
||||
func TestDeleteAfterConcurrentRemovalIsNoSuchPost(t *testing.T) {
|
||||
s, content := newStore(t)
|
||||
writePost(t, content, "gone.md", "+++\ntitle = \"Gone\"\nslug = \"gone\"\n+++\ncontent\n")
|
||||
first, _, err := s.Delete("gone", "")
|
||||
if err != nil || first == nil {
|
||||
t.Fatalf("first Delete: %v", err)
|
||||
}
|
||||
// Simulate the race window: the cache still holds the post, the file
|
||||
// is already gone.
|
||||
s.mu.Lock()
|
||||
cached := s.cached
|
||||
s.mu.Unlock()
|
||||
_ = cached
|
||||
os.Remove(filepath.Join(content, "gone.md"))
|
||||
|
||||
// Recreate the same window the race would produce: find succeeded
|
||||
// before the removal, so call Delete on a slug whose cache entry is
|
||||
// repopulated with the stale file.
|
||||
s.InvalidateCache()
|
||||
if p := s.Find("gone", ""); p != nil {
|
||||
t.Fatal("the post should not resolve after removal")
|
||||
}
|
||||
deleted, _, err := s.Delete("gone", "")
|
||||
if err != nil {
|
||||
t.Fatalf("second Delete errored: %v", err)
|
||||
}
|
||||
if deleted != nil {
|
||||
t.Fatalf("second Delete returned %v, want nil", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
// Repeated delete cycles keep one tombstone: the archive must not grow
|
||||
// without bound when revision_limit deliberately ignores tombstones.
|
||||
func TestTombstonesArePrunedToTheNewest(t *testing.T) {
|
||||
s, content := newStore(t)
|
||||
revDir := filepath.Join(content, ".revisions", "cycle")
|
||||
for i := range 3 {
|
||||
writePost(t, content, "cycle.md",
|
||||
"+++\ntitle = \"Cycle\"\nslug = \"cycle\"\n+++\ncontent "+strings.Repeat("x", i+1)+"\n")
|
||||
if p := s.Find("cycle", ""); p == nil {
|
||||
t.Fatalf("cycle %d: post missing", i)
|
||||
}
|
||||
if _, _, err := s.Delete("cycle", ""); err != nil {
|
||||
t.Fatalf("cycle %d delete: %v", i, err)
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(revDir)
|
||||
if err != nil {
|
||||
t.Fatalf("read revisions: %v", err)
|
||||
}
|
||||
tombstones := 0
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), ".deleted-") {
|
||||
tombstones++
|
||||
}
|
||||
}
|
||||
if tombstones != 1 {
|
||||
t.Fatalf("%d tombstones after three deletes, want 1", tombstones)
|
||||
}
|
||||
if s.TombstonePath("cycle") == "" {
|
||||
t.Fatal("the newest tombstone must survive for undelete")
|
||||
}
|
||||
}
|
||||
|
||||
// Two language variants of one slug can save concurrently; their
|
||||
// revisions serialise on the shared archive directory rather than
|
||||
// colliding on one stamp or one temp file.
|
||||
func TestArchiveRevisionSerialisesPerSlug(t *testing.T) {
|
||||
s, content := newStore(t)
|
||||
if err := os.MkdirAll(filepath.Join(content, "en"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(content, "fr"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
writePost(t, content, "en/shared.md", "+++\nslug = \"shared\"\nlang = \"en\"\ntitle = \"EN\"\n+++\nbody\n")
|
||||
writePost(t, content, "fr/shared.md", "+++\nslug = \"shared\"\nlang = \"fr\"\ntitle = \"FR\"\n+++\nbody\n")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for range 8 {
|
||||
wg.Go(func() {
|
||||
en := s.Find("shared", "en")
|
||||
fr := s.Find("shared", "fr")
|
||||
if en != nil {
|
||||
if _, err := s.Save(en.Clone()); err != nil {
|
||||
t.Errorf("save en: %v", err)
|
||||
}
|
||||
}
|
||||
if fr != nil {
|
||||
if _, err := s.Save(fr.Clone()); err != nil {
|
||||
t.Errorf("save fr: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
// Every archived revision must be a complete document: a truncated
|
||||
// or interleaved file would fail to parse.
|
||||
for _, rev := range s.Revisions("shared") {
|
||||
if content := s.RevisionContent("shared", rev.Name); !strings.Contains(content, "+++\nslug = \"shared\"") {
|
||||
t.Fatalf("revision %s is not a complete document:\n%s", rev.Name, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A content file past the size bound is reported unreadable rather than
|
||||
// pulled into memory: no saved post can reach the bound, so anything
|
||||
// that does is debris.
|
||||
func TestOversizedFileIsReportedNotLoaded(t *testing.T) {
|
||||
s, content := newStore(t)
|
||||
big := content + "/big.md"
|
||||
handle, err := os.Create(big)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if err := handle.Truncate(maxPostFileBytes + 1); err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
handle.Close()
|
||||
|
||||
if posts := s.All(); len(posts) != 0 {
|
||||
t.Fatalf("an oversized file became %d posts", len(posts))
|
||||
}
|
||||
skipped := s.Unreadable()
|
||||
if len(skipped) != 1 || !strings.Contains(skipped[0].Path, "big.md") {
|
||||
t.Fatalf("unreadable = %+v", skipped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDOILinkIndex(t *testing.T) {
|
||||
s, content := newStore(t)
|
||||
writePost(t, content, "tdssc.md", `+++
|
||||
title = "TDSSC"
|
||||
slug = "tdssc"
|
||||
doi = "10.5555/tdssc.2026"
|
||||
+++
|
||||
|
||||
Text.
|
||||
`)
|
||||
writePost(t, content, "sfs.md", `+++
|
||||
title = "SFS"
|
||||
slug = "sfs"
|
||||
|
||||
[[refs]]
|
||||
title = "Teorie deterministického substrátu"
|
||||
doi = "10.5555/tdssc.2026"
|
||||
+++
|
||||
|
||||
Odkaz [1].
|
||||
`)
|
||||
sfs := s.Find("sfs", "")
|
||||
if sfs == nil {
|
||||
t.Fatal("sfs missing")
|
||||
}
|
||||
htmlOut, err := sfs.HTML()
|
||||
if err != nil {
|
||||
t.Fatalf("HTML: %v", err)
|
||||
}
|
||||
if !strings.Contains(htmlOut, `href="/api/volumen/posts/tdssc"`) {
|
||||
t.Fatalf("cross-post DOI did not resolve:\n%s", htmlOut)
|
||||
}
|
||||
// The cited post carrying the DOI links to neither itself nor a peer:
|
||||
// it has no refs at all here.
|
||||
tdssc := s.Find("tdssc", "")
|
||||
if h, _ := tdssc.HTML(); strings.Contains(h, "refs-link") {
|
||||
t.Fatalf("tdssc unexpectedly wove a bibliography:\n%s", h)
|
||||
}
|
||||
|
||||
// Removing the DOI from the cited post reverts the reference to its
|
||||
// resolver on the next read.
|
||||
writePost(t, content, "tdssc.md", `+++
|
||||
title = "TDSSC"
|
||||
slug = "tdssc"
|
||||
+++
|
||||
|
||||
Text.
|
||||
`)
|
||||
if h, _ := s.Find("sfs", "").HTML(); strings.Contains(h, "/api/volumen/posts/tdssc") {
|
||||
t.Fatal("stale index survived the change")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user