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

Assisted-by: GLM 5.3
This commit is contained in:
2026-09-29 10:03:32 +02:00
commit f8ed33df83
206 changed files with 44165 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"sourcedock.dev/petrbalvin/volumen/internal/app"
"sourcedock.dev/petrbalvin/volumen/internal/backup"
"sourcedock.dev/petrbalvin/volumen/internal/config"
)
func runExport(args []string, stderr io.Writer) int {
flags := flag.NewFlagSet("export", flag.ContinueOnError)
flags.SetOutput(stderr)
configPath := flags.String("config", config.ResolveConfigPath(), "config path")
outFlag := flags.String("out", "volumen-backup.tar.gz", "output archive path")
if err := flags.Parse(args); err != nil {
return flagExitCode(err)
}
if extra := extraArg(flags); extra != "" {
fmt.Fprintf(stderr, "volumen export: unexpected argument %q\n", extra)
return 2
}
cfg, err := config.Load(*configPath, config.Overrides{Port: config.PortUnset})
if err != nil {
fmt.Fprintf(stderr, "volumen export: %v\n", err)
return 1
}
if err := writeBackup(*outFlag, cfg); err != nil {
fmt.Fprintf(stderr, "volumen export: %v\n", err)
return 1
}
fmt.Fprintf(stdout, "exported backup to %s\n", *outFlag)
return 0
}
// writeBackup writes an archive of the content directory and the
// users, templates and tokens files. It holds no secrets: the config
// file stays out, because the import ignores it and its session_key
// would be a credential in a file that is easy to copy around.
func writeBackup(outPath string, cfg *config.Config) error {
dir := filepath.Dir(outPath)
tmp, err := os.CreateTemp(dir, ".volumen-backup-*.tmp")
if err != nil {
return err
}
tmpPath := tmp.Name()
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
os.Remove(tmpPath)
return err
}
if err := backup.Write(tmp, app.BackupOptions(cfg)); err != nil {
tmp.Close()
os.Remove(tmpPath)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return err
}
// The rename happens last, so a failed export leaves the previous
// archive in place rather than a truncated one.
if err := os.Rename(tmpPath, outPath); err != nil {
os.Remove(tmpPath)
return err
}
return nil
}
func runImport(args []string, stderr io.Writer) int {
flags := flag.NewFlagSet("import", flag.ContinueOnError)
flags.SetOutput(stderr)
configPath := flags.String("config", config.ResolveConfigPath(), "config path")
if err := flags.Parse(args); err != nil {
return flagExitCode(err)
}
if flags.NArg() != 1 {
fmt.Fprintln(stderr, "volumen import: archive path required")
return 2
}
cfg, err := config.Load(*configPath, config.Overrides{Port: config.PortUnset})
if err != nil {
fmt.Fprintf(stderr, "volumen import: %v\n", err)
return 1
}
if err := restoreBackup(flags.Arg(0), cfg); err != nil {
fmt.Fprintf(stderr, "volumen import: %v\n", err)
return 1
}
fmt.Fprintln(stdout, "backup imported.")
return 0
}
// restoreBackup extracts an exported archive into the configured
// locations. Entries outside the layout are skipped, and the extraction
// is confined to the content directory by construction.
func restoreBackup(archivePath string, cfg *config.Config) error {
file, err := os.Open(archivePath)
if err != nil {
return err
}
defer file.Close()
written, err := backup.Restore(file, app.BackupOptions(cfg))
if err != nil {
return err
}
if written == 0 {
return errors.New("the archive holds no files this deployment recognises")
}
return nil
}
+453
View File
@@ -0,0 +1,453 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package main
import (
"encoding/json/jsontext"
json "encoding/json/v2"
"flag"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/password"
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
"sourcedock.dev/petrbalvin/volumen/internal/post"
"sourcedock.dev/petrbalvin/volumen/internal/scheduler"
"sourcedock.dev/petrbalvin/volumen/internal/store"
"sourcedock.dev/petrbalvin/volumen/internal/updater"
"sourcedock.dev/petrbalvin/volumen/internal/users"
"sourcedock.dev/petrbalvin/volumen/internal/version"
"sourcedock.dev/petrbalvin/volumen/internal/webhooks"
)
// extraArg names the first stray positional argument of a subcommand
// that takes none, so `volumen export config.toml` is a usage error
// rather than an export that quietly reads the default config.
func extraArg(flags *flag.FlagSet) string {
if flags.NArg() > 0 {
return flags.Arg(0)
}
return ""
}
// writeCommandJSON writes one indented JSON document and the newline a
// line-oriented consumer expects.
func writeCommandJSON(w io.Writer, value any) {
if err := json.MarshalWrite(w, value, jsontext.WithIndent(" "), json.Deterministic(true)); err != nil {
slog.Warn("volumen: cannot encode the JSON output", "error", err)
return
}
fmt.Fprintln(w)
}
// runStatus reports the configuration, the post count and the user
// count, without changing anything on disk.
func runStatus(args []string, stderr io.Writer) int {
flags := flag.NewFlagSet("status", flag.ContinueOnError)
flags.SetOutput(stderr)
configPath := flags.String("config", config.ResolveConfigPath(), "config path to inspect")
dataDir := flags.String("data", "", "data directory to inspect")
usersFile := flags.String("users-file", "", "users.toml path to inspect")
jsonOut := flags.Bool("json", false, "machine-readable output")
if err := flags.Parse(args); err != nil {
return flagExitCode(err)
}
if extra := extraArg(flags); extra != "" {
fmt.Fprintf(stderr, "volumen status: unexpected argument %q\n", extra)
return 2
}
checks := map[string]string{}
ok := true
if _, err := os.Stat(*configPath); err == nil {
checks["config"] = *configPath
} else {
checks["config"] = "missing (" + *configPath + ")"
ok = false
}
cfg, err := config.Load(*configPath, config.Overrides{Port: config.PortUnset})
if err != nil {
checks["config_error"] = err.Error()
ok = false
} else {
if verr := cfg.Validate(); verr != nil {
checks["config_validate"] = verr.Error()
ok = false
}
content := cfg.ContentDir
if *dataDir != "" {
content = *dataDir
}
st := store.New(store.Options{ContentDir: content, DefaultLang: cfg.Site.Language, RevisionLimit: cfg.RevisionLimit})
posts := st.All()
drafts := 0
for _, p := range posts {
if p.Status() == post.StatusDraft {
drafts++
}
}
checks["posts"] = fmt.Sprintf("%d (%d drafts)", len(posts), drafts)
checks["content_dir"] = content
if skipped := st.Unreadable(); len(skipped) > 0 {
checks["posts_unreadable"] = fmt.Sprintf("%d file(s)", len(skipped))
ok = false
}
usersPath := cfg.UsersFile
if *usersFile != "" {
usersPath = *usersFile
}
usersObj := users.New(usersPath)
if err := usersObj.Health(); err != nil {
checks["users"] = "unreadable"
ok = false
} else if count := len(usersObj.All()); count == 0 {
checks["users"] = "0 (open /admin to run the setup wizard)"
} else {
checks["users"] = fmt.Sprintf("%d", count)
}
}
if *jsonOut {
writeCommandJSON(stdout, map[string]any{"ok": ok, "checks": checks})
} else {
for _, key := range []string{
"config", "config_error", "config_validate", "content_dir",
"posts", "posts_unreadable", "users",
} {
if value, present := checks[key]; present {
fmt.Fprintf(stdout, "%-14s %s\n", key, value)
}
}
}
if !ok {
return 1
}
return 0
}
func runDoctor(args []string, stderr io.Writer) int {
flags := flag.NewFlagSet("doctor", flag.ContinueOnError)
flags.SetOutput(stderr)
configPath := flags.String("config", config.ResolveConfigPath(), "config path to check")
jsonOut := flags.Bool("json", false, "machine-readable output")
if err := flags.Parse(args); err != nil {
return flagExitCode(err)
}
if extra := extraArg(flags); extra != "" {
fmt.Fprintf(stderr, "volumen doctor: unexpected argument %q\n", extra)
return 2
}
type check struct {
Name string `json:"name"`
Status string `json:"status"`
Detail string `json:"detail,omitempty"`
}
var checks []check
add := func(name, status, detail string) {
checks = append(checks, check{Name: name, Status: status, Detail: detail})
}
if _, err := os.Stat(*configPath); err != nil {
add("config", "fail", "missing "+*configPath)
} else {
add("config", "ok", *configPath)
}
cfg, err := config.Load(*configPath, config.Overrides{Port: config.PortUnset})
if err != nil {
add("config-parse", "fail", err.Error())
} else if err := cfg.Validate(); err != nil {
add("config-validate", "fail", err.Error())
} else {
add("config-validate", "ok", "")
}
if cfg != nil {
st := openStore(cfg)
skipped := st.Unreadable()
if len(skipped) > 0 {
// A file that cannot be parsed is invisible to every read, so
// it fails a check rather than warning.
add("posts-readable", "fail",
fmt.Sprintf("%d file(s) cannot be parsed: %s", len(skipped), skipped[0].Path))
} else {
add("posts-readable", "ok", "")
}
broken := 0
for _, p := range st.All() {
if _, err := p.HTML(); err != nil {
broken++
}
}
if broken > 0 {
add("posts-render", "warn", fmt.Sprintf("%d post(s) fail to render", broken))
} else {
add("posts-render", "ok", "")
}
usersObj := users.New(cfg.UsersFile)
if err := usersObj.Health(); err != nil {
add("users-file", "fail", err.Error())
} else {
add("users-file", "ok", "")
if !usersObj.Any() {
add("setup", "warn", "no accounts yet: run the first-run wizard at /admin")
} else {
add("setup", "ok", "")
}
weak := 0
for _, user := range usersObj.All() {
if password.NeedsRehash(user.PasswordHash) {
weak++
}
}
if weak > 0 {
add("password-hashes", "warn", fmt.Sprintf("%d account(s) use weak scrypt parameters", weak))
} else {
add("password-hashes", "ok", "")
}
}
}
failed := false
for _, c := range checks {
if c.Status == "fail" {
failed = true
}
}
if *jsonOut {
writeCommandJSON(stdout, map[string]any{"ok": !failed, "checks": checks})
} else {
for _, c := range checks {
line := fmt.Sprintf("%-16s %s", c.Name, c.Status)
if c.Detail != "" {
line += " " + c.Detail
}
fmt.Fprintln(stdout, line)
}
}
if failed {
return 1
}
return 0
}
func runCheckUpdate(args []string, stderr io.Writer) int {
flags := flag.NewFlagSet("check-update", flag.ContinueOnError)
flags.SetOutput(stderr)
jsonOut := flags.Bool("json", false, "machine-readable output")
if err := flags.Parse(args); err != nil {
return flagExitCode(err)
}
if extra := extraArg(flags); extra != "" {
fmt.Fprintf(stderr, "volumen check-update: unexpected argument %q\n", extra)
return 2
}
latest, err := updater.CheckLatest()
if err != nil {
// A network failure is an operational failure, not a usage one:
// 2 stays reserved for wrong arguments.
fmt.Fprintf(stderr, "volumen check-update: %v\n", err)
return 1
}
available := latest != "" && updater.CompareVersions(latest, version.Version()) > 0
if *jsonOut {
_ = json.MarshalWrite(stdout, map[string]any{
"current": version.Version(), "latest": latest, "available": available,
})
} else if available {
fmt.Fprintf(stdout, "volumen %s is available (running %s).\n", latest, version.Version())
} else {
fmt.Fprintf(stdout, "volumen %s is up to date.\n", version.Version())
}
if available {
return 1
}
return 0
}
func runPublishDue(args []string, stderr io.Writer) int {
flags := flag.NewFlagSet("publish-due", flag.ContinueOnError)
flags.SetOutput(stderr)
configPath := flags.String("config", config.ResolveConfigPath(), "config path")
dryRun := flags.Bool("dry-run", false, "only list due posts, do not modify files")
jsonOut := flags.Bool("json", false, "machine-readable output")
if err := flags.Parse(args); err != nil {
return flagExitCode(err)
}
if extra := extraArg(flags); extra != "" {
fmt.Fprintf(stderr, "volumen publish-due: unexpected argument %q\n", extra)
return 2
}
cfg, err := config.Load(*configPath, config.Overrides{Port: config.PortUnset})
if err != nil {
fmt.Fprintf(stderr, "volumen publish-due: %v\n", err)
return 1
}
st := openStore(cfg)
if *dryRun {
// A dry run must not touch the tree, so the tombstone cleanup the
// serving path performs is deliberately absent here.
due := scheduler.DuePosts(st)
slugs := make([]string, 0, len(due))
for _, p := range due {
slugs = append(slugs, p.Slug())
}
if *jsonOut {
_ = json.MarshalWrite(stdout, map[string]any{"due": slugs, "dry_run": true})
} else {
for _, slug := range slugs {
fmt.Fprintln(stdout, slug)
}
}
return 0
}
// Webhooks configured in the file are delivered here too, so a
// front-end that rebuilds from them hears about a CLI publish.
hooks := webhookManager(cfg)
published, failures := scheduler.PublishDueWith(st, func(p *post.Post) {
hooks.Fire("post.published", map[string]any{"post": payloads.BuildSummary(p)}, false)
})
hooks.Wait()
if published == nil {
published = []string{}
}
if *jsonOut {
_ = json.MarshalWrite(stdout, map[string]any{
"published": published, "failed": failures,
})
} else {
for _, slug := range published {
fmt.Fprintf(stdout, "published %s\n", slug)
}
}
if failures > 0 {
fmt.Fprintf(stderr, "volumen publish-due: %d post(s) could not be published\n", failures)
return 1
}
return 0
}
// webhookManager builds a delivery manager from the file's [[webhooks]]
// plus the admin-managed store beside the users file, the same merge the
// server runs, so a short-lived command reaches the same endpoints.
func webhookManager(cfg *config.Config) *webhooks.Manager {
hooks := make([]webhooks.Webhook, 0, len(cfg.Webhooks))
for _, hook := range cfg.Webhooks {
hooks = append(hooks, webhooks.Webhook{
URL: hook.URL, Secret: hook.Secret,
Events: hook.Events, Enabled: hook.Delivers(),
})
}
fileHooks, err := webhooks.LoadFile(filepath.Join(filepath.Dir(cfg.UsersFile), "webhooks.toml"))
if err != nil {
slog.Warn("volumen: ignoring the webhook store", "error", err)
} else {
hooks = append(hooks, fileHooks...)
}
return webhooks.NewManager(hooks, version.Version())
}
func runValidate(args []string, stderr io.Writer) int {
flags := flag.NewFlagSet("validate", flag.ContinueOnError)
flags.SetOutput(stderr)
configPath := flags.String("config", config.ResolveConfigPath(), "config path")
jsonOut := flags.Bool("json", false, "machine-readable output")
if err := flags.Parse(args); err != nil {
return flagExitCode(err)
}
if extra := extraArg(flags); extra != "" {
fmt.Fprintf(stderr, "volumen validate: unexpected argument %q\n", extra)
return 2
}
cfg, err := config.Load(*configPath, config.Overrides{Port: config.PortUnset})
if err != nil {
fmt.Fprintf(stderr, "volumen validate: %v\n", err)
return 1
}
st := openStore(cfg)
type problem struct {
Slug string `json:"slug"`
Path string `json:"path,omitempty"`
Error string `json:"error"`
}
var problems []problem
seen := map[string]string{}
aliases := map[string]string{}
// A file the store cannot parse is invisible to every other command,
// so it is reported first: a post that silently disappeared is the
// failure an operator most needs named.
for _, broken := range st.Unreadable() {
problems = append(problems, problem{Path: broken.Path, Error: broken.Error})
}
for _, p := range st.All() {
if _, err := p.HTML(); err != nil {
problems = append(problems, problem{Slug: p.Slug(), Error: err.Error()})
}
if other, dup := seen[p.Slug()]; dup {
problems = append(problems, problem{
Slug: p.Slug(),
Error: fmt.Sprintf("duplicate slug (also %s)", other),
})
}
seen[p.Slug()] = p.Path
if !payloads.SlugRegex.MatchString(p.Slug()) {
problems = append(problems, problem{Slug: p.Slug(), Error: "invalid slug format"})
}
if p.Title() == "" {
problems = append(problems, problem{Slug: p.Slug(), Error: "missing title"})
}
if value, present := p.Metadata.Get("publish_at"); present && value != nil {
if _, ok := p.DueAt(); !ok {
problems = append(problems, problem{
Slug: p.Slug(),
Error: "publish_at is not a date, so the post stays withheld",
})
}
}
for _, alias := range p.Aliases() {
if existing, dup := aliases[alias]; dup {
problems = append(problems, problem{
Slug: p.Slug(),
Error: fmt.Sprintf("alias %q already used by %s", alias, existing),
})
continue
}
aliases[alias] = p.Slug()
}
}
// An alias that shadows a live slug would shadow a real post.
for alias, owner := range aliases {
if _, clash := seen[alias]; clash {
problems = append(problems, problem{
Slug: owner,
Error: fmt.Sprintf("alias %q collides with a live slug", alias),
})
}
}
if *jsonOut {
_ = json.MarshalWrite(stdout, map[string]any{"problems": problems})
} else if len(problems) == 0 {
fmt.Fprintln(stdout, "content OK")
} else {
for _, p := range problems {
where := p.Slug
if where == "" {
where = p.Path
}
fmt.Fprintf(stdout, "%s: %s\n", where, p.Error)
}
}
if len(problems) > 0 {
return 1
}
return 0
}
Binary file not shown.
+101
View File
@@ -0,0 +1,101 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Command volumen is a lightweight publishing platform for scientists: a
// headless JSON API plus a server-rendered admin, on posts that are files.
package main
import (
"fmt"
"io"
"os"
"sourcedock.dev/petrbalvin/volumen/internal/updater"
"sourcedock.dev/petrbalvin/volumen/internal/version"
)
func main() {
os.Exit(run(os.Args[1:], os.Stderr))
}
// stdout is indirected for tests.
var stdout io.Writer = os.Stdout
func usage(stderr io.Writer) {
fmt.Fprintf(stderr, `volumen %s - lightweight publishing platform for scientists
Usage: volumen <command> [options]
Commands:
serve Start the publishing server
status Show installation status
doctor Check installation health
check-update Compare with the latest Gitea release
export Export posts, media, and users to a tar.gz
import Import a backup archive
publish-due Publish scheduled posts whose date arrived
validate Validate the content directory
version Show version
`, version.Version())
}
// run dispatches subcommands and returns the process exit code.
func run(args []string, stderr io.Writer) int {
if len(args) == 0 {
usage(stderr)
return 2
}
switch args[0] {
case "help", "-h", "--help":
usage(stdout)
return 0
case "-v", "--version":
fmt.Fprintf(stdout, "volumen %s\n", version.Version())
return 0
case "serve":
return runServe(args[1:], stderr)
case "status":
return runStatus(args[1:], stderr)
case "doctor":
return runDoctor(args[1:], stderr)
case "check-update":
return runCheckUpdate(args[1:], stderr)
case "export":
return runExport(args[1:], stderr)
case "import":
return runImport(args[1:], stderr)
case "publish-due":
return runPublishDue(args[1:], stderr)
case "validate":
return runValidate(args[1:], stderr)
case "version":
fmt.Fprintf(stdout, "volumen %s\n", version.Version())
return 0
default:
fmt.Fprintf(stderr, "volumen: unknown command %q\n", args[0])
usage(stderr)
return 2
}
}
func (c *versionCache) refresh() {
value := updater.UpdateAvailable(version.Version())
c.mu.Lock()
c.value = value
c.ready = true
c.inFlight = false
c.mu.Unlock()
}
func (c *versionCache) check() (string, error) {
// Never block a request: return the cached value, and start at most
// one refresh at a time, because the caller is a page render that may
// be anonymous.
c.mu.Lock()
defer c.mu.Unlock()
if !c.ready && !c.inFlight {
c.inFlight = true
go c.refresh()
}
return c.value, nil
}
+461
View File
@@ -0,0 +1,461 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/updater"
)
func TestRunWithoutArgs(t *testing.T) {
var buf bytes.Buffer
if code := run(nil, &buf); code != 2 {
t.Fatalf("exit code = %d, want 2", code)
}
if !strings.Contains(buf.String(), "Usage: volumen") {
t.Fatalf("stderr = %q, want usage", buf.String())
}
if !strings.Contains(buf.String(), "lightweight publishing platform for scientists") {
t.Fatalf("stderr = %q, want the platform definition", buf.String())
}
}
func TestRunUnknownSubcommand(t *testing.T) {
var buf bytes.Buffer
if code := run([]string{"frobnicate"}, &buf); code != 2 {
t.Fatalf("exit code = %d, want 2", code)
}
if !strings.Contains(buf.String(), "unknown command") {
t.Fatalf("stderr = %q", buf.String())
}
}
func TestRunVersion(t *testing.T) {
var out bytes.Buffer
old := stdout
stdout = &out
defer func() { stdout = old }()
if code := run([]string{"version"}, &bytes.Buffer{}); code != 0 {
t.Fatalf("exit code = %d", code)
}
got := strings.TrimSpace(out.String())
// <name> <version>: the recorded version carries the v prefix at a tag,
// a pseudo-version in a plain checkout, or (devel) outside version control.
if !strings.HasPrefix(got, "volumen ") || strings.TrimSpace(strings.TrimPrefix(got, "volumen ")) == "" {
t.Fatalf("stdout = %q, want \"volumen <version>\"", out.String())
}
}
func TestRunServeBadFlags(t *testing.T) {
var buf bytes.Buffer
if code := run([]string{"serve", "--nonsense"}, &buf); code != 2 {
t.Fatalf("exit code = %d, want 2", code)
}
}
func TestRunServeBadConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte("not = valid = toml"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
var buf bytes.Buffer
if code := run([]string{"serve", "--config", path}, &buf); code != 1 {
t.Fatalf("exit code = %d, want 1; stderr = %q", code, buf.String())
}
}
func writeCLIConfig(t *testing.T) (string, string) {
t.Helper()
dir := t.TempDir()
content := filepath.Join(dir, "posts")
if err := os.MkdirAll(content, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(dir, "config.toml")
body := "content_dir = \"" + content +
"\"\nusers_file = \"" + filepath.Join(dir, "users.toml") + "\"\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
return path, content
}
func captureStdout(t *testing.T, fn func() int) (int, string) {
t.Helper()
var buf bytes.Buffer
old := stdout
stdout = &buf
defer func() { stdout = old }()
code := fn()
return code, buf.String()
}
func TestStatusAndDoctorCommands(t *testing.T) {
configPath, content := writeCLIConfig(t)
if err := os.WriteFile(filepath.Join(content, "a.md"),
[]byte("+++\nslug = \"a\"\ntitle = \"A\"\ndate = 2026-01-01\n+++\nbody\n"), 0o644); err != nil {
t.Fatalf("write post: %v", err)
}
code, out := captureStdout(t, func() int {
return run([]string{"status", "--config", configPath, "--json"}, &bytes.Buffer{})
})
if code != 0 || !strings.Contains(out, `"posts": "1 (0 drafts)"`) {
t.Fatalf("status code=%d out=%s", code, out)
}
// With no accounts yet the report points at the wizard rather than
// leaving the zero unexplained.
if !strings.Contains(out, `"users": "0 (open /admin to run the setup wizard)"`) {
t.Fatalf("status users hint missing: %s", out)
}
code, out = captureStdout(t, func() int {
return run([]string{"doctor", "--config", configPath, "--json"}, &bytes.Buffer{})
})
if code != 0 || !strings.Contains(out, `"ok": true`) {
t.Fatalf("doctor code=%d out=%s", code, out)
}
if !strings.Contains(out, `"name": "setup"`) || !strings.Contains(out, "first-run wizard") {
t.Fatalf("doctor setup check missing: %s", out)
}
}
func TestValidateCommand(t *testing.T) {
configPath, content := writeCLIConfig(t)
if err := os.WriteFile(filepath.Join(content, "a.md"),
[]byte("+++\nslug = \"a\"\ntitle = \"A\"\n+++\nbody\n"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
code, out := captureStdout(t, func() int {
return run([]string{"validate", "--config", configPath}, &bytes.Buffer{})
})
if code != 0 || !strings.Contains(out, "content OK") {
t.Fatalf("code=%d out=%q", code, out)
}
// A duplicate slug is reported.
if err := os.WriteFile(filepath.Join(content, "b.md"),
[]byte("+++\nslug = \"a\"\ntitle = \"B\"\n+++\nbody\n"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
code, out = captureStdout(t, func() int {
return run([]string{"validate", "--config", configPath}, &bytes.Buffer{})
})
if code != 1 || !strings.Contains(out, "duplicate slug") {
t.Fatalf("code=%d out=%q", code, out)
}
}
func TestPublishDueDryRun(t *testing.T) {
configPath, content := writeCLIConfig(t)
due := time.Now().UTC().Format("2006-01-02")
body := "+++\nslug = \"due\"\npublish_at = " + due + "\n+++\nx\n"
if err := os.WriteFile(filepath.Join(content, "due.md"), []byte(body), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
code, out := captureStdout(t, func() int {
return run([]string{"publish-due", "--config", configPath, "--dry-run"}, &bytes.Buffer{})
})
if code != 0 || !strings.Contains(out, "due") {
t.Fatalf("code=%d out=%q", code, out)
}
// Real run publishes it.
code, out = captureStdout(t, func() int {
return run([]string{"publish-due", "--config", configPath}, &bytes.Buffer{})
})
if code != 0 || !strings.Contains(out, "published due") {
t.Fatalf("code=%d out=%q", code, out)
}
}
func TestExportImportRoundTrip(t *testing.T) {
configPath, content := writeCLIConfig(t)
if err := os.WriteFile(filepath.Join(content, "a.md"),
[]byte("+++\nslug = \"a\"\ntitle = \"A\"\n+++\nbody\n"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
archive := filepath.Join(t.TempDir(), "backup.tar.gz")
code, _ := captureStdout(t, func() int {
return run([]string{"export", "--config", configPath, "--out", archive}, &bytes.Buffer{})
})
if code != 0 {
t.Fatalf("export code = %d", code)
}
if err := os.Remove(filepath.Join(content, "a.md")); err != nil {
t.Fatalf("remove: %v", err)
}
code, _ = captureStdout(t, func() int {
return run([]string{"import", "--config", configPath, archive}, &bytes.Buffer{})
})
if code != 0 {
t.Fatalf("import code = %d", code)
}
raw, err := os.ReadFile(filepath.Join(content, "a.md"))
if err != nil || !strings.Contains(string(raw), "slug = \"a\"") {
t.Fatalf("restored post missing: %v", err)
}
}
func TestServeBadConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte("not = valid = toml"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
var buf bytes.Buffer
if code := run([]string{"serve", "--config", path}, &buf); code != 1 {
t.Fatalf("exit code = %d, want 1; stderr = %q", code, buf.String())
}
}
func TestCheckUpdateFlagParsing(t *testing.T) {
var buf bytes.Buffer
if code := run([]string{"check-update", "--nonsense"}, &buf); code != 2 {
t.Fatalf("exit code = %d, want 2", code)
}
}
func TestCheckUpdateCommand(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"tag_name": "v99.0.0"})
}))
defer srv.Close()
old := updater.ReleaseBase
updater.ReleaseBase = srv.URL
defer func() { updater.ReleaseBase = old }()
code, out := captureStdout(t, func() int {
return run([]string{"check-update", "--json"}, &bytes.Buffer{})
})
if code != 1 || !strings.Contains(out, `"available":true`) {
t.Fatalf("code=%d out=%s", code, out)
}
}
func TestValidateReportsContentProblems(t *testing.T) {
configPath, content := writeCLIConfig(t)
posts := map[string]string{
"a.md": "+++\nslug = \"a\"\n+++\nbody\n", // missing title
"b.md": "+++\nslug = \"Bad Slug\"\ntitle = \"B\"\n+++\nbody\n", // invalid slug
"c.md": "+++\nslug = \"c\"\ntitle = \"C\"\naliases = [\"a\"]\n+++\nx\n", // alias clash
}
for name, body := range posts {
if err := os.WriteFile(filepath.Join(content, name), []byte(body), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
}
code, out := captureStdout(t, func() int {
return run([]string{"validate", "--config", configPath, "--json"}, &bytes.Buffer{})
})
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
for _, want := range []string{"missing title", "invalid slug format", "collides with a live slug"} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q:\n%s", want, out)
}
}
}
func TestLoadConfigOverrides(t *testing.T) {
dir := t.TempDir()
cfg, err := loadConfig(filepath.Join(dir, "absent.toml"), filepath.Join(dir, "posts"),
"127.0.0.1", 9191)
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
if cfg.Server.Host != "127.0.0.1" || cfg.Server.Port != 9191 {
t.Fatalf("host/port = %q/%d", cfg.Server.Host, cfg.Server.Port)
}
if cfg.ContentDir != filepath.Join(dir, "posts") {
t.Fatalf("content dir = %q", cfg.ContentDir)
}
if _, err := loadConfig(filepath.Join(dir, "absent.toml"), "", "", 70000); err == nil {
t.Fatal("an out-of-range port was accepted")
}
if _, err := loadConfig(filepath.Join(dir, "absent.toml"), "", "", 0); err != nil {
t.Fatalf("zero means no override: %v", err)
}
}
func TestVersionCacheServesWithoutBlocking(t *testing.T) {
// The first check spawns a refresh that would otherwise leave for the
// real release host; the stub holds it until the assertions are done,
// so the test touches no network and the goroutine cannot overwrite
// the value the test is about to read. The defers only restore the
// global after the refresh has read it, which the request arrival
// orders.
release := make(chan struct{})
started := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
close(started)
<-release
}))
defer func() {
close(release)
srv.Close()
}()
old := updater.ReleaseBase
updater.ReleaseBase = srv.URL
defer func() {
<-started
updater.ReleaseBase = old
}()
c := newVersionCache()
if got, err := c.check(); got != "" || err != nil {
t.Fatalf("check before the first refresh = %q, %v", got, err)
}
c.mu.Lock()
c.value = "1.2.3"
c.ready = true
c.mu.Unlock()
if got, _ := c.check(); got != "1.2.3" {
t.Fatalf("check = %q", got)
}
}
func TestFlagHelpExitsZero(t *testing.T) {
var stderr bytes.Buffer
if code := run([]string{"serve", "-h"}, &stderr); code != 0 {
t.Fatalf("serve -h exit code = %d, want 0", code)
}
stderr.Reset()
if code := run([]string{"export", "-nope"}, &stderr); code != 2 {
t.Fatalf("an unknown flag exit code = %d, want 2", code)
}
stderr.Reset()
if code := run([]string{"serve", "extra"}, &stderr); code != 2 {
t.Fatalf("a stray argument exit code = %d, want 2", code)
}
}
func TestStatusAndDoctorReportABrokenConfig(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
if err := os.WriteFile(configPath, []byte("[server]\nport = 70000\n"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
var out, errOut bytes.Buffer
swapStdout(t, &out)
if code := run([]string{"status", "--config", configPath, "--json"}, &errOut); code != 1 {
t.Fatalf("status exit code = %d, want 1 for an invalid config", code)
}
if !strings.Contains(out.String(), "config_validate") {
t.Fatalf("status did not report the validation failure: %s", out.String())
}
out.Reset()
if code := run([]string{"doctor", "--config", configPath}, &errOut); code != 1 {
t.Fatalf("doctor exit code = %d, want 1", code)
}
if !strings.Contains(out.String(), "config-validate") {
t.Fatalf("doctor output = %s", out.String())
}
}
func TestValidateReportsAnUnreadablePost(t *testing.T) {
dir := t.TempDir()
content := filepath.Join(dir, "posts")
if err := os.MkdirAll(content, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
body := "+++\nslug = \"broken\"\ninvalid = = = \n+++\nx\n"
if err := os.WriteFile(filepath.Join(content, "broken.md"), []byte(body), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
configPath := filepath.Join(dir, "config.toml")
settings := fmt.Sprintf("content_dir = %q\nusers_file = %q\n[site]\nbase_url = \"https://example.com\"\n",
content, filepath.Join(dir, "users.toml"))
if err := os.WriteFile(configPath, []byte(settings), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var out, errOut bytes.Buffer
swapStdout(t, &out)
if code := run([]string{"validate", "--config", configPath, "--json"}, &errOut); code != 1 {
t.Fatalf("exit code = %d, want 1", code)
}
if !strings.Contains(out.String(), "broken.md") {
t.Fatalf("validate did not name the unreadable file: %s", out.String())
}
}
func swapStdout(t *testing.T, w io.Writer) {
t.Helper()
previous := stdout
stdout = w
t.Cleanup(func() { stdout = previous })
}
// A listen failure (the port is taken) exits through the same cleanup
// path as a signal: background work is waited for and the audit log
// closed before the process gives up, and the exit code is 1.
func TestServeFailsCleanlyOnABusyPort(t *testing.T) {
release := make(chan struct{})
started := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
close(started)
<-release
}))
defer func() {
close(release)
srv.Close()
}()
old := updater.ReleaseBase
updater.ReleaseBase = srv.URL
// The serve path starts a background refresh; the global is restored
// only after that goroutine has read it.
defer func() {
<-started
updater.ReleaseBase = old
}()
occupied, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer occupied.Close()
port := occupied.Addr().(*net.TCPAddr).Port
configPath, _ := writeCLIConfig(t)
var stderr bytes.Buffer
code := run([]string{"serve", "--config", configPath, "--port", strconv.Itoa(port)}, &stderr)
if code != 1 {
t.Fatalf("exit code = %d, want 1; stderr = %s", code, stderr.String())
}
if !strings.Contains(stderr.String(), "address already in use") &&
!strings.Contains(stderr.String(), "bind") {
t.Fatalf("stderr does not name the listen failure: %s", stderr.String())
}
}
// Every subcommand that takes no positional argument says so with exit
// code 2 rather than ignoring the extra word.
func TestSubcommandsRejectStrayArguments(t *testing.T) {
for _, name := range []string{
"serve", "status", "doctor", "check-update", "publish-due",
"validate", "export",
} {
var stderr bytes.Buffer
code := run([]string{name, "stray-argument"}, &stderr)
if code != 2 {
t.Errorf("%s: exit code = %d, want 2 (stderr = %s)", name, code, stderr.String())
continue
}
if !strings.Contains(stderr.String(), "unexpected argument") {
t.Errorf("%s: stderr = %s", name, stderr.String())
}
}
}
+198
View File
@@ -0,0 +1,198 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/app"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/scheduler"
"sourcedock.dev/petrbalvin/volumen/internal/store"
"sourcedock.dev/petrbalvin/volumen/internal/updater"
"sourcedock.dev/petrbalvin/volumen/internal/version"
)
// loadConfig resolves overrides from the common flags.
func loadConfig(configPath, contentDir, host string, port int) (*config.Config, error) {
overrides := config.Overrides{Port: config.PortUnset}
if host != "" {
overrides.Host = host
}
if port != 0 {
if port < 1 || port > 65535 {
return nil, fmt.Errorf("--port must be in 1..65535 (got %d)", port)
}
overrides.Port = port
}
if contentDir != "" {
overrides.ContentDir = contentDir
}
return config.Load(configPath, overrides)
}
func openStore(cfg *config.Config) *store.Store {
return store.New(store.Options{ContentDir: cfg.ContentDir, DefaultLang: cfg.Site.Language, RevisionLimit: cfg.RevisionLimit})
}
// The server bounds how long a connection may occupy a goroutine: the API
// serves small responses, so the values are generous but finite, and
// without them a client that dribbles a request holds a socket
// indefinitely.
const (
readHeaderTimeout = 10 * time.Second
readTimeout = 60 * time.Second
writeTimeout = 120 * time.Second
idleTimeout = 120 * time.Second
shutdownGrace = 15 * time.Second
// maxHeaderBytes bounds a request line and its headers, which is what
// a client can make the server buffer before any handler runs.
maxHeaderBytes = 1 << 20
)
func runServe(args []string, stderr io.Writer) int {
flags := flag.NewFlagSet("serve", flag.ContinueOnError)
flags.SetOutput(stderr)
configPath := flags.String("config", config.ResolveConfigPath(), "path to config.toml")
contentDir := flags.String("content", "", "override the posts directory")
host := flags.String("host", "", "override the listen host")
port := flags.Int("port", 0, "override the listen port")
if err := flags.Parse(args); err != nil {
return flagExitCode(err)
}
if extra := extraArg(flags); extra != "" {
fmt.Fprintf(stderr, "volumen serve: unexpected argument %q\n", extra)
return 2
}
cfg, err := loadConfig(*configPath, *contentDir, *host, *port)
if err != nil {
fmt.Fprintf(stderr, "volumen serve: %v\n", err)
return 1
}
if cfg.Server.LogFormat == config.LogFormatJSON {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stderr, nil)))
}
st := openStore(cfg)
server, err := app.New(cfg, st)
if err != nil {
fmt.Fprintf(stderr, "volumen serve: %v\n", err)
return 1
}
// Serving is the moment a stale tombstone becomes a hazard, so the
// cleanup runs here rather than in the store constructor, where the
// read-only CLI commands would trigger it too.
st.CleanupStaleTombstones()
// Background update check feeding the admin banner.
latest := newVersionCache()
go latest.refresh()
server.Admin.SetUpdateHooks(latest.check, func() (string, error) {
return updater.SelfUpdate(version.Version())
})
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
var background sync.WaitGroup
// The scheduler runs until the shutdown path closes its stop channel,
// and the wait below depends on that happening.
var schedulerStop chan struct{}
if cfg.Scheduler.Enabled {
schedulerStop = make(chan struct{})
background.Go(func() {
scheduler.Run(st, time.Duration(cfg.Scheduler.Interval)*time.Second,
schedulerStop, server.PublishEvent)
})
}
listenAddr, err := cfg.ListenAddr()
if err != nil {
fmt.Fprintf(stderr, "volumen serve: %v\n", err)
return 1
}
addr := listenAddr.String()
httpServer := &http.Server{
Addr: addr,
Handler: server.Handler(),
ReadHeaderTimeout: readHeaderTimeout,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
IdleTimeout: idleTimeout,
MaxHeaderBytes: maxHeaderBytes,
// net/http writes its own errors to stderr by default, outside
// the log configuration; route them through slog instead.
ErrorLog: slog.NewLogLogger(slog.Default().Handler(), slog.LevelError),
}
errCh := make(chan error, 1)
background.Go(func() {
slog.Info("volumen: listening", "addr", addr, "version", version.Version())
errCh <- httpServer.ListenAndServe()
})
exitCode := 0
select {
case err := <-errCh:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
fmt.Fprintf(stderr, "volumen serve: %v\n", err)
// The listen failure path still flushes what the background
// work started, the same as the signal path.
exitCode = 1
}
if schedulerStop != nil {
close(schedulerStop)
}
case <-ctx.Done():
slog.Info("volumen: shutting down")
if schedulerStop != nil {
close(schedulerStop)
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownGrace)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
slog.Warn("volumen: shutdown did not finish cleanly", "error", err)
}
}
background.Wait()
// Deliveries fired during the last requests run on the webhook
// manager's own tracker, not on background: without this wait a
// SIGTERM would kill a delivery mid-retry and the receiver would
// never hear about the change.
server.Webhooks.Wait()
if err := server.Audit.Close(); err != nil {
slog.Warn("volumen: cannot close the audit log", "error", err)
}
return exitCode
}
// flagExitCode maps a flag parse failure to the process exit code: a
// help request is not an error, anything else is a usage error.
func flagExitCode(err error) int {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
// versionCache memoises the release check so admin page renders never
// block on the network.
type versionCache struct {
mu sync.Mutex
value string
ready bool
inFlight bool
}
func newVersionCache() *versionCache { return &versionCache{} }