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
+39
View File
@@ -0,0 +1,39 @@
# Race, Go. Dispatched by hand.
#
# The race detector roughly doubles both time and memory, which the shared runner box
# cannot afford on a push, and it is not part of a release either: locally it belongs to
# `just gates`, which races the tree before the tag is cut. Here it is an explicit
# decision rather than a routine.
#
# Every step is one command, so the step that fails is the gate that failed.
name: Race
on:
workflow_dispatch:
env:
# interpres is fetched directly from the self-hosted Gitea, not via
# proxy.golang.org, and skips the public checksum database.
GOPRIVATE: sourcedock.dev
# One core: parallelism buys no speed here and costs memory the box does not have.
GOFLAGS: -p=1
GOMAXPROCS: "2"
jobs:
race:
runs-on: fedora
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Install gcc
# The race detector needs cgo and the runner image carries no C compiler.
run: dnf install -y gcc
- name: Race
run: go test -race -count=1 -timeout 10m ./...
+424
View File
@@ -0,0 +1,424 @@
# Release, Go binaries. Runs on version tags (v1.2.3) pushed to main.
#
# The ci skill's go-release template, adapted only for the binary name, the
# matrix and the version subcommand. The release job's steps are the template's
# verbatim: the read-back rides the release download route, which is the
# verified one, not the API attachment route, which serves a stale body for
# the first attachment after creation. The checksums file is volumen's own
# addition beside the template: the admin self-update verifies every download
# against it.
#
# The gates run in their own job, once, before the matrix, minus the race detector: race
# never runs on a push path or a tag, and the local gate raced this tree before the tag
# was cut. Putting the gates inside the matrix would run the whole suite once per target
# on the box that also hosts the forge. Each job validates the tag for itself rather than
# passing a value between jobs, so no workflow feature has to be trusted for the version
# to reach the file name.
name: Release
on:
push:
tags: ["v*"]
env:
# The box is shared with the forge, so parallelism is bounded on purpose. The gates job
# needs it most; the build jobs inherit it for their parallel compilation.
GOFLAGS: -p=1
GOMAXPROCS: "2"
jobs:
gates:
runs-on: fedora
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Install Perl
# Perl for the steps below. The install is a no-op where the package
# is already present.
run: dnf install -y perl
- name: Validate the tag
env:
VERSION: ${{ gitea.ref_name }}
run: |
perl -e '
my $v = $ENV{VERSION} // q{};
$v =~ m{^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$}
or die qq{ERROR: expected a semver tag like v1.2.3, got: $v\n};
print qq{tag $v\n};
'
- name: Build
run: go build ./...
- name: Format
run: |
perl -e '
open(my $g, q{-|}, q{gofmt}, q{-l}, q{.}) or die qq{gofmt: $!};
my @bad = <$g>;
close($g);
print @bad;
exit(@bad ? 1 : 0);
'
- name: Vet
run: go vet ./...
- name: Modernise
run: go fix -diff ./...
- name: Tests
run: go test -count=1 -timeout 10m -coverprofile=coverage.out ./...
- name: Coverage floor
run: |
perl -e '
open(my $c, q{-|}, q{go}, q{tool}, q{cover}, q{-func=coverage.out}) or die qq{cover: $!};
my $total;
while (my $l = <$c>) { $total = $1 if $l =~ m{^total:\s+\S+\s+([0-9.]+)%} }
close($c);
die qq{no total line in coverage.out\n} unless defined $total;
printf qq{Total coverage: %s%%\n}, $total;
exit($total < 80 ? 1 : 0);
'
build:
runs-on: fedora
timeout-minutes: 25
needs: gates
strategy:
fail-fast: false
matrix:
# Portable targets: amd64, arm64, loong64 and riscv64 on Linux,
# amd64 and arm64 on FreeBSD, which has no loong64 port and whose
# riscv64 build does not run. No 32-bit, no wasm, no macOS, no Windows.
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: linux
goarch: loong64
- goos: linux
goarch: riscv64
- goos: freebsd
goarch: amd64
- goos: freebsd
goarch: arm64
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Install Perl
run: dnf install -y perl
- name: Validate the tag
id: version
env:
VERSION: ${{ gitea.ref_name }}
run: |
perl -e '
my $v = $ENV{VERSION} // q{};
$v =~ m{^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$}
or die qq{ERROR: expected a semver tag like v1.2.3, got: $v\n};
(my $nv = $v) =~ s{^v}{};
open(my $o, q{>>}, $ENV{GITEA_OUTPUT}) or die qq{GITEA_OUTPUT: $!};
print $o qq{version_no_v=$nv\n};
close($o);
print qq{version $nv\n};
'
- name: Build
env:
VERSION_NO_V: ${{ steps.version.outputs.version_no_v }}
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: |
# Nothing is injected. The toolchain records the tag into the binary's build
# information, so the version is right because this build happens at the tag, and
# there is no path for anyone to get wrong. -s -w only strips symbols.
go build -ldflags "-s -w" -o "bin/volumen-${VERSION_NO_V}-${GOOS}-${GOARCH}" ./cmd/volumen
# Artifacts stay on v3: v4 and later detect Gitea as GHES and abort.
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: volumen-${{ matrix.goos }}-${{ matrix.goarch }}
path: bin/volumen-${{ steps.version.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }}
if-no-files-found: error
- name: Smoke test
# Only a binary matching the runner can be run here. The check is not that --version
# exits cleanly but that it reports the tag and nothing more: a build outside version
# control reports (devel), and a build whose tree was dirty reports +dirty, and both
# would otherwise be published.
if: matrix.goos == 'linux' && matrix.goarch == 'amd64'
env:
TAG: ${{ gitea.ref_name }}
BIN: bin/volumen-${{ steps.version.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }}
run: |
perl -e '
my $want = $ENV{TAG} // die qq{ERROR: no tag\n};
open(my $bin, q{-|}, $ENV{BIN}, q{version}) or die qq{$ENV{BIN}: $!};
my $got = <$bin>;
close($bin);
$got = defined $got ? $got : q{};
chomp $got;
index($got, $want) >= 0
or die qq{ERROR: the binary printed "$got", which does not contain $want. Version control was disabled, so there is no recorded version.\n};
index($got, q{+dirty}) < 0
or die qq{ERROR: the binary printed "$got". The tree was dirty at build time, which means the checkout was not the tag, or the build artefacts are not ignored.\n};
print qq{$ENV{BIN} reports $got\n};
'
release:
runs-on: fedora
timeout-minutes: 15
needs: build
permissions:
# contents: read is required for the checkout: a job that declares any
# permissions gets a token scoped to exactly those, and releases: write
# alone leaves the fetch with no read access, which Gitea answers with
# a 404 "Repository not found". Verified on the instance 2026-09-16.
contents: read
releases: write
steps:
- uses: actions/checkout@v7
- name: Download all artifacts
uses: actions/download-artifact@v3
with:
path: dist
- name: Install Perl
run: dnf install -y perl
- name: Build the checksums file
# The admin self-update verifies every download against this file, so
# it is part of the update contract: one line per asset, hash then
# bare file name, matching volumen-<version>-<os>-<arch>. Every line
# is built from a validated digest and the written file is re-read and
# re-checked, because a hashless line here silently breaks the update
# contract for every binary at once.
run: |
perl -e '
chdir(q{dist}) or die qq{cannot enter dist: $!\n};
my @paths = grep { -f $_ } (sort(glob(q{*/*})));
@paths or die qq{ERROR: no assets under dist\n};
open(my $out, q{>}, q{checksums.txt}) or die qq{checksums.txt: $!\n};
for my $path (@paths) {
my @cmd = (q{sha256sum}, $path);
open(my $sum, q{-|}, @cmd) or die qq{sha256sum: $!\n};
my $line = <$sum>;
my @rest = <$sum>;
close($sum) or die qq{sha256sum failed for $path\n};
@rest and die qq{ERROR: unexpected extra sha256sum output for $path\n};
$line = defined $line ? $line : q{};
$line =~ s/\r?\n\z//;
my ($digest, $seen) = split / /, $line, 2;
defined $digest && defined $seen
or die qq{ERROR: malformed sha256sum output for $path: $line\n};
$digest =~ m{^[0-9a-f]{64}\z}
or die qq{ERROR: no sha256 digest in sha256sum output for $path: $line\n};
(my $name = $path) =~ s{.*/}{};
$seen eq $path
or die qq{ERROR: sha256sum named $seen for the path $path\n};
print {$out} qq{$digest $name\n};
}
close($out) or die qq{cannot flush checksums.txt: $!\n};
open(my $back, q{<}, q{checksums.txt}) or die qq{re-read: $!\n};
my $count = 0;
while (my $line = <$back>) {
$line =~ m{^[0-9a-f]{64} \S}
or die qq{ERROR: hashless line in the written file: $line\n};
$count++;
}
close($back);
$count == @paths
or die qq{ERROR: wrote $count lines for } . scalar(@paths) . qq{ assets\n};
print qq{checksums.txt written: $count verified lines\n};
'
- name: Extract the CHANGELOG section
env:
VERSION: ${{ gitea.ref_name }}
run: |
# Each step derives what it needs from the tag, so no value has to travel between
# jobs.
perl -e '
my $v = $ENV{VERSION} // q{};
$v =~ s{^v}{};
open(my $vout, q{>}, q{version-no-v.txt}) or die qq{version-no-v.txt: $!};
print $vout $v;
close($vout);
open(my $in, q{<}, q{CHANGELOG.md}) or die qq{CHANGELOG.md: $!};
my @lines = <$in>;
close($in);
my ($start, $end) = (-1, scalar @lines);
for my $i (0 .. $#lines) {
if ($start < 0) { $start = $i if $lines[$i] =~ m{^##\s+\[\Q$v\E\]} }
elsif ($lines[$i] =~ m{^##\s+\[}) { $end = $i; last }
}
$start >= 0 or die qq{ERROR: no CHANGELOG section for $v, expected a heading like: ## [$v] - YYYY-MM-DD\n};
my @body = grep { m{\S} } @lines[$start + 1 .. $end - 1];
@body or die qq{ERROR: the CHANGELOG section for $v is empty\n};
open(my $out, q{>}, q{release-body.md}) or die qq{release-body.md: $!};
print $out @body;
close($out);
printf qq{notes for %s: %d lines\n}, $v, scalar @body;
'
- name: Build the release request
run: |
perl -e '
open(my $vin, q{<}, q{version-no-v.txt}) or die qq{version-no-v.txt: $!};
my $v = <$vin>;
close($vin);
chomp $v;
open(my $in, q{<:raw}, q{release-body.md}) or die qq{release-body.md: $!};
my $body = do { local $/; <$in> };
close($in);
# Byte-oriented escaping: JSON is UTF-8, so non-ASCII passes through and only the
# characters JSON forbids are rewritten.
$body =~ s/([\\"])/\\$1/g;
$body =~ s/\t/\\t/g;
$body =~ s/\r//g;
$body =~ s/\n/\\n/g;
$body =~ s/([\x00-\x08\x0b\x0c\x0e-\x1f])/sprintf(q{\u%04x}, ord($1))/ge;
my $json = sprintf(qq{{"tag_name":"v%s","name":"v%s","body":"%s","draft":false,"prerelease":false}}, $v, $v, $body);
open(my $out, q{>}, q{release.json}) or die qq{release.json: $!};
print $out $json;
close($out);
print qq{release.json written for v$v\n};
'
- name: Create the release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
run: |
perl -e '
my @cmd = (q{curl}, q{-sS}, q{-o}, q{response.json}, q{-w}, q{%{http_code}},
q{-H}, qq{Authorization: token $ENV{GITEA_TOKEN}},
q{-H}, q{Content-Type: application/json},
q{-X}, q{POST},
qq{$ENV{GITEA_SERVER_URL}/api/v1/repos/$ENV{GITEA_REPOSITORY}/releases},
q{--data-binary}, q{@release.json});
open(my $curl, q{-|}, @cmd) or die qq{curl: $!};
my $code = <$curl>;
my $ok = close($curl);
my $exit = $? >> 8;
$code = defined $code ? $code : q{};
$ok or die qq{ERROR: curl failed (exit $exit) calling $ENV{GITEA_SERVER_URL}\n};
open(my $r, q{<:raw}, q{response.json}) or die qq{response.json: $!};
my $body = do { local $/; <$r> };
close($r);
$code eq q{201} or die qq{ERROR: the release was not created, HTTP $code: $body\n};
$body =~ m{"id"\s*:\s*([0-9]+)} or die qq{ERROR: no release id in the response: $body\n};
open(my $o, q{>}, q{release-id.txt}) or die qq{release-id.txt: $!};
print $o $1;
close($o);
print qq{release id $1\n};
'
- name: Upload assets
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
run: |
perl -e '
open(my $f, q{<}, q{release-id.txt}) or die qq{release-id.txt: $!\n};
my $id = <$f>;
close($f);
chomp $id;
my @files = grep { -f $_ } (glob(q{dist/*/*}), q{dist/checksums.txt});
@files or die qq{ERROR: no assets under dist/\n};
my $bad = 0;
for my $path (@files) {
(my $name = $path) =~ s{.*/}{};
my @cmd = (q{curl}, q{-sS}, q{-o}, q{/dev/null}, q{-w}, q{%{http_code}},
q{-H}, qq{Authorization: token $ENV{GITEA_TOKEN}},
q{-H}, q{Content-Type: application/octet-stream},
# The @ must not sit inside a qq{} string: there it starts an
# array interpolation and the upload body collapses to empty,
# which Gitea stores as a 201-created zero-byte attachment.
q{-X}, q{POST}, q{--data-binary}, q{@} . $path,
qq{$ENV{GITEA_SERVER_URL}/api/v1/repos/$ENV{GITEA_REPOSITORY}/releases/$id/assets?name=$name});
open(my $curl, q{-|}, @cmd) or die qq{curl: $!};
my $code = <$curl>;
my $ok = close($curl);
my $exit = $? >> 8;
$code = defined $code ? $code : q{};
unless ($ok) {
printf qq{%s: curl failed (exit %d)\n}, $name, $exit;
$bad = 1;
next;
}
printf qq{%s: HTTP %s\n}, $name, $code;
$bad = 1 if $code ne q{201};
}
exit($bad ? 1 : 0);
'
- name: Read the assets back
# HTTP 201 from the upload alone lies: an attachment can be created
# and still stored empty. Every asset is read back through the release
# download route, and the served length must equal the sent file.
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
TAG: ${{ gitea.ref_name }}
run: |
perl -e '
my @files = grep { -f $_ } (glob(q{dist/*/*}), q{dist/checksums.txt});
@files or die qq{ERROR: no assets under dist/\n};
my $bad = 0;
for my $path (@files) {
(my $name = $path) =~ s{.*/}{};
my @cmd = (q{curl}, q{-sS}, q{-o}, q{asset-readback.bin},
q{-w}, q{%{http_code} %{size_download}},
q{-H}, qq{Authorization: token $ENV{GITEA_TOKEN}},
qq{$ENV{GITEA_SERVER_URL}/$ENV{GITEA_REPOSITORY}/releases/download/$ENV{TAG}/$name});
open(my $curl, q{-|}, @cmd) or die qq{curl: $!};
my $line = <$curl>;
my $ok = close($curl);
my $exit = $? >> 8;
unless ($ok) {
printf qq{%s: curl failed (exit %d)\n}, $name, $exit;
$bad = 1;
next;
}
$line = defined $line ? $line : q{};
chomp $line;
my ($code, $served) = split q{ }, $line;
$code //= q{};
$served //= 0;
my $sent = -s $path;
unless ($code eq q{200}) {
printf qq{%s: HTTP %s on read-back\n}, $name, $code;
$bad = 1;
next;
}
unless ($served == $sent) {
printf qq{%s: served %s bytes, sent %d\n}, $name, $served, $sent;
$bad = 1;
next;
}
printf qq{%s: read back, %d bytes\n}, $name, $served;
}
exit($bad ? 1 : 0);
'
+92
View File
@@ -0,0 +1,92 @@
# Test, Go. Push and pull request to development. Never on main.
#
# The gates are the ones the justfile's `gates` recipe runs, minus race: the shared
# runner box cannot afford the race detector on every push, so it lives in race.yml.
# The box is one core and 2 GB beside Gitea, so parallelism is bounded on purpose and
# everything runs in one job. Extra jobs would duplicate the checkout, the Go setup and
# the dependency download three times without buying any parallelism.
#
# Every step is one command, so the step that fails is the gate that failed, and no shell
# option has to be trusted for the run to stop. The scripted steps are Perl, not shell and
# not shell: Perl behaves the same on both runner images, there is no bashism to trip over
# on ash, and it is one language instead of two. The Perl uses builtins only, because
# Fedora packages the Perl modules separately and nothing beyond `perl` itself may be
# assumed present.
name: Test
on:
push:
branches: [development]
pull_request:
branches: [development]
# A superseded run of the same ref is cancelled instead of queueing behind a run that
# no longer matters.
concurrency:
group: ${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
env:
# interpres is fetched directly from the self-hosted Gitea, not via
# proxy.golang.org, and skips the public checksum database.
GOPRIVATE: sourcedock.dev
# One core: parallelism buys no speed here and costs memory the box does not have.
GOFLAGS: -p=1
GOMAXPROCS: "2"
jobs:
test:
runs-on: fedora
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
# The module is the source of truth for the version, so it cannot drift.
go-version-file: go.mod
cache: true
- name: Install Perl
# The runner images are minimal and Perl is not guaranteed. The install is a
# no-op where it is already present; drop this step once verified on the box.
run: dnf install -y perl
- name: Build
run: go build ./...
- name: Format
run: |
perl -e '
open(my $g, q{-|}, q{gofmt}, q{-l}, q{.}) or die qq{gofmt: $!};
my @bad = <$g>;
close($g);
print @bad;
exit(@bad ? 1 : 0);
'
- name: Vet
run: go vet ./...
- name: Modernise
# Exits non-zero when it has something to rewrite, so it needs no output capture.
run: go fix -diff ./...
- name: Tests
# Scope the pattern to the packages that hold the logic when a thin cmd/ drags the
# total under the floor, and keep it equal to `packages` in the project's justfile.
# The inner timeout matches the job's, so a hanging test reports its own
# goroutine dump rather than being killed by the job timeout.
run: go test -count=1 -timeout 10m -coverprofile=coverage.out ./...
- name: Coverage floor
run: |
perl -e '
open(my $c, q{-|}, q{go}, q{tool}, q{cover}, q{-func=coverage.out}) or die qq{cover: $!};
my $total;
while (my $l = <$c>) { $total = $1 if $l =~ m{^total:\s+\S+\s+([0-9.]+)%} }
close($c);
die qq{no total line in coverage.out\n} unless defined $total;
printf qq{Total coverage: %s%%\n}, $total;
exit($total < 80 ? 1 : 0);
'
+21
View File
@@ -0,0 +1,21 @@
.idea/
.zcode/
# Go build and test output
/bin/
/dist/
/coverage.out
*.test
# Scratch output
/tmp/
/*.log
# Local config and data (the config holds secrets)
/config.toml
/posts/
/users.toml
/secret.key
/templates.toml
/tokens.toml
/webhooks.toml
+250
View File
@@ -0,0 +1,250 @@
# Changelog
All notable changes to **Volumen** are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [development]
### Added
-
## [1.0.0] - 2026-09-29
### Added
- **First release** of the platform, the API, the admin and the command line.
**Content**
- Posts are Markdown files with a TOML frontmatter block: `title`, `slug`,
`date`, `lang`, `author`, `tags`, `excerpt`, `cover` with alt text and
caption, `series` with `series_order`, `draft`, `publish_at`, `all_langs`,
`aliases`, `translations`, `fediverse_creator`, `doi`, `orcid`, `refs`, and
any key of your own, preserved in the order you wrote it.
- Mathematics: `$…$` and `$$…$$` in a post body render as MathML Core. The
conversion is server-side and carried by scriptorium, whose symbol tables
cover the standard TeX surface, so equations reach every consumer of the
API as native HTML with no JavaScript and no external service. A display
equation may span lines, the shape the papers in the corpus are written in.
A construct no MathML element can carry is never guessed at: it stays
visible as its verbatim TeX in the equation, marked as an error, exactly
where the author wrote it.
- Diagrams: a fenced `mermaid` code block renders server-side to an inline
SVG, with flowcharts (including the historical `graph` spelling) and
sequence diagrams carried in full: every node shape and edge kind,
subgraphs, classes and styles on one side, participants, all arrow kinds,
notes, activations, the block constructs, coloured rects, autonumbering and
dividers on the other. A diagram type outside the two families stays the
code block the author wrote, and a line that does not parse keeps it too,
so nothing is half-drawn.
- Scholarly identifiers are first-class: a post can carry `doi` and `orcid`
in its frontmatter and the editor, and an account can carry its owner's
ORCID, which pre-fills the field of new posts. A DOI is normalised from a
doi.org URL or doi: prefix to the bare `10.…/…` form; an ORCID is checked
to its ISO 7064 check digit. Validation is syntactic and offline: the
engine never calls doi.org or orcid.org.
- Bibliography is first-class: a post carries a `refs` array of tables in its
frontmatter and marks where the list belongs with a `[[refs]]` line in the
body. The engine renders a numbered reference section, links every inline
`[n]` citation to its entry, and turns each entry's DOI, arXiv id and ORCID
into resolver links; hand-written entries keep their verbatim text with
their identifiers made live. A reference whose DOI belongs to another post
of the same instance links to that post instead of leaving for the
resolver.
- Multi-language posts: one file per language, either in the content root or
in a per-language subdirectory, linked by a `translations` map, with
`all_langs` for a post that belongs to every language.
- Scheduled publishing: `volumen publish-due` for cron or a systemd timer, and
the in-process `[scheduler]`, which publishes the due posts at start-up and
then every interval.
- Post revisions: every save archives the previous version under
`posts/.revisions/<slug>/`, pruned to `revision_limit` versions; deleting a
post moves it into that archive, so it stays undoable.
- Post templates, an import of a `.md` file, and a download of any post with
its frontmatter. A template pre-fills the new-post form: its name, title,
slug, tags and body, plus TOML `key = value` lines for any editor input
(`series`, `doi`, `orcid`, `author`, `lang`, `cover`, `excerpt`, …), stored
in `templates.toml` under `[templates.fields]` and shown on the template's
row, so a scientific volume or a short note starts with its series and
author already in place.
- A media library of uploaded images, and cover images per post. Uploads are
stored under a UUID with the extension their bytes carry, WebP, AVIF and
SVG being the recognised formats, an SVG recognised by its root element;
each tile shows the pixel size read from the container headers or from the
SVG root's size attributes and `viewBox`, and the library takes several
uploads at once, filters by name, and offers copy-link, open and delete on
every tile.
**Public API** under `/api/volumen/`
- Site metadata, paginated post lists with `lang`, `tag` and `q` filters,
single posts with raw Markdown, rendered HTML and a table of contents,
several posts in one request, tag and series listings, RSS 2.0, Atom 1.0,
JSON Feed 1.1 and an XML sitemap.
- The `q` search ranks its results by relevance across the title, the tags,
the excerpt and the body: a title hit leads, and equal scores keep the
date order.
- Pagination in two shapes: page numbers with `has_next` and `has_prev`, or a
cursor with `next_cursor`.
- `ETag` on the list responses and on a post detail, `If-None-Match` answered
with `304`, and `PUT` and `DELETE` honouring `If-Match`, so a write is
refused with `412` instead of silently overwriting a change the client
never saw.
- A post's `doi` and `orcid` appear in the payload and in the JSON-LD block
as resolvable identifiers, and a post with `refs` carries its `references`
array, each entry's DOI, arXiv id and ORCID turned into a resolver link and
the JSON-LD block carrying the `citation` objects beside them: the citation
the web can hand to a reference manager.
- Frontmatter keys the engine does not consume itself pass through in a
`fields` object on a post detail, with nested tables, arrays and date-times
intact.
- A sliding-window rate limit per client address with `X-RateLimit-*` headers
and a `429` carrying `Retry-After`.
- Personal access tokens with the `write` and `delete` scopes for `POST`,
`PUT` and `DELETE`, for publishing from scripts and CI. Only the SHA-256
digest is stored, and the raw token is shown once.
- One error envelope for every failure, with a machine-readable `error` code
and, where it helps, a `message` and a `field`.
**Admin** under `/admin/`
- A first-run wizard founds the installation in place of the login: it
creates the administrator account, takes the interface language and the
colour scheme with a live preview, shows a password strength meter against
the configured policy, and ends with the operator signed in. The first
account is created in one write and under a lock, so two visitors claiming
a fresh installation cannot both open an identity.
- Username and password login with the `admin` and `author` roles, CSRF
tokens on every state-changing form, and a strict Content-Security-Policy
with a per-request nonce. Sessions are bound to the password they were
issued under, so a password change retires every session issued before it,
and an admin can reset another account's password from Settings, Users.
- An optional second factor for any account: time-based one-time passwords
with the enrolment QR drawn by the server itself, one-time recovery codes
shown exactly once and stored only as digests, a replay floor that refuses
a code a second time, and the same lockout guarding code guesses as
password guesses. Nothing changes for an account until its owner finishes
the setup.
- The interface is built on the author's website design system: a layered
stylesheet with `oklch` neutrals and the Viridis, Plasma and Magma palettes
from the exact matplotlib colour-map stops, self-hosted Ubuntu and Ubuntu
Mono, a light/dark/system mode toggle, one shared icon sprite, Graphis, native
dialogs for confirmations and prompts, and a sidebar that folds to an icon
rail remembered on the device. On the post list the status and tag filters
lead with a funnel and a tag glyph and the card's Edit action takes a pencil. The sheets and fonts are served under
`/admin/assets/` and revalidated by a content ETag, so a visit fetches them
once per change. The interface ships in English and Czech, both per-account
choices, and the login screen follows the last one.
- A dashboard with status counts, a tag cloud, search, status filters, bulk
publish, draft and delete, and the next scheduled posts with their publish
dates. The list shows one card per publication: the language versions are
merged by their `translations` frontmatter, the card names the version in
your interface language, and small language chips switch it to another
version; a bulk selection acts on the whole publication, and the counters
and the tag cloud count publications, not files.
- A post editor with a Markdown source view and a visual view over the same
document, live preview, toolbar and keyboard shortcuts, drag-and-drop and
pasted image upload, slug generation, reading-time counters and autosave
with restore. The bibliography has its own card below the editor, as the
reference list sits below the body of a paper: it lists every entry the
post carries, edits the verbatim citation or the structured fields
(authors with ORCID, venue, year, volume, pages, DOI, arXiv, URL),
reorders and removes entries, inserts the `[[refs]]` marker into the body,
names its count in the heading and scrolls inside itself. A save writes
the list back as `[[refs]]` tables with every other frontmatter key
untouched and the identifiers checked on the way in: a DOI normalises from
a doi.org URL, an ORCID checks its own digit. The live preview splices in
the saved post's bibliography, so the author sees what the page will show.
- Revision history per post, with download of any revision, a line-difference
comparison against the current content and one-click restore, and an undo
for the last delete.
- Settings: account (password, username, display name, fediverse handle,
profile photo), users and roles, post templates, the media library, API
tokens, webhooks with a test delivery and per-endpoint enable and remove
that apply without a restart, backup and restore, the version panel with a
checksum-verified in-place self-update, and the audit log switch.
- The surface reaches a phone: below 760 px a navigation sheet behind a
hamburger button carries the sidebar links, the signed-in user and the
logout button, and every control keeps a visible keyboard focus.
**Command line**
- `serve`, `status`, `doctor`, `check-update`, `export`, `import`,
`publish-due`, `validate` and `version`, each with `-h`, and every
operational failure reported with a non-zero exit code. A stray positional
argument is a usage error.
- A manual page, `man/volumen.1`, documents every subcommand and flag, and
the README links it beside the command list.
**Operations**
- Installing is: copy the binary, run `volumen serve`, open `/admin`. With no
`--config` the server reads `/etc/volumen/config.toml` if it exists, then
`~/.config/volumen/config.toml`, and with neither it runs on per-user
defaults whose state lives under `~/.local/share/volumen` (honouring
`XDG_DATA_HOME`), so a plain start works without root and without writing
any file first. The commented configuration template, `config.toml.example`,
is committed at the repository root.
- A fresh installation presents itself as Volumen: the default site title is
`Volumen` and the description `Powered by Volumen.`, in the API, the feeds
and the admin.
- `volumen doctor` and `volumen validate` check an installation and its
content, `volumen status` reports the installation's state, and a
deployment with no accounts yet reports the first-run wizard as the next
step (a warning, not a failure). `GET /healthz` reports readiness for a
supervisor.
- `volumen export` and `volumen import` move a whole deployment, or the admin
Backup panel does, through the same archive: the posts, media and revisions
under the content directory, plus the users, templates and tokens files.
- The session secret is generated by the server: on first start it writes a
64-character key to `secret.key` beside the users file and reuses it
across restarts, so an operator never edits a config to get sessions that
survive. `[admin].session_key` remains as an explicit override.
- Structured JSON logging with `[server].log_format = "json"`, an append-only
audit log, and outgoing webhooks that notify a front end when a post
changes.
- Every request carries a 16-character id, answered in `X-Request-Id` and
attached to each line the handlers write while serving it, and one access
line per request records the method, the path, the status and the duration.
- `[server].trusted_proxies` names the addresses whose `X-Forwarded-For` may
be believed; an empty list never reads the header.
- Read, header, write and idle timeouts on the server, and a shutdown on
`SIGINT` or `SIGTERM` that drains the requests in flight.
- `NOTICE.md` in the repository reproduces the licence of every Go module the
binary is compiled from and the terms of the artwork embedded in it.
### Security
- Post bodies are rendered by scriptorium and then sanitised by bluemonday
against a strict allowlist, so a body is treated as untrusted even though
its author is authenticated; `<script>`, event handlers, inline styles and
unknown URL schemes do not survive, and links gain
`rel="noopener noreferrer"`.
- Passwords are hashed with scrypt at fixed parameters and compared in
constant time; a stored hash below the policy floor or above a 64 MiB
memory bound is rejected rather than trusted.
- Sessions are HMAC-SHA256-signed cookies, `HttpOnly` and `SameSite=Strict`,
`Secure` when TLS terminates in front. Production refuses to start without
a session key of at least 64 bytes.
- The admin refuses a state-changing request a browser sends from another
origin before any handler runs, using the standard library's fetch-metadata
rule: `Sec-Fetch-Site` when the browser sends it, the `Origin` header
against `Host` otherwise. The per-session CSRF token stays as the inner
guard, because it also covers a same-site request from another port.
- Login attempts are limited per address, and the failure message is generic,
so neither the rate limit nor the response time reveals whether an account
exists.
- Uploads are accepted only when the bytes carry a WebP or AVIF signature or
the root element of an SVG, and the stored extension comes from those bytes
rather than from the file name or the declared type.
- Media names, slugs, revision names and backup-archive members are confined
to their directories, and a restore writes through an `os.Root`; an archive
cannot plant a document where the public media route would serve it.
- The self-update verifies the download against the release's `checksums.txt`
and refuses a version that is not newer.
- The backend binds to loopback behind a TLS-terminating proxy, and
`trust_proxy` takes the client address from the last `X-Forwarded-For`
entry, the one the proxy appended.
+149
View File
@@ -0,0 +1,149 @@
# Contributing
Contributions to **Volumen** are governed by the Contributor terms
below; submitting one means you accept them.
## Contributor terms
1. This project belongs to its owner alone. The owner decides what is
accepted, in what form and when; the decision is final and needs no
justification.
2. By submitting a contribution you assign to Petr Balvín
<opensource@petrbalvin.org> all present and future copyright and
related rights in it, worldwide, for the full term of the rights,
with the right to relicense and sublicense without restriction,
including under proprietary terms.
3. Where that assignment is not effective, it counts as a perpetual,
irrevocable, royalty-free licence with the same scope.
4. To the fullest extent permitted by law, you waive any right of
attribution and integrity in the contribution. The project names no
contributors and keeps no credits list.
5. By submitting you represent that the work is yours and that you
hold the rights to assign it as above.
## Development setup
Requirements: Go 1.27.1 (the version the `go` directive in `go.mod`
declares), [just](https://github.com/casey/just) for the recipes, Perl for
the scripted recipes (`just test` and `just fmt-check` are Perl, builtins
only), and gcc for the race detector, which `just race` and `just gates` run.
The build itself is pure Go with `CGO_ENABLED=0`.
```sh
git clone https://sourcedock.dev/petrbalvin/volumen.git
cd volumen
just build
just test
```
`just dev` serves the content directory `./posts` and the local
`config.toml` on <http://127.0.0.1:9091>; the config and the state files are
git-ignored. On a fresh checkout the first visit to `/admin` shows the
first-run wizard; it creates the developer's account with any password the
policy accepts. For a real install there is no bootstrap command: copy
`config.toml.example`, start the server, complete the wizard;
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) has the full production setup.
The direct dependency set is fixed: scriptorium (Markdown, mathematics and
diagrams), bluemonday (sanitisation), interpres (TOML), `golang.org/x/crypto`
(scrypt) and `golang.org/x/text` (NFKC). A new third-party module needs a
justification that survives review.
## Workflow
1. Branch from `development`. Never commit directly to `main`, which is release-only.
2. Commit in [Conventional Commits](https://www.conventionalcommits.org/) form:
`type(scope): description`, subject line only, imperative mood, lowercase after the
colon, no trailing full stop. Allowed types: `feat`, `fix`, `docs`, `style`,
`refactor`, `perf`, `test`, `chore`, `ci`, `build`, `revert`.
3. One logical change per commit. A refactor, a behaviour change and a formatting pass
are three commits, never one.
4. Record every user-visible change in `CHANGELOG.md` under `## [development]`, using
the categories `Added`, `Changed`, `Fixed`, `Removed` and `Security`.
5. Add or update tests. Coverage stays at 80 percent or more; it is a hard gate.
6. Update the documentation when the public API, the configuration or the behaviour
changes: `README.md`, `docs/CONFIGURATION.md`, `docs/API.md` and
`docs/ARCHITECTURE.md` are the ones that move most often. A change to the command
line moves `docs/CLI.md` and `man/volumen.1` in the same commit as the flags it
documents.
7. Never commit while `just gates` is red; run it locally first.
8. Open a pull request against `development`.
Releases are cut by merging `development` into `main` and tagging `vX.Y.Z`. The release
workflow runs the gates at the tag, builds the portable matrix and publishes the release
with the notes it extracts from `CHANGELOG.md`;
[docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) has the detail.
## Code style
`gofmt` and `go vet` run through `just fmt` and `just vet`, with zero diff and zero
warnings tolerated; `just vet` is `go vet` followed by `go fix -diff`. `just gates` is
the definition of done in one command, and the recipe file names what it contains.
Errors are checked explicitly, wrapped as `fmt.Errorf("context: %w", err)`, and nothing
panics outside `main`; diagnostics go through `log/slog`, never `fmt.Print` in library
code. Names, messages and comments are in British English. The recipe file holds the
commands.
Tests live next to the code in `*_test.go`, use `t.TempDir()` for fixtures and
`net/http/httptest` for the HTTP layer, and never start a real network server or reach
the internet. Table-driven tests where the cases are enumerable; run `just race` before
merging anything that shares state. Handlers live in `internal/httpapi` (public API) and
`internal/admin` (admin UI), with the middleware in `internal/web`; read the security
model in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) before touching auth, uploads or
rendering.
New source files open with the project's two-line licence header, whose SPDX
identifier matches `LICENSE`. Configuration files, workflows and dotfiles do not carry
it.
## AI contribution policy
AI tools are welcome as productivity aids and are a normal part of modern software
development. What matters is that the contribution stays understandable, reviewable and
genuinely useful.
- **Disclose the assistance.** If AI helped draft any part of a commit, issue, pull
request or review, say so.
- **Commit messages carry exactly one trailer**, on the line after the subject:
```text
Assisted-by: MODEL
```
Name the model that did the work, spelled the way its maker spells it, for example
`GLM 5.3`, `DeepSeek V4.1 Flash` or `Qwen 3.8 Flash`. No `Co-Authored-By`, no
`Signed-off-by`, no other trailers, and no prose: the trailer is the disclosure.
- **Issues and pull requests** attribute the assistance in a comment, for example
`_Assisted-by: GLM 5.3_`. It does not belong in the pull request description.
- **Take responsibility.** You are accountable for the accuracy, completeness and
intent of everything you submit, whether or not AI produced it.
- **Review before marking ready.** Read the diff carefully, run it locally, and add the
tests it needs. Do not mark a pull request ready until you can defend every change in
it.
- **Quality over quantity.** Contributions that look like un-reviewed output, or whose
author cannot engage substantively during review, may be closed.
- **Preferred models.** Prefer open-weight models with transparent training data and
minimal output filtering.
AI assists. It does not replace judgement.
## Continuous integration
Workflows live in `.gitea/workflows/` and run on the project's own runners:
| Workflow | Trigger | What it does |
|---|---|---|
| Test | push or pull request to `development` | build, format check, vet, modernisation, the test suite with the coverage floor |
| Race | dispatched by hand | the suite under the race detector, as a second opinion after the local gate |
| Release | a `v*` tag | the same gates as Test, then the matrix build, the proven version and the release itself; the race detector runs locally in `just gates` before the tag is cut |
The local equivalent is `just gates`, which is the same set plus the race detector.
## Reporting bugs
Open an issue at `https://sourcedock.dev/petrbalvin/volumen/issues` with the
version (`volumen version`), the operating system and architecture, the exact command,
the full output, and the expected against the actual behaviour.
**Security issues do not go in the issue tracker.** Report them as
[SECURITY.md](SECURITY.md) describes.
+133
View File
@@ -0,0 +1,133 @@
# PolyForm Noncommercial License 1.0.0
<https://polyformproject.org/licenses/noncommercial/1.0.0>
Required Notice: Copyright 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
## Acceptance
In order to get any license under these terms, you must agree
to them as both strict obligations and conditions to all
your licenses.
## Copyright License
The licensor grants you a copyright license for the
software to do everything you might do with the software
that would otherwise infringe the licensor's copyright
in it for any permitted purpose. However, you may
only distribute the software according to [Distribution
License](#distribution-license) and make changes or new works
based on the software according to [Changes and New Works
License](#changes-and-new-works-license).
## Distribution License
The licensor grants you an additional copyright license
to distribute copies of the software. Your license
to distribute covers distributing the software with
changes and new works permitted by [Changes and New Works
License](#changes-and-new-works-license).
## Notices
You must ensure that anyone who gets a copy of any part of
the software from you also gets a copy of these terms or the
URL for them above, as well as copies of any plain-text lines
beginning with `Required Notice:` that the licensor provided
with the software. For example:
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
## Changes and New Works License
The licensor grants you an additional copyright license to
make changes and new works based on the software for any
permitted purpose.
## Patent License
The licensor grants you a patent license for the software that
covers patent claims the licensor can license, or becomes able
to license, that you would infringe by using the software.
## Noncommercial Purposes
Any noncommercial purpose is a permitted purpose.
## Personal Uses
Personal use for research, experiment, and testing for
the benefit of public knowledge, personal study, private
entertainment, hobby projects, amateur pursuits, or religious
observance, without any anticipated commercial application,
is use for a permitted purpose.
## Noncommercial Organizations
Use by any charitable organization, educational institution,
public research organization, public safety or health
organization, environmental protection organization,
or government institution is use for a permitted purpose
regardless of the source of funding or obligations resulting
from the funding.
## Fair Use
You may have "fair use" rights for the software under the
law. These terms do not limit them.
## No Other Rights
These terms do not allow you to sublicense or transfer any of
your licenses to anyone else, or prevent the licensor from
granting licenses to anyone else. These terms do not imply
any other licenses.
## Patent Defense
If you make any written claim that the software infringes or
contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If
your company makes such a claim, your patent license ends
immediately for work on behalf of your company.
## Violations
The first time you are notified in writing that you have
violated any of these terms, or done anything with the software
not covered by your licenses, your licenses can nonetheless
continue if you come into full compliance with these terms,
and take practical steps to correct past violations, within
32 days of receiving notice. Otherwise, all your licenses
end immediately.
## No Liability
***As far as the law allows, the software comes as is, without
any warranty or condition, and the licensor will not be liable
to you for any damages arising out of these terms or the use
or nature of the software, under any kind of legal claim.***
## Definitions
The **licensor** is the individual or entity offering these
terms, and the **software** is the software the licensor makes
available under these terms.
**You** refers to the individual or entity agreeing to these
terms.
**Your company** is any legal entity, sole proprietorship,
or other kind of organization that you work for, plus all
organizations that have control over, are under the control of,
or are under common control with that organization. **Control**
means ownership of substantially all the assets of an entity,
or the power to direct its management and policies by vote,
contract, or otherwise. Control can be direct or indirect.
**Your licenses** are all the licenses granted to you for the
software under these terms.
**Use** means anything you do with the software requiring one
of your licenses.
+289
View File
@@ -0,0 +1,289 @@
# Third-party notices
The project is under the licence in [LICENSE](LICENSE). Everything else that
a built binary carries is here: the Go modules it is compiled from, with each
licence reproduced verbatim, and the terms of the artwork and the fonts
embedded in it. The owner's own modules come first, golang.org next, every
other source last. Every licence text below is quoted inside a code block, so
a Markdown renderer cannot reflow it.
Generated on 2026-09-28 by `perl scripts/notices.pl`, which takes the module list
from `go list -deps ./cmd/volumen`, so it names what the toolchain actually
links rather than the wider build list. Run it again after a dependency changes.
## sourcedock.dev/petrbalvin/interpres/v2 v2.0.0
```text
MIT License
Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## sourcedock.dev/petrbalvin/scriptorium v1.0.1
```text
MIT License
Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## golang.org/x/crypto v0.57.0
```text
Copyright 2009 The Go Authors.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google LLC nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
## golang.org/x/net v0.59.0
```text
Copyright 2009 The Go Authors.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google LLC nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
## golang.org/x/text v0.42.0
```text
Copyright 2009 The Go Authors.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google LLC nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
## github.com/aymerick/douceur v0.2.0
```text
The MIT License (MIT)
Copyright (c) 2015 Aymerick JEHANNE
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## github.com/gorilla/css v1.0.1
```text
Copyright (c) 2023 The Gorilla Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
## github.com/microcosm-cc/bluemonday v1.0.27
```text
Copyright (c) 2014, David Kitchen <david@buro9.com>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the organisation (Microcosm) nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
## Artwork embedded in the binary
### graphis.svg
Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
[CC-BY-NC-4.0](https://creativecommons.org/licenses/by-nc/4.0/)
### volumen-icon.svg
Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
[CC-BY-NC-4.0](https://creativecommons.org/licenses/by-nc/4.0/)
## Fonts embedded in the binary
Ubuntu and Ubuntu Mono (latin and latin-ext subsets), the interface faces of
the admin. Copyright 2010, 2011 Canonical Ltd, with the reserved font names
"Ubuntu" and "Ubuntu Mono"; distributed under the
[Ubuntu Font Licence 1.0](https://ubuntu.com/legal/font-licence), whose terms
follow the SIL Open Font Licence 1.1.
The files carried:
- ubuntu-italic-400-latin-ext.woff2
- ubuntu-italic-400-latin.woff2
- ubuntu-mono-normal-400-latin-ext.woff2
- ubuntu-mono-normal-400-latin.woff2
- ubuntu-mono-normal-700-latin-ext.woff2
- ubuntu-mono-normal-700-latin.woff2
- ubuntu-normal-400-latin-ext.woff2
- ubuntu-normal-400-latin.woff2
- ubuntu-normal-500-latin-ext.woff2
- ubuntu-normal-500-latin.woff2
- ubuntu-normal-700-latin-ext.woff2
- ubuntu-normal-700-latin.woff2
+125
View File
@@ -0,0 +1,125 @@
# Volumen
**Volumen** is a lightweight publishing platform for scientists. Posts are
Markdown files with a TOML frontmatter block, served as a headless JSON API
and edited through a server-rendered admin; the public site is a separate
front end that consumes the API. A publication is plain files under version
control, and the server is one static binary with no database, no build step
and no runtime dependencies. In production it runs behind a reverse proxy:
[Caddy](https://caddyserver.com) is the recommended front, with automatic
HTTPS and a one-line site block.
## Features
- **Markdown posts with TOML frontmatter**: posts are `.md` files carrying
`title`, `slug`, `date`, `lang`, `tags`, `draft`, `publish_at`, `series`,
`translations`, `doi`, `orcid`, `refs` and the author's own fields, safe to
commit and editable in any editor.
- **Scholarly apparatus**: `$…$` and `$$…$$` mathematics render server-side
as MathML with no JavaScript, `doi` and `orcid` are validated and surfaced
as resolvable identifiers, and a frontmatter `refs` table renders as a
numbered bibliography with every inline citation linked and every DOI,
arXiv id and ORCID turned into a resolver link; the admin editor edits
the reference list in place.
- **Diagrams**: fenced `mermaid` blocks render server-side to inline SVG,
flowcharts and sequence diagrams in full, with a diagram the engine does
not carry staying the code block the author wrote.
- **One static binary**: the Go standard library and a handful of small
modules, with the admin templates and assets embedded and posts read from
the directory you point it at.
- **Public JSON API**: site metadata, filterable post lists, post details with
rendered HTML, tags, series, RSS, Atom, JSON Feed and a sitemap under
`/api/volumen`, with `ETag` conditional requests.
- **Token-authenticated writes**: scoped bearer tokens for `POST`, `PUT` and
`DELETE`, and `If-Match` against the served `ETag` so a blind overwrite is
refused with `412`.
- **Admin UI**: a first-run wizard that founds the installation, login
with roles and an optional second factor (TOTP with a QR code and
one-time recovery codes), dashboard, Markdown editor with live
preview, media library, post templates, revision history, backups,
self-update and per-account language and colour scheme under `/admin`.
- **Multi-language posts**: one file per language, in the content root or a
per-language subdirectory, linked by a `translations` map or shared with the
`all_langs` flag.
- **Scheduled publishing**: `publish_at` dates honoured by `volumen
publish-due` from cron or a timer, or by the in-process `[scheduler]`.
- **Post revisions**: every save archives the previous version and a delete is
a move into that archive, so both stay undoable.
- **Webhooks**: signed JSON events on post create, update, delete and publish
let a front end rebuild its cache or static pages.
- **Diagnostics**: `doctor` and `validate` commands, a `/healthz` endpoint, a
per-request id on every line, an append-only audit log and structured JSON
logging.
## Install
Prebuilt binaries for Linux and FreeBSD are on the
[releases page](https://sourcedock.dev/petrbalvin/volumen/releases), together
with `checksums.txt`. The commented configuration template,
`config.toml.example`, lives at the repository root.
From source:
```sh
go install sourcedock.dev/petrbalvin/volumen/cmd/volumen@latest
```
## Quick start
```sh
volumen serve
```
With no configuration file the server runs on per-user paths
(`~/.local/share/volumen`). Open `http://localhost:9091/admin/`: the
first-run wizard creates the administrator account, picks the interface
language and the colour scheme, and signs you in. Then, in a second
shell, create the first post and read it back:
```sh
printf '+++\ntitle = "Hello"\ndate = 2026-09-25\n+++\n\nFirst body.\n' \
> ~/.local/share/volumen/posts/hello.md
curl -s http://localhost:9091/api/volumen/posts/hello
```
## Usage
```sh
volumen serve --config /etc/volumen/config.toml --content /var/lib/volumen/posts
volumen status
volumen doctor
volumen validate
volumen publish-due --dry-run
volumen export --out backup.tar.gz
volumen import backup.tar.gz
volumen check-update
volumen version
```
## Development
```sh
just build # build
just test # the test suite
just fmt # format
```
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for the full workflow, and
[CONTRIBUTING.md](CONTRIBUTING.md) for how to contribute.
## Documentation
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): components and data flow
- [docs/API.md](docs/API.md): the API reference
- [docs/CLI.md](docs/CLI.md): every subcommand and flag
- [docs/CONFIGURATION.md](docs/CONFIGURATION.md): every configuration key
- [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md): how it runs in production
- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md): setup, recipes, tests, releases
- [docs/BENCHMARKING.md](docs/BENCHMARKING.md): how performance is measured
- [man/volumen.1](man/volumen.1): the manual page
## Licence
PolyForm Noncommercial 1.0.0. See [LICENSE](LICENSE).
Copyright © 2026 [Petr Balvín](https://petrbalvin.org)
+35
View File
@@ -0,0 +1,35 @@
# Security policy
## Supported versions
Security fixes go to the newest release and to the `development` branch. Older
releases do not receive them.
## Reporting a vulnerability
**Do not open a public issue for a security problem.** A public report tells everyone
about the flaw before there is a fix. Report it privately to
**opensource@petrbalvin.org**.
Include:
- the version or commit you tested, and the platform
- what the problem is, and what an attacker gains from it
- the smallest reproducer you have, ideally a test or a single command
- a suggested fix, if you have one
## What to expect
- A human reads the report, and you get an acknowledgement.
- You are kept informed while the fix is being made, and told when it ships.
- The fix is released before the details are published, and the timing is agreed with
you.
- The reporter is credited in the release notes only if they ask to be.
## Out of scope
- Findings that require the attacker to already run code as the user, or to have local
access.
- Missing hardening with no demonstrated impact.
- Flaws in a third-party dependency: report them to that project, and to this one only
when this project's use of it makes them reachable.
+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{} }
+91
View File
@@ -0,0 +1,91 @@
# volumen configuration.
#
# Every key this file accepts is listed here. Copy it to
# /etc/volumen/config.toml (or ~/.config/volumen/config.toml for a
# per-user installation), then edit.
#
# Keys that belong to no table come first, because a key written below a
# [table] header belongs to that table.
# Directory of the Markdown posts (.md with TOML frontmatter).
content_dir = "/var/lib/volumen/posts"
# File holding the admin accounts (managed from the admin Settings page).
users_file = "/var/lib/volumen/users.toml"
# How many previous versions of each post to keep in .revisions/
# (0 keeps none, which also makes deleting a post permanent).
revision_limit = 10
# Where the audit log is appended, or "" to disable auditing. Records
# who changed what, and when, in JSON lines.
audit_log = ""
[server]
# Address to bind, as an IP address: "::" is every interface, "::1" is
# loopback only, which is what a reverse proxy needs.
host = "::"
port = 9091
# Environment label: "development" or "production". It decides the
# startup safety checks (session key length, cookie flags, password
# policy).
env = "development"
# Set true ONLY when a trusted reverse proxy terminates TLS in front of
# volumen. Client addresses are then taken from X-Forwarded-For and
# cookies are marked Secure.
trust_proxy = false
# Addresses whose X-Forwarded-For may be believed, as addresses or CIDR
# prefixes. An empty list never reads the header and always uses the
# connection address; list the proxy so its clients each rate-limit
# under their own address.
trusted_proxies = []
# Set true in production to force the Secure flag on session cookies.
cookie_secure = false
# Log output format: "text" (human readable) or "json" (structured).
log_format = "text"
[site]
title = "Volumen"
description = "Powered by Volumen."
# Absolute URL of the public site, without a trailing slash.
base_url = "https://example.com"
language = "en"
author = "Anonymous"
# Fediverse handle surfaced as the author in feeds and meta tags.
# Leave empty to disable.
fediverse_creator = ""
[admin]
# Secret that signs session cookies (at least 64 bytes in production).
# Leave empty: the server generates one and keeps it in secret.key next
# to users.toml. A value here overrides that file.
session_key = ""
# Session lifetime in seconds (24 hours by default).
session_ttl = 86400
# Minimum password length enforced when a password is set in the admin UI.
min_password_length = 10
# Maximum password length, to bound the scrypt work.
max_password_length = 1024
# Maximum upload size in bytes (10 MB by default).
max_upload_bytes = 10485760
[api]
# Public API rate limit: requests allowed per window per client address.
# 0 disables rate limiting.
rate_limit = 60
# Rate-limit window length in seconds.
rate_limit_window = 60
# Scheduled publishing, for a post whose frontmatter carries publish_at.
# [scheduler]
# enabled = false
# interval = 300
# Outgoing webhooks: POST a signed JSON payload on post changes so a
# front-end can rebuild its cache or static pages. Repeat the block for
# more endpoints; events may be omitted to receive every event.
# [[webhooks]]
# url = "https://example.com/hooks/rebuild"
# secret = "a-long-random-string" # HMAC-SHA256 signing key
# events = ["post.created", "post.updated", "post.deleted", "post.published"]
# enabled = true
+670
View File
@@ -0,0 +1,670 @@
# API
Volumen serves a JSON API under `/api/volumen`; the admin interface at
`/admin` is server-rendered HTML for humans and is not part of this API.
## HTTP API
Base URL: `{base_url}/api/volumen`, where `base_url` is the `[site].base_url`
setting ([CONFIGURATION.md](CONFIGURATION.md)). Every path below is relative
to that prefix.
Reads need no credential. A write carries `Authorization: Bearer <token>`,
where the token is minted in the admin at **Settings, API tokens** and shown
once, at creation. Two scopes exist: `write` for `POST` and `PUT`, and
`delete` for `DELETE`. A token created with no scope selected is unrestricted,
and a scope list that names no recognised scope is refused rather than turned
into an unrestricted token. There is no read scope, because every read
endpoint is public, so a token can only widen access to the write and delete
operations.
A `HEAD` request is answered as the `GET` of the same path: same status, same
headers, no body.
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/volumen/site` | Site metadata |
| `GET` | `/api/volumen/posts` | Paginated, filterable post list |
| `GET` | `/api/volumen/posts/batch` | Several post details in one request |
| `GET` | `/api/volumen/posts/{slug}` | One post with body and rendered HTML |
| `POST` | `/api/volumen/posts` | Create a post (scope `write`) |
| `PUT` | `/api/volumen/posts/{slug}` | Update a post (scope `write`) |
| `DELETE` | `/api/volumen/posts/{slug}` | Delete a post (scope `delete`) |
| `GET` | `/api/volumen/tags` | Tag cloud with counts |
| `GET` | `/api/volumen/tags/{tag}` | Posts carrying one tag |
| `GET` | `/api/volumen/tags/{tag}/feed.xml` | RSS 2.0 feed for one tag |
| `GET` | `/api/volumen/tags/{tag}/feed.atom` | Atom 1.0 feed for one tag |
| `GET` | `/api/volumen/tags/{tag}/feed.json` | JSON Feed 1.1 for one tag |
| `GET` | `/api/volumen/series` | Series list with post counts |
| `GET` | `/api/volumen/series/{name}` | Posts of one series, in reading order |
| `GET` | `/api/volumen/series/{name}/feed.xml` | RSS 2.0 feed for one series |
| `GET` | `/api/volumen/series/{name}/feed.atom` | Atom 1.0 feed for one series |
| `GET` | `/api/volumen/series/{name}/feed.json` | JSON Feed 1.1 for one series |
| `GET` | `/api/volumen/feed.xml` | RSS 2.0 feed |
| `GET` | `/api/volumen/feed.atom` | Atom 1.0 feed |
| `GET` | `/api/volumen/feed.json` | JSON Feed 1.1 document |
| `GET` | `/api/volumen/sitemap.xml` | XML sitemap |
| `OPTIONS` | `/api/volumen/{rest...}` | CORS preflight for the whole subtree |
### `GET /api/volumen/site`
Site metadata, taken from the configuration. Every value is echoed as
configured and every one of them is a string, so `fediverse_creator` is `""`
when the key is unset rather than `null`.
```sh
curl -s 'https://lab.example.com/api/volumen/site'
```
```json
{
"title": "Research Notes",
"description": "Powered by Volumen.",
"base_url": "https://lab.example",
"language": "en",
"author": "Anonymous",
"fediverse_creator": ""
}
```
The response carries an `ETag`; send it back in `If-None-Match` to receive
`304 Not Modified` with no body. The post list, the post batch, the tag cloud,
a tag's post list and a single post carry one as well. The feeds, the sitemap
and the other endpoints do not.
### `GET /api/volumen/posts`
| Parameter | Type | Default | Effect |
|---|---|---|---|
| `page` | integer | `1` | Page number, `1` to `1000000`. Ignored when `cursor` is set |
| `limit` | integer | `20` | Page size, `1` to `100` |
| `lang` | string | unset | Keep posts whose language is this value, plus posts flagged `all_langs` |
| `tag` | string | unset | Keep posts carrying this tag |
| `q` | string | unset | Case-insensitive search over the title, the tags, the excerpt and the body. Matching posts are ordered by relevance: a title hit outranks a tag hit, an excerpt hit and a body hit, and a title that starts with the query leads its class; equal scores keep the date order |
| `cursor` | string | unset | Slug to start after; switches the response to the cursor shape |
A malformed `page` or `limit` is rejected with `422` and the `validation`
envelope. An unknown slug in `cursor` yields a page with no posts and a `null`
cursor rather than silently rewinding to the first page, so a paging client
never receives posts it already holds.
```sh
curl -s 'https://lab.example.com/api/volumen/posts?limit=20'
```
```json
{
"page_size": 20,
"total": 2,
"posts": [
{
"slug": "alpha",
"title": "Alpha",
"excerpt": "Alpha body with a [link](https://example.com).",
"date": "2026-08-18",
"lang": "cs",
"tags": ["go", "research"],
"author": "Petr",
"fediverse_creator": "@petr@social",
"cover": "/media/c.webp",
"cover_alt": "alt",
"cover_caption": "caption",
"reading_time": 1,
"translations": { "en": "alpha-en" },
"series": "Series",
"series_order": 1,
"url": "/api/volumen/posts/alpha"
},
{
"slug": "beta",
"title": "Beta",
"excerpt": "Beta body.",
"date": "2026-07-01",
"lang": "en",
"tags": ["go"],
"reading_time": 1,
"url": "/api/volumen/posts/beta"
}
],
"page": 1,
"has_next": false,
"has_prev": false
}
```
Without a cursor the response carries `page`, `has_next` and `has_prev`. With
a cursor it carries `next_cursor` instead: the slug of the last post on the
page, or `null` when the list is exhausted. When two posts share a slug (a
post and its translation may) the cursor is `slug.lang`, so the next page
resumes after the exact post rather than after the first variant that
matches. Either way `total` counts every post the filter matched, before
pagination, and `page_size` is the limit the server applied.
```json
{
"page_size": 1,
"total": 2,
"posts": [
{
"slug": "beta",
"title": "Beta",
"excerpt": "Beta body.",
"date": "2026-07-01",
"lang": "en",
"tags": ["go"],
"reading_time": 1,
"url": "/api/volumen/posts/beta"
}
],
"next_cursor": null
}
```
#### Fields
Used by the list endpoints, the tag and series lists, the series detail and
the write responses. The shape is `payloads.Summary`, built by
`payloads.BuildSummary`; the struct tags decide which fields are omitted.
| Field | Type | Presence | Notes |
|---|---|---|---|
| `slug` | string | omitted when empty | Stable post identifier |
| `title` | string | omitted when empty | Post title |
| `excerpt` | string | always present | The frontmatter excerpt, else the first non-heading body paragraph cut to 200 characters, with an ellipsis when anything was dropped |
| `date` | string | omitted when empty | Publication date, `YYYY-MM-DD` |
| `lang` | string | omitted when empty | Post language |
| `tags` | array of strings | omitted when empty | Post tags |
| `author` | string | omitted when empty | Post author from the frontmatter; the site author is not substituted into the payload |
| `fediverse_creator` | string | omitted when empty | `@user@host`, per-post override of the site default |
| `doi` | string | omitted when empty | Digital Object Identifier, bare `10.…/…` form; a stored `doi.org` URL or `doi:` prefix is normalised for display, and a value that fails the syntax rule passes through untouched. Set in the frontmatter or the admin editor, not by the write endpoints |
| `orcid` | string | omitted when empty | The author's ORCID iD, `0000-0000-0000-000X`, check digit verified when the value is written from the admin editor; upper-cased for display when valid, the raw text otherwise. Set in the frontmatter or the admin editor, not by the write endpoints |
| `cover` | string | omitted when empty | Cover image path or URL, typically `/media/...` |
| `cover_alt` | string | omitted when empty | Alt text for the cover |
| `cover_caption` | string | omitted when empty | Caption for the cover |
| `reading_time` | integer | always present | Minutes, `ceil(words / 200)`, at least 1 |
| `translations` | object | omitted when empty | Maps a language code to the slug of a sibling translation |
| `series` | string | omitted when empty | Series name |
| `series_order` | integer | omitted when the frontmatter has none | Position within the series |
| `url` | string | always present | API detail URL, `/api/volumen/posts/{slug}` |
### `GET /api/volumen/posts/batch`
Returns the detail of several posts in one response. `slugs` is a
comma-separated list; entries are trimmed, empty ones dropped, and the list is
capped at 100 slugs. A slug that names no published post is skipped, so the
`posts` array may be shorter than the request and the endpoint never answers
`404`. Without `slugs` the `posts` array is empty.
```sh
curl -s 'https://lab.example.com/api/volumen/posts/batch?slugs=alpha,beta'
```
The entry below is one detail response, printed in full:
```json
{
"posts": [
{
"slug": "alpha",
"title": "Alpha",
"excerpt": "Alpha body with a [link](https://example.com).",
"date": "2026-08-18",
"lang": "cs",
"tags": ["go", "research"],
"author": "Petr",
"fediverse_creator": "@petr@social",
"cover": "/media/c.webp",
"cover_alt": "alt",
"cover_caption": "caption",
"reading_time": 1,
"translations": { "en": "alpha-en" },
"series": "Series",
"series_order": 1,
"url": "/api/volumen/posts/alpha",
"body": "Alpha **body** with a [link](https://example.com).\n",
"html": "<p>Alpha <strong>body</strong> with a <a rel=\"noopener noreferrer\" href=\"https://example.com\">link</a>.</p>\n",
"toc": "<div class=\"toc\">\n<ul></ul>\n</div>\n",
"meta": {
"url": "https://lab.example/alpha",
"json_ld": "{\"@context\":\"https://schema.org\",\"@type\":\"Article\",\"author\":{\"@type\":\"Person\",\"name\":\"Petr\"},\"creator\":{\"@type\":\"Person\",\"name\":\"@petr@social\"},\"dateModified\":\"2026-08-18\",\"datePublished\":\"2026-08-18\",\"description\":\"Alpha body with a [link](https://example.com).\",\"headline\":\"Alpha\",\"image\":[\"/media/c.webp\"],\"inLanguage\":\"cs\",\"keywords\":[\"go\",\"research\"],\"mainEntityOfPage\":{\"@id\":\"https://lab.example/alpha\",\"@type\":\"WebPage\"},\"url\":\"https://lab.example/alpha\"}",
"og": {
"article:author": "Petr",
"article:published_time": "2026-08-18",
"article:tag": ["go", "research"],
"og:description": "Alpha body with a [link](https://example.com).",
"og:image": "/media/c.webp",
"og:locale": "cs",
"og:title": "Alpha",
"og:type": "article",
"og:url": "https://lab.example/alpha"
},
"twitter": {
"twitter:card": "summary_large_image",
"twitter:creator": "@petr@social",
"twitter:description": "Alpha body with a [link](https://example.com).",
"twitter:image": "/media/c.webp",
"twitter:title": "Alpha"
}
}
}
]
}
```
The JSON here is reformatted for the page; the response shape is pinned by
the fixtures under
[`internal/httpapi/testdata/contract/`](../internal/httpapi/testdata/contract/),
which carry a differently configured deployment's values.
### `GET /api/volumen/posts/{slug}`
| Parameter | Type | Default | Effect |
|---|---|---|---|
| `lang` | string | unset | Select the language variant; a post flagged `all_langs` matches every value |
| `preview_token` | string | unset | Signed preview credential for a draft or a scheduled post |
The response is a post detail. A slug that matches an alias is answered with
`301 Moved Permanently` and a `Location` header naming the canonical post. A
draft and a scheduled post are distinguishable on purpose: the error code says
which of the two withheld the post, because the client already knows the slug
and the two states need different handling.
The response carries an `ETag` naming the post's state: send it back in
`If-None-Match` for a `304`, or in `If-Match` on a write to refuse overwriting
a change you never saw.
| Status | Meaning |
|---|---|
| `200` | The post detail |
| `301` | The slug is an alias; `Location` names the canonical slug |
| `304` | The request carried `If-None-Match` matching the current `ETag` |
| `404` | `{"error": "not_found"}`, or `{"error": "draft"}` / `{"error": "scheduled"}` for a withheld post with no valid preview token |
| `500` | `{"error": "render_failed"}` when the Markdown pipeline fails |
A preview link is signed as an HMAC over the slug and an expiry stamp, keyed
with `[admin].session_key`, and stays valid for seven days. Without a session
key no link is issued at all: the admin route `GET
/admin/posts/{slug}/preview-link` then answers `409` with the code
`no_session_key`. A preview response carries `Cache-Control: no-store`: it
holds unpublished content, and a shared cache must not keep it.
#### Fields
Returned by `GET /api/volumen/posts/{slug}` and by the batch endpoint. The
shape is `payloads.Detail`: the summary above, plus:
| Field | Type | Notes |
|---|---|---|
| `body` | string | Raw Markdown source |
| `html` | string | Rendered, sanitised HTML |
| `toc` | string | Sanitised table of contents |
| `meta` | object | SEO and discovery metadata, computed per request from the post and never persisted |
| `references` | array of objects | The structured bibliography from the post's `refs` frontmatter: each entry has `num`, optional `authors` (`{name, orcid}`), `title`, `venue`, `year`, `volume`, `pages`, `doi`, `arxiv`, `url`, or the verbatim `raw` text. When the entry's DOI is published by another post of the same instance, it also carries `internal`: the API address of that post, so a consumer can keep the reader on site. Omitted when the post cites nothing |
| `fields` | object | The frontmatter keys the engine does not consume itself, the author's own: `colour: "#0f0"` lands here as `{"colour": "#0f0"}`. Nested tables, arrays and TOML date-times pass through (dates as their canonical strings). Omitted when every key is a known one |
`html` is rendered by scriptorium (CommonMark, GFM, footnotes, definition lists) with
`$$…$$` and `$…$` mathematics converted to MathML Core and fenced mermaid blocks
(flowcharts and sequence diagrams) drawn as inline SVG, and it is
sanitised by bluemonday against a narrow allowlist: headings keep their `id`
attributes, fenced code blocks carry `class="language-..."`, an image whose
title is set becomes a `figure` with a `figcaption`, a post with `refs` gets a
numbered `<section class="refs" id="references">` spliced at its `[[refs]]`
marker, or appended at the end of the body when it carries none, with every
inline `[n]` citation linked to it. A reference whose DOI
is published by another post of the same instance links to that post's API
address instead of the external resolver, and the same address appears as
the `url` of its JSON-LD citation next to the canonical identifier; a post
citing its own DOI keeps the resolver link. Every link in the
body and in the table of contents gains `rel="noopener noreferrer"`. The
allowlist admits `http`, `https` and `mailto` URLs plus relative ones, so a
body cannot inject a script, a style or an event handler. A mermaid diagram
the renderer does not carry stays a fenced code block, and a mathematics
construct outside the mappable surface stays visible as its verbatim source
in the place it was written.
`toc` is the wrapper `<div class="toc">` with a nested list of heading links.
The wrapper is always present, even when the body has no headings, in which
case it holds an empty list:
```json
"toc": "<div class=\"toc\">\n<ul></ul>\n</div>\n"
```
An empty body has no table of contents at all and yields an empty `toc`
string.
`meta` carries:
| Field | Type | Notes |
|---|---|---|
| `url` | string | Canonical post URL: `base_url` plus the slug, or the API path when no `base_url` is set |
| `json_ld` | string | A Schema.org `Article` document, serialised as a JSON string |
| `og` | object | Open Graph fields, keyed `og:type`, `og:title`, `og:description`, `og:url`, `og:locale`, `article:published_time`, plus `og:image`, `article:tag` and `article:author` when the post carries them |
| `twitter` | object | Card fields, keyed `twitter:card`, `twitter:title`, `twitter:description`, plus `twitter:image` and `twitter:creator` when the post carries them |
### `POST /api/volumen/posts`
Creates a post. The body is a JSON object; fields not listed are ignored. The
response is `201 Created` with the post summary as its body, no `Location`
header, and an `ETag` naming the new state for a later `If-Match` write.
| Field | Type | Effect |
|---|---|---|
| `title` | string | Post title |
| `slug` | string | Post slug; required, and unique |
| `lang` | string | Language code |
| `author` | string | Author name |
| `fediverse_creator` | string | `@user@host` handle |
| `excerpt` | string | Summary text, overrides the derived excerpt |
| `body` | string | Markdown source, at most 1 MiB |
| `tags` | array of strings, or a comma-separated string | Post tags |
| `cover` | string | Cover image path or URL |
| `cover_alt` | string | Cover alt text |
| `cover_caption` | string | Cover caption |
| `series` | string | Series name |
| `series_order` | integer | Position within the series |
| `date` | string | Publication date, `YYYY-MM-DD` |
| `publish_at` | string | Scheduled publication date, `YYYY-MM-DD` |
| `draft` | boolean | Withhold the post |
| `all_langs` | boolean | Serve the post for every requested language |
An empty string or `null` clears an optional field. A value of the wrong type
is rejected with `400` rather than coerced. Validation failures answer
`{"error": "validation", "message": "..."}` with messages such as
`Slug is required.`, `Invalid slug.`, `A post with that slug already exists.`
and `Body must be at most 1048576 bytes.`
```sh
curl -s -X POST 'https://lab.example.com/api/volumen/posts' \
-H 'Authorization: Bearer vol_...' \
-H 'Content-Type: application/json' \
-d '{"slug":"hello","title":"Hello","body":"Hi.","tags":["meta"]}'
```
### `PUT /api/volumen/posts/{slug}`
Updates the post named by the path. Fields omitted from the body keep their
stored value, so the request is a merge rather than a replacement. The
response is `200 OK` with the post summary.
A `slug` in the body that differs from the path renames the post: the file is
written under the new slug and the old file is soft-deleted, so the rename
stays undoable.
The request may carry `If-Match` with the `ETag` a `GET` of the post served:
when it no longer names the stored state, the write is refused with `412`
instead of overwriting a change the client never saw. `If-Match: *` demands
only that the post exists. A write without the header stays unconditional.
The `200` response carries the new state's `ETag`, so edits can chain without
re-reading. The precondition is checked against the post the path names, the
one a `GET` without `lang` serves.
| Status | Meaning |
|---|---|
| `200` | The post summary, with the new `ETag` |
| `400` | `{"error": "invalid_json"}` or the `validation` envelope |
| `401` | `{"error": "unauthorized"}` |
| `403` | `{"error": "forbidden", "message": "Token lacks 'write' scope"}` |
| `404` | `{"error": "not_found"}` |
| `412` | `{"error": "precondition_failed"}` when `If-Match` names an older state |
| `500` | `{"error": "save_failed"}` when the file cannot be written, or `{"error": "render_failed"}` when an `If-Match` precondition has to render a stored post that no longer renders |
### `DELETE /api/volumen/posts/{slug}`
Deletes the post. The response is `204 No Content` with no body. The post is
soft-deleted as a tombstone, so it can be restored from the admin while the
tombstone survives.
An `If-Match` header carrying the `ETag` a `GET` of the post served refuses
the delete with `412` when it names an older state; `*` and a missing header
are unconditional.
| Status | Meaning |
|---|---|
| `204` | Deleted |
| `401` | `{"error": "unauthorized"}` |
| `403` | `{"error": "forbidden", "message": "Token lacks 'delete' scope"}` |
| `404` | `{"error": "not_found"}` |
| `412` | `{"error": "precondition_failed"}` when `If-Match` names an older state |
| `500` | `{"error": "delete_failed"}`, or `{"error": "render_failed"}` when an `If-Match` precondition has to render a stored post that no longer renders |
### `GET /api/volumen/tags`
```json
{
"tags": [
{ "name": "go", "count": 2 },
{ "name": "research", "count": 1 }
]
}
```
Tags are ordered by count, most used first, then by name. Only published posts
are counted. The response carries an `ETag`.
### `GET /api/volumen/tags/{tag}`
A page of the posts carrying one tag, in the shape of
[`GET /api/volumen/posts`](#get-apivolumenposts) with the same `page`,
`limit`, `lang` and `cursor` parameters. A tag that names no published post
answers `404` with `{"error": "not_found"}`.
### `GET /api/volumen/series`
```json
{
"series": [{ "name": "Series", "count": 1 }]
}
```
Ordered by count, most posts first, then by name.
### `GET /api/volumen/series/{name}`
```json
{
"name": "Series",
"count": 1,
"posts": [
{
"slug": "alpha",
"title": "Alpha",
"excerpt": "Alpha body with a [link](https://example.com).",
"date": "2026-08-18",
"lang": "cs",
"tags": ["go", "research"],
"author": "Petr",
"reading_time": 1,
"series": "Series",
"series_order": 1,
"url": "/api/volumen/posts/alpha"
}
]
}
```
The posts are in reading order: by `series_order` first, and a post without
one after every ordered post, then by date and slug. A name that matches no
series answers `404` with `{"error": "not_found"}`.
### Feeds
| Path | Document |
|---|---|
| `/api/volumen/feed.xml` | RSS 2.0, `application/rss+xml` |
| `/api/volumen/feed.atom` | Atom 1.0, `application/atom+xml` |
| `/api/volumen/feed.json` | JSON Feed 1.1, `application/json` |
| `/api/volumen/tags/{tag}/feed.xml`, `/api/volumen/tags/{tag}/feed.atom`, `/api/volumen/tags/{tag}/feed.json` | The same three formats for one tag |
| `/api/volumen/series/{name}/feed.xml`, `/api/volumen/series/{name}/feed.atom`, `/api/volumen/series/{name}/feed.json` | The same three formats for one series |
A feed carries at most 20 posts: the newest 20 for the site feed and a tag
feed, and the first 20 of the series, in its reading order, for a series feed.
Every document names its own address: RSS carries `<atom:link rel="self">`,
Atom carries `<link rel="self">`, and JSON Feed carries `feed_url`, each with
the path that was actually requested, so a tag feed or a series feed reports
its own URL rather than the site feed's. A tag or series feed answers `404`
with `{"error": "not_found"}` when nothing matches.
### `GET /api/volumen/sitemap.xml`
Every published post, truncated to the newest 50 000 (the ceiling the
sitemaps.org protocol sets for one document), with `<lastmod>` from the file's
modification time and, for a post whose file cannot be read, from its date.
The content type is `application/xml`.
### `OPTIONS /api/volumen/{rest...}`
The CORS preflight for the whole API subtree. It advertises
`GET, POST, PUT, DELETE, OPTIONS` with `Content-Type, Authorization`, sets
`Access-Control-Max-Age: 600`, and answers `200` with an empty
`text/plain; charset=utf-8` body. The write methods are listed on purpose: a
browser preflight for a cross-origin `POST` fails when the answer names only
`GET`. The answer carries the write CORS set rather than the read one, so the
allowed origin follows the rule the Headers section states for a
token-authenticated write.
### Flow
The token-authenticated write flow, from the request to the stored file:
```mermaid
sequenceDiagram
participant Client
participant API
participant Tokens as Token store
participant Posts as Content directory
Client->>API: POST /api/volumen/posts with Authorization: Bearer
API->>Tokens: Authenticate the raw token
Tokens->>Tokens: compare the SHA-256 digest
Tokens-->>API: token record with its scopes
API->>API: require the write scope
API->>Posts: Save the post, archiving the previous version
Posts-->>API: the saved path
API-->>Client: 201 Created with the post summary
```
At each step, a failure surfaces in the response: a missing or unknown token
as `401 unauthorized`, a token without the scope as `403 forbidden`, a body
that is not a JSON object as `400 invalid_json`, and a rejected field as `400
validation`. When the post is stored, the `post.created` event fires and every
webhook subscribed to it receives the post summary.
## Notes
The Go structs and their JSON tags are the authority on the response shapes,
and the committed fixtures under
[`internal/httpapi/testdata/contract/`](../internal/httpapi/testdata/contract/)
pin them: a change to a body or a status code fails the contract test until
the fixture is regenerated with `go test ./internal/httpapi -update-contract`
and the change is recorded in the changelog. `go doc ./internal/payloads`
covers the exported types. This file explains what the surface is for; it does
not repeat the struct definitions, because a copy of a signature is a future
lie.
### Errors
Every failure uses one envelope: an `error` code, optionally with a
human-readable `message` and extra fields.
```json
{ "error": "not_found" }
```
```json
{ "error": "validation", "message": "Invalid slug.", "field": "slug" }
```
| Code | Status | Extra fields | Meaning |
|---|---|---|---|
| `not_found` | 404 | | No such post, tag, series, or media file |
| `draft` | 404 | | The post exists but is a draft |
| `scheduled` | 404 | | The post exists but is scheduled for the future |
| `validation` | 400, 422 | `message`, and `field` for a query parameter | The payload or query is invalid |
| `invalid_json` | 400 | | The request body is not a JSON object |
| `payload_too_large` | 413 | | The request body exceeds 10 MiB |
| `precondition_failed` | 412 | | The request's `If-Match` names an older state than the stored one |
| `unauthorized` | 401 | | Missing or invalid bearer token |
| `forbidden` | 403 | `message` | The token lacks the required scope |
| `rate_limited` | 429 | `retry_after` | Too many requests |
| `render_failed` | 500 | | The Markdown pipeline failed |
| `save_failed` | 500 | | The post could not be written to disk |
| `delete_failed` | 500 | | The post could not be deleted |
A `401` carries `WWW-Authenticate: Bearer`. Failures written by the API carry
`Cache-Control: no-store`, so a shared cache never keeps one; the `429` is
written by the rate limiter before the handler and carries the rate-limit
headers instead.
The router answers for itself outside the routes above: a path under
`/api/volumen/` that matches no route, or a method a route does not serve,
gets `405 Method Not Allowed` with an `Allow` header listing the methods the
path does serve, and a plain-text body rather than the JSON envelope.
### Headers and caching
- JSON responses use `Content-Type: application/json`. The XML documents carry
`application/rss+xml`, `application/atom+xml` and `application/xml`.
- A read answers with the permissive set:
`Access-Control-Allow-Origin: *`, `Access-Control-Allow-Methods: GET, POST,
PUT, DELETE, OPTIONS` and `Access-Control-Allow-Headers: Content-Type,
Authorization`. A token-authenticated write echoes the configured `base_url`
as the allowed origin when the request carries an `Origin` header, and falls
back to `*` when no base URL is configured or the request carries no `Origin`
header, so a browser can read a
cross-origin write from the site itself and not from anywhere else. The
router's `405` and the rate limiter's `429` are written outside a handler and
carry only what their own sections note.
- Successful reads carry `Cache-Control: public, max-age=60,
stale-while-revalidate=21600`. Write responses carry `no-store`.
- Bodies of 500 bytes or more are gzip-compressed when the client sends
`Accept-Encoding: gzip`, and such a response carries `Vary:
Accept-Encoding` so a shared cache keys on it.
- Dates are ISO 8601 (`YYYY-MM-DD`).
- Lists are newest first by post date. Drafts and scheduled posts never appear.
- Empty optional fields are omitted rather than sent as `null`; a client must
treat a missing key as "not set". Two fields of a post summary are the
exception and are always present: `excerpt` and `reading_time`. The site
payload is different in kind: it echoes the configured values, and every one
of them is a string, so its `fediverse_creator` is `""` when the key is
unset.
- Bodies are written by the Go 1.27 `encoding/json/v2` encoder with
deterministic member order and no HTML escaping, so `<`, `>` and `&` appear
as themselves and the same content produces the same bytes. That is what
makes the `ETag` round trip above reliable. A request body that names the
same member twice is rejected as `invalid_json` rather than silently taking
one of the two values.
- Malformed query parameters are rejected with `422` and the validation
envelope.
### Rate limiting
With `[api].rate_limit` above zero, every `/api/volumen/*` response carries
`X-RateLimit-Limit` and `X-RateLimit-Remaining`, counted per client address in
a sliding window of `[api].rate_limit_window` seconds. A request over the
budget is answered `429`:
```json
{ "error": "rate_limited", "retry_after": 42 }
```
with a `Retry-After` header carrying the same number of seconds. The limiter
covers the API prefix only: `/media/*`, `/admin/*` and `/healthz` are not
counted. The client address is the last entry of `X-Forwarded-For` when
`[server].trust_proxy` is set and the connection comes from a peer listed in
`[server].trusted_proxies`, and the connection address otherwise. The refusal
carries the read CORS set (`GET, OPTIONS` with `Content-Type`) rather than the
wide one, and no cache header.
### Operational routes
Outside the API prefix, the application serves a few operational routes:
| Route | Behaviour |
|---|---|
| `GET /healthz` | `{"status": "ok", "checks": {...}}` with `Cache-Control: no-store`. The status is `degraded` and the code `503` when the content directory is missing, a post file cannot be parsed, the users, tokens or templates file cannot be read, or the filesystem has under 100 MB free. `content_dir`, `users_file` and `disk` are always present; `content_files`, `tokens_file` and `templates_file` appear only when something is wrong with them. Not rate limited |
| `GET /robots.txt` | Allows all crawlers and points at the sitemap: `Sitemap: {base_url}/api/volumen/sitemap.xml` |
| `GET /sitemap.xml` | `301` redirect to `/api/volumen/sitemap.xml` |
| `GET /favicon.ico` | The bundled SVG icon, `Cache-Control: public, max-age=86400` |
| `GET /media/{path}` | An uploaded image from `<content_dir>/media/`. The file name is a UUID with a `.webp`, `.avif` or `.svg` extension, assigned when the upload is stored, so no uploader chooses a name; only those three extensions resolve, and anything else, including a missing file, answers `404` with `{"error": "not_found"}`. The type is set from the extension rather than sniffed, so a file whose bytes do not match is still served as an image and never as a document; an SVG, the one accepted image that is also a document, is additionally served with a sandboxing `Content-Security-Policy`, so opened at its own URL it runs as no one. Responses carry `Cache-Control: public, max-age=604800`. They are served outside the gzip and session layers, so a large image is never buffered in memory |
| `GET /` | `303` redirect to `/admin/` |
| anything else outside the API prefix | `404` with `{"error": "not_found"}` |
+198
View File
@@ -0,0 +1,198 @@
# Architecture
How Volumen is put together. Every node, package and arrow below exists in the
source tree; nothing is aspirational.
## Overview
```mermaid
flowchart TD
cli["cmd/volumen, the flag dispatcher"] --> app["internal/app, server assembly and middleware chain"]
cli --> backup["internal/backup, the tar.gz archive"]
cli --> scheduler["internal/scheduler, the publish_at sweep"]
app --> api["internal/httpapi, the public JSON API"]
app --> admin["internal/admin, the server-rendered admin"]
admin --> backup
api --> preview["internal/preview, the signed preview link"]
admin --> preview
api --> store["internal/store, snapshot cache, atomic writes, revisions"]
admin --> store
scheduler --> store
store --> post["internal/post, the domain object"]
store --> disk[("content directory: posts, media, revisions")]
post --> markdown["internal/markdown, scriptorium then bluemonday"]
admin --> i18n["internal/i18n, the admin interface catalogue"]
admin --> diff["internal/diff, the revision comparison"]
app --> config["internal/config, TOML over built-in defaults"]
admin --> fediverse["internal/fediverse, the @user@host rule"]
config --> fediverse
admin --> users["internal/users, internal/tokens, internal/templates"]
```
`cmd/volumen` is the only entry point. `serve` assembles `internal/app`, which
builds the file-backed stores from the configuration, wires the middleware
chain in front of the public API and the admin, and starts the scheduler loop
when the configuration enables it; a deployment is founded through the admin
itself, by the first-run wizard. `export`, `import` and
`publish-due` reach the filesystem and the archive without starting a server.
Volumen does not render the public site: the front end consumes the JSON API,
and the admin is the only HTML it serves.
## Packages
| Package | Responsibility |
|---|---|
| `config` | Reads `config.toml` into a typed value over the built-in defaults, applies the command-line overrides and validates it; with no file the defaults move the state under the user's home. It owns the commented template (`template.go`) shipped as `config.toml.example` and nothing about the content. |
| `store` | Owns the content directory through one `os.Root`: reading and writing posts, the snapshot cache, per-target write locks, the revision archive and its tombstones, and the media library. It decides where a file goes and never what a post means. |
| `imagefile` | The upload rule: which extensions are accepted, the WebP, AVIF and SVG signatures, the extension a byte string is, and the pixel size the header carries (the WebP chunks, the AVIF `ispe` box, the SVG root's size attributes or `viewBox`), so the media library can show it. The store asks it what an upload is; the media route asks it what a name may be. |
| `post` | The `Post` domain object: frontmatter metadata, the raw Markdown body, and the lazily rendered HTML and table of contents. It derives the slug, language, date, excerpt, reading time and publication status, and it clones deeply, because the store hands one instance to several readers. |
| `frontmatter` | Parses and serialises the `+++` block on an interpres `Document`, so a save keeps what the author wrote: the key order at every level, the comments, and whether a table is a header section or an inline table. |
| `markdown` | Owns rendering and sanitisation: scriptorium renders the body (CommonMark with the GFM extensions, footnotes and definition lists), a pre-render scan lifts `$$…$$` and `$…$` runs out of the source and splices the MathML back after rendering, fenced mermaid blocks become their SVG, headings gain ids and a table of contents, a figure pass wraps titled images, and the bluemonday allowlist sanitises the result. This is the only place that turns a body into HTML. |
| `feeds` | Renders RSS 2.0, Atom 1.0, JSON Feed 1.1 and the sitemap from the same posts the API serves. |
| `payloads` | Builds the API's wire shapes: site metadata, post summaries and details, tag and series listings, pagination in both modes, and the validation used by the write endpoints and the admin forms. |
| `httpapi` | Serves `/api/volumen/*`: reads, feeds, the sitemap, and the token-authenticated writes. It never writes a file; it calls `store`. |
| `admin` | Serves `/admin`: the first-run wizard that founds the installation, login, the dashboard, post CRUD, the editor and its preview, import and download, history, media, settings, users, tokens, webhooks, backups and self-update, with the session, role and CSRF guards. |
| `users`, `tokens`, `templates` | The file-backed stores beside `users.toml`: accounts with roles (the first one created by the wizard's serialised `AddFirst`), API token digests with scopes, and named post templates. They own their files and their atomic writes. |
| `session` | The signed session cookie: load, verify, sign, expire. The cookie also carries a fingerprint of the account's password hash, so changing a password retires every session issued before the change. |
| `preview` | The shared preview-link rule: an HMAC over the slug and an expiry stamp. The admin issues links, the API honours them, and neither implements the rule itself. |
| `fediverse` | The `@user@host` rule, a leaf so that the configuration, the admin account form and the post payload validation share one validator. |
| `identifiers` | The DOI and ORCID rules, a leaf like `fediverse`: syntax and normalisation for a DOI, shape and ISO 7064 check digit for an ORCID, and the resolver URLs both are published under. |
| `biblio` | The bibliography leaf: the `refs` frontmatter parsed into numbered entries, inline `[n]` citations linked to them, DOIs, arXiv ids and ORCIDs turned into resolver links, and the `[[refs]]` marker replaced by the rendered list. It imports no other domain package; the post annotates the same-instance links. |
| `app` | Assembles the server: stores, route tree, middleware chain, and the health, robots, favicon, media and 404 handlers. |
| `web` | The shared middleware (gzip, cross-origin refusal, security headers, the request logger and its id, the client address) and access to the embedded templates and assets. |
| `i18n` | The admin interface catalogue: English source strings, Czech translations, and the plural rules both languages need. The public API's messages stay English by contract. |
| `diff` | The line-based comparison behind the revision history's diff view, with a context collapse and a bounded table, so an oversized input degrades to a whole-text replacement instead of burning memory. |
| `ratelimit` | The sliding-window counter behind both the public API limit and the login limit, with a key bound enforced on every request. |
| `webhooks` | Delivers signed JSON events to the configured endpoints, with a bounded number in flight, retries, and an in-memory delivery history. Hooks come from `config.toml` and from the admin-managed `webhooks.toml`, whose changes apply through `SetHooks` without a restart. |
| `backup` | The one writer and reader of the tar.gz archive, shared by the CLI and the admin. It owns the layout and the containment of a restore. |
| `audit` | The append-only JSON-lines audit log. |
| `scheduler` | Publishes posts whose `publish_at` has arrived, from the in-app loop or the CLI, and reports each one to the webhook sink. |
| `updater` | The Gitea release check, the checksum-verified download, and the in-place replacement of the running binary. |
| `password` | scrypt hashing and verification with fixed parameters. |
| `tomlfile` | The shared atomic TOML writer (temp file, `fsync`, rename, mode `0600`). |
| `version` | Reports the version the toolchain recorded in the build information. Nothing writes a version number. |
## Data flow
```mermaid
sequenceDiagram
participant C as Client
participant G as web.Gzip
participant X as web.CrossOrigin
participant H as web.SecurityHeaders
participant R as apiRateLimit
participant M as session.Middleware
participant L as web.RequestLogger
participant A as httpapi handleSingle
participant S as store
participant P as the rendering pipeline
C->>G: GET /api/volumen/posts/hello-world
G->>X: buffers the body when the client accepts gzip
X->>H: refuses a state-changing request from another origin
H->>R: baseline security headers, CSP nonce for /admin
alt over the limit
R-->>C: 429 with Retry-After
else within the limit
R->>M: X-RateLimit-Limit and X-RateLimit-Remaining set
M->>L: the signed session cookie is loaded and verified
L->>A: the request id is assigned and carried in the context
A->>S: Find(slug, lang)
S->>S: compare the path, mtime and size snapshot with the cache
S-->>A: the post, an alias to redirect, or nothing
A->>P: render, splice mathematics and diagrams, sanitise
P-->>A: the HTML, cached on the post
A-->>C: 200 with CORS, an ETag and Cache-Control
end
```
The chain is built outermost first, so a request passes `web.Gzip`,
`web.CrossOrigin`, `web.SecurityHeaders`, `apiRateLimit`, `session.Middleware`
and `web.RequestLogger` before the route mux. The cross-origin gate is the
outer one of the two write guards: it refuses a request a browser sent from
another site before a handler runs, and the admin's per-session CSRF token is
the inner one, which also covers a same-site request from another port. The
request logger assigns a 16-character id, answers with it in `X-Request-Id`,
puts a logger carrying it into the context so a handler's own lines share it,
and writes one access line when the request finishes. The media route is split
off before all of it and wrapped only in the security headers, because the
session and gzip layers buffer a whole response and would hold entire images in
memory.
Errors are produced close to their cause and mapped once, at the edge: the
store returns an error for a failed write, the API turns it into an error
envelope, and the admin renders the same message in the form it came from. A
post file that cannot be parsed is never an error at the edge; the store skips
it, records it, and `volumen validate`, `volumen doctor` and `/healthz` report
it.
Admin handlers add `requireLogin` or `requireAdmin` in front of the handler
and validate the CSRF token before doing any work. Write endpoints
authenticate a `Bearer` token and check its scope. Both paths converge on the
same store calls, so a post saved from the admin and one saved from the API
land on disk the same way, including a rename, which moves the file and
tombstones the old one.
## State and lifetime
- **The content directory is the only durable state.** Posts, media,
revisions, `users.toml`, `templates.toml`, `tokens.toml`, `webhooks.toml`
and the audit log all live on disk; a restart loses only the in-memory
caches, the sitemap memo and the webhook delivery history.
- **One process, one content directory.** Every cache and limiter is
in-process, so two servers must never share a content directory. The lockers
are for concurrent requests inside one process, not for two writers on one
tree.
- **Cached posts are shared.** `store.All` and `store.Find` return the same
`*post.Post` to several readers, so a writer clones first (`Post.Clone`
deep-copies the metadata). The rendered HTML is cached on the instance with
a `sync.Once`, which is safe for concurrent readers by construction.
- **Locks.** The store holds one mutex over its cache and snapshot, a
reference-counted mutex per write target, and its own guard for that map.
The users, tokens, templates and audit stores each hold one mutex; the rate
limiters hold one each and bound their key sets.
- **Sessions live in the cookie**, signed with the secret from
`[admin].session_key`, or from the `secret.key` the server generated beside
the users file when the config leaves the key empty, and
bound to the account's password hash by a fingerprint, so a password change
ends every session issued before it while the device that made the change
re-signs itself. A request that carries a valid cookie is authenticated
without server state, so signing out expires the browser's copy rather than
revoking the value, and rotating the key is what ends every session at once.
- **Long-lived goroutines** are the scheduler loop and the HTTP server; both
are joined on shutdown, which drains in-flight requests for up to 15 seconds
after `SIGINT` or `SIGTERM`. Webhook deliveries and the release check are
bounded, detached, and die with the process.
- **Post files are written atomically**: a temp file in the target directory,
`fsync`, rename, directory `fsync`. The previous version is archived before
the rename, and a delete is a move into that archive, so both are reversible.
- **The content directory is reached through an `os.Root`.** Every read, write
and delete inside it goes through the handle, which refuses a path that
would escape the tree through `..` or a symlink, so confinement is a
property of the store rather than a check each caller has to remember.
## Dependencies
The direct requires in `go.mod` are the whole list, and each is there because
the standard library does not do the job:
- `sourcedock.dev/petrbalvin/scriptorium` renders the Markdown body, the TeX
mathematics and the Mermaid diagrams, deterministically and on the standard
library alone.
- `bluemonday` sanitises the
rendered HTML against an allowlist. It is the reason a post body can be
treated as untrusted even though its author is authenticated.
- `sourcedock.dev/petrbalvin/interpres/v2` parses TOML. It keeps a real date a
date, rather than turning `date = 2026-01-15` into a locale-dependent guess,
and its `Document` keeps key order and comments so a save round-trips the
author's own frontmatter.
- `golang.org/x/crypto` provides scrypt, which the standard library does not
ship, for password hashing.
- `golang.org/x/text` provides the NFKC normalisation applied to passwords
before hashing, so a password typed with a different Unicode form still
verifies.
Everything else is the standard library: `net/http` for the server and its
routing, `html/template` and `embed` for the admin, `archive/tar` and
`compress/gzip` for the archive, `encoding/json/v2` for the API, and
`log/slog` for diagnostics.
+92
View File
@@ -0,0 +1,92 @@
# Benchmarking
How **Volumen** is measured. Every number a document, a README or a changelog
quotes comes from here and nowhere else.
## The method
The benchmarks live next to the code they measure. The tree carries exactly
two, and nothing else is measured:
| Benchmark | What it measures |
|---|---|
| `BenchmarkRender` | the Markdown pipeline, from source to sanitised HTML and a table of contents, for a medium body and a large one |
| `BenchmarkServer` | the wired server over real HTTP: a post list, a single post, the tag cloud, a tag feed and the sitemap, against a corpus of five hundred posts |
`BenchmarkRender` in `internal/markdown/markdown_test.go` has two
sub-benchmarks and calls `SetBytes`, so a result reads as input bytes per
second: the medium input is 2400 bytes of Markdown, the large one eighteen
copies of it, 43200 bytes.
`BenchmarkServer` in `internal/app/bench_test.go` builds a server over a
temporary content directory and drives it through an `httptest` server, so it
covers the whole chain rather than one function: routing, the middleware, the
session layer, the store, the payload builders and the renderer. It is the
workload a release is judged on, and the one a profile is recorded from.
- The machine is the development workstation, idle: AMD Ryzen AI MAX+ PRO 395
with Radeon 8060S, 16 cores and 32 threads, 117 GiB of memory, Fedora Linux
44 (`linux/amd64`). A loaded box times whatever else is running, and the
fastest sample can land on the wrong function.
- The toolchain is `go1.27.1 linux/amd64`. Benchmarks run through `go test`,
which builds the package's test binary; the command build's `-trimpath` and
`-buildvcs=true` are not part of a benchmark's build.
- Comparisons run inside one process. A loaded machine and separate processes
of identical binaries differ by more than the effects being measured, so
A/B runs alternate the two sides rather than run one after the other, and
the counts are compared through their medians, with the allocations and the
bytes per operation alongside the times. Differences within a few percent
of the spread between runs are noise; only a difference beyond that is a
result.
- When timing is hopeless, the allocation and byte counts are the result.
- A profile says where the time goes, and it is only read from an idle
machine. Record one with `-cpuprofile` on `BenchmarkServer`, then `go tool
pprof -top` it.
- Profile-guided optimisation: a profile is committed at
`cmd/volumen/default.pgo`, and the toolchain consumes it automatically when
it builds the command (measured: a `-pgo=off` build and a default build of
the same tree produce different binaries). A benchmark's test binary never
sees it, because the profile belongs to the main package, so the early A/B
run of `BenchmarkServer` compared two plain builds and could not have shown
a difference; its numbers stand as the plain build's, and they understate
the shipped binary. The comparison measured on 2026-09-25 against the two
real builds (five hundred posts, the six JSON and feed endpoints
interleaved, rate limiting off, both arms served the same twelve thousand
six hundred requests) puts the profile's effect beyond the noise: server
CPU over the warm mix is about six percent lower, the per-request median
about thirty-two percent lower on `site`, twenty-three percent on the post
list and fifteen percent on a single post, while the tag feed, the sitemap
and the cold store scan are unchanged within the noise. The profile
predates the search-relevance and custom-fields changes to the hot path.
## Running
```sh
just bench
```
The recipe runs the whole module with five counts:
```sh
go test -run '^$' -bench=. -benchmem -count=5 ./...
```
`-benchmem` is not optional: allocations per operation are part of the
result. A first look at one target, before the full battery is worth the
time:
```sh
go test -run '^$' -bench 'BenchmarkServer' -benchmem -benchtime=1x -count=1 ./internal/app
```
The full battery runs once, deliberately, on an idle machine. A benchmark
command is capped at about two minutes per round; longer sweeps are split.
A server benchmark also spends time in the kernel, so read the allocation
numbers alongside the time: a change that halves allocations and leaves the
time flat has moved the cost to the filesystem.
## Reports
The repository stores no benchmark reports. A performance claim in
`CHANGELOG.md` is measured with the method above on the change that makes
it, on the named machine, and the number travels with the claim.
+273
View File
@@ -0,0 +1,273 @@
# Command line
The reference below is taken from the program's own `--help`. If the two
disagree, the program is right and this file is a defect. The same reference
ships as a manual page, [man/volumen.1](../man/volumen.1), and the page moves
in the same commit as the flags it documents.
## Synopsis
```sh
volumen <command> [options]
```
## Commands
| Command | Purpose |
|---|---|
| `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 |
## `volumen serve`
```text
volumen serve [--config <path>] [--content <dir>] [--host <addr>] [--port <n>]
```
| Flag | Default | Meaning |
|------|---------|---------|
| `--config` | `/etc/volumen/config.toml`, then `~/.config/volumen/config.toml` | Path to `config.toml` |
| `--content` | from config | Override the posts directory |
| `--host` | from config (`::`) | Override the bind address |
| `--port` | from config (`9091`) | Override the port |
A `--port` outside `1..65535` is rejected rather than ignored, and a positional
argument is an error.
Starts the HTTP server: the public API under `/api/volumen`, the admin under
`/admin`, uploaded media under `/media`, plus `/healthz`, `/robots.txt`,
`/sitemap.xml` and `/favicon.ico`. The configuration is validated at startup;
invalid values abort with exit code `1`, before the socket is bound.
With no `--config` the server reads `/etc/volumen/config.toml` if it exists,
then `~/.config/volumen/config.toml`, and when neither exists it runs on the
built-in defaults. Those defaults put the state under the user's home
(`~/.local/share/volumen`, honouring `XDG_DATA_HOME`), so a plain
`volumen serve` on a fresh machine works without root and without any
configuration file. On the first start the account file is empty and
`/admin` shows the first-run wizard: it creates the administrator account,
the interface language and the colour scheme, and signs the operator in. The
server keeps the session secret it generates in `secret.key` beside the
account file; `[admin].session_key` overrides it.
The server sets a 10 second read-header timeout, a 60 second read timeout, a
120 second write timeout and a 120 second idle timeout, and bounds a request
line and its headers at 1 MiB. `SIGINT` and `SIGTERM` drain in-flight requests
for up to 15 seconds and then exit `0`.
When `[scheduler].enabled = true`, an internal goroutine publishes the due
posts once at start-up and then every `[scheduler].interval` seconds. A
background release check fills the admin update banner without blocking
requests. Logging goes to stderr, either as text or as JSON lines when
`[server].log_format = "json"`; the server's own protocol errors go there too,
through `log/slog`, rather than to a bare stderr line. Every request carries a
16-character id: it is answered in `X-Request-Id`, it is attached to every line
the handlers write for that request, and the request's own access line records
the method, the path, the status and the duration under it.
## `volumen status`
```text
volumen status [--config <path>] [--data <dir>] [--users-file <path>] [--json]
```
Reports whether the config exists, parses and validates, the number of posts
(with the draft count), and the number of users; with no accounts yet the user
count carries the hint `open /admin to run the setup wizard`. `--json` prints
`{"ok": true, "checks": {…}}`, with a `config_validate` entry naming the first
invalid value, a `posts_unreadable` entry when a post file cannot be parsed, and
`users: "unreadable"` when the accounts file cannot be read. Exit code `1` when
the config is missing, unparseable or invalid, when content cannot be parsed, or
when the users file cannot be read.
Read-only: it inspects the content directory without creating or changing
anything, so a mistyped `content_dir` is reported rather than turned into an
empty tree.
## `volumen doctor`
```text
volumen doctor [--config <path>] [--json]
```
Runs the health checks: config presence and validation, whether every post file
can be parsed, whether every post renders, whether the users file can be read,
whether the installation has its first account, and whether any stored scrypt
parameters are below the policy floor. The
checks carry two levels: a missing or broken config, an unreadable content
directory and an unreadable users file are `fail` and exit code `1`; posts
that fail to render, an installation still waiting for the first-run wizard
and weak stored hashes are `warn`, reported without
changing the exit code. `--json` prints
`{"ok": bool, "checks": [{"name", "status", "detail"}]}`.
## `volumen check-update`
```text
volumen check-update [--json]
```
Compares the running version with the latest release published on the Gitea
instance (`https://sourcedock.dev/petrbalvin/volumen/releases`).
| Exit code | Meaning |
|-----------|---------|
| `0` | Up to date (or the local build is newer) |
| `1` | A newer release is available, or the release check failed (offline, timeout, malformed response) |
| `2` | The arguments were wrong |
`--json` prints `{"current": …, "latest": …, "available": bool}` and keeps the
same exit-code contract, so it works as a CI or monitoring probe.
## `volumen export`
```text
volumen export [--config <path>] [--out <archive>]
```
Writes a `.tar.gz` containing `posts/` (including `.revisions/` and `media/`),
`users.toml`, `templates.toml` and `tokens.toml`. Default output:
`volumen-backup.tar.gz`, written mode `0600`.
The config file is deliberately not included: it may hold the session signing
key override, and `volumen import` ignores it anyway. A file that exists but cannot be read
aborts the export rather than producing an archive that looks complete: the
archive is written to a temporary file and renamed into place, so a failure
leaves the previous backup untouched. Exit code `1` on I/O errors.
## `volumen import`
```text
volumen import [--config <path>] <archive>
```
Restores an archive produced by `volumen export` or the admin Backup panel
into the configured locations. An archive entry named `users.toml`,
`templates.toml` or `tokens.toml` is written to the path the deployment
configures for that file, which may be a different name; the post files under
`posts/` are written into the content directory.
Extraction is confined to the content directory, so an entry that tries to
escape it is refused, and only post files, revision archives and images with an
allowed extension are written: the media directory is served from a public
route, so an archive can never plant a document there. The decompressed total
is bounded. Exit code `1` on a malformed archive or one that holds no file the
layout recognises.
## `volumen publish-due`
```text
volumen publish-due [--config <path>] [--dry-run] [--json]
```
Publishes every post whose `publish_at` date is today or earlier: removes the
`publish_at` key and defaults `date` to it when the post has no date. This is
the cron and systemd-timer counterpart to the in-app `[scheduler]`.
- `--dry-run` lists the due slugs without touching files.
- `--json` prints `{"published": […], "failed": n}` (or
`{"due": […], "dry_run": true}`).
- The configured webhooks receive `post.published` for every post this
publishes, the same event the admin delivers.
- Exit code `1` when a post could not be written, so a timer notices.
## `volumen validate`
```text
volumen validate [--config <path>] [--json]
```
Scans the content directory and reports: files that cannot be parsed, posts
that fail to render, duplicate slugs, slug values that do not match the slug
format, missing titles, a `publish_at` that is not a date (which withholds the
post), and alias conflicts (an alias used twice or shadowing a live slug).
Prints `content OK` when clean; exit code `1` with a problem list otherwise.
`--json` prints `{"problems": [{"slug", "path", "error"}]}`, where `path` names
the file when there is no slug to name.
A file that cannot be parsed is invisible to every other command, which is why
it is named here first.
## `volumen version`
```text
volumen version
```
Prints `volumen <version>`: the release the toolchain recorded in the
binary's build information. A release binary reports its tag (`v1.0.0`); a
build from a plain checkout reports a pseudo-version naming the commit; a
build outside version control reports `(devel)`, and a dirty tree appends
`+dirty`. Nothing injects the version, so the reported value cannot go stale.
## Global flags
| Flag | Effect |
|---|---|
| `-h`, `--help`, `help` | prints the usage block and exits `0` |
| `-v`, `--version` | prints `volumen <version>` and exits `0` |
| `-h` on a subcommand | prints that subcommand's flags and exits `0`; `version` prints the version instead |
An unknown command and an unknown flag exit `2`. Every subcommand rejects a
stray positional argument with exit code `2`; `import` is the one command whose
positional argument (`<archive>`) is required.
## Exit codes
| Code | Meaning |
|---|---|
| `0` | success |
| `1` | a failure the program detected: an unreadable config, a failed write, a content directory with a problem, a release check that could not run |
| `2` | the arguments were wrong |
`check-update` is the exception that proves the rule: it exits `1` both when a
newer release exists and when the release check failed, so a monitoring probe
reads its `--json` output rather than its status alone.
## Examples
Serve a checkout against its own content, with the scheduler on:
```sh
volumen serve --config ./config.toml --content ./posts
```
Start a fresh per-user installation and open the wizard:
```sh
volumen serve
```
The server comes up on `http://localhost:9091`; `/admin` shows the
first-run wizard, which creates the administrator account and signs the
operator in. The state lives under `~/.local/share/volumen`, and the
session secret in `secret.key` beside it.
Publish what a timer missed, then check the content directory:
```sh
volumen publish-due --dry-run
volumen publish-due
volumen validate
```
Back up a deployment and restore it into a second one:
```sh
volumen export --config /etc/volumen/config.toml --out /backup/volumen.tar.gz
volumen import --config /srv/staging/config.toml /backup/volumen.tar.gz
```
Find out why a fresh install will not start:
```sh
volumen doctor --config /etc/volumen/config.toml
volumen status --config /etc/volumen/config.toml --json
```
+272
View File
@@ -0,0 +1,272 @@
# Configuration
Volumen reads its configuration from one TOML file:
`/etc/volumen/config.toml` for a system install,
`~/.config/volumen/config.toml` for a per-user install, with the file that
exists tried first when no `--config` is given. With no file anywhere the
server runs on the built-in defaults, which put the state under
`~/.local/share/volumen`. No environment variable is read (the XDG ones only
locate those paths). Flags passed to `volumen serve` override the file, as
described under [Precedence](#precedence).
## File
The file is copied from the commented template (`internal/config/template.go`),
which is committed as `config.toml.example` at the repository root. Every key
the loader accepts is listed there with its
meaning, so an operator sees the whole surface and edits what this deployment
needs rather than discovering keys from a bare table dump. The copy lives at
the config path with mode `600`, because it is deployment state.
```toml
# volumen configuration.
#
# Every key this file accepts is listed here. Copy it to
# /etc/volumen/config.toml (or ~/.config/volumen/config.toml for a
# per-user installation), then edit.
#
# Keys that belong to no table come first, because a key written below a
# [table] header belongs to that table.
# Directory of the Markdown posts (.md with TOML frontmatter).
content_dir = "/var/lib/volumen/posts"
# File holding the admin accounts (managed from the admin Settings page).
users_file = "/var/lib/volumen/users.toml"
# How many previous versions of each post to keep in .revisions/
# (0 keeps none, which also makes deleting a post permanent).
revision_limit = 10
# Where the audit log is appended, or "" to disable auditing. Records
# who changed what, and when, in JSON lines.
audit_log = ""
[server]
# Address to bind, as an IP address: "::" is every interface, "::1" is
# loopback only, which is what a reverse proxy needs.
host = "::"
port = 9091
# Environment label: "development" or "production". It decides the
# startup safety checks (session key length, cookie flags, password
# policy).
env = "development"
# Set true ONLY when a trusted reverse proxy terminates TLS in front of
# volumen. Client addresses are then taken from X-Forwarded-For and
# cookies are marked Secure.
trust_proxy = false
# Addresses whose X-Forwarded-For may be believed, as addresses or CIDR
# prefixes. An empty list never reads the header and always uses the
# connection address; list the proxy so its clients each rate-limit
# under their own address.
trusted_proxies = []
# Set true in production to force the Secure flag on session cookies.
cookie_secure = false
# Log output format: "text" (human readable) or "json" (structured).
log_format = "text"
[site]
title = "Volumen"
description = "Powered by Volumen."
# Absolute URL of the public site, without a trailing slash.
base_url = "https://example.com"
language = "en"
author = "Anonymous"
# Fediverse handle surfaced as the author in feeds and meta tags.
# Leave empty to disable.
fediverse_creator = ""
[admin]
# Secret that signs session cookies (at least 64 bytes in production).
# Leave empty: the server generates one and keeps it in secret.key next
# to users.toml. A value here overrides that file.
session_key = ""
# Session lifetime in seconds (24 hours by default).
session_ttl = 86400
# Minimum password length enforced when a password is set in the admin UI.
min_password_length = 10
# Maximum password length, to bound the scrypt work.
max_password_length = 1024
# Maximum upload size in bytes (10 MB by default).
max_upload_bytes = 10485760
[api]
# Public API rate limit: requests allowed per window per client address.
# 0 disables rate limiting.
rate_limit = 60
# Rate-limit window length in seconds.
rate_limit_window = 60
# Scheduled publishing, for a post whose frontmatter carries publish_at.
# [scheduler]
# enabled = false
# interval = 300
# Outgoing webhooks: POST a signed JSON payload on post changes so a
# front-end can rebuild its cache or static pages. Repeat the block for
# more endpoints; events may be omitted to receive every event.
# [[webhooks]]
# url = "https://example.com/hooks/rebuild"
# secret = "a-long-random-string" # HMAC-SHA256 signing key
# events = ["post.created", "post.updated", "post.deleted", "post.published"]
# enabled = true
```
The template lists the four keys that belong to no table first, before any
header, because TOML puts a key written below a `[table]` header inside that
table. The loader refuses such a file rather than falling back to the default:
a `content_dir` written under `[server]` stops the start with a message naming
the fix, so a misplaced key is never silently ignored.
Five state files live next to `users_file`, in the same directory, and are not
read from the configuration file itself: `users.toml` holds the admin
accounts, started by the first-run wizard; `secret.key` holds the session
secret the server generated on first start (unless `[admin].session_key`
overrides it); `templates.toml` the `[[templates]]` array of post templates offered
in the admin "New post" form, each with `name`, `title`, `slug`, `tags`,
`body`, and an optional `[templates.fields]` table of editor inputs to
pre-fill (`author`, `lang`, `doi`, `orcid`, `series`, `series_order`,
`excerpt`, `cover`, …); `tokens.toml` the API token records, each with `name`, `token_hash`
(the SHA-256 digest, never the raw token), `created`, `last_used` and `scopes`;
and `webhooks.toml` the admin-managed webhook endpoints, of the same shape as
`[[webhooks]]` below. The admin rewrites each atomically at mode `600`, so
editing one by hand while the server runs is not advised. A token record
without a `scopes` list is unrestricted; a token created with a scope list
that names no recognised scope is refused rather than turned into an
unrestricted one; the scopes are `write` and `delete`
([`internal/tokens/tokens.go`](../internal/tokens/tokens.go)). A `webhooks.toml`
that cannot be parsed is logged and ignored, and the config-declared hooks
keep working.
## Keys
| Key | Type | Default | Effect |
|---|---|---|---|
| `content_dir` | string | `"/var/lib/volumen/posts"` | Directory of `.md` files with `+++` TOML frontmatter. Archived revisions live under `<content_dir>/.revisions/`, uploaded media under `<content_dir>/media/`. The parent directory must be writable, checked at startup with a write probe |
| `users_file` | string | `"/var/lib/volumen/users.toml"` | File holding the admin accounts. Its parent directory must be writable. `templates.toml`, `tokens.toml` and `webhooks.toml` are created next to it |
| `revision_limit` | integer | `10` | Archived versions kept per post under `<content_dir>/.revisions/`. `0` disables archiving: a save then keeps no previous version and a delete is permanent |
| `audit_log` | string | `""` | Path of the JSON-lines audit log, or empty to disable auditing. Each line is one JSON object carrying `ts` (RFC 3339, UTC), `action` and `user`, and, where the action knows them, `resource`, `detail` and `ip`. The file is created at mode `600`; a write failure is logged and never fatal |
| `server.host` | string | `"::"` | Bind address, written as an IP address rather than a name: the address is parsed, not resolved, so `"localhost"` is refused. `"::"` listens on every interface with IPv4 dual-stack; `"::1"` or `"127.0.0.1"` binds loopback only, for a reverse proxy in front |
| `server.port` | integer | `9091` | TCP port to bind. `1` to `65535` |
| `server.env` | string | `"development"` | `"development"` or `"production"`. The label decides one thing beyond its own validation: in production the session-key rules are fatal, as the `admin.session_key` row and [Validation](#validation) describe |
| `server.trust_proxy` | boolean | `false` | Take the client address from `X-Forwarded-For` instead of the connection, and mark session cookies `Secure`. The header is read only when the peer address is inside `server.trusted_proxies`, and the address taken is its last entry, the one the proxy appends when it forwards a request; the entries to its left are client-supplied and can be forged to rotate the rate-limit key, so the proxy must append the connecting address rather than pass the header through. Set it only behind a trusted reverse proxy that terminates TLS, and outside production the start logs a warning saying so |
| `server.trusted_proxies` | array of strings | `[]` | Addresses or CIDR prefixes whose `X-Forwarded-For` may be believed. An empty list never reads the header and always uses the connection address, so every client behind the proxy shares one rate-limit budget; a loopback deployment behind nginx lists `["::1", "127.0.0.1"]`. Each entry must parse as an address or a prefix. The address taken is always the header's last entry, the one the trusted proxy appended; with two chained proxies in front of the listener that entry is the front proxy's address, so all of its clients then share one rate-limit bucket, and the deployment must either let only the immediate proxy append the header or size the limit for the aggregate |
| `server.cookie_secure` | boolean | `false` | Mark session cookies `Secure` so a browser sends them over HTTPS only. Cookies also carry the flag when `server.trust_proxy` is set, and either setting makes responses carry `Strict-Transport-Security` |
| `server.log_format` | string | `"text"` | `"text"` for human-readable lines or `"json"` for one structured object per line through `log/slog`. Applied once at startup by `volumen serve` |
| `site.title` | string | `"Volumen"` | Site title, served by `/api/volumen/site` and used in the feeds |
| `site.description` | string | `"Powered by Volumen."` | Site description, used in the RSS channel, the Atom subtitle, the JSON Feed and `/api/volumen/site` |
| `site.base_url` | string | `"https://example.com"` | Canonical site URL, without a trailing slash. Must be an absolute URL. Builds every absolute link: post permalinks in the feeds, `feed_url`, the sitemap, preview links and the `Sitemap:` line in `robots.txt` |
| `site.language` | string | `"en"` | Default language, inherited by a post whose frontmatter and directory carry none. Emitted as `<language>` in RSS and `language` in the JSON Feed. Seeds the admin interface language for the login screen until an account picks its own |
| `site.author` | string | `"Anonymous"` | Site author, served by `/api/volumen/site`. It is not substituted into a post's own `author` field |
| `site.fediverse_creator` | string | `""` | Optional site-wide handle in `@user@host` form, validated against that pattern when set. Served by `/api/volumen/site`, used as the author of a JSON Feed item whose post has none, and offered as the default in the admin post form. A per-post value takes precedence |
| `admin.session_key` | string | `""` | Secret that signs session cookies and preview links. Leave it empty: the server generates a 64-character hex secret on first start and keeps it in `secret.key` beside the users file, so sessions survive restarts without the operator doing anything. A value here overrides that file and must be at least 64 bytes in production; a shorter configured value is refused at startup there, and tolerated in development, where an empty or short key means an ephemeral secret |
| `admin.session_ttl` | integer | `86400` | Session lifetime in seconds, used as the cookie `max-age` and enforced when the cookie is loaded. `1` to `31536000` (24 hours by default, one year at most) |
| `admin.min_password_length` | integer | `10` | Shortest password the admin accepts when an account is created or a password is changed. At least `1` and at most `admin.max_password_length` |
| `admin.max_password_length` | integer | `1024` | Longest password the admin accepts. Bounds the scrypt work, because unbounded input would be a denial-of-service vector. `1` to `1024`; a larger value is refused at startup rather than turning into a failed password change later |
| `admin.max_upload_bytes` | integer | `10485760` | Largest accepted upload in bytes: a media upload (`POST /admin/uploads`, refused with `413` and the code `too_large`), a post import and a profile photo, which report the size in the form instead. `1` to `1073741824` (10 MiB by default, 1 GiB at most) |
| `api.rate_limit` | integer | `60` | Requests allowed per window per client address across `/api/volumen/*`, counted in a sliding window. `0` disables rate limiting: the middleware is not installed and no `X-RateLimit-*` header is sent. Zero or greater. See the [API documentation](API.md#rate-limiting) for the headers and the `429` body |
| `api.rate_limit_window` | integer | `60` | Window length in seconds for the limit above. `1` to `86400` while rate limiting is enabled; a value outside that range is refused at startup |
| `scheduler.enabled` | boolean | `false` | Start the in-process publish loop, which is the alternative to running `volumen publish-due` from cron: with the scheduler enabled, `volumen serve` publishes due posts itself, once at startup and then on the interval. Read once, at startup |
| `scheduler.interval` | integer | `300` | Seconds between runs. At least `1` when the scheduler is enabled |
| `[[webhooks]].url` | string | unset | Endpoint URL, required, absolute `http` or `https`; an entry without it stops the start |
| `[[webhooks]].secret` | string | `""` | HMAC-SHA256 signing key. When set, each request carries `X-Volumen-Signature: sha256=<hex digest of the raw body>` |
| `[[webhooks]].events` | array of strings | `[]` | Events to receive: `post.created`, `post.updated`, `post.deleted`, `post.published`. Empty or absent receives every event |
| `[[webhooks]].enabled` | boolean | `true` | `false` keeps the entry but skips delivery |
Both scheduled-publishing mechanisms do the same work: a post whose
`publish_at` date is today or earlier loses that key and gains a `date` when
it carried none, and each published post fires the `post.published` webhook.
See [DEPLOYMENT.md](DEPLOYMENT.md#scheduled-publishing).
The optional `[[webhooks]]` array registers one outgoing webhook per entry.
When a post is created, updated, deleted or published, Volumen POSTs a signed
JSON body to every matching endpoint. Every request also carries
`X-Volumen-Event` (the event name), `X-Volumen-Delivery` (a unique delivery
id) and `User-Agent: volumen/<version>`. The body carries `event`,
`timestamp`, `version` and, for a post event, a `post` object holding the
post summary. Delivery runs in the background with up to three attempts. The
most recent deliveries are listed in the admin at **Settings, Webhooks**,
where a **Send test** button fires a `ping` event. Endpoints added in the
admin are not written into this file: they live in `webhooks.toml` beside the
users file, and a change applies there without a restart; the config-declared
entries are read-only in the admin and both sets deliver.
## Precedence
Sources, strongest first: the flags of `volumen serve`, then the configuration
file, then the built-in defaults. There is no environment variable and no
second file.
| Flag | Effect |
|---|---|
| `--config PATH` | Selects the file to read. Without it the server tries `/etc/volumen/config.toml`, then `~/.config/volumen/config.toml`, and uses the built-in defaults when neither exists |
| `--content DIR` | Overrides `content_dir` |
| `--host ADDR` | Overrides `server.host` |
| `--port N` | Overrides `server.port`, and must be `1` to `65535` |
```sh
volumen serve --config /etc/volumen/config.toml \
--content /var/lib/volumen/posts \
--host 127.0.0.1 \
--port 9000
```
A key absent from the file takes its default, and a key the loader does not
know is ignored, so a file left over from an older release still starts. A
missing file is not an error either: the defaults are used and one line is
logged saying so.
## Validation
`Config.Validate()` runs at startup, before the server binds.
The file is decoded into typed values, and a key whose TOML type does not match
its field is an error rather than a silent fallback to the default, because a
typo that quietly disables a setting is worse than a refusal to start. The
decoder lives in [`internal/config/config.go`](../internal/config/config.go)
and covers every key in the table above. A key the decoder does not know is
ignored, so a file written for another release still loads, and a root key
written below a table header is refused with the fix in the message, as the
[File](#file) section describes. The message names the key and the type it
found:
```text
volumen serve: parse config /etc/volumen/config.toml: interpres: server.port: cannot assign string to int
```
The remaining rules are value rules:
| Key | Rule |
|---|---|
| `server.host` | Non-empty, and an IP address |
| `server.port` | `1` to `65535` |
| `server.env` | `development` or `production` |
| `server.log_format` | `text` or `json` |
| `server.trusted_proxies` | Each entry an address or a CIDR prefix |
| `site.base_url` | Non-empty, and an absolute URL with a scheme and a host |
| `site.fediverse_creator` | `@user@host` when set |
| `admin.session_ttl` | `1` to `31536000` |
| `admin.min_password_length` | At least `1`, and at most `admin.max_password_length` |
| `admin.max_password_length` | At most `1024` |
| `admin.max_upload_bytes` | `1` to `1073741824` |
| `admin.session_key` | At least 64 bytes when `env = "production"` |
| `revision_limit` | Zero or greater |
| `api.rate_limit` | Zero or greater |
| `api.rate_limit_window` | `1` to `86400` while rate limiting is enabled |
| `scheduler.interval` | At least `1` while the scheduler is enabled |
| `[[webhooks]].url` | Absolute `http` or `https` URL |
| `content_dir` | The parent directory must be creatable and writable |
| `users_file` | The parent directory must be creatable and writable |
A failure stops the process with the message on standard error and exit code
`1`, as the example above shows. `volumen doctor --config PATH` reports the
same check without starting the server:
```text
config ok /etc/volumen/config.toml
config-validate ok
posts-readable ok
posts-render ok
users-file ok
password-hashes ok
```
+748
View File
@@ -0,0 +1,748 @@
# Deployment
How Volumen runs in production.
## Topology
```mermaid
flowchart TD
net["Internet"] -->|HTTPS| nginx
nginx["nginx :443, TLS termination and reverse proxy"] -->|"proxy_pass http://[::1]:9091"| app
app["volumen, one static binary, loopback only"] --> data
app --> etc
data["/var/lib/volumen holding posts/ (with media/ and .revisions/ inside) and users.toml, templates.toml, tokens.toml, webhooks.toml, secret.key"]
etc["/etc/volumen/config.toml"]
```
nginx terminates TLS on the public hostname and proxies `/api/volumen`,
`/admin` and `/media` to the backend on loopback; everything else on that
hostname is the public site, served separately. The engine owns one content
directory (posts, media and revisions inside it) and four files beside it:
`users.toml`, `templates.toml`, `tokens.toml` and `webhooks.toml`. There is
one process per content directory.
## Requirements
- **A supported platform**: the release builds cover Linux on `amd64`,
`arm64`, `loong64`, and `riscv64`, and FreeBSD on `amd64` and
`arm64`. The binary is static
(`CGO_ENABLED=0`), so no runtime libraries are needed.
- **Go 1.27.1** or newer only when building from source: the module declares
it.
- **nginx** and a TLS certificate (e.g. via certbot) for the public hostname.
- A dedicated system user, `volumen` by default, for the service. The
documentation creates it by hand; nothing in the binary creates users.
- **systemd** for the managed service (optional; an rc.d script, a supervisor,
or a plain foreground run all work).
- Nothing else: no interpreter, no virtual environment, no package manager.
The deployment itself is one command: start `volumen serve` and open
`/admin`, where the first-run wizard creates the administrator account. The
commented configuration template, `config.toml.example`, is committed at
the repository root, and the session secret is generated by the server.
## Build
```sh
just build # writes bin/volumen for the host platform
```
Releases are built by the tag pipeline for Linux (`amd64`, `arm64`, `loong64`,
`riscv64`) and FreeBSD (`amd64`, `arm64`), with `CGO_ENABLED=0` and
no build tags, so every artefact is a static binary. `-trimpath` keeps the
checkout's own path out of the binary and `-buildvcs=true` records the
revision it came from, so the same tree builds the same bytes from any
directory and `volumen version` still names the tag or the commit. The version
is recorded by the toolchain at build time; nothing injects it.
## Run
Installing the binary is a copy, and the installation is the wizard: start
`volumen serve`, open `/admin`, and the first-run screen creates the
administrator account, the interface language and the colour scheme. A
system deployment writes the config by copying `config.toml.example`; a
per-user one needs no config at all.
Any account can then add a second factor from Settings, Security: scan the
offered QR with an authenticator application and confirm one code. The
recovery codes shown at that moment open the account when the application
is lost; they work once each and are not shown again, so they belong in a
password manager the moment they appear.
### From a Gitea release
Every release on
[sourcedock.dev](https://sourcedock.dev/petrbalvin/volumen/releases) ships
platform binaries and a `checksums.txt` with their SHA-256 digests. Asset
names follow `volumen-<version>-<os>-<arch>`:
```sh
VERSION=1.0.0
BASE="https://sourcedock.dev/petrbalvin/volumen/releases/download/v${VERSION}"
curl -fLO "${BASE}/volumen-${VERSION}-linux-amd64"
curl -fLO "${BASE}/checksums.txt"
# Verify the download against the published digest.
sha256sum --ignore-missing -c checksums.txt
sudo install -m 0755 "volumen-${VERSION}-linux-amd64" /usr/local/bin/volumen
volumen version
```
Replace `linux-amd64` with `linux-arm64`, `linux-loong64`, `linux-riscv64`,
`freebsd-amd64`, or `freebsd-arm64`
as needed. The release binary carries its version, so `volumen version`
confirms what you installed.
### From source
```sh
git clone https://sourcedock.dev/petrbalvin/volumen.git
cd volumen
just install
sudo install -m 0755 bin/volumen /usr/local/bin/volumen
```
The binary reports the version the toolchain recorded at build time:
`volumen version` names the commit of the checkout it was built from, and a
release build names its tag. There is no version flag to pass and nothing to
inject.
### Bootstrap the deployment
There is no bootstrap command. A deployment is a directory for its state, a
configuration file copied from the template when the defaults do not fit, a
supervisor to keep the server running, and one visit to `/admin`, where the
first-run wizard creates the first account. The wizard stays open until that
account exists: on a machine reachable from the network, start the service and
claim the installation straight away.
### System install (root, systemd)
```sh
# 1. Service account and its group.
sudo useradd --system --user-group --home-dir /var/lib/volumen \
--shell /usr/sbin/nologin volumen
# 2. The config: copy the commented template from the repository root.
sudo mkdir -p /etc/volumen /var/lib/volumen/posts/media
sudo cp config.toml.example /etc/volumen/config.toml
# 3. Edit the copy for production behind a reverse proxy:
# host = "::1", env = "production", trust_proxy = true,
# cookie_secure = true,
# trusted_proxies = ["::1", "127.0.0.1"]
# Leave [admin].session_key empty: the server generates its secret into
# /var/lib/volumen/secret.key on first start and keeps it there.
# 4. Own the data directory so the service can write posts, media, accounts
# and its secret.
sudo chown -R volumen:volumen /var/lib/volumen
# 5. Install the unit (the Service unit section below), then:
sudo systemctl daemon-reload
sudo systemctl enable --now volumen.service
# 6. Open http://localhost:9091/admin/ and complete the wizard.
```
The two proxy settings carry weight: `trust_proxy = true` takes the client
address from the proxy's `X-Forwarded-For`, and `cookie_secure = true` keeps
the session cookie on HTTPS. The config is deployment state and stays mode
`0600`.
### Per-user install (no root)
```sh
volumen serve
```
With no config file the server runs on the per-user paths:
`~/.local/share/volumen/posts` and `~/.local/share/volumen/users.toml`
(honouring `XDG_DATA_HOME`), and it reads `~/.config/volumen/config.toml`
when that file exists. No systemd unit is installed in this mode; run the
server in the foreground or through a supervisor of your choice.
### Configuration locations
| Path | Purpose |
|------|---------|
| `/etc/volumen/config.toml` | configuration: a copy of the commented `config.toml.example`, edited to fit |
| `/var/lib/volumen/posts` | Markdown posts |
| `/var/lib/volumen/posts/media` | uploaded images (WebP / AVIF / SVG) |
| `/var/lib/volumen/posts/.revisions` | archived post versions and delete tombstones |
| `/var/lib/volumen/users.toml` | admin users (created by the first-run wizard or the Settings page) |
| `/var/lib/volumen/secret.key` | the session secret the server generated on first start |
| `/var/lib/volumen/templates.toml` | post templates (created from the admin Settings page) |
| `/var/lib/volumen/tokens.toml` | API access tokens (created from the admin Settings page) |
| `/var/lib/volumen/webhooks.toml` | admin-managed webhook endpoints (created from the admin Settings page) |
| `/var/lib/volumen/audit.log` | audit trail (only when `audit_log` is configured) |
| `/etc/systemd/system/volumen.service` | systemd unit (written by the operator, see the Service unit section) |
| `~/.config/volumen/config.toml` | per-user config, read when it exists |
| `~/.local/share/volumen/` | per-user data dir (the default when no config file exists) |
The users, templates, tokens and secret files carry password hashes, token
digests and the signing key, so never make them world-readable: every write
the server performs re-applies mode `600`, media and post writes are atomic.
Every configuration key is documented in
[CONFIGURATION.md](CONFIGURATION.md).
### Scheduled publishing
Posts may carry a `publish_at` date in their frontmatter; they stay hidden
until that date arrives. Two mechanisms can flip them, and both do the same
thing: drop `publish_at` and set `date` when the post had none.
**From cron** (or a systemd timer):
```text
*/5 * * * * /usr/local/bin/volumen publish-due --config /etc/volumen/config.toml
```
Use the absolute path to the binary, since cron's `PATH` is minimal.
`--dry-run` lists due posts without touching files; `--json` emits
machine-readable output. A real run delivers the `post.published` event to the
configured `[[webhooks]]` for every post it publishes and waits for those
deliveries to finish, so a front end that rebuilds from a webhook hears about
a publish that came from cron. A post whose file could not be saved is
reported on stderr and the command exits `1`, so a timer unit or a monitoring
check can see the failure rather than assume success.
**From the server itself**, via the `[scheduler]` configuration section:
```toml
[scheduler]
enabled = true
interval = 300 # seconds
```
The in-process loop starts with `volumen serve` and needs no external timer.
It sweeps once at start-up, so a post whose date passed while the service was
down is published as soon as it comes back, and then once every `interval`
seconds. An interval below one second never reaches the loop: the
configuration validation refuses it at start-up. The loop delivers the same
`post.published` webhook as the
admin does, and a save failure is logged and skipped, so one bad file cannot
stop the sweep or the loop.
The systemd timer equivalent for the CLI path:
```ini
# /etc/systemd/system/volumen-publish.service
[Unit]
Description=Publish due volumen posts
[Service]
Type=oneshot
User=volumen
ExecStart=/usr/local/bin/volumen publish-due --config /etc/volumen/config.toml
```
```ini
# /etc/systemd/system/volumen-publish.timer
[Unit]
Description=Publish due volumen posts every five minutes
[Timer]
OnCalendar=*:0/5
[Install]
WantedBy=timers.target
```
```sh
sudo systemctl daemon-reload
sudo systemctl enable --now volumen-publish.timer
```
Adjust the `ExecStart` path to match the one in `volumen.service`.
## Service unit
`/etc/systemd/system/volumen.service`, written by the operator (paths and
binary location fitted to the deployment):
```ini
[Unit]
Description=Volumen, a lightweight publishing platform for scientists
After=network.target
[Service]
Type=simple
User=volumen
Group=volumen
WorkingDirectory="/etc/volumen"
ExecStart="/usr/local/bin/volumen" serve --config "/etc/volumen/config.toml" --content "/var/lib/volumen/posts"
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths="/var/lib/volumen"
PrivateTmp=true
[Install]
WantedBy=multi-user.target
```
Quote every path so a layout with spaces in it stays one argument, and double
any `%`, because unit files expand `%X` specifiers.
- `User` / `Group` name the service account (default `volumen`), and
`WorkingDirectory` is the directory holding the config file.
- `ExecStart` names the installed binary.
- `ReadWritePaths` is the **parent of the data directory**, so a layout with
posts at `/srv/volumen/posts` yields `ReadWritePaths=/srv/volumen`: the
service also writes the users file, its templates, tokens, webhooks and
`secret.key` there. With `ProtectSystem=strict` the rest of the filesystem
is read-only to the service.
- `NoNewPrivileges`, `ProtectHome` (`/home`, `/root`, `/run/user`
inaccessible), `PrivateTmp`, and a two-second restart delay on failure.
The server bounds every connection: a 10 second read-header timeout, a 60
second read timeout, a 120 second write timeout, and a 120 second idle
timeout, so a client that opens a socket and dribbles a request cannot hold it
indefinitely. On `SIGINT` or `SIGTERM` (what `systemctl stop` sends) it stops
accepting new connections and drains the requests already in flight, giving
them at most 15 seconds.
### FreeBSD (rc.d)
The `freebsd/amd64` and `freebsd/arm64` release binaries
run natively; nothing is compiled at install time.
```sh
# Install the binary from the release, as above, to /usr/local/bin/volumen.
pw useradd volumen -d /var/db/volumen -s /usr/sbin/nologin -c "Volumen publishing platform"
mkdir -p /usr/local/etc/volumen /var/db/volumen/posts/media
cp config.toml.example /usr/local/etc/volumen/config.toml
chown -R volumen /var/db/volumen
```
Edit the copied config for a production deployment behind a proxy: set the
three paths, `host = "::1"`, `env = "production"`, `trust_proxy = true`,
`cookie_secure = true` and `trusted_proxies = ["::1", "127.0.0.1"]`. Leave
`[admin].session_key` empty; the server writes `secret.key` into
`/var/db/volumen`, which is owned by the `volumen` user.
Conventional FreeBSD paths: config in `/usr/local/etc/volumen/`, data in
`/var/db/volumen/`. Drop an `rc.d` script in
`/usr/local/etc/rc.d/volumen`:
```sh
#!/bin/sh
#
# PROVIDE: volumen
# REQUIRE: NETWORKING
# KEYWORD: shutdown
. /etc/rc.subr
name="volumen"
rcvar="volumen_enable"
load_rc_config $name
: ${volumen_enable:="NO"}
: ${volumen_user:="volumen"}
: ${volumen_config:="/usr/local/etc/volumen/config.toml"}
: ${volumen_content:="/var/db/volumen/posts"}
pidfile="/var/run/${name}.pid"
command="/usr/sbin/daemon"
command_args="-f -r -P ${pidfile} -u ${volumen_user} \
/usr/local/bin/volumen serve --config ${volumen_config} --content ${volumen_content}"
run_rc_command "$1"
```
Enable and start:
```sh
chmod +x /usr/local/etc/rc.d/volumen
sysrc volumen_enable=YES
service volumen start
```
## Production configuration
Volumen only owns `/api/volumen`, `/admin`, and `/media`. Serve your public
site from the same hostname and proxy those prefixes to the backend, so the
admin runs same-origin (session cookies and CSRF work without extra
configuration). A production deployment binds the service to `::1`, so proxy
to `[::1]:9091`:
```nginx
server {
listen 443 ssl http2;
server_name lab.example.com;
ssl_certificate /etc/letsencrypt/live/lab.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/lab.example.com/privkey.pem;
# Public site: any front end, static files or a built one.
root /var/www/site;
location / {
try_files $uri $uri/ /index.html;
}
# volumen API, admin and media.
location ~ ^/(api/volumen|admin|media)(/|$) {
proxy_pass http://[::1]:9091;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name lab.example.com;
return 301 https://$host$request_uri;
}
```
Caddy is the recommended front, and the same shape costs a handful of
lines: certificates and the HTTP redirect are its own work.
```caddyfile
lab.example.com {
root * /var/www/site
@backend path /api/volumen/* /admin /admin/* /media /media/*
handle @backend {
reverse_proxy [::1]:9091
}
handle {
try_files {path} {path}/ /index.html
}
}
```
Caddy appends the connecting address to `X-Forwarded-For` the way
`$proxy_add_x_forwarded_for` does, so the guarantee described below holds
with either front.
With `[server].trust_proxy = true` (a production deployment turns it on), the
client IP used by the API rate limiter and the login limiter is the **last**
entry of `X-Forwarded-For`, and only when the connection itself comes from an
address listed in `[server].trusted_proxies`. Session cookies then always
carry `Secure`.
The last entry is the one the peer that wrote it saw as its client, so the
directives above are safe as written: `$proxy_add_x_forwarded_for` appends
`$remote_addr` after anything the client sent, which means a client cannot
displace its own address by sending a header of its own. The guarantee the
proxy must provide is the other half: only nginx may reach the backend. Bind
it to `::1`, and never expose port 9091,
because anything that can open a connection to the backend directly can set
`X-Forwarded-For` itself and choose the address it is rate-limited under (see
the security notes below).
`[server].trusted_proxies` turns that guarantee into a check rather than a
promise: with `trusted_proxies = ["::1", "127.0.0.1"]`, the forwarded address
is believed only when the connection itself comes from the loopback proxy, and
a request that arrives from anywhere else is measured by its real address.
A loopback proxy behind `nginx` uses exactly that list. An empty list never reads
the header at all: behind a proxy every client is then measured under the
proxy's own connection address and shares one rate-limit budget, so list the
proxy to measure each client by its forwarded address.
### Hardening the login
The engine rate-limits `/admin/login` itself (10 attempts per IP per 60 s;
see [the session and rate-limit rules](ARCHITECTURE.md#state-and-lifetime)),
but nginx is the durable line of defence. Add brute-force throttling (and,
optionally, an IP allowlist) at the proxy.
**1. A rate-limit zone** in the `http { }` context (e.g.
`/etc/nginx/conf.d/volumen.conf`):
```nginx
limit_req_zone $binary_remote_addr zone=volumen_login:10m rate=5r/m;
```
**2. A reusable proxy snippet** at `/etc/nginx/snippets/volumen-proxy.conf`:
```nginx
proxy_pass http://[::1]:9091;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
```
**3. Split the single proxy location** in the `server { }` block into three, namely
public API/media, the throttled login, and the rest of the admin:
```nginx
# Public API and uploaded media, open to everyone.
location ~ ^/(api/volumen|media)(/|$) {
include snippets/volumen-proxy.conf;
}
# Login: throttle brute-force attempts on top of the app-level limit.
location = /admin/login {
# Optional IP allowlist (uncomment to restrict):
# allow 203.0.113.0/24;
# deny all;
limit_req zone=volumen_login burst=10 nodelay;
include snippets/volumen-proxy.conf;
}
# The rest of the admin.
location ^~ /admin {
include snippets/volumen-proxy.conf;
}
```
nginx matches `= /admin/login` (exact) first, then `^~ /admin` (which stops
regex matching), then the `~` regex for API/media. Apply with
`sudo nginx -t && sudo systemctl reload nginx`.
### Security notes
- Bind to `::1` (or `127.0.0.1`); only nginx is public. TLS is terminated by
nginx. The production config sets `host = "::1"`.
- Session cookies are `HttpOnly` and `SameSite=Strict`, and gain the `Secure`
attribute when `[server].cookie_secure = true` or when `trust_proxy = true`.
The session secret signs them and survives restarts: by default the server
generates it into `secret.key` beside the users file, and `[admin].session_key`
(at least 64 bytes, mandatory in production) overrides that file.
- `[server].trusted_proxies` names the addresses whose `X-Forwarded-For` is
believed. A loopback-nginx deployment lists `["::1", "127.0.0.1"]`, or narrows
it to the proxy's address, so a client that reaches the backend directly cannot
choose the address it is rate-limited under. An empty list ignores the
forwarded header, which behind a proxy folds every client into one
rate-limit bucket.
- Keep `config.toml`, `users.toml`, `tokens.toml` and `secret.key` readable only
by the `volumen` user (mode `600`). Every write the server makes enforces this.
- To change a forgotten administrator password, reset it from the admin Settings
page as another admin, or rotate the account in `users.toml`. Never put a
password on the command line.
- The controls themselves, and their limits, are described in
[the architecture](ARCHITECTURE.md#state-and-lifetime): how a body is
sanitised before it is served, how a password is stored, and what the session
cookie does and does not guarantee.
## Upgrade
### From the admin panel
The Version panel in Settings shows the running release and, when the release
check has found a newer one, an "Update now" action. The update endpoint is
admin-only and CSRF-protected. On confirmation the server:
1. queries the latest release from the Gitea releases API
(`/api/v1/repos/petrbalvin/volumen/releases/latest`),
2. downloads the matching asset from
`https://sourcedock.dev/petrbalvin/volumen/releases/download/v<version>/volumen-<version>-<os>-<arch>`,
3. verifies the SHA-256 digest against the release's `checksums.txt`,
4. stages the new binary in the executable's directory and renames it over the
running one,
5. re-executes itself with the original command-line arguments, preserving the
process ID so systemd never observes a restart.
The response page polls `/healthz` until the server is back (up to three
minutes). Nothing is installed without an admin clicking through.
The self-update writes the running binary in place, so the service user needs
write access to that directory. With the standard layout
(`/usr/local/bin/volumen` owned by root, service running as `volumen`) the
rename fails; either grant the service user write access to the install
directory or use the manual path below. A checksum or download error in the
panel means the release asset could not be fetched for the running platform;
the state on disk is untouched.
### From the shell
```sh
sudo systemctl stop volumen
sudo install -m 0755 volumen-1.0.0-linux-amd64 /usr/local/bin/volumen
volumen version
sudo systemctl start volumen
volumen status # confirm: config, posts, users
```
The persistent state (`config.toml`, `users.toml`, posts, revisions) is
untouched by either path.
### Monitoring for updates
```sh
volumen check-update # human-readable
volumen check-update --json # machine-readable
```
Exit codes: `0` when the running version is the latest release, `1` when a
newer release exists, and `1` as well when the release API cannot be reached
(the cause is on stderr, so a monitoring check distinguishes the two by its
output rather than by the code); `2` is a usage error and nothing else.
## Rollback
There is no rehearsed rollback procedure for a running installation, and this
document does not pretend otherwise.
- **A bad upgrade** is undone by putting the previous binary back and
restarting: the release assets are versioned, so an older one is still on the
releases page, and the data is untouched by an upgrade. A restore from a
backup archive is the fallback when data changed as well.
- **A bad content change** does not need a rollback: every save archives the
previous version under `posts/.revisions/<slug>/`, and a delete is a move into
that archive, so the admin's History view restores either one.
- **A bad configuration change** is undone by editing the file and restarting;
a configuration that fails validation stops the process before it binds its
port, so a broken edit cannot half-start the service.
### Backups
All state is files, so any filesystem backup works. Volumen also ships its
own archive commands:
```sh
volumen export --out /backup/volumen-$(date +%F).tar.gz
volumen import /backup/volumen-2026-08-02.tar.gz
```
`export` packs the content directory (posts, media, `.revisions`) under
`posts/`, plus `users.toml`, `templates.toml` and `tokens.toml`, into a
`tar.gz` written at mode `600`; `--out` is the flag, and the default output
path is `volumen-backup.tar.gz` in the working directory. `config.toml` is not
in the archive: the import has no use for it and the session key it carries is
a credential that should not travel in a file copied around. `import` restores
the posts, users, templates and tokens into the locations from the config.
A restore is confined by construction: the archive's entries are matched
against the fixed `posts/`, `users.toml`, `templates.toml` and `tokens.toml`
names, a `posts/` path must be a post, a revision or a media file with an
allowed image extension, and every write goes through an `os.Root` opened on
the content directory, which refuses an escape through `..` or through a
symlink. The decompressed size is bounded at 512 MiB, so a compression bomb
cannot exhaust memory. `.toml` files are written with mode `600`, everything
else with `644`.
The same export and restore is available from the admin Settings page, where
both directions require the **admin** role, because the archive carries the
users file with its password hashes and the tokens file with its digests. The
admin path calls the same two functions as the CLI, so the archive and the
containment rules are one implementation, not two.
## Monitoring
After the first-run wizard, confirm the installation state:
```sh
volumen status # config, content dir, post and user counts
volumen status --json # machine-readable; exit code 1 on any issue
volumen doctor # config validation, post rendering, weak hashes
volumen doctor --json # machine-readable; exit code 1 when a check fails
volumen validate # content check: slugs, titles, aliases, rendering
sudo systemctl status volumen
journalctl -u volumen -f
```
`volumen status` reads the config and reports the post count (with a draft
count) and the number of users; with no accounts yet it names the wizard as
the next step. `volumen doctor` validates the configuration,
renders every post, and warns when a stored password hash uses scrypt
parameters below the current policy floor. The exit code follows the levels:
a missing or broken config, an unreadable content directory and an unreadable
users file fail the command with exit code 1, while an installation still
waiting for its first account, posts that fail to render
and weak stored hashes are warnings that leave the exit code at 0.
`volumen validate` reports
duplicate slugs, invalid slugs, missing titles, aliased collisions, and posts
that fail to render.
Those commands check a deployment. The build of the binary is checked by
`just gates`, which runs `build`, `fmt-check`, `vet`, `test` and `race` in one
pass; the release pipeline runs the same set without the race detector (see
[Pipeline](#pipeline)).
Logs go to stderr and therefore to journald under systemd. For structured
output, set the log format to JSON:
```toml
[server]
log_format = "json"
```
Each line is then one JSON object from `log/slog` (time, level, message, and
the structured attributes of the event), suitable for `journalctl -o json`,
Loki, or similar. The default `"text"` format is human-readable. Every request
is logged once when it finishes, with its method, path, status and duration,
under a 16-character request id; the same id is answered in the `X-Request-Id`
header and attached to every line the handlers write while serving that
request, so one id finds a request and everything it did in the journal. The
server's own protocol errors are written through the same logger rather than
straight to stderr.
Set `audit_log` in the configuration to keep a separate, append-only
JSON-lines record of administrative actions. Logins and logouts are not
recorded; post creation, edits, deletions and bulk actions are, together with
media deletions, user and token management, and backup imports. Each line is
one JSON object carrying the timestamp `ts` (RFC 3339, UTC), the acting `user`
and the `action` as its message, plus the `resource`, the client `ip` and a
`detail` object wherever the action knows them.
## Pipeline
Releases are built by `.gitea/workflows/release.yml`, which runs when a `v*`
tag is pushed. The branch flow is the one in
[CONTRIBUTING.md](../CONTRIBUTING.md): work lands on `development`,
`development` is merged into `main`, and the tag is cut on `main`. The build
happens at the tag, and the toolchain records the tag into the binary's build
information, so the version in the binary is right because of where the build
ran; nothing is injected, and each job derives the version from the tag itself
rather than receiving it from another job.
```mermaid
flowchart TD
subgraph gates["gates job, 10 minute timeout"]
direction TB
g1["validate the tag against the semver pattern"] --> g2["go build ./..."] --> g3["gofmt -l . prints nothing"] --> g4["go vet ./..."] --> g5["go fix -diff ./..."] --> g6["go test with a coverage profile"] --> g7["the coverage floor of 80 per cent"]
end
subgraph builds["build job, 25 minute timeout, six targets in one matrix"]
direction TB
b1["linux on amd64, arm64, loong64, riscv64"] --> b2["freebsd on amd64, arm64"]
b2 --> b3["CGO_ENABLED=0 build with -trimpath and -buildvcs into bin/volumen-VERSION-OS-ARCH"]
b3 --> b4["upload the artefact"] --> b5["linux/amd64 smoke test, the binary reports the tag and no +dirty"]
end
subgraph rel["release job, 15 minute timeout"]
direction TB
r1["download the artefacts"] --> r2["write checksums.txt over them"] --> r3["take the CHANGELOG section for the tag"] --> r4["create the release over the Gitea API"] --> r5["upload the six binaries and checksums.txt"]
end
tag["a v* tag is pushed"] --> gates
gates --> builds
builds --> rel
```
The gates job runs the same set as `just gates` minus the race detector:
build, format check, `go vet` together with `go fix -diff`, the suite, and the
coverage floor of 80 per cent. Race is deliberately absent: the runner is
shared with the forge, and `just gates` races the tree on the machine where
the tag is cut. A push or a pull request to `development` runs the same steps
without race through `.gitea/workflows/test.yml`, and
`.gitea/workflows/race.yml` runs the suite under the race detector when it is
dispatched by hand.
The release is the only publisher: nothing is deployed out of the repository,
and a production installation takes its binary from a release, through the
admin self-update (see [Upgrade](#upgrade)) or a manual install followed by a
service restart.
Each matrix entry builds one static binary named
`volumen-<version>-<os>-<arch>` for the version without its leading `v`, with
`-trimpath` and `-buildvcs=true`, uploads it, and, on linux/amd64 only, runs it
and requires the output of `volumen version` to contain the tag and not
`+dirty`. The release job then
writes `checksums.txt`, one `sha256sum` line per asset, takes the release
notes from the `CHANGELOG.md` section for the tag, creates the release over
the Gitea API, and uploads the six binaries with the checksums file. That
file is part of the update contract: the admin self-update refuses a download
whose SHA-256 does not match it.
The release carries no licence files of its own, because the repository holds
them: `LICENSE` for Volumen, and
[NOTICE.md](../NOTICE.md) for every module the binary
is compiled from. Gitea attaches the tag's own source archive to the release
beside the binaries, and that archive contains both.
+161
View File
@@ -0,0 +1,161 @@
# Development
How to work on **Volumen**.
## Prerequisites
- Go 1.27.1, the newest stable release; the `go` directive in `go.mod`
declares exactly that version, and nothing older builds the module.
- [just](https://github.com/casey/just) for the recipes.
- gcc, but only for the race detector: `just race` needs cgo. The build
itself is pure Go with `CGO_ENABLED=0`.
- Perl for the scripted recipe lines and `scripts/notices.pl`; the base
interpreter with builtins only is enough.
## Setup
```sh
git clone https://sourcedock.dev/petrbalvin/volumen.git
cd volumen
just build
```
`just run` starts the server against `./config.toml` and `./posts` on the
configured host and port (default `[::]:9091`); `just dev` binds it to
`127.0.0.1:9091`. `config.toml` and `users.toml` are git-ignored, because the
config holds secrets: on a fresh clone the server runs on the built-in
defaults and the overridden content directory. Both recipes pass
`-buildvcs=true`, because plain `go run` does not stamp the build, so the
reported version would be `(devel)`. Go has no built-in reload; restart the
process after code changes. The whole binary rebuilds in seconds, which
avoids waiting for a reload cycle.
## Recipes
Every recipe in the project's file, and what it does. Taken from the file
itself, so the names and the list match it exactly, in the order the file
declares them.
| Recipe | What it does |
|---|---|
| `just` | the `default` recipe, `just --list`: every recipe with its one-line summary |
| `just build` | compiles `cmd/volumen` into `bin/volumen` with `CGO_ENABLED=0`, `-trimpath` and `-buildvcs=true`, stripped, zero errors and zero warnings |
| `just test` | the full suite as CI runs it, with the 80 percent coverage floor |
| `just race` | the same suite under the race detector; the expensive one |
| `just unit ./internal/store 'TestName'` | a scoped run for iterating, cached, no race, no coverage |
| `just fuzz <target> <package>` | a time-boxed fuzz of one target in one package, never a gate |
| `just bench` | benchmarks with `-benchmem` and five counts, on an idle machine only |
| `just fmt` | gofmt over the tree, in place |
| `just fmt-check` | zero diff; prints nothing when everything is formatted |
| `just vet` | `go vet` and `go fix -diff` |
| `just gates` | the definition of done in one command: `build`, `fmt-check`, `vet`, `test`, `race`, in that order |
| `just clean` | removes `bin/` and `coverage.out` |
| `just install` | builds, then copies the binary into `~/.local/bin` |
| `just uninstall` | removes the installed binary |
| `just run` | runs the server against `./config.toml` and `./posts`, with the build stamped |
| `just dev` | the same, bound to `127.0.0.1:9091` |
`just fmt` and `just fmt-check` are the formatting authority, `just vet` the
static one, and the three of them plus `build`, `test` and `race` are the
whole gate. Style rules the tools cannot see (error wrapping, `log/slog`
only, no panics outside `main`, British English names and comments) are
written in [CONTRIBUTING.md](../CONTRIBUTING.md).
## Running a single test
```sh
go test -run TestName ./internal/store
```
Narrow selections do not apply the coverage floor (that lives in the `test`
recipe), so use them freely while iterating. Or stay on the recipe:
`just unit ./internal/store 'TestName'`. Add `-v` for the sub-test names,
`-race` when the change touches concurrency, and `-count=1` when a cached
result looks stale; the gate itself runs with `-count=1`, so a cached pass
never stands in for a fresh one.
The suite mirrors the package layout (`*_test.go` next to the code), shares
no global fixtures, and uses `t.TempDir()` and `net/http/httptest` rather
than a real network:
| Package | Covers |
|---|---|
| `cmd/volumen` | CLI dispatch and every subcommand against temp directories. |
| `internal/admin` | Login, CSRF, post CRUD, settings, media, roles. |
| `internal/app` | Server assembly, the route table, `/healthz`, `robots.txt`, media serving, rate-limit headers. |
| `internal/audit` | The JSON-lines audit log, and a disabled or unwritable one. |
| `internal/backup` | Archive round-trips, the restore allowlist and the decompression budget. |
| `internal/biblio` | The `refs` frontmatter into a numbered, linked reference list. |
| `internal/config` | Loading, merging, defaults, overrides, validation, and the example file matching the embedded template. |
| `internal/diff` | The line-based LCS diff behind the revision comparison view. |
| `internal/fediverse` | The `@user@host` handle rule. |
| `internal/identifiers` | The scholarly identifier rules: DOI syntax and normalisation, ORCID shape and ISO 7064 check digit. |
| `internal/feeds` | RSS, Atom, JSON Feed and sitemap output. |
| `internal/frontmatter` | TOML frontmatter parsing, key order and round-trips. |
| `internal/httpapi` | The public API over `httptest`: ETags, CORS, pagination, token writes, and the committed contract fixtures. |
| `internal/i18n` | The admin interface catalogue: English keys, Czech translations, plural rules. |
| `internal/imagefile` | The accepted extensions, the WebP, AVIF and SVG signatures, the detected type and the header dimensions. |
| `internal/markdown` | Rendering, sanitisation, the mathematics and diagram passes, the table of contents, and the golden corpus in `testdata/`. |
| `internal/password` | scrypt hashing and verification. |
| `internal/payloads` | Payload builders, filtering and validation. |
| `internal/post` | The Post model: parsing, metadata, rendering, cloning. |
| `internal/preview` | The signed share links for unpublished posts. |
| `internal/ratelimit` | The sliding-window limiter. |
| `internal/scheduler` | Due-post selection, the publish sweep and the interval loop. |
| `internal/session` | HMAC-signed cookies and middleware. |
| `internal/store` | Loading, saving, deleting posts, revisions, tombstones, media. |
| `internal/templates` | The `templates.toml` post templates. |
| `internal/tokens` | Minting, scopes, authentication and revocation. |
| `internal/tomlfile` | Atomic `0600` TOML writes and the shape helpers. |
| `internal/updater` | Release checks, version comparison and the verified self-update. |
| `internal/users` | `users.toml`, roles and last-admin safeguards. |
| `internal/version` | The release identity from `debug.ReadBuildInfo`. |
| `internal/web` | Middleware: gzip, the cross-origin gate, security headers, the CSP nonce, the request logger and the client IP. |
| `internal/webhooks` | Signed deliveries, retries, payload shapes. |
## Coverage
```sh
just test
go tool cover -func=coverage.out
```
The `total:` line is the number that matters, and it stays at 80 percent or
more; the `test` recipe fails below the floor.
## Benchmarks
```sh
just bench
```
The recipe is `go test -run '^$' -bench=. -benchmem -count=5 ./...` over the
whole module. Two benchmarks live in the tree, `BenchmarkRender` in
`internal/markdown` and `BenchmarkServer` in `internal/app`; the binding
measurement method is in [BENCHMARKING.md](BENCHMARKING.md). Benchmark on an
idle machine, and compare only runs made in one process against each other.
## Debugging the build
```sh
go build -gcflags='-m' ./... # inlining decisions
go build -gcflags='-S' ./... # what the compiler generated
```
## Continuous integration
Workflows live in `.gitea/workflows/` and run on the project's own runners.
They are written by hand rather than through `just`, but they enforce the same
set of gates, so a green `just gates` locally is the fastest way to a green
pipeline.
## Releases
Releases are cut by merging `development` into `main` and tagging `vX.Y.Z`. The
tag drives the release workflow, which builds the assets and publishes the
notes it extracted from `CHANGELOG.md`.
Dependency changes touch one more file: `NOTICE.md` in the root
reproduces the licence of every module the binary is compiled from, and
`perl scripts/notices.pl` writes it again after a module is added, removed or
upgraded.
+17
View File
@@ -0,0 +1,17 @@
module sourcedock.dev/petrbalvin/volumen
go 1.27.1
require (
github.com/microcosm-cc/bluemonday v1.0.27
golang.org/x/crypto v0.57.0
golang.org/x/text v0.42.0
sourcedock.dev/petrbalvin/interpres/v2 v2.0.0
sourcedock.dev/petrbalvin/scriptorium v1.0.1
)
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
golang.org/x/net v0.59.0 // indirect
)
+16
View File
@@ -0,0 +1,16 @@
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
sourcedock.dev/petrbalvin/interpres/v2 v2.0.0 h1:DkWtszKv4BTafedilEKY5QuTaOSv/J8gpcxHoi6oHnw=
sourcedock.dev/petrbalvin/interpres/v2 v2.0.0/go.mod h1:SCMhffAzwoPrmeHKHeAar7dKm58QKKMdL8qhaZmc4ds=
sourcedock.dev/petrbalvin/scriptorium v1.0.1 h1:CiPkG+Zu4PZvM0qlRCuxmxZ6c4iztG4v5gTwROh4thU=
sourcedock.dev/petrbalvin/scriptorium v1.0.1/go.mod h1:k46dgd6Es8eEvUjoTMP1tMup/paHvI12fsIsC6kxOEc=
+480
View File
@@ -0,0 +1,480 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"fmt"
"html"
"log/slog"
"net/http"
"net/netip"
"strconv"
"strings"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/audit"
"sourcedock.dev/petrbalvin/volumen/internal/backup"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/post"
"sourcedock.dev/petrbalvin/volumen/internal/ratelimit"
"sourcedock.dev/petrbalvin/volumen/internal/session"
"sourcedock.dev/petrbalvin/volumen/internal/store"
"sourcedock.dev/petrbalvin/volumen/internal/templates"
"sourcedock.dev/petrbalvin/volumen/internal/tokens"
"sourcedock.dev/petrbalvin/volumen/internal/users"
"sourcedock.dev/petrbalvin/volumen/internal/web"
"sourcedock.dev/petrbalvin/volumen/internal/webhooks"
)
// Content is the content surface the admin uses: every post, one post by
// slug, the write and delete paths, the revision archive and the media
// library. It is an interface rather than *store.Store so the handlers can
// be tested against a fake and a second backend stays conceivable.
type Content interface {
All() []*post.Post
Find(slug, lang string) *post.Post
Save(p *post.Post) (*post.Post, error)
Delete(slug, lang string) (*post.Post, bool, error)
Undelete(slug string) *post.Post
Revisions(slug string) []store.Revision
RevisionContent(slug, name string) string
RestoreRevision(p *post.Post, name string) *post.Post
InvalidateCache()
StoreUpload(originalName string, data []byte) (string, error)
MediaPath(name string) (string, error)
DeleteMedia(url string) bool
ListMedia() []store.Media
}
// Deps are the shared services the admin UI needs.
type Deps struct {
Config *config.Config
Store Content
Users *users.Users
Templates *templates.Store
Tokens *tokens.Store
Audit *audit.Log
LoginLim *ratelimit.LoginLimiter
Sessions *session.Store
Webhooks *webhooks.Manager
// PreviewKey signs the shareable preview links: the session secret
// the app layer resolved, from [admin].session_key or from the
// secret.key file it generated, so a default deployment offers
// preview links the way the configuration documents it. Empty means
// no link can be signed and none is offered.
PreviewKey string
// WebhooksFile is the admin-managed hook store the settings forms
// rewrite, and StaticWebhooks the hooks that came from config.toml
// and are read-only here. Together they are what the manager
// delivers; every mutation re-saves the file and refreshes the
// manager with the merge of the two.
WebhooksFile string
StaticWebhooks []webhooks.Webhook
Version string
OnEvent func(event string, payload map[string]any)
Backup backup.Options
// CheckUpdate returns the latest available version ("" when none),
// and SelfUpdate replaces the running binary; both are wired by the
// CLI layer and may be nil.
CheckUpdate func() (string, error)
SelfUpdate func() (target string, err error)
// UpdateInfo returns the newer version for the update banner, or ""
// when the running version is current.
UpdateInfo func() string
}
// Admin serves /admin routes.
type Admin struct {
deps Deps
renderer *Renderer
// trustedProxies are the peers whose X-Forwarded-For is believed.
// The configuration is validated before the admin is built, so a
// parse failure here cannot happen.
trustedProxies []netip.Prefix
}
// New builds the admin handler.
func New(deps Deps) (*Admin, error) {
renderer, err := NewRenderer()
if err != nil {
return nil, err
}
trusted, err := deps.Config.TrustedProxyPrefixes()
if err != nil {
return nil, err
}
if !deps.Config.Server.TrustProxy {
trusted = nil
}
return &Admin{deps: deps, renderer: renderer, trustedProxies: trusted}, nil
}
// SetUpdateHooks wires the version-check callbacks after construction.
func (a *Admin) SetUpdateHooks(check func() (string, error), selfUpdate func() (string, error)) {
a.deps.CheckUpdate = check
a.deps.SelfUpdate = selfUpdate
a.deps.UpdateInfo = func() string {
latest, err := check()
if err != nil || latest == "" || latest == a.deps.Version {
return ""
}
return latest
}
}
// Handler returns the admin route tree.
func (a *Admin) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /admin", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
})
// The interface sheets and fonts: public by design (the login page
// needs them before any session), confined to the embedded set.
mux.HandleFunc("GET /admin/assets/", web.AssetHandler)
mux.HandleFunc("GET /admin/login", a.handleLoginForm)
mux.HandleFunc("POST /admin/login", a.handleLogin)
mux.HandleFunc("GET /admin/twofactor", a.handleTwofactorForm)
mux.HandleFunc("POST /admin/twofactor", a.handleTwofactor)
mux.HandleFunc("POST /admin/logout", a.handleLogout)
a.registerSetupRoutes(mux)
a.registerPostRoutes(mux)
a.registerSettingsRoutes(mux)
a.registerMediaRoutes(mux)
// The subtree catch-all answers any /admin path no route above claims,
// so a mistyped URL meets the shell's own 404 page rather than the
// engine's bare text. It is the least specific pattern, so every
// registered route still wins, and it sits behind requireLogin so an
// anonymous visitor is sent to the sign-in screen first.
mux.HandleFunc("/admin/", a.requireLogin(a.handleNotFound))
return mux
}
// handleNotFound renders the admin 404 page inside the shell. Nothing
// about the request reaches the page beyond the interface strings, so the
// answer leaks nothing and carries the honest status.
func (a *Admin) handleNotFound(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
a.renderPage(w, r, "notfound.html", a.pageData(r), http.StatusNotFound)
}
// record appends an audit entry for one request, filling in the acting
// user and the client address so the log can answer "who, from where".
func (a *Admin) record(r *http.Request, action, resource string, detail map[string]any) {
a.deps.Audit.Record(audit.Entry{
User: a.currentUser(r),
Action: action,
Resource: resource,
Detail: detail,
IP: a.clientIP(r),
})
}
// clientIP resolves the request origin with proxy awareness.
func (a *Admin) clientIP(r *http.Request) string {
return web.ClientIP(r, a.trustedProxies)
}
// lang resolves the interface language for one request: the account's
// choice first, then the language cookie the choice set for the login
// screen, then the site language when it is one the UI ships, and
// English otherwise.
func (a *Admin) lang(r *http.Request, record *users.User) string {
if record != nil && record.Language != "" {
return record.Language
}
if c, err := r.Cookie(i18n.Cookie); err == nil {
if lang := i18n.Normalize(c.Value); lang != "" {
return lang
}
}
if lang := i18n.Normalize(a.deps.Config.Site.Language); lang != "" {
return lang
}
return "en"
}
// theme resolves the colour scheme for one request: the account's
// choice first, then the theme cookie the choice set for the login
// screen, then the default scheme.
func (a *Admin) theme(r *http.Request, record *users.User) string {
if record != nil && record.Theme != "" {
if web.ValidTheme(record.Theme) {
return record.Theme
}
}
if c, err := r.Cookie(web.ThemeCookie); err == nil && web.ValidTheme(c.Value) {
return c.Value
}
return web.DefaultTheme
}
// langFor resolves the interface language from the request alone,
// without a page context: the signed-in account's choice, the language
// cookie, the site language, then English.
func (a *Admin) langFor(r *http.Request) string {
return a.lang(r, a.deps.Users.Find(session.FromContext(r.Context()).Get("user")))
}
// tr translates an admin interface message in the request's language.
func (a *Admin) tr(r *http.Request, s string) string {
return i18n.Admin.T(a.langFor(r), s)
}
// trf translates an admin interface message with one value.
func (a *Admin) trf(r *http.Request, s, arg string) string {
return i18n.Admin.Tf(a.langFor(r), s, arg)
}
// trf2 translates an admin interface message with two values.
func (a *Admin) trf2(r *http.Request, s, first, second string) string {
return i18n.Admin.Tf2(a.langFor(r), s, first, second)
}
// pageData builds the shared template context for one request.
func (a *Admin) pageData(r *http.Request) *PageData {
sess := session.FromContext(r.Context())
username := sess.Get("user")
record := a.deps.Users.Find(username)
data := &PageData{
Config: a.deps.Config,
Path: r.URL.Path,
CSPNonce: web.Nonce(r.Context()),
Version: a.deps.Version,
Lang: a.lang(r, record),
Theme: a.theme(r, record),
CurrentUser: username,
CurrentUserRecord: record,
UsersExist: a.deps.Users.Any(),
CSRFToken: CSRFToken(sess),
IsLogin: strings.HasPrefix(r.URL.Path, "/admin/login") || r.URL.Path == "/admin/twofactor",
IsSetup: r.URL.Path == "/admin/setup",
NavPosts: r.URL.Path == "/admin/" || r.URL.Path == "/admin",
NavNew: r.URL.Path == "/admin/posts/new",
NavImport: r.URL.Path == "/admin/posts/import",
NavMedia: strings.HasPrefix(r.URL.Path, "/admin/media"),
NavSettings: strings.HasPrefix(r.URL.Path, "/admin/settings"),
}
if a.deps.UpdateInfo != nil {
data.UpdateAvailable = a.deps.UpdateInfo()
}
if record != nil {
data.IsAuthenticated = true
data.CurrentRole = record.Role
data.DisplayName = record.Name
data.UserPhoto = record.Photo
if data.DisplayName == "" {
data.DisplayName = record.Username
}
data.UserInitial = firstUpper(data.DisplayName, "?")
}
return data
}
// templateEscape neutralises the characters that would end an HTML
// comment or open a tag inside one.
func templateEscape(s string) string {
s = strings.ReplaceAll(s, "--", "- -")
return html.EscapeString(s)
}
// renderPage executes an admin page with HTTP semantics.
func (a *Admin) renderPage(w http.ResponseWriter, r *http.Request, page string, data *PageData, status int) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := a.renderer.Render(r.Context(), w, page, data); err != nil {
// The status line is already sent, and the page is the user's
// only signal, so it carries a note rather than nothing. The
// text is escaped: an error message quoting content must not
// close the comment and inject markup.
fmt.Fprintf(w, "<!-- template error: %s -->", templateEscape(err.Error()))
}
}
func (a *Admin) handleLoginForm(w http.ResponseWriter, r *http.Request) {
sess := session.FromContext(r.Context())
if sess.Get("user") != "" {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
// A session that already answered the password sits halfway in: the
// second step is where it belongs, not the first one again.
if sess.Get("totp_user") != "" {
http.Redirect(w, r, "/admin/twofactor", http.StatusSeeOther)
return
}
// A deployment with no accounts is one that has not been set up
// yet: the login screen would only be a door with nothing behind
// it, so the first visit goes to the wizard instead. It is a redirect,
// not a rewrite, so the wizard has its own honest URL.
if needed, broken := a.setupNeeded(); needed && broken == nil {
http.Redirect(w, r, "/admin/setup", http.StatusSeeOther)
return
}
a.renderPage(w, r, "login.html", a.pageData(r), http.StatusOK)
}
func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
ip := a.clientIP(r)
a.deps.LoginLim.Record(ip)
if blocked, retryAfter := a.deps.LoginLim.Blocked(ip); blocked {
data := a.pageData(r)
data.Error = i18n.Admin.N(a.lang(r, nil), "login.seconds", retryAfter)
data.RetryAfter = retryAfter
a.renderPage(w, r, "login.html", data, http.StatusTooManyRequests)
return
}
sess := session.FromContext(r.Context())
if !ValidateCSRF(r, sess) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
username := r.PostFormValue("username")
password := r.PostFormValue("password")
if user := a.deps.Users.Authenticate(username, password); user != nil {
// An account with the second factor answers one more question
// before it is in: the session holds the half-way name and no
// user, so nothing behind requireLogin opens yet.
if user.TotpSecret != "" {
sess.Set("totp_user", user.Username)
sess.Set("totp_at", strconv.FormatInt(time.Now().Unix(), 10))
http.Redirect(w, r, "/admin/twofactor", http.StatusSeeOther)
return
}
sess.Set("user", user.Username)
sess.Set("pv", sessionFingerprint(user.PasswordHash))
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
data := a.pageData(r)
data.Error = data.Tr("Invalid username or password.")
a.renderPage(w, r, "login.html", data, http.StatusUnauthorized)
}
// twofactorWindow bounds how long a password already answered may wait
// for its code before the whole sign-in starts over.
const twofactorWindow = 10 * time.Minute
// handleTwofactorForm shows the code prompt while a session holds a
// password-verified name; anything else goes back to the first step.
func (a *Admin) handleTwofactorForm(w http.ResponseWriter, r *http.Request) {
sess := session.FromContext(r.Context())
if sess.Get("user") != "" {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
if !a.twofactorPending(sess) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
a.renderPage(w, r, "twofactor.html", a.pageData(r), http.StatusOK)
}
func (a *Admin) twofactorPending(sess *session.Session) bool {
name := sess.Get("totp_user")
if name == "" {
return false
}
started, err := strconv.ParseInt(sess.Get("totp_at"), 10, 64)
if err != nil || time.Since(time.Unix(started, 0)) > twofactorWindow {
sess.Delete("totp_user")
sess.Delete("totp_at")
return false
}
return true
}
// handleTwofactor answers the second question: a six-digit code from
// the account's application, or one of its recovery codes. The same
// limiter guards it as the password, so guessing a code costs the same
// lockout as guessing a password.
func (a *Admin) handleTwofactor(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
ip := a.clientIP(r)
a.deps.LoginLim.Record(ip)
if blocked, retryAfter := a.deps.LoginLim.Blocked(ip); blocked {
data := a.pageData(r)
data.Error = i18n.Admin.N(a.lang(r, nil), "login.seconds", retryAfter)
data.RetryAfter = retryAfter
a.renderPage(w, r, "twofactor.html", data, http.StatusTooManyRequests)
return
}
sess := session.FromContext(r.Context())
if !ValidateCSRF(r, sess) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
if !a.twofactorPending(sess) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
name := sess.Get("totp_user")
code := strings.TrimSpace(r.PostFormValue("code"))
ok := false
if isTotpShape(code) {
ok = a.deps.Users.VerifyTotp(name, code, time.Now())
} else {
ok = a.deps.Users.ConsumeRecovery(name, code)
}
if !ok {
slog.Warn("admin: second factor refused", "user", name)
data := a.pageData(r)
data.Error = data.Tr("Wrong or expired code.")
a.renderPage(w, r, "twofactor.html", data, http.StatusUnauthorized)
return
}
user := a.deps.Users.Find(name)
if user == nil || user.TotpSecret == "" {
sess.Delete("totp_user")
sess.Delete("totp_at")
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
sess.Delete("totp_user")
sess.Delete("totp_at")
sess.Set("user", user.Username)
sess.Set("pv", sessionFingerprint(user.PasswordHash))
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
}
// isTotpShape reports whether the answer looks like an application
// code; everything else is tried as a recovery code.
func isTotpShape(code string) bool {
clean := strings.NewReplacer(" ", "", "-", "").Replace(code)
return len(clean) == 6 && strings.Trim(clean, "0123456789") == ""
}
func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
sess := session.FromContext(r.Context())
if !ValidateCSRF(r, sess) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
// Abandon rather than Clear: Clear would leave the session dirty, and
// the middleware's Save would then append a fresh cookie after
// Destroy's expiring one, which the browser applies last.
sess.Abandon()
a.deps.Sessions.Destroy(w)
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
}
+364
View File
@@ -0,0 +1,364 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"sourcedock.dev/petrbalvin/volumen/internal/audit"
"sourcedock.dev/petrbalvin/volumen/internal/backup"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/ratelimit"
"sourcedock.dev/petrbalvin/volumen/internal/session"
"sourcedock.dev/petrbalvin/volumen/internal/store"
"sourcedock.dev/petrbalvin/volumen/internal/templates"
"sourcedock.dev/petrbalvin/volumen/internal/tokens"
"sourcedock.dev/petrbalvin/volumen/internal/users"
)
var csrfRe = regexp.MustCompile(`name="_csrf" value="([a-f0-9]+)"`)
type fixture struct {
handler http.Handler
admin *Admin
users *users.Users
store *session.Store
storeObj *store.Store
contentDir string
events []string
payloads []map[string]any
}
func newFixture(t *testing.T) *fixture {
t.Helper()
return newFixtureSeeded(t, true)
}
// newFixtureSeeded builds the same handler over an empty users file
// when seeded is false: that is the first-run state, with no account
// and the wizard serving the admin screen.
func newFixtureSeeded(t *testing.T, seeded bool) *fixture {
t.Helper()
dir := t.TempDir()
content := filepath.Join(dir, "posts")
if err := os.MkdirAll(content, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
Port: config.PortUnset,
ContentDir: content,
UsersFile: filepath.Join(dir, "users.toml"),
})
// Preview links are signed with the session key, so the fixture sets
// one the way a real deployment does.
cfg.Admin.SessionKey = strings.Repeat("k", 64)
if err != nil {
t.Fatalf("config: %v", err)
}
st := store.New(store.Options{ContentDir: content, DefaultLang: "en", RevisionLimit: 10})
usersObj := users.New(cfg.UsersFile)
if seeded {
if _, err := usersObj.Add("admin", "correct-horse-9", "admin"); err != nil {
t.Fatalf("seed admin: %v", err)
}
}
f := &fixture{storeObj: st, contentDir: content}
a, err := New(Deps{
Config: cfg,
Store: st,
Users: usersObj,
Templates: templates.New(filepath.Join(dir, "templates.toml")),
Tokens: tokens.New(filepath.Join(dir, "tokens.toml")),
Backup: backup.Options{
ContentDir: content,
UsersFile: cfg.UsersFile,
TemplatesFile: cfg.TemplatesFile(),
TokensFile: cfg.TokensFile(),
},
Audit: audit.New(""),
LoginLim: ratelimit.NewLoginLimiter(),
Sessions: session.New(strings.Repeat("k", 64), 0, false),
Version: "0.0.0-test",
PreviewKey: strings.Repeat("k", 64),
OnEvent: func(event string, payload map[string]any) {
f.events = append(f.events, event)
f.payloads = append(f.payloads, payload)
},
})
if err != nil {
t.Fatalf("New: %v", err)
}
// Production mounts this handler inside the session middleware; the
// fixture mirrors that so cookies round-trip.
f.handler = a.deps.Sessions.Middleware(a.Handler())
f.admin = a
f.users = usersObj
f.store = a.deps.Sessions
return f
}
func (f *fixture) do(t *testing.T, req *http.Request) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
f.handler.ServeHTTP(rec, req)
return rec
}
func extractCSRF(t *testing.T, body string) string {
t.Helper()
m := csrfRe.FindStringSubmatch(body)
if m == nil {
t.Fatalf("no CSRF token in body:\n%s", body[:min(len(body), 500)])
}
return m[1]
}
func sessionCookie(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie {
t.Helper()
for _, cookie := range rec.Result().Cookies() {
if cookie.Name == session.CookieName {
return cookie
}
}
t.Fatal("no session cookie")
return nil
}
func TestLoginPageRenders(t *testing.T) {
f := newFixture(t)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"Sign in", "Volumen admin", "0.0.0-test"} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q", want)
}
}
if !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
t.Fatalf("content-type = %q", rec.Header().Get("Content-Type"))
}
}
func TestLoginRedirectsAuthenticatedUser(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/login", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
}
// login performs the full CSRF + credential flow and returns the
// authenticated session cookie.
func login(t *testing.T, f *fixture, username, secret string) *http.Cookie {
t.Helper()
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
csrf := extractCSRF(t, get.Body.String())
cookie := sessionCookie(t, get)
form := url.Values{"_csrf": {csrf}, "username": {username}, "password": {secret}}
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("login failed: code=%d body=%s", rec.Code, rec.Body.String())
}
return sessionCookie(t, rec)
}
func TestLoginRejectsWrongPassword(t *testing.T) {
f := newFixture(t)
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
csrf := extractCSRF(t, get.Body.String())
cookie := sessionCookie(t, get)
form := url.Values{"_csrf": {csrf}, "username": {"admin"}, "password": {"nope"}}
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("code = %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Invalid username or password.") {
t.Fatal("error message missing")
}
}
func TestLoginRejectsMissingCSRF(t *testing.T) {
f := newFixture(t)
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
cookie := sessionCookie(t, get)
form := url.Values{"username": {"admin"}, "password": {"correct-horse-9"}}
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403", rec.Code)
}
}
func TestLoginRateLimitRendersCountdown(t *testing.T) {
f := newFixture(t)
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
csrf := extractCSRF(t, get.Body.String())
cookie := sessionCookie(t, get)
var last *httptest.ResponseRecorder
for range 12 {
form := url.Values{"_csrf": {csrf}, "username": {"admin"}, "password": {"wrong"}}
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
last = f.do(t, req)
}
if last.Code != http.StatusTooManyRequests {
t.Fatalf("code = %d, want 429", last.Code)
}
if !strings.Contains(last.Body.String(), "Auto-unlock in") {
t.Fatal("lockout message missing")
}
if !strings.Contains(last.Body.String(), `id="lockout-countdown"`) {
t.Fatal("countdown element missing")
}
}
func TestLogout(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
// Grab a fresh CSRF token via the session cookie's page.
req := httptest.NewRequest(http.MethodGet, "/admin/login", nil)
req.AddCookie(cookie)
// Authenticated users are redirected; get the CSRF from the session
// store directly instead.
sess := f.store.Load(req)
csrf := CSRFToken(sess)
form := url.Values{"_csrf": {csrf}}
logoutReq := httptest.NewRequest(http.MethodPost, "/admin/logout", strings.NewReader(form.Encode()))
logoutReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
logoutReq.AddCookie(cookie)
rec := f.do(t, logoutReq)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
}
func TestBareAdminRedirects(t *testing.T) {
f := newFixture(t)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin", nil))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
}
// An /admin path no route claims meets the shell's own 404 page, not the
// engine's bare text, and an anonymous visitor is still sent to the
// sign-in screen first.
func TestAdminNotFound(t *testing.T) {
f := newFixture(t)
anon := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/no-such-page", nil))
if anon.Code != http.StatusSeeOther || anon.Header().Get("Location") != "/admin/login" {
t.Fatalf("anonymous: code=%d location=%q", anon.Code, anon.Header().Get("Location"))
}
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/no-such-page", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Fatalf("content type = %q", ct)
}
body := rec.Body.String()
if !strings.Contains(body, "Page not found") {
t.Fatalf("body lacks the 404 heading:\n%s", body[:min(len(body), 500)])
}
if !strings.Contains(body, `<html`) || !strings.Contains(body, `class="shell"`) {
t.Fatalf("body is not rendered in the shell")
}
post := httptest.NewRequest(http.MethodPost, "/admin/no-such-page", strings.NewReader(""))
post.AddCookie(cookie)
if rec := f.do(t, post); rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "<html") {
t.Fatalf("POST: code=%d", rec.Code)
}
}
func TestPasswordError(t *testing.T) {
if key, n := PasswordError("", 10, 1024); key != "New password cannot be empty." || n != 0 {
t.Fatalf("empty password = %q, %d", key, n)
}
if key, n := PasswordError("short", 10, 1024); key != "password.min" || n != 10 {
t.Fatalf("short password = %q, %d", key, n)
}
if key, n := PasswordError(strings.Repeat("x", 2000), 10, 1024); key != "password.max" || n != 1024 {
t.Fatalf("long password = %q, %d", key, n)
}
if key, n := PasswordError("password123", 10, 1024); key != "This password is too common." || n != 0 {
t.Fatalf("common password = %q, %d", key, n)
}
if key, n := PasswordError("a genuinely unique passphrase", 10, 1024); key != "" || n != 0 {
t.Fatalf("valid password = %q, %d", key, n)
}
}
func TestFirstUpper(t *testing.T) {
if got := firstUpper("petr", "?"); got != "P" {
t.Fatalf("got = %q", got)
}
if got := firstUpper("", "?"); got != "?" {
t.Fatalf("got = %q", got)
}
}
func TestHumanSize(t *testing.T) {
cases := map[int64]string{
0: "",
512: "1 kB",
10 * 1024: "10 kB",
1024 * 1024: "1.0 MB",
5 << 20: "5.0 MB",
1536 * 1024: "1.5 MB",
}
for in, want := range cases {
if got := humanSize(in); got != want {
t.Errorf("humanSize(%d) = %q, want %q", in, got, want)
}
}
}
// A post-template body containing "</script>" must not be able to end
// the script element the JSON literal is embedded in.
func TestTemplatesJSONEscapesScriptClose(t *testing.T) {
list := []tplOption{{
Name: "s", Title: "T", Slug: "s", Tags: []string{},
Body: `Use <script>document.write("x")</script> carefully`,
}}
out := string(templatesJSON(list))
if strings.Contains(out, "</script>") {
t.Fatalf("literal script close survived: %s", out)
}
if !strings.Contains(out, `\u003c/script>`) {
t.Fatalf("expected unicode escapes: %s", out)
}
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"net/http"
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
"sourcedock.dev/petrbalvin/volumen/internal/session"
)
// commonPasswords is the blocklist of trivially guessable passwords.
// This is the policy every admin password change goes through.
var commonPasswords = map[string]bool{
"password": true, "password1": true, "password123": true,
"123456": true, "12345678": true, "123456789": true,
"qwerty": true, "qwerty123": true, "letmein": true, "iloveyou": true,
"admin": true, "admin123": true, "welcome": true, "welcome1": true,
"monkey": true, "dragon": true, "football": true, "baseball": true,
"sunshine": true, "princess": true, "abc123": true, "111111": true,
"123123": true, "1q2w3e4r": true, "passw0rd": true, "trustno1": true,
"changeme": true, "secret": true, "secret123": true, "test": true,
"test123": true, "guest": true, "master": true, "000000": true,
"696969": true, "qwertyuiop": true, "superman": true, "batman": true,
"jordan": true, "harley": true, "hunter": true, "hunter2": true,
"shadow": true, "michael": true, "jennifer": true, "abcdef": true,
"abcdefg": true,
}
// PasswordError validates a newly chosen password and reports the
// first problem as a catalogue key. The key is either a plain sentence or
// the id of a plural message whose numeral n is the offending length;
// "" with n 0 means accepted.
func PasswordError(password string, minLength, maxLength int) (string, int) {
if strings.TrimSpace(password) == "" {
return "New password cannot be empty.", 0
}
length := len([]rune(password))
if length < minLength {
return "password.min", minLength
}
if length > maxLength {
return "password.max", maxLength
}
normalized := strings.ToLower(norm.NFKC.String(password))
if commonPasswords[normalized] {
return "This password is too common.", 0
}
return "", 0
}
// CSRFToken returns (and lazily creates) the CSRF token stored in the
// session.
func CSRFToken(sess *session.Session) string {
token := sess.Get("csrf")
if token == "" {
token = newTokenHex(32)
sess.Set("csrf", token)
}
return token
}
// sessionFingerprint derives the value the session carries to bind it to
// one password: it changes whenever the account's hash changes, so a
// password change or an admin reset retires every cookie issued before
// it. It is a digest of the stored hash, never of the password, and
// carries too few bits to help anyone invert the hash.
func sessionFingerprint(storedHash string) string {
sum := sha256.Sum256([]byte("volumen-session-v1:" + storedHash))
return hex.EncodeToString(sum[:8])
}
// ValidateCSRF compares the form's _csrf field against the session
// token in constant time.
func ValidateCSRF(r *http.Request, sess *session.Session) bool {
token := r.PostFormValue("_csrf")
sessionToken := sess.Get("csrf")
if token == "" || sessionToken == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(sessionToken), []byte(token)) == 1
}
func newTokenHex(nBytes int) string {
buf := make([]byte, nBytes)
if _, err := rand.Read(buf); err != nil {
return ""
}
return hex.EncodeToString(buf)
}
// firstUpper returns the uppercased first rune, or fallback.
func firstUpper(s, fallback string) string {
for _, r := range s {
if unicode.IsSpace(r) {
continue
}
return string(unicode.ToUpper(r))
}
return fallback
}
+63
View File
@@ -0,0 +1,63 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"fmt"
"net/http"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/store"
)
func (a *Admin) registerMediaRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /admin/media", a.requireLogin(a.handleMediaLibrary))
mux.HandleFunc("POST /admin/media/{name}/delete", a.requireLogin(a.handleMediaDelete))
}
func (a *Admin) handleMediaLibrary(w http.ResponseWriter, r *http.Request) {
data := a.pageData(r)
items := a.deps.Store.ListMedia()
data.MediaItems = mediaRows(items)
data.MediaTotal = humanSize(totalSize(items))
data.Crumbs = []Crumb{{Label: "Media", IsLast: true, UI: true}}
a.renderPage(w, r, "media.html", data, http.StatusOK)
}
// totalSize sums the byte sizes of the media library.
func totalSize(items []store.Media) int64 {
var total int64
for _, item := range items {
total += item.Size
}
return total
}
// humanSize formats a byte count for the library summary: kilobytes
// below a megabyte (rounded up, so nothing reads as zero), megabytes
// above it, empty for an empty library.
func humanSize(total int64) string {
switch {
case total <= 0:
return ""
case total < 1024*1024:
kb := (total + 1023) / 1024
return fmt.Sprintf("%d kB", kb)
default:
return fmt.Sprintf("%.1f MB", float64(total)/(1024*1024))
}
}
func (a *Admin) handleMediaDelete(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
name := r.PathValue("name")
if !a.deps.Store.DeleteMedia("/media/" + strings.TrimPrefix(name, "/")) {
http.Error(w, "File not found", http.StatusNotFound)
return
}
a.record(r, "media.deleted", fmt.Sprintf("/media/%s", name), nil)
http.Redirect(w, r, "/admin/media", http.StatusSeeOther)
}
+160
View File
@@ -0,0 +1,160 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"fmt"
"net/http"
"path/filepath"
"regexp"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/diff"
)
// unsafeSlugRe strips anything that is not safe in a header value.
var unsafeSlugRe = regexp.MustCompile(`[^a-z0-9._-]`)
// attachmentName reduces a path segment to a safe Content-Disposition
// filename.
func attachmentName(value string) string { return unsafeSlugRe.ReplaceAllString(value, "") }
func (a *Admin) handleDownload(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
p := a.deps.Store.Find(slug, "")
if p == nil {
http.NotFound(w, r)
return
}
content, err := p.ToFile()
if err != nil {
http.Error(w, "export failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Header().Set("Content-Disposition",
fmt.Sprintf(`attachment; filename="%s.md"`, attachmentName(slug)))
fmt.Fprint(w, content)
}
func (a *Admin) handleHistory(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
p := a.deps.Store.Find(slug, "")
if p == nil {
http.NotFound(w, r)
return
}
revisions := a.deps.Store.Revisions(slug)
rows := make([]revisionRow, 0, len(revisions))
for _, rev := range revisions {
rows = append(rows, newRevisionRow(rev))
}
heading := p.Title()
if heading == "" {
heading = slug
}
data := a.pageData(r)
data.Slug = slug
data.Heading = heading
data.Revisions = rows
data.Post = newEditorPost(p)
data.Crumbs = []Crumb{
{Label: "Posts", Href: "/admin/", UI: true},
{Label: heading, Href: "/admin/posts/" + slug + "/edit"},
{Label: "History", IsLast: true, UI: true},
}
a.renderPage(w, r, "history.html", data, http.StatusOK)
}
func (a *Admin) handleHistoryDownload(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
name := r.PathValue("name")
content := a.deps.Store.RevisionContent(slug, name)
if content == "" {
http.NotFound(w, r)
return
}
// The revision name is a server-generated stamp, but it arrives from
// the URL: the value is reduced to what cannot end the quoted string
// (a quote, a backslash, a control byte). Unlike attachmentName this
// keeps the stamp's uppercase T and Z.
safeName := strings.Map(func(r rune) rune {
if r == '"' || r == '\\' || r < 0x20 || r == 0x7f {
return -1
}
return r
}, filepath.Base(name))
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Header().Set("Content-Disposition",
fmt.Sprintf(`attachment; filename="%s-%s"`, attachmentName(slug), safeName))
fmt.Fprint(w, content)
}
// handleHistoryDiff compares one archived revision with the current
// content, so the editor can judge what a restore would change before
// committing to it.
func (a *Admin) handleHistoryDiff(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
name := r.PathValue("name")
p := a.deps.Store.Find(slug, "")
if p == nil {
http.NotFound(w, r)
return
}
revision := a.deps.Store.RevisionContent(slug, name)
if revision == "" {
http.NotFound(w, r)
return
}
current, err := p.ToFile()
if err != nil {
http.Error(w, "export failed", http.StatusInternalServerError)
return
}
var when string
for _, rev := range a.deps.Store.Revisions(slug) {
if rev.Name == name {
when = rev.When
break
}
}
heading := p.Title()
if heading == "" {
heading = slug
}
data := a.pageData(r)
data.Slug = slug
data.Heading = heading
data.DiffName = name
data.DiffWhen = when
data.DiffChunks = diff.Chunks(revision, current, 3)
data.Post = newEditorPost(p)
data.Crumbs = []Crumb{
{Label: "Posts", Href: "/admin/", UI: true},
{Label: heading, Href: "/admin/posts/" + slug + "/edit"},
{Label: "History", Href: "/admin/posts/" + slug + "/history", UI: true},
{Label: "Changes", IsLast: true, UI: true},
}
a.renderPage(w, r, "diff.html", data, http.StatusOK)
}
func (a *Admin) handleHistoryRestore(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
slug := r.PathValue("slug")
name := r.PathValue("name")
p := a.deps.Store.Find(slug, "")
if p == nil {
http.NotFound(w, r)
return
}
if a.deps.Store.RestoreRevision(p, name) == nil {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/admin/posts/"+slug+"/edit?restored=1", http.StatusSeeOther)
}
// --- uploads and static SVGs ------------------------------------------------
+92
View File
@@ -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 admin
import (
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
"sourcedock.dev/petrbalvin/volumen/internal/post"
)
func (a *Admin) handleImportForm(w http.ResponseWriter, r *http.Request) {
data := a.pageData(r)
data.Crumbs = []Crumb{
{Label: "Posts", Href: "/admin/", UI: true},
{Label: "Import", IsLast: true, UI: true},
}
a.renderPage(w, r, "import.html", data, http.StatusOK)
}
func (a *Admin) handleImport(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
data := a.pageData(r)
data.Crumbs = []Crumb{
{Label: "Posts", Href: "/admin/", UI: true},
{Label: "Import", IsLast: true, UI: true},
}
fail := func(msg string) {
data.Error = i18n.Admin.T(data.Lang, msg)
a.renderPage(w, r, "import.html", data, http.StatusUnprocessableEntity)
}
file, header, err := r.FormFile("file")
if err != nil {
fail("No file selected.")
return
}
defer file.Close()
if !strings.HasSuffix(strings.ToLower(header.Filename), ".md") {
fail("Only .md files are accepted.")
return
}
raw, err := readLimited(file, int64(a.deps.Config.Admin.MaxUploadBytes))
if err != nil {
fail("File is too large.")
return
}
content := string(raw)
p, err := post.Parse(content)
if err != nil {
fail(i18n.Admin.Tf(data.Lang, "The file could not be read as a post: %s", err.Error()))
return
}
if p.Slug() == "" {
base := strings.ToLower(filepath.Base(header.Filename))
stem := strings.TrimSuffix(base, filepath.Ext(base))
p.Metadata.Set("slug", strings.ReplaceAll(stem, " ", "-"))
}
if p.Lang() == "" {
p.Metadata.Set("lang", a.deps.Config.Site.Language)
}
if err := payloads.CreationError(p, a.deps.Store, nil); err != nil {
fail(err.Error())
return
}
if _, err := a.deps.Store.Save(p); err != nil {
fail("Import failed.")
return
}
http.Redirect(w, r, "/admin/posts/"+p.Slug()+"/edit", http.StatusSeeOther)
}
// readLimited reads at most limit+1 bytes so callers can detect
// oversize uploads.
func readLimited(r io.Reader, limit int64) ([]byte, error) {
raw, err := io.ReadAll(io.LimitReader(r, limit+1))
if err != nil {
return nil, err
}
if int64(len(raw)) > limit {
return nil, fmt.Errorf("payload too large")
}
return raw, nil
}
+642
View File
@@ -0,0 +1,642 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/biblio"
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/markdown"
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
"sourcedock.dev/petrbalvin/volumen/internal/post"
"sourcedock.dev/petrbalvin/volumen/internal/preview"
"sourcedock.dev/petrbalvin/volumen/internal/session"
"sourcedock.dev/petrbalvin/volumen/internal/web"
)
// registerPostRoutes mounts the post, preview, import and upload
// endpoints. Literal paths are registered before {slug} patterns.
func (a *Admin) registerPostRoutes(mux *http.ServeMux) {
// The exact path, not a subtree: an unknown URL under /admin/ must be
// a 404 rather than a dashboard.
mux.HandleFunc("GET /admin/{$}", a.requireLogin(a.handleDashboard))
mux.HandleFunc("GET /admin/posts/exists", a.requireLogin(a.handleExists))
mux.HandleFunc("GET /admin/posts/new", a.requireLogin(a.handleNewForm))
mux.HandleFunc("GET /admin/posts/import", a.requireLogin(a.handleImportForm))
mux.HandleFunc("POST /admin/posts/import", a.requireLogin(a.handleImport))
mux.HandleFunc("POST /admin/posts/bulk", a.requireLogin(a.handleBulk))
mux.HandleFunc("POST /admin/posts", a.requireLogin(a.handleCreate))
mux.HandleFunc("POST /admin/preview", a.requireLogin(a.handlePreview))
mux.HandleFunc("POST /admin/uploads", a.requireLogin(a.handleUpload))
mux.HandleFunc("GET /admin/posts/{slug}/download", a.requireLogin(a.handleDownload))
mux.HandleFunc("GET /admin/posts/{slug}/history", a.requireLogin(a.handleHistory))
mux.HandleFunc("GET /admin/posts/{slug}/history/{name}", a.requireLogin(a.handleHistoryDownload))
mux.HandleFunc("GET /admin/posts/{slug}/history/{name}/diff", a.requireLogin(a.handleHistoryDiff))
mux.HandleFunc("POST /admin/posts/{slug}/history/{name}/restore", a.requireLogin(a.handleHistoryRestore))
mux.HandleFunc("GET /admin/posts/{slug}/edit", a.requireLogin(a.handleEditForm))
mux.HandleFunc("POST /admin/posts/{slug}/delete", a.requireLogin(a.handleDelete))
mux.HandleFunc("POST /admin/posts/{slug}/undelete", a.requireLogin(a.handleUndelete))
mux.HandleFunc("POST /admin/posts/{slug}/duplicate", a.requireLogin(a.handleDuplicate))
mux.HandleFunc("GET /admin/posts/{slug}/preview-link", a.requireLogin(a.handlePreviewLink))
mux.HandleFunc("POST /admin/posts/{slug}", a.requireLogin(a.handleUpdate))
mux.HandleFunc("GET /admin/icon.svg", a.handleIcon)
}
// requireLogin redirects unauthenticated requests to the login form.
func (a *Admin) requireLogin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := session.FromContext(r.Context())
username := sess.Get("user")
record := a.deps.Users.Find(username)
if username == "" || record == nil {
if username != "" {
sess.Clear()
}
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// The session is bound to the password it was issued under: a
// change (the owner's or an admin reset) retires every cookie
// still in the wild, which is what "change the password" has to
// mean for a compromised account.
if sess.Get("pv") != sessionFingerprint(record.PasswordHash) {
sess.Clear()
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Everything logged from here on names the account it happened
// for.
ctx := web.WithLogger(r.Context(), web.Logger(r.Context()).With("user", username))
next(w, r.WithContext(ctx))
}
}
// maxBackupImportBytes bounds the settings import request. A backup
// archive legitimately exceeds max_upload_bytes (it carries every post
// and image), so it gets the same budget backup.Restore enforces on the
// decompressed side.
const maxBackupImportBytes = 512 << 20
// requestLimit is the byte bound on one admin POST body.
func (a *Admin) requestLimit(r *http.Request) int64 {
if r.URL.Path == "/admin/settings/import" {
return maxBackupImportBytes + 1<<20
}
return int64(a.deps.Config.Admin.MaxUploadBytes) + 1<<20
}
// requireCSRF validates the form token and writes the error response
// itself when invalid.
func (a *Admin) requireCSRF(w http.ResponseWriter, r *http.Request) bool {
// The body is bounded before parsing: ParseMultipartForm's argument
// is only the in-memory threshold, and net/http drains the rest of a
// multipart body to temp files on disk whatever the threshold says.
// Wrapping the body also lifts ParseForm's internal 10 MiB urlencoded
// cap, so this limit is the one that applies.
r.Body = http.MaxBytesReader(w, r.Body, a.requestLimit(r))
var parseErr error
if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/") {
parseErr = r.ParseMultipartForm(32 << 20)
} else {
// ParseMultipartForm would call ParseForm internally, swallow its
// error and leave the body consumed, so the content type decides
// which parser runs.
parseErr = r.ParseForm()
}
if parseErr != nil {
if _, ok := errors.AsType[*http.MaxBytesError](parseErr); ok {
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
return false
}
if !errors.Is(parseErr, http.ErrNotMultipart) {
http.Error(w, "bad form", http.StatusBadRequest)
return false
}
// A body that claims a multipart type but is not parseable as one
// falls through; the token check rejects.
}
if !ValidateCSRF(r, session.FromContext(r.Context())) {
http.Error(w, a.tr(r, "Invalid CSRF token"), http.StatusForbidden)
return false
}
return true
}
func (a *Admin) currentUser(r *http.Request) string {
return session.FromContext(r.Context()).Get("user")
}
func (a *Admin) handleDashboard(w http.ResponseWriter, r *http.Request) {
notice := ""
if bulkAction := r.URL.Query().Get("bulk"); bulkAction == "delete" ||
bulkAction == "draft" || bulkAction == "publish" {
if n, err := strconv.Atoi(r.URL.Query().Get("n")); err == nil && n > 0 {
id := map[string]string{
"delete": "posts.deleted", "draft": "posts.drafted", "publish": "posts.published",
}[bulkAction]
notice = i18n.Admin.N(a.langFor(r), id, n)
}
}
a.renderPage(w, r, "list.html", a.dashboardData(r, notice), http.StatusOK)
}
// dashboardData builds the dashboard page: one card per publication, the
// language versions merged into a group that the card can switch between,
// and the counters, the recent list and the tag cloud over the same set.
func (a *Admin) dashboardData(r *http.Request, notice string) *PageData {
posts := a.allPostsSorted()
lang := a.langFor(r)
groups := groupPosts(posts)
display := make([]*post.Post, 0, len(groups))
for _, group := range groups {
display = append(display, pickDisplay(group, lang))
}
cards := make([]postCard, 0, len(groups))
stats := dashboardStats{}
var recent []recentPost
type pending struct {
post *post.Post
due time.Time
}
var upcoming []pending
for i, group := range groups {
p := display[i]
card := newPostCard(p)
if len(group) > 1 {
variants := make([]postVariant, 0, len(group))
var slugs []string
seenSlug := map[string]bool{}
for _, member := range group {
variants = append(variants, newPostVariant(member))
if !seenSlug[member.Slug()] {
seenSlug[member.Slug()] = true
slugs = append(slugs, member.Slug())
}
}
slices.SortFunc(variants, func(x, y postVariant) int {
return strings.Compare(x.Lang, y.Lang)
})
card.Variants = variants
card.VariantsJS = variantsJSON(variants)
// One selection acts on the whole publication: the bulk
// form carries every variant slug, split by commas.
card.GroupSlugs = strings.Join(slugs, ",")
}
if card.GroupSlugs == "" {
card.GroupSlugs = p.Slug()
}
cards = append(cards, card)
switch p.Status() {
case post.StatusDraft:
stats.Drafts++
case post.StatusScheduled:
stats.Scheduled++
if due, ok := p.DueAt(); ok {
upcoming = append(upcoming, pending{p, due})
}
default:
stats.Published++
if len(recent) < 3 {
recent = append(recent, recentPost{
Slug: p.Slug(),
Title: p.Title(),
DateString: p.DateString(),
})
}
}
}
stats.Total = len(cards)
stats.Recent = recent
slices.SortStableFunc(upcoming, func(x, y pending) int {
return x.due.Compare(y.due)
})
for _, entry := range upcoming {
if len(stats.Upcoming) >= 5 {
break
}
when := entry.due.Format("2006-01-02")
if h, m := entry.due.Hour(), entry.due.Minute(); h != 0 || m != 0 {
when = entry.due.Format("2006-01-02 15:04")
}
stats.Upcoming = append(stats.Upcoming, scheduledPost{
Slug: entry.post.Slug(),
Title: entry.post.Title(),
When: when,
})
}
var tagCounts []tagCount
for _, entry := range payloads.BuildTagCounts(display) {
tagCounts = append(tagCounts, tagCount{Name: entry.Name, Count: entry.Count})
}
data := a.pageData(r)
data.Posts = cards
data.Stats = stats
data.TagCounts = tagCounts
data.Q = strings.TrimSpace(r.URL.Query().Get("q"))
data.Notice = notice
data.Crumbs = []Crumb{{Label: "Posts", IsLast: true, UI: true}}
return data
}
func (a *Admin) allPostsSorted() []*post.Post {
posts := a.deps.Store.All()
sorted := slices.Clone(posts)
slices.SortStableFunc(sorted, func(x, y *post.Post) int {
return strings.Compare(y.DateString(), x.DateString())
})
return sorted
}
// handleExists answers the slug-availability check of the editor's slug
// field.
func (a *Admin) handleExists(w http.ResponseWriter, r *http.Request) {
slug := r.URL.Query().Get("slug")
if slug == "" {
writeAdminJSON(w, http.StatusOK, map[string]any{"available": true, "slug": ""})
return
}
existing := a.deps.Store.Find(slug, "")
if existing == nil || (r.URL.Query().Get("exclude") != "" &&
existing.Slug() == r.URL.Query().Get("exclude")) {
writeAdminJSON(w, http.StatusOK, map[string]any{"available": true, "slug": slug})
return
}
writeAdminJSON(w, http.StatusOK, map[string]any{
"available": false, "slug": slug, "title": existing.Title(),
})
}
// editorData fills the shared editor context for new and edit forms.
func (a *Admin) editorData(r *http.Request, mode string, p *post.Post, errorMsg string) *PageData {
data := a.pageData(r)
// The shared validation messages are catalogue keys; an unknown
// message falls back to itself, so nothing breaks untranslated.
data.Error = i18n.Admin.T(data.Lang, errorMsg)
data.Restored = r.URL.Query().Get("restored") != ""
data.Duplicated = r.URL.Query().Get("duplicated") != ""
data.AuthorPlaceholder = i18n.Admin.T(data.Lang, "Author name")
if record := data.CurrentUserRecord; record != nil && record.Name != "" {
data.AuthorPlaceholder = record.Name
}
data.IsNew = mode == "new"
data.IsEdit = mode == "edit"
view := newEditorPost(p)
// The author falls back to the current user record, and the fediverse
// handle to the user record and then to the site.
if view.Author == "" {
if record := data.CurrentUserRecord; record != nil {
view.Author = record.Name
} else {
view.Author = data.CurrentUser
}
}
if view.FediverseCreator == "" {
view.FediverseCreator = a.deps.Config.Site.FediverseCreator
if record := data.CurrentUserRecord; record != nil && record.FediverseCreator != "" {
view.FediverseCreator = record.FediverseCreator
}
}
// The author's ORCID rides on the account: a new post carries it
// unless its own frontmatter names another identifier.
if view.ORCID == "" {
if record := data.CurrentUserRecord; record != nil {
view.ORCID = record.Orcid
}
}
data.Post = view
// The page head's API link carries a preview token, so it opens a
// draft as well as a published post. The token is signed for a week,
// far longer than an editor tab stays open.
data.PreviewToken = preview.Token(view.Slug, a.deps.PreviewKey, time.Now())
// The default excerpt placeholder is interface copy; a derived
// excerpt (the post's own first paragraph) is content and passes
// through untranslated.
if view.ExcerptPlaceholder == "Short summary for listings and previews" {
view.ExcerptPlaceholder = i18n.Admin.T(data.Lang, view.ExcerptPlaceholder)
}
if data.IsNew {
options := tplOptions(a.deps.Templates.All())
data.PostTemplates = options
data.TemplatesJSON = templatesJSON(options)
data.Crumbs = []Crumb{
{Label: "Posts", Href: "/admin/", UI: true},
{Label: "New post", IsLast: true, UI: true},
}
} else {
label := view.Title
if strings.TrimSpace(label) == "" {
label = i18n.Admin.T(data.Lang, "Untitled")
}
data.Crumbs = []Crumb{
{Label: "Posts", Href: "/admin/", UI: true},
{Label: label, IsLast: true},
}
}
return data
}
func (a *Admin) handleNewForm(w http.ResponseWriter, r *http.Request) {
p := post.New(frontmatter.NewMeta(), "")
p.Metadata.Set("lang", a.deps.Config.Site.Language)
a.renderPage(w, r, "form.html", a.editorData(r, "new", p, ""), http.StatusOK)
}
func (a *Admin) handleEditForm(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
existing := a.deps.Store.Find(slug, "")
if existing == nil {
http.NotFound(w, r)
return
}
a.renderPage(w, r, "form.html", a.editorData(r, "edit", existing, ""), http.StatusOK)
}
// formMap flattens the request form into a plain string map.
func formMap(r *http.Request) map[string]string {
out := map[string]string{}
for key, values := range r.PostForm {
if len(values) > 0 {
out[key] = values[0]
}
}
return out
}
func (a *Admin) handleCreate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
p, err := payloads.PostFromParams(formMap(r), nil)
if err != nil {
a.renderPage(w, r, "form.html", a.editorData(r, "new", p, err.Error()), http.StatusUnprocessableEntity)
return
}
if err := payloads.CreationError(p, a.deps.Store, nil); err != nil {
a.renderPage(w, r, "form.html", a.editorData(r, "new", p, err.Error()), http.StatusUnprocessableEntity)
return
}
saved, err := payloads.SavePost(a.deps.Store, p, nil)
if err != nil {
a.renderPage(w, r, "form.html",
a.editorData(r, "new", p, i18n.Admin.Tf(a.langFor(r), "The post could not be saved: %s", err.Error())), http.StatusInternalServerError)
return
}
a.fire("post.created", saved)
a.record(r, "post.created", saved.Slug(), nil)
http.Redirect(w, r, "/admin/?saved=created", http.StatusSeeOther)
}
func (a *Admin) handleUpdate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
slug := r.PathValue("slug")
existing := a.deps.Store.Find(slug, "")
if existing == nil {
http.NotFound(w, r)
return
}
p, err := payloads.PostFromParams(formMap(r), existing)
if err != nil {
a.renderPage(w, r, "form.html", a.editorData(r, "edit", p, err.Error()), http.StatusUnprocessableEntity)
return
}
if err := payloads.CreationError(p, a.deps.Store, existing); err != nil {
a.renderPage(w, r, "form.html", a.editorData(r, "edit", p, err.Error()), http.StatusUnprocessableEntity)
return
}
// SavePost moves the file when the slug changed, the same way the API
// does, so a rename behaves alike from either entry point.
saved, err := payloads.SavePost(a.deps.Store, p, existing)
if err != nil {
a.renderPage(w, r, "form.html",
a.editorData(r, "edit", p, i18n.Admin.Tf(a.langFor(r), "The post could not be saved: %s", err.Error())), http.StatusInternalServerError)
return
}
a.fire("post.updated", saved)
a.record(r, "post.updated", saved.Slug(), nil)
http.Redirect(w, r, "/admin/?saved=updated", http.StatusSeeOther)
}
func (a *Admin) handleDelete(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
slug := r.PathValue("slug")
deleted, undoable, err := a.deps.Store.Delete(slug, "")
if err != nil {
a.renderPage(w, r, "list.html",
a.dashboardData(r, i18n.Admin.Tf(a.langFor(r), "The post could not be deleted: %s", err.Error())), http.StatusInternalServerError)
return
}
if deleted == nil {
http.Redirect(w, r, "/admin/?saved=not_found", http.StatusSeeOther)
return
}
// The payload names the post under "post" like every other post
// event, so a subscriber sees one shape whichever entry point fired.
a.fireRaw("post.deleted", map[string]any{
"post": map[string]any{"slug": deleted.Slug(), "title": deleted.Title()},
})
a.record(r, "post.deleted", deleted.Slug(), nil)
target := "/admin/?saved=deleted"
if undoable {
// Only offer Undo when a tombstone exists to undo.
target += "&undo=" + url.QueryEscape(slug)
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
func (a *Admin) handleUndelete(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
slug := r.PathValue("slug")
if restored := a.deps.Store.Undelete(slug); restored != nil {
a.fire("post.created", restored)
a.record(r, "post.undeleted", slug, nil)
http.Redirect(w, r, "/admin/?saved=undone", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/?saved=undelete_failed", http.StatusSeeOther)
}
func (a *Admin) handleDuplicate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
slug := r.PathValue("slug")
source := a.deps.Store.Find(slug, "")
if source == nil {
http.NotFound(w, r)
return
}
newSlug := a.nextAvailableSlug(slug + "-copy")
meta := frontmatter.NewMeta()
for _, key := range source.Metadata.Keys() {
if key == "slug" || key == "date" {
continue
}
value, _ := source.Metadata.Get(key)
meta.Set(key, value)
}
meta.Set("slug", newSlug)
meta.Set("draft", true)
clone := post.New(meta, source.Body)
if err := payloads.CreationError(clone, a.deps.Store, nil); err != nil {
slog.Warn("admin: duplicate rejected", "slug", slug, "error", err)
http.Redirect(w, r, "/admin/?saved=duplicate_failed", http.StatusSeeOther)
return
}
if _, err := a.deps.Store.Save(clone); err != nil {
slog.Warn("admin: duplicate failed", "slug", slug, "error", err)
http.Redirect(w, r, "/admin/?saved=duplicate_failed", http.StatusSeeOther)
return
}
a.fire("post.created", clone)
http.Redirect(w, r, "/admin/posts/"+newSlug+"/edit?saved=duplicated", http.StatusSeeOther)
}
func (a *Admin) nextAvailableSlug(base string) string {
candidate := base
for n := 2; a.deps.Store.Find(candidate, "") != nil; n++ {
candidate = fmt.Sprintf("%s-%d", base, n)
}
return candidate
}
func (a *Admin) handleBulk(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
action := r.PostFormValue("action")
var slugs []string
for slug := range strings.SplitSeq(r.PostFormValue("slugs"), ",") {
if slug != "" {
slugs = append(slugs, slug)
}
}
if len(slugs) == 0 || (action != "delete" && action != "draft" && action != "publish") {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
affected := 0
for _, slug := range slugs {
switch action {
case "delete":
deleted, _, err := a.deps.Store.Delete(slug, "")
if err == nil && deleted != nil {
a.fireRaw("post.deleted", map[string]any{
"post": map[string]any{"slug": slug, "title": deleted.Title()},
})
affected++
}
case "draft":
if cached := a.deps.Store.Find(slug, ""); cached != nil && !cached.Draft() {
// Cached posts are shared with other requests: clone first.
p := cached.Clone()
p.Metadata.Set("draft", true)
if _, err := a.deps.Store.Save(p); err == nil {
a.fire("post.updated", p)
affected++
}
}
case "publish":
if cached := a.deps.Store.Find(slug, ""); cached != nil && cached.Draft() {
p := cached.Clone()
p.Metadata.Delete("draft")
if _, err := a.deps.Store.Save(p); err == nil {
a.fireRaw("post.published", map[string]any{"post": payloads.BuildSummary(p)})
affected++
}
}
}
}
if affected > 0 {
a.record(r, "post.bulk_"+action, "", map[string]any{"slugs": slugs, "affected": affected})
}
http.Redirect(w, r, fmt.Sprintf("/admin/?bulk=%s&n=%d", action, affected), http.StatusSeeOther)
}
func (a *Admin) handlePreview(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
htmlOut, err := markdown.Render(r.PostFormValue("body"))
if err != nil {
http.Error(w, "render failed", http.StatusInternalServerError)
return
}
// The preview body carries no frontmatter, so the reference list it
// knows about comes from the saved post under the same slug: the
// editor then sees the bibliography the published page will show,
// not the raw [[refs]] marker. A new post has no saved refs, and its
// marker paragraph stays as written.
slug := r.PostFormValue("slug")
lang := r.PostFormValue("lang")
if slug != "" {
if p := a.deps.Store.Find(slug, lang); p != nil {
if refs := p.RefsLinked(); len(refs) > 0 {
htmlOut = biblio.LinkCitations(htmlOut, refs)
htmlOut = biblio.Place(htmlOut, refs)
}
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, htmlOut)
}
// --- import, download, history ---------------------------------------------
// fire and fireRaw notify the optional webhook sink.
func (a *Admin) fire(event string, p *post.Post) {
a.fireRaw(event, map[string]any{"post": payloads.BuildSummary(p)})
}
func (a *Admin) fireRaw(event string, payload map[string]any) {
if a.deps.OnEvent != nil {
a.deps.OnEvent(event, payload)
}
}
// handlePreviewLink returns a shareable preview URL for a draft or
// scheduled post. The link is signed with the session key, so without
// one no link can be honoured and none is offered.
func (a *Admin) handlePreviewLink(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if a.deps.Store.Find(slug, "") == nil {
http.NotFound(w, r)
return
}
token := preview.Token(slug, a.deps.PreviewKey, time.Now())
if token == "" {
writeAdminJSONError(w, http.StatusConflict, "no_session_key",
"Preview links need a session key: set [admin].session_key or make the state directory writable.")
return
}
base := strings.TrimRight(a.deps.Config.Site.BaseURL, "/")
writeAdminJSON(w, http.StatusOK, map[string]any{
"url": fmt.Sprintf("%s/api/volumen/posts/%s?preview_token=%s", base, slug, token),
"token": token,
})
}
+929
View File
@@ -0,0 +1,929 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/biblio"
"sourcedock.dev/petrbalvin/volumen/internal/post"
"sourcedock.dev/petrbalvin/volumen/internal/preview"
)
func writeTestPost(t *testing.T, f *fixture, name, body string) {
t.Helper()
path := filepath.Join(f.contentDir, name)
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write post: %v", err)
}
}
const samplePost = `+++
title = "Hello"
slug = "hello"
date = 2026-08-18
lang = "cs"
tags = ["go", "blog"]
+++
Hello **body**.
`
func TestDashboardRenders(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
"Posts", "Hello", `data-slug="hello"`, "Published", "Drafts",
`data-tag="go"`, "1 post", "Log out",
} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q", want)
}
}
}
func TestDashboardGroupsLanguageVariants(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", `+++
title = "Hello"
slug = "hello"
date = 2026-08-18
lang = "en"
translations = { cs = "ahoj" }
+++
English body.
`)
writeTestPost(t, f, "ahoj.md", `+++
title = "Ahoj"
slug = "ahoj"
date = 2026-08-18
lang = "cs"
translations = { en = "hello" }
+++
České tělo.
`)
writeTestPost(t, f, "lonely.md", `+++
title = "Lonely"
slug = "lonely"
date = 2026-08-17
lang = "en"
+++
Alone.
`)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(cookie)
body := f.do(t, req).Body.String()
// The linked pair is one card, the unrelated post the second one.
if got := strings.Count(body, `<article class="post"`); got != 2 {
t.Fatalf("cards = %d, want 2", got)
}
if got := strings.Count(body, `data-variants=`); got != 1 {
t.Fatalf("cards with variants = %d, want 1", got)
}
if !strings.Contains(body, `data-slug="hello"`) || strings.Contains(body, `data-slug="ahoj"`) {
t.Fatal("the variant ahoj must not stand as its own card")
}
// Both language versions are reachable from the switch chips.
if !strings.Contains(body, `data-lang="en"`) || !strings.Contains(body, `data-lang="cs"`) {
t.Fatal("the card must offer both language variants")
}
// The selection acts on the whole publication: both slugs ride along.
if !strings.Contains(body, `value="hello,ahoj"`) && !strings.Contains(body, `value="ahoj,hello"`) {
t.Fatal("the bulk selection must carry every variant slug")
}
}
func TestDashboardRequiresLogin(t *testing.T) {
f := newFixture(t)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/", nil))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
}
func TestNewFormRendersEditor(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/posts/new", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"New post", `id="post-form"`, `action="/admin/posts"`, "Markdown"} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q", want)
}
}
}
func TestEditFormRendersPost(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/edit", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
"Edit post", `value="Hello"`, `value="hello"`, `readonly`,
`action="/admin/posts/hello"`, "Hello **body**.",
} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q", want)
}
}
req = httptest.NewRequest(http.MethodGet, "/admin/posts/ghost/edit", nil)
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
}
func TestCreatePostViaForm(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
form := url.Values{
"_csrf": {csrf},
"title": {"Fresh"},
"slug": {"fresh"},
"lang": {"cs"},
"tags": {"a, b"},
"body": {"content"},
"draft": {"on"},
"author": {"Petr"},
}
rec := postForm(t, f, "/admin/posts", form, cookie)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/?saved=created" {
t.Fatalf("code=%d location=%q body=%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
}
if p := f.findPost("fresh"); p == nil || p.Title() != "Fresh" || !p.Draft() {
t.Fatalf("post = %v", p)
}
if len(f.events) == 0 || f.events[len(f.events)-1] != "post.created" {
t.Fatalf("events = %v", f.events)
}
}
func TestCreatePostValidationRendersForm(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
form := url.Values{"_csrf": {csrf}, "title": {"X"}, "slug": {"Bad Slug"}, "body": {"b"}}
rec := postForm(t, f, "/admin/posts", form, cookie)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Invalid slug.") {
t.Fatal("validation message missing")
}
}
func TestUpdatePostKeepsPath(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
form := url.Values{
"_csrf": {csrf}, "title": {"Updated"}, "slug": {"hello"},
"lang": {"cs"}, "tags": {"go"}, "body": {"new body"},
}
rec := postForm(t, f, "/admin/posts/hello", form, cookie)
if rec.Code != http.StatusSeeOther {
t.Fatalf("code = %d", rec.Code)
}
p := f.findPost("hello")
if p == nil || p.Title() != "Updated" || p.Body != "new body\n" {
t.Fatalf("post = %v", p)
}
}
// The bibliography card writes the refs tables through the whole save
// path: the editor's JSON reaches the file as [[refs]] blocks, an
// author's ORCID survives as a name table, and a frontmatter key the
// form never names round-trips untouched beside them.
func TestEditorSavesTheBibliography(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", `+++
title = "Cite"
slug = "hello"
date = 2026-08-18
note = "keep me"
tags = ["go"]
[[refs]]
raw = "Old entry."
+++
Cites [1].
`)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
// The edit form hands the stored list to the card's script, and the
// card sits below the editor with its own bounded list.
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/edit", nil)
req.AddCookie(cookie)
body := f.do(t, req).Body.String()
for _, want := range []string{`id="refs-card"`, "Old entry.", `id="ref-row-template"`, `id="refs-count"`, `class="refs-scroll"`} {
if !strings.Contains(body, want) {
t.Fatalf("edit form missing %q", want)
}
}
if strings.Index(body, `id="refs-card"`) < strings.Index(body, `id="post-form"`) {
t.Fatal("the bibliography card must not precede the form")
}
editorSection := strings.Index(body, `id="markdown-view"`)
refsCard := strings.Index(body, `id="refs-card"`)
if editorSection == -1 || refsCard == -1 || refsCard < editorSection {
t.Fatal("the bibliography card must sit below the editor")
}
form := url.Values{
"_csrf": {csrf}, "title": {"Cite"}, "slug": {"hello"}, "body": {"Cites [1].\n\n[[refs]]\n"},
"refs": {`[` +
`{"num":2,"raw":"Kept and edited."},` +
`{"raw":"Added verbatim.","doi":"10.1086/300499"},` +
`{"authors":[{"name":"Adam Riess","orcid":"0000-0002-1825-0097"}],` +
`"title":"Observational Evidence","year":"1998"}` +
`]`},
}
rec := postForm(t, f, "/admin/posts/hello", form, cookie)
if rec.Code != http.StatusSeeOther {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
raw, err := os.ReadFile(filepath.Join(f.contentDir, "hello.md"))
if err != nil {
t.Fatalf("read post: %v", err)
}
file := string(raw)
for _, want := range []string{
`note = "keep me"`,
"[[refs]]",
"raw = \"Kept and edited.\"",
"num = 2",
"raw = \"Added verbatim.\"",
`doi = "10.1086/300499"`,
`title = "Observational Evidence"`,
`{name = "Adam Riess", orcid = "0000-0002-1825-0097"}`,
} {
if !strings.Contains(file, want) {
t.Fatalf("saved file missing %q:\n%s", want, file)
}
}
if strings.Contains(file, "Old entry.") {
t.Fatalf("the replaced entry survived:\n%s", file)
}
// The rewritten list renders with its citations linked.
p := f.findPost("hello")
refs := p.RefsLinked()
if len(refs) != 3 {
t.Fatalf("refs = %v", refs)
}
if refs[0].Num != 2 || refs[0].Raw != "Kept and edited." {
t.Fatalf("first entry = %+v", refs[0])
}
html, err := p.HTML()
if err != nil {
t.Fatalf("render: %v", err)
}
// The preserved numbers name the anchors: the first entry keeps its
// explicit num = 2, so the list carries ref-2 and ref-3 and no ref-1.
if !strings.Contains(html, `id="ref-2"`) || !strings.Contains(html, `id="ref-3"`) {
t.Fatalf("rendered list wrong:\n%s", html)
}
if strings.Contains(html, `id="ref-1"`) {
t.Fatalf("an unnumbered anchor appeared:\n%s", html)
}
}
// A save whose form carries no refs field keeps the stored list, so the
// bibliography never disappears under a page that does not edit it.
func TestEditorWithoutRefsKeepsTheStoredList(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", `+++
title = "Cite"
slug = "hello"
date = 2026-08-18
[[refs]]
raw = "Old entry."
+++
Body.
`)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
form := url.Values{
"_csrf": {csrf}, "title": {"Cite"}, "slug": {"hello"}, "body": {"Body.\n"},
}
if rec := postForm(t, f, "/admin/posts/hello", form, cookie); rec.Code != http.StatusSeeOther {
t.Fatalf("code = %d", rec.Code)
}
if refs := f.findPost("hello").Refs(); len(refs) != 1 || refs[0].Raw != "Old entry." {
t.Fatalf("refs = %v", refs)
}
}
func TestDeleteAndUndeleteFlow(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/hello/delete", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusSeeOther ||
!strings.Contains(rec.Header().Get("Location"), "saved=deleted&undo=hello") {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
if f.findPost("hello") != nil {
t.Fatal("post still present")
}
// The webhook contract names the post under the "post" key, the same
// shape every other post event and the API's delete endpoint deliver.
last := len(f.events) - 1
if last < 0 || f.events[last] != "post.deleted" {
t.Fatalf("events = %v", f.events)
}
inner, ok := f.payloads[last]["post"].(map[string]any)
if !ok || inner["slug"] != "hello" || inner["title"] != "Hello" {
t.Fatalf("post.deleted payload = %#v", f.payloads[last])
}
rec = postForm(t, f, "/admin/posts/hello/undelete", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/?saved=undone" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
if f.findPost("hello") == nil {
t.Fatal("post not restored")
}
}
// A bulk delete delivers the same post.deleted shape as the single
// delete, so a subscriber cannot tell which screen the change came from.
func TestBulkDeleteEventShape(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/bulk",
url.Values{"_csrf": {csrf}, "action": {"delete"}, "slugs": {"hello"}}, cookie)
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "n=1") {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
last := len(f.events) - 1
if last < 0 || f.events[last] != "post.deleted" {
t.Fatalf("events = %v", f.events)
}
inner, ok := f.payloads[last]["post"].(map[string]any)
if !ok || inner["slug"] != "hello" {
t.Fatalf("post.deleted payload = %#v", f.payloads[last])
}
}
func TestDuplicateCreatesDraftCopy(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/hello/duplicate", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusSeeOther ||
!strings.Contains(rec.Header().Get("Location"), "/admin/posts/hello-copy/edit") {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
copyPost := f.findPost("hello-copy")
if copyPost == nil || !copyPost.Draft() {
t.Fatalf("copy = %v", copyPost)
}
if copyPost.DateString() != "" {
t.Fatalf("copy kept the date: %q", copyPost.DateString())
}
// A second duplicate gets the -copy-2 suffix.
rec = postForm(t, f, "/admin/posts/hello/duplicate", url.Values{"_csrf": {csrf}}, cookie)
if !strings.Contains(rec.Header().Get("Location"), "hello-copy-2") {
t.Fatalf("location = %q", rec.Header().Get("Location"))
}
}
// A duplicate that cannot be created must say so on the dashboard, not
// disappear behind a silent redirect.
func TestDuplicateFailureIsReported(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", `+++
title = "Hello"
slug = "hello"
date = 2026-08-18
doi = "not-a-doi"
+++
Body.
`)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/hello/duplicate", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/?saved=duplicate_failed" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
if f.findPost("hello-copy") != nil {
t.Fatal("a rejected duplicate must not be saved")
}
}
// The editor's API link carries a valid preview token, so it opens a
// draft as well as a published post.
func TestEditFormCarriesPreviewToken(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/edit", nil)
req.AddCookie(cookie)
body := f.do(t, req).Body.String()
mark := `/api/volumen/posts/hello?preview_token=`
i := strings.Index(body, mark)
if i < 0 {
t.Fatal("API link without a preview token")
}
token, _, _ := strings.Cut(body[i+len(mark):], `"`)
if !preview.Valid(token, "hello", f.admin.deps.PreviewKey, time.Now()) {
t.Fatalf("preview token invalid: %q", token)
}
}
func TestBulkActions(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "a.md", "+++\nslug = \"a\"\ntitle = \"A\"\ndraft = true\n+++\nx\n")
writeTestPost(t, f, "b.md", "+++\nslug = \"b\"\ntitle = \"B\"\n+++\nx\n")
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/bulk",
url.Values{"_csrf": {csrf}, "action": {"publish"}, "slugs": {"a"}}, cookie)
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "bulk=publish&n=1") {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
if f.findPost("a").Draft() {
t.Fatal("post not published")
}
rec = postForm(t, f, "/admin/posts/bulk",
url.Values{"_csrf": {csrf}, "action": {"delete"}, "slugs": {"a,b"}}, cookie)
if !strings.Contains(rec.Header().Get("Location"), "n=2") {
t.Fatalf("location = %q", rec.Header().Get("Location"))
}
if f.findPost("a") != nil || f.findPost("b") != nil {
t.Fatal("posts not deleted")
}
}
func TestSlugExistsEndpoint(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
for path, want := range map[string]string{
"/admin/posts/exists?slug=hello": `"available":false`,
"/admin/posts/exists?slug=fresh": `"available":true`,
"/admin/posts/exists?slug=": `"available":true`,
"/admin/posts/exists?slug=hello&exclude=hello": `"available":true`,
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), want) {
t.Fatalf("%s: code=%d body=%s", path, rec.Code, rec.Body.String())
}
}
}
func TestPreviewEndpoint(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/preview", url.Values{"_csrf": {csrf}, "body": {"**bold**"}}, cookie)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "<strong>bold</strong>") {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestPreviewLinkEndpoint(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "draft.md", "+++\nslug = \"d\"\ndraft = true\n+++\nx\n")
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/posts/d/preview-link", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "preview_token=") {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestImportFlow(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := multipartForm(t, f, "/admin/posts/import", cookie, csrf,
"file", "imported.md", "+++\ntitle = \"Imported\"\nslug = \"imported\"\n+++\nbody\n")
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/posts/imported/edit" {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
if f.findPost("imported") == nil {
t.Fatal("import not saved")
}
// Non-.md rejected.
rec = multipartForm(t, f, "/admin/posts/import", cookie, csrf,
"file", "evil.txt", "content")
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d", rec.Code)
}
// Import without a slug derives one from the file name.
rec = multipartForm(t, f, "/admin/posts/import", cookie, csrf,
"file", "derived-slug.md", "Just a body, no frontmatter.\n")
if rec.Code != http.StatusSeeOther {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
if f.findPost("derived-slug") == nil {
t.Fatal("derived slug import failed")
}
}
func TestDownloadAndHistory(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
// Saving twice archives one revision.
form := url.Values{
"_csrf": {csrf}, "title": {"Hello"}, "slug": {"hello"},
"lang": {"cs"}, "body": {"changed"},
}
postForm(t, f, "/admin/posts/hello", form, cookie)
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/download", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "title = \"Hello\"") {
t.Fatalf("download code=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Header().Get("Content-Disposition"), `filename="hello.md"`) {
t.Fatalf("disposition = %q", rec.Header().Get("Content-Disposition"))
}
req = httptest.NewRequest(http.MethodGet, "/admin/posts/hello/history", nil)
req.AddCookie(cookie)
rec = f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "History") {
t.Fatalf("history code=%d", rec.Code)
}
if !strings.Contains(rec.Body.String(), " kB") {
t.Fatal("revision size missing")
}
}
func TestUploadRejectsGarbage(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := multipartForm(t, f, "/admin/uploads", cookie, csrf,
"file", "x.webp", "not an image at all")
if rec.Code != http.StatusUnsupportedMediaType {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
webpData := append([]byte("RIFF"), 0, 0, 0, 0)
webpData = append(webpData, []byte("WEBPVP8 ")...)
rec = multipartForm(t, f, "/admin/uploads", cookie, csrf,
"file", "pic.png", string(webpData))
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "/media/") {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestCSRFRequiredOnMutations(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
for _, path := range []string{
"/admin/posts", "/admin/posts/bulk", "/admin/preview",
"/admin/posts/import",
} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("_csrf=wrong"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusForbidden {
t.Fatalf("%s: code = %d, want 403", path, rec.Code)
}
}
}
// --- helpers ---------------------------------------------------------------
func (f *fixture) findPost(slug string) *post.Post {
return f.storeObj.Find(slug, "")
}
// csrfFromSession performs the login GET flow and returns the CSRF token.
func csrfFromSession(t *testing.T, f *fixture, cookie *http.Cookie) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/admin/login", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code == http.StatusOK {
return extractCSRF(t, rec.Body.String())
}
// Authenticated: pull the token from the session instead.
sess := f.store.Load(req)
return CSRFToken(sess)
}
func postForm(t *testing.T, f *fixture, path string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
return f.do(t, req)
}
func multipartForm(t *testing.T, f *fixture, path string, cookie *http.Cookie, csrf, field, filename, content string) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
_ = mw.WriteField("_csrf", csrf)
part, err := mw.CreateFormFile(field, filename)
if err != nil {
t.Fatalf("create form file: %v", err)
}
if _, err := part.Write([]byte(content)); err != nil {
t.Fatalf("write part: %v", err)
}
_ = mw.Close()
req := httptest.NewRequest(http.MethodPost, path, &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(cookie)
return f.do(t, req)
}
// The history page's two per-revision endpoints are the ones an operator
// reaches for after a bad edit, so both are exercised: the download and
// the restore, including the redirect that carries the flash message.
func TestHistoryDownloadAndRestore(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
// One save archives the original.
postForm(t, f, "/admin/posts/hello", url.Values{
"_csrf": {csrf}, "title": {"Hello"}, "slug": {"hello"},
"lang": {"cs"}, "body": {"changed"},
}, cookie)
revisions := f.admin.deps.Store.Revisions("hello")
if len(revisions) != 1 {
t.Fatalf("revisions = %v", revisions)
}
name := revisions[0].Name
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/history/"+name, nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("history download code = %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Hello **body**.") {
t.Fatalf("revision body = %s", rec.Body.String())
}
if got := rec.Header().Get("Content-Disposition"); !strings.Contains(got, "hello-"+name) {
t.Fatalf("disposition = %q", got)
}
// An unknown revision name is a 404, not an empty file.
req = httptest.NewRequest(http.MethodGet, "/admin/posts/hello/history/nope.md", nil)
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusNotFound {
t.Fatalf("unknown revision code = %d", rec.Code)
}
// Restoring puts the archived body back and redirects to the editor.
req = httptest.NewRequest(http.MethodPost, "/admin/posts/hello/history/"+name+"/restore",
strings.NewReader("_csrf="+url.QueryEscape(csrf)))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec = f.do(t, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("restore code = %d body=%s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Location"); got != "/admin/posts/hello/edit?restored=1" {
t.Fatalf("location = %q", got)
}
restored := f.storeObj.Find("hello", "")
if restored == nil || !strings.Contains(restored.Body, "Hello **body**.") {
t.Fatalf("body after restore = %q", restored.Body)
}
}
// The brand SVG loads on every admin page, so a broken embed pattern
// would break the whole UI silently. The one asset is the icon: the
// favicon, the login brand and the topbar badge all read the same file.
func TestStaticSVGRoutes(t *testing.T) {
f := newFixture(t)
const path = "/admin/icon.svg"
rec := f.do(t, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Fatalf("%s: code = %d", path, rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != "image/svg+xml" {
t.Fatalf("%s: content-type = %q", path, got)
}
if !strings.Contains(rec.Body.String(), "<svg") {
t.Fatalf("%s: body is not an SVG", path)
}
}
func TestImportFormRenders(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/posts/import", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `name="file"`) {
t.Fatalf("import form code=%d body=%s", rec.Code, rec.Body.String())
}
}
// Deleting a media file removes it from the library and from the public
// route, and a second delete reports the absence.
func TestMediaDelete(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := multipartForm(t, f, "/admin/uploads", cookie, csrf, "file", "x.webp", string(webpFixture()))
if rec.Code != http.StatusOK {
t.Fatalf("upload code = %d body=%s", rec.Code, rec.Body.String())
}
items := f.storeObj.ListMedia()
if len(items) != 1 {
t.Fatalf("media = %v", items)
}
name := items[0].Name
req := httptest.NewRequest(http.MethodPost, "/admin/media/"+name+"/delete",
strings.NewReader("_csrf="+url.QueryEscape(csrf)))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusSeeOther {
t.Fatalf("delete code = %d", rec.Code)
}
if len(f.storeObj.ListMedia()) != 0 {
t.Fatal("media survived the delete")
}
req = httptest.NewRequest(http.MethodPost, "/admin/media/"+name+"/delete",
strings.NewReader("_csrf="+url.QueryEscape(csrf)))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusNotFound {
t.Fatalf("second delete code = %d, want 404", rec.Code)
}
}
// webpFixture is a minimal RIFF/WEBP header, enough for the signature
// check the upload path performs.
func webpFixture() []byte {
data := append([]byte("RIFF"), 0, 0, 0, 0)
return append(data, []byte("WEBPVP8 ")...)
}
// A malformed date in the editor form is rejected with the post
// re-rendered, rather than silently dropping an inherited schedule.
func TestEditorRejectsMalformedPublishAt(t *testing.T) {
f := newFixture(t)
if err := os.WriteFile(filepath.Join(f.contentDir, "sched.md"),
[]byte("+++\ntitle = \"S\"\nslug = \"sched\"\npublish_at = 2999-01-01\n+++\nbody\n"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/sched", url.Values{
"_csrf": {csrf}, "title": {"S"}, "slug": {"sched"},
"publish_at": {"not a date"}, "body": {"body"},
}, cookie)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "publish_at must be an ISO 8601 date") {
t.Fatal("validation message missing")
}
if p := f.admin.deps.Store.Find("sched", ""); p == nil || !p.Scheduled() {
t.Fatal("the stored schedule was dropped by the rejected save")
}
}
// An admin POST body over the configured allowance is cut off with 413
// before it can fill the temp directory.
func TestAdminBodyOverTheLimitIs413(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
huge := strings.Repeat("a", 12*1024*1024)
rec := postForm(t, f, "/admin/posts", url.Values{
"_csrf": {csrf}, "title": {"Big"}, "slug": {"big"}, "body": {huge},
}, cookie)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("code = %d, want 413", rec.Code)
}
}
// The editor preview must show the bibliography the published page
// will show: the refs live in the saved post's frontmatter, which the
// body-only render cannot see on its own.
func TestPreviewWeavesSavedRefs(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "cite.md", `+++
title = "Cite"
slug = "cite"
[[refs]]
title = "Observational evidence from supernovae"
doi = "10.1103/PhysRevD.59.103502"
+++
Tvrzení [1].
[[refs]]
`)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/preview", url.Values{
"_csrf": {csrf}, "body": {"Tvrzení [1].\n\n[[refs]]\n"},
"slug": {"cite"},
}, cookie)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
`<section class="refs" id="references">`,
`<a class="ref-cite" href="#ref-1">[1]</a>`,
`href="https://doi.org/10.1103/PhysRevD.59.103502"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("preview missing %q:\n%s", want, body)
}
}
// A new post with no saved refs keeps the marker as inert text, and
// an unknown slug is just the plain body render.
for _, slug := range []string{"", "ghost"} {
rec := postForm(t, f, "/admin/preview", url.Values{
"_csrf": {csrf}, "body": {"[[refs]]\n"}, "slug": {slug},
}, cookie)
if !strings.Contains(rec.Body.String(), biblio.Marker) {
t.Fatalf("slug %q: preview rewrote an unsaved marker:\n%s", slug, rec.Body.String())
}
}
}
+88
View File
@@ -0,0 +1,88 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
json "encoding/json/v2"
"log/slog"
"net/http"
"strconv"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/imagefile"
"sourcedock.dev/petrbalvin/volumen/internal/web"
)
// writeAdminJSON writes one JSON object response; encoding/json escapes
// what a browser's JSON.parse requires, which a %q verb does not.
func writeAdminJSON(w http.ResponseWriter, status int, value map[string]any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.MarshalWrite(w, value, json.Deterministic(true)); err != nil {
slog.Warn("admin: cannot encode JSON response", "error", err)
}
}
func (a *Admin) handleUpload(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeAdminJSONError(w, http.StatusBadRequest, "no_file", i18n.Admin.T(a.langFor(r), "No file was uploaded."))
return
}
defer file.Close()
limit := int64(a.deps.Config.Admin.MaxUploadBytes)
raw, err := readLimited(file, limit)
if err != nil {
writeAdminJSONError(w, http.StatusRequestEntityTooLarge, "too_large",
i18n.Admin.Tf(a.langFor(r),
"The file could not be read (limit %s bytes).",
strconv.FormatInt(limit, 10)))
return
}
if errMsg := validateImageData(raw); errMsg != "" {
writeAdminJSONError(w, http.StatusUnsupportedMediaType, errMsg,
i18n.Admin.T(a.langFor(r), "Only WebP, AVIF and SVG images are supported."))
return
}
url, err := a.deps.Store.StoreUpload(header.Filename, raw)
if err != nil {
writeAdminJSONError(w, http.StatusInternalServerError, "upload_failed",
i18n.Admin.T(a.langFor(r), "The upload could not be stored."))
return
}
writeAdminJSON(w, http.StatusOK, map[string]any{"url": url})
}
func writeAdminJSONError(w http.ResponseWriter, status int, code, message string) {
writeAdminJSON(w, status, map[string]any{"error": code, "message": message})
}
// validateImageData reports why data is not an acceptable upload. The
// stored extension is taken from the byte signature, so the declared
// filename's type is irrelevant: what matters is that the bytes are one
// of the accepted image formats.
func validateImageData(data []byte) string {
if imagefile.Detect(data) == "" {
return "invalid_signature"
}
return ""
}
func (a *Admin) handleIcon(w http.ResponseWriter, _ *http.Request) {
a.serveStaticSVG(w, "volumen-icon.svg")
}
func (a *Admin) serveStaticSVG(w http.ResponseWriter, name string) {
data, err := web.StaticFile(name)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "image/svg+xml")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
+244
View File
@@ -0,0 +1,244 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package admin serves the server-rendered admin UI: authentication,
// post management, settings, and the media library.
package admin
import (
"bytes"
"context"
"encoding/json/v2"
"fmt"
"html/template"
"io"
"strings"
"sync"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/diff"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/users"
"sourcedock.dev/petrbalvin/volumen/internal/web"
)
// Crumb is one breadcrumb entry. UI marks a fixed interface label the
// renderer translates; content labels (post titles) pass through.
type Crumb struct {
Label string
Href string
IsLast bool
UI bool
}
// PageData is the template context shared by every admin page; page
// handlers fill the specific fields they need.
type PageData struct {
Config *config.Config
Path string
CSPNonce string
Version string
UpdateAvailable string
// Lang is the interface language this request renders in ("en" or
// "cs"), resolved from the account, the language cookie, or the
// site language. It feeds the html lang attribute, which the date
// picker and the relative-time formatter read.
Lang string
// Theme is the colour scheme this request renders in, resolved
// from the account, the theme cookie, or the default. It feeds the
// html data-theme attribute the stylesheet's scheme blocks read.
Theme string
CurrentUser string
CurrentRole string
CurrentUserRecord *users.User
UsersExist bool
CSRFToken string
IsLogin bool
IsSetup bool
IsAuthenticated bool
// SetupI18n carries the wizard's strings in every shipped
// language, so the language chips can swap the page text without
// a reload and without losing what the operator typed.
SetupI18n template.JS
DisplayName string
UserPhoto string
UserInitial string
Crumbs []Crumb
Error string
RetryAfter int
Notice string
// Dashboard.
Posts []postCard
Stats dashboardStats
TagCounts []tagCount
Q string
// Editor and history.
IsNew bool
IsEdit bool
Post *editorPost
PostTemplates []tplOption
TemplatesJSON template.JS
Restored bool
Duplicated bool
AuthorPlaceholder string
PreviewToken string
Slug string
Heading string
Revisions []revisionRow
DiffChunks []diff.Chunk
DiffName string
DiffWhen string
// Settings.
IsAdmin bool
Roles []string
DefaultRole string
UserRows []userRow
TemplatesList []tplOption
WebhookRows []hookRow
WebhookDeliveries []deliveryRow
TokenRows []tokenRow
NewToken string
MediaItems []mediaRow
// The second factor: its state on the account, an enrolment in
// flight, and the one-time recovery codes a change just produced.
TotpEnabled bool
TotpPending bool
TotpSVG template.HTML
TotpSecret string
TotpURI string
RecoveryCodes []string
RecoveryNotice string
MediaTotal string
Target string
// Sidebar navigation highlighting.
NavPosts bool
NavNew bool
NavImport bool
NavMedia bool
NavSettings bool
}
// Tr translates a simple message in this request's language. Handlers
// use it for the strings they compose in Go; templates use the tr and
// trn funcs, which read the same catalogue.
func (d *PageData) Tr(s string) string {
return i18n.Admin.T(d.Lang, s)
}
// Trf translates a simple message with one value.
func (d *PageData) Trf(s, arg string) string {
return i18n.Admin.Tf(d.Lang, s, arg)
}
// langRenderer is one language's parsed template set. The translation
// funcs close over the language, so a page renders whole in one tongue
// with no per-string lookups in the handlers.
type langRenderer struct {
pages map[string]*template.Template
}
// Renderer executes the embedded admin templates in every shipped
// language.
type Renderer struct {
mu sync.Mutex
langs map[string]*langRenderer
}
// NewRenderer parses the layout together with every page template, one
// set per shipped language.
func NewRenderer() (*Renderer, error) {
fs := web.TemplateFS()
r := &Renderer{langs: map[string]*langRenderer{}}
for _, lang := range i18n.Languages {
base, err := template.New("layout.html").Funcs(funcMap(lang)).ParseFS(fs, "templates/layout.html")
if err != nil {
return nil, fmt.Errorf("parse layout (%s): %w", lang, err)
}
lr := &langRenderer{pages: map[string]*template.Template{}}
for _, page := range pageNames() {
clone, err := base.Clone()
if err != nil {
return nil, fmt.Errorf("clone layout for %s (%s): %w", page, lang, err)
}
if _, err := clone.ParseFS(fs, "templates/"+page); err != nil {
return nil, fmt.Errorf("parse %s (%s): %w", page, lang, err)
}
lr.pages[page] = clone
}
r.langs[lang] = lr
}
return r, nil
}
// pageNames lists the page templates parsed alongside the layout.
func pageNames() []string {
return []string{
"login.html", "setup.html", "twofactor.html", "list.html", "form.html", "history.html", "diff.html",
"import.html",
"settings.html", "media.html", "update.html", "notfound.html",
}
}
// Render executes the named page inside the layout shell, in the
// language the page data carries. The context is the request's, so a
// template failure is logged against it.
func (r *Renderer) Render(ctx context.Context, w io.Writer, page string, data *PageData) error {
lang := data.Lang
if !i18n.Valid(lang) {
lang = "en"
}
// Fixed breadcrumb labels are interface strings; content labels
// (a post title) pass through untouched.
for i, c := range data.Crumbs {
if c.UI {
data.Crumbs[i].Label = i18n.Admin.T(lang, c.Label)
}
}
r.mu.Lock()
lr := r.langs[lang]
r.mu.Unlock()
tmpl, ok := lr.pages[page]
if !ok {
return fmt.Errorf("unknown admin page %q", page)
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil {
web.Logger(ctx).Warn("admin: template error", "page", page, "error", err)
return err
}
_, err := w.Write(buf.Bytes())
return err
}
func funcMap(lang string) template.FuncMap {
cat := i18n.Admin
return template.FuncMap{
"lower": strings.ToLower,
"join": func(items []string, sep string) string { return strings.Join(items, sep) },
"tr": func(s string) string { return cat.T(lang, s) },
"trh": func(s string) template.HTML { return template.HTML(cat.TH(lang, s)) },
"trf": func(s, arg string) string { return cat.Tf(lang, s, arg) },
"trn": func(n int, id string) string { return cat.N(lang, id, n) },
"i18nJSON": func() template.JS {
b, err := json.Marshal(cat.JS(lang))
if err != nil {
return "{}"
}
// The catalogue holds authored strings only, but the same
// script-embedding rule as templatesJSON applies: no literal
// "<" may reach the page inside a script element.
return template.JS(strings.ReplaceAll(string(b), "<", `\u003c`))
},
}
}
+221
View File
@@ -0,0 +1,221 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"net/http"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/fediverse"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/identifiers"
"sourcedock.dev/petrbalvin/volumen/internal/session"
)
// passwordPolicy returns the configured length bounds. Validate
// guarantees a minimum of at least one and a maximum at or above it
// before the server starts.
func (a *Admin) passwordPolicy() (int, int) {
return a.deps.Config.Admin.MinPasswordLength, a.deps.Config.Admin.MaxPasswordLength
}
func (a *Admin) handleSettingsPassword(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
username := a.currentUser(r)
if a.deps.Users.Authenticate(username, r.PostFormValue("current_password")) == nil {
a.renderSettings(w, r, a.tr(r, "Current password is incorrect."), "", http.StatusUnprocessableEntity)
return
}
newPassword := r.PostFormValue("new_password")
if strings.TrimSpace(newPassword) == "" {
a.renderSettings(w, r, a.tr(r, "New password cannot be empty."), "", http.StatusUnprocessableEntity)
return
}
minLen, maxLen := a.passwordPolicy()
if key, n := PasswordError(newPassword, minLen, maxLen); key != "" {
msg := a.tr(r, key)
if n > 0 {
msg = i18n.Admin.N(a.langFor(r), key, n)
}
a.renderSettings(w, r, msg, "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Users.UpdatePassword(username, newPassword); err != nil {
a.renderSettings(w, r, a.trf(r, "The new password could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
// Every other session dies with the changed fingerprint; this one is
// re-bound, so the device the change was made on stays signed in.
if updated := a.deps.Users.Find(username); updated != nil {
session.FromContext(r.Context()).Set("pv", sessionFingerprint(updated.PasswordHash))
}
a.record(r, "user.password_changed", username, nil)
a.renderSettings(w, r, "", a.tr(r, "Password updated."), http.StatusOK)
}
func (a *Admin) handleSettingsUsername(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
current := a.currentUser(r)
name := strings.TrimSpace(r.PostFormValue("username"))
sess := session.FromContext(r.Context())
switch {
case name == "":
a.renderSettings(w, r, a.tr(r, "Username cannot be empty."), "", http.StatusUnprocessableEntity)
case !usernameRe.MatchString(name):
a.renderSettings(w, r, a.tr(r, "Username may use letters, numbers, dot, dash, underscore."), "", http.StatusUnprocessableEntity)
case name == current:
a.renderSettings(w, r, "", a.tr(r, "Username unchanged."), http.StatusOK)
default:
if _, err := a.deps.Users.Rename(current, name); err != nil {
a.renderSettings(w, r, a.trf(r, "The username could not be changed: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
sess.Set("user", name)
a.record(r, "user.renamed", name, nil)
a.renderSettings(w, r, "", a.tr(r, "Username updated."), http.StatusOK)
}
}
func (a *Admin) handleSettingsName(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
name := strings.TrimSpace(r.PostFormValue("name"))
if _, err := a.deps.Users.UpdateName(a.currentUser(r), name); err != nil {
a.renderSettings(w, r, a.trf(r, "The display name could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
notice := a.tr(r, "Display name updated.")
if name == "" {
notice = a.tr(r, "Display name cleared.")
}
a.renderSettings(w, r, "", notice, http.StatusOK)
}
func (a *Admin) handleSettingsFediverse(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
value := strings.TrimSpace(r.PostFormValue("fediverse_creator"))
if value == "" {
if _, err := a.deps.Users.UpdateFediverseCreator(a.currentUser(r), ""); err != nil {
a.renderSettings(w, r, a.trf(r, "The handle could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.renderSettings(w, r, "", a.tr(r, "Fediverse handle cleared."), http.StatusOK)
return
}
if !fediverse.Valid(value) {
a.renderSettings(w, r, a.tr(r, "Fediverse handle must look like @user@host."), "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Users.UpdateFediverseCreator(a.currentUser(r), value); err != nil {
a.renderSettings(w, r, a.trf(r, "The handle could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.renderSettings(w, r, "", a.tr(r, "Fediverse handle updated."), http.StatusOK)
}
func (a *Admin) handleSettingsOrcid(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
value := identifiers.NormalizeORCID(r.PostFormValue("orcid"))
if value == "" {
if _, err := a.deps.Users.UpdateOrcid(a.currentUser(r), ""); err != nil {
a.renderSettings(w, r, a.trf(r, "The ORCID could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.renderSettings(w, r, "", a.tr(r, "ORCID cleared."), http.StatusOK)
return
}
if !identifiers.ValidORCID(value) {
a.renderSettings(w, r, a.tr(r, "ORCID must look like 0000-0002-1825-0097."), "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Users.UpdateOrcid(a.currentUser(r), value); err != nil {
a.renderSettings(w, r, a.trf(r, "The ORCID could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.renderSettings(w, r, "", a.tr(r, "ORCID updated."), http.StatusOK)
}
func (a *Admin) handleSettingsPhoto(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
file, header, err := r.FormFile("photo")
if err != nil {
a.renderSettings(w, r, a.tr(r, "No file selected."), "", http.StatusUnprocessableEntity)
return
}
defer file.Close()
raw, err := readLimited(file, int64(a.deps.Config.Admin.MaxUploadBytes))
if err != nil {
a.renderSettings(w, r, a.tr(r, "File is too large."), "", http.StatusUnprocessableEntity)
return
}
if validateImageData(raw) != "" {
a.renderSettings(w, r,
a.tr(r, "Only WebP, AVIF and SVG images are supported."), "", http.StatusUnprocessableEntity)
return
}
username := a.currentUser(r)
url, err := a.deps.Store.StoreUpload(header.Filename, raw)
if err != nil {
a.renderSettings(w, r, a.tr(r, "The photo could not be stored."), "", http.StatusInternalServerError)
return
}
previous := ""
if record := a.deps.Users.Find(username); record != nil {
previous = record.Photo
}
if _, err := a.deps.Users.UpdatePhoto(username, url); err != nil {
a.renderSettings(w, r, a.trf(r, "The profile photo could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
if previous != "" && previous != url {
a.deleteUnreferencedMedia(previous)
}
a.renderSettings(w, r, "", a.tr(r, "Profile photo updated."), http.StatusOK)
}
func (a *Admin) handleSettingsPhotoRemove(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
username := a.currentUser(r)
previous := ""
if record := a.deps.Users.Find(username); record != nil {
previous = record.Photo
}
if _, err := a.deps.Users.UpdatePhoto(username, ""); err != nil {
a.renderSettings(w, r, a.trf(r, "The profile photo could not be removed: %s", err.Error()), "", http.StatusInternalServerError)
return
}
if previous != "" {
a.deleteUnreferencedMedia(previous)
}
a.renderSettings(w, r, "", a.tr(r, "Profile photo removed."), http.StatusOK)
}
// deleteUnreferencedMedia removes a photo file no user references any
// more.
func (a *Admin) deleteUnreferencedMedia(url string) {
if !strings.HasPrefix(url, "/media/") {
return
}
for _, user := range a.deps.Users.All() {
if user.Photo == url {
return
}
}
a.deps.Store.DeleteMedia(url)
}
// --- users panel ------------------------------------------------------------
+170
View File
@@ -0,0 +1,170 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"fmt"
"maps"
"net/http"
"slices"
"strconv"
"strings"
"sourcedock.dev/petrbalvin/interpres/v2"
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
"sourcedock.dev/petrbalvin/volumen/internal/templates"
)
// templateFields are the editor inputs a template may pre-fill beyond
// its title, slug, tags and body; anything else in the fields box is a
// typo waiting to seed every new post with a key nobody reads.
var templateFields = map[string]bool{
"lang": true, "author": true, "fediverse_creator": true,
"doi": true, "orcid": true,
"series": true, "series_order": true,
"excerpt": true, "cover": true, "cover_alt": true, "cover_caption": true,
}
// parseTemplateFields reads the fields box: key = value lines in TOML,
// each key an editor field. unknown names the first key outside the
// allowed set; err reports text that is not a small TOML document.
func parseTemplateFields(text string) (fields map[string]string, unknown string, err error) {
if strings.TrimSpace(text) == "" {
return nil, "", nil
}
data, err := interpres.ParseMap([]byte(text))
if err != nil {
return nil, "", fmt.Errorf("template fields must be key = value lines")
}
out := map[string]string{}
for _, key := range slices.Sorted(maps.Keys(data)) {
if !templateFields[key] {
return nil, key, nil
}
value := data[key]
if value == nil {
continue
}
if text, isString := value.(string); isString {
if text == "" {
continue
}
out[key] = text
continue
}
if number, isInt := value.(int64); isInt {
out[key] = strconv.FormatInt(number, 10)
continue
}
out[key] = fmt.Sprintf("%v", value)
}
if len(out) == 0 {
return nil, "", nil
}
return out, "", nil
}
func (a *Admin) handleSettingsTemplateCreate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
name := strings.TrimSpace(r.PostFormValue("name"))
if name == "" {
a.renderSettings(w, r, a.tr(r, "Template name is required."), "", http.StatusUnprocessableEntity)
return
}
fields, unknown, err := parseTemplateFields(r.PostFormValue("fields"))
switch {
case err != nil:
a.renderSettings(w, r, a.tr(r, "Template fields must be key = value TOML lines."), "", http.StatusUnprocessableEntity)
return
case unknown != "":
a.renderSettings(w, r, a.trf(r, "Unknown template field %s.", unknown), "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Templates.Add(templates.PostTemplate{
Name: name,
Tags: payloads.ParseTags(r.PostFormValue("tags")),
Body: r.PostFormValue("body"),
Title: strings.TrimSpace(r.PostFormValue("title")),
Slug: strings.TrimSpace(r.PostFormValue("slug")),
Fields: fields,
}); err != nil {
a.renderSettings(w, r, a.trf(r, "That template could not be added: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
a.renderSettings(w, r, "", a.tr(r, "Template added."), http.StatusOK)
}
func (a *Admin) handleSettingsTemplateDelete(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
if err := a.deps.Templates.Delete(r.PathValue("name")); err != nil {
a.renderSettings(w, r, a.trf(r, "The template could not be deleted: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
a.renderSettings(w, r, "", a.tr(r, "Template deleted."), http.StatusOK)
}
// --- backup export / import -------------------------------------------------
// handleSettingsWebhookTest delivers a ping inline and reports the
// outcome from the delivery it produced, not from the shared history a
// concurrent delivery could reshuffle.
func (a *Admin) handleSettingsWebhookTest(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
if a.deps.Webhooks == nil {
a.renderSettings(w, r, a.tr(r, "No webhooks configured."), "", http.StatusUnprocessableEntity)
return
}
// The list is taken once: a settings change that reshuffles it between
// the bounds check and the fetch would test a different hook than the
// one the form named.
hooks := a.deps.Webhooks.Hooks()
index, err := strconv.Atoi(r.PathValue("index"))
if err != nil || index < 0 || index >= len(hooks) {
a.renderSettings(w, r, a.tr(r, "Webhook not found."), "", http.StatusUnprocessableEntity)
return
}
hook := hooks[index]
delivery := a.deps.Webhooks.TestHook(hook)
a.record(r, "webhook.tested", hook.URL, nil)
notice := a.tr(r, "Test delivery failed.")
if delivery.Status == "ok" {
notice = a.tr(r, "Test delivery sent.")
}
a.renderSettings(w, r, "", notice, http.StatusOK)
}
func (a *Admin) handleSettingsTokenCreate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
created, raw, err := a.deps.Tokens.Create(r.PostFormValue("name"), r.PostForm["scope"])
if err != nil {
a.renderSettings(w, r, a.trf(r, "The token could not be created: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
a.record(r, "token.created", created.Name, nil)
data := a.settingsData(r)
data.NewToken = raw
a.renderPage(w, r, "settings.html", data, http.StatusOK)
}
func (a *Admin) handleSettingsTokenDelete(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
name := r.PathValue("name")
notice := a.tr(r, "Token revoked.")
if !a.deps.Tokens.Revoke(name) {
notice = a.tr(r, "That token was not found.")
} else {
a.record(r, "token.revoked", name, nil)
}
a.renderSettings(w, r, "", notice, http.StatusOK)
}
+82
View File
@@ -0,0 +1,82 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"fmt"
"log/slog"
"net/http"
"sourcedock.dev/petrbalvin/volumen/internal/backup"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
)
func (a *Admin) handleSettingsExport(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", `attachment; filename="volumen-backup.tar.gz"`)
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
// The archive streams straight to the client: the app routes it past
// the buffering wrappers, so no copy of it waits in memory. An error
// before the first byte still answers as a plain 500; after it, the
// download ends truncated and the gzip footer makes that visible.
sent := false
if err := backup.Write(writeTracker{w, &sent}, a.deps.Backup); err != nil {
slog.Error("admin: backup export failed", "error", err)
if !sent {
w.Header().Del("Content-Type")
w.Header().Del("Content-Disposition")
http.Error(w, "The backup could not be written: "+err.Error(), http.StatusInternalServerError)
}
}
}
// writeTracker records whether anything reached the client, so a failure
// can still choose between a clean error page and a logged truncation.
type writeTracker struct {
w http.ResponseWriter
sent *bool
}
func (t writeTracker) Write(p []byte) (int, error) {
*t.sent = true
return t.w.Write(p)
}
func (a *Admin) handleSettingsImport(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
file, _, err := r.FormFile("backup")
if err != nil {
a.renderSettings(w, r, a.tr(r, "No backup file selected."), "", http.StatusUnprocessableEntity)
return
}
defer file.Close()
written, err := backup.Restore(file, a.deps.Backup)
if written > 0 {
// A partial restore changed files on disk; the caches must drop
// even when a later entry failed, or the admin keeps serving the
// pre-import state until an unrelated write invalidates them.
a.deps.Store.InvalidateCache()
a.deps.Users.Invalidate()
a.deps.Templates.Invalidate()
a.deps.Tokens.Invalidate()
}
if err != nil {
slog.Warn("admin: backup import failed", "error", err)
a.renderSettings(w, r, a.trf(r, "Could not restore backup: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
if written == 0 {
a.renderSettings(w, r, a.tr(r, "The archive holds no files this deployment recognises."), "", http.StatusUnprocessableEntity)
return
}
a.record(r, "backup.imported", fmt.Sprintf("%d files", written), nil)
a.renderSettings(w, r, "", i18n.Admin.N(a.langFor(r), "backup.files", written), http.StatusOK)
}
// --- updates ----------------------------------------------------------------
+179
View File
@@ -0,0 +1,179 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"net/http"
"regexp"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/session"
"sourcedock.dev/petrbalvin/volumen/internal/users"
"sourcedock.dev/petrbalvin/volumen/internal/web"
)
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
func (a *Admin) registerSettingsRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /admin/settings", a.requireLogin(a.handleSettings))
mux.HandleFunc("POST /admin/settings/password", a.requireLogin(a.handleSettingsPassword))
mux.HandleFunc("POST /admin/settings/username", a.requireLogin(a.handleSettingsUsername))
mux.HandleFunc("POST /admin/settings/name", a.requireLogin(a.handleSettingsName))
mux.HandleFunc("POST /admin/settings/language", a.requireLogin(a.handleSettingsLanguage))
mux.HandleFunc("POST /admin/settings/theme", a.requireLogin(a.handleSettingsTheme))
mux.HandleFunc("POST /admin/settings/fediverse", a.requireLogin(a.handleSettingsFediverse))
mux.HandleFunc("POST /admin/settings/orcid", a.requireLogin(a.handleSettingsOrcid))
mux.HandleFunc("POST /admin/settings/photo", a.requireLogin(a.handleSettingsPhoto))
mux.HandleFunc("POST /admin/settings/photo/remove", a.requireLogin(a.handleSettingsPhotoRemove))
mux.HandleFunc("POST /admin/settings/users", a.requireAdmin(a.handleSettingsUserCreate))
mux.HandleFunc("POST /admin/settings/users/{name}/role", a.requireAdmin(a.handleSettingsUserRole))
mux.HandleFunc("POST /admin/settings/users/{name}/password", a.requireAdmin(a.handleSettingsUserPassword))
mux.HandleFunc("POST /admin/settings/users/{name}/delete", a.requireAdmin(a.handleSettingsUserDelete))
mux.HandleFunc("POST /admin/settings/templates", a.requireAdmin(a.handleSettingsTemplateCreate))
mux.HandleFunc("POST /admin/settings/templates/{name}/delete", a.requireAdmin(a.handleSettingsTemplateDelete))
mux.HandleFunc("GET /admin/settings/export", a.requireAdmin(a.handleSettingsExport))
mux.HandleFunc("POST /admin/settings/import", a.requireAdmin(a.handleSettingsImport))
mux.HandleFunc("POST /admin/settings/check-update", a.requireAdmin(a.handleSettingsCheckUpdate))
mux.HandleFunc("POST /admin/settings/update", a.requireAdmin(a.handleSettingsUpdate))
mux.HandleFunc("POST /admin/settings/webhooks", a.requireAdmin(a.handleSettingsWebhookAdd))
mux.HandleFunc("POST /admin/settings/webhooks/toggle", a.requireAdmin(a.handleSettingsWebhookToggle))
mux.HandleFunc("POST /admin/settings/webhooks/delete", a.requireAdmin(a.handleSettingsWebhookDelete))
mux.HandleFunc("POST /admin/settings/webhooks/{index}/test", a.requireAdmin(a.handleSettingsWebhookTest))
mux.HandleFunc("POST /admin/settings/tokens", a.requireAdmin(a.handleSettingsTokenCreate))
mux.HandleFunc("POST /admin/settings/tokens/{name}/delete", a.requireAdmin(a.handleSettingsTokenDelete))
mux.HandleFunc("POST /admin/settings/twofactor/start", a.requireLogin(a.handleTotpStart))
mux.HandleFunc("POST /admin/settings/twofactor/cancel", a.requireLogin(a.handleTotpCancel))
mux.HandleFunc("POST /admin/settings/twofactor/verify", a.requireLogin(a.handleTotpVerify))
mux.HandleFunc("POST /admin/settings/twofactor/disable", a.requireLogin(a.handleTotpDisable))
mux.HandleFunc("POST /admin/settings/twofactor/codes", a.requireLogin(a.handleTotpCodes))
}
// requireAdmin additionally enforces the admin role.
func (a *Admin) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return a.requireLogin(func(w http.ResponseWriter, r *http.Request) {
sess := session.FromContext(r.Context())
record := a.deps.Users.Find(sess.Get("user"))
if record == nil || record.Role != "admin" {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next(w, r)
})
}
// settingsData builds the settings page context: the account record,
// the user list, the post templates, the webhook rows and the API
// tokens.
func (a *Admin) settingsData(r *http.Request) *PageData {
data := a.pageData(r)
data.IsAdmin = data.CurrentRole == "admin"
data.Roles = users.Roles
data.DefaultRole = users.DefaultRole
data.UserRows = userRows(data.CurrentUser, a.deps.Users.All())
data.TemplatesList = tplOptions(a.deps.Templates.All())
if a.deps.Webhooks != nil {
// The manager delivers the config-declared hooks first, the
// admin-managed ones after it, so the row's position tells where
// it came from and which forms apply to it.
data.WebhookRows = hookRows(a.deps.Webhooks.Hooks(), len(a.deps.StaticWebhooks))
deliveries := a.deps.Webhooks.Deliveries("")
data.WebhookDeliveries = deliveryRows(deliveries)
for i, d := range deliveries {
if d.Status != "ok" {
data.WebhookDeliveries[i].Result = i18n.Admin.N(data.Lang, "deliveries.attempts", d.Attempts)
}
}
}
data.TokenRows = tokenRows(a.deps.Tokens.All())
a.fillTotpState(data, r)
data.Crumbs = []Crumb{{Label: "Settings", IsLast: true, UI: true}}
return data
}
// handleSettingsLanguage switches the signed-in account's interface
// language. The choice persists on the user record for every request
// and in a cookie, so the login screen follows it too; the confirmation
// renders in the language just picked.
func (a *Admin) handleSettingsLanguage(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
lang := r.PostFormValue("language")
if !i18n.Valid(lang) {
a.renderSettings(w, r, i18n.Admin.T("en", "Unsupported language."), "", http.StatusUnprocessableEntity)
return
}
sess := session.FromContext(r.Context())
username := sess.Get("user")
if _, err := a.deps.Users.UpdateLanguage(username, lang); err != nil {
a.renderSettings(w, r, i18n.Admin.Tf("en", "The language could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: i18n.Cookie,
Value: lang,
Path: "/admin",
MaxAge: 365 * 24 * 3600,
HttpOnly: true,
Secure: a.cookieSecure(),
SameSite: http.SameSiteLaxMode,
})
data := a.settingsData(r)
data.Lang = lang
data.Notice = i18n.Admin.T(lang, "The interface language is set.")
a.renderPage(w, r, "settings.html", data, http.StatusOK)
}
// handleSettingsTheme switches the signed-in account's colour scheme.
// The choice persists on the user record for every request and in a
// cookie, so the login screen follows it too.
func (a *Admin) handleSettingsTheme(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
theme := r.PostFormValue("theme")
if !web.ValidTheme(theme) {
a.renderSettings(w, r, i18n.Admin.T("en", "Unsupported colour scheme."), "", http.StatusUnprocessableEntity)
return
}
sess := session.FromContext(r.Context())
username := sess.Get("user")
if _, err := a.deps.Users.UpdateTheme(username, theme); err != nil {
a.renderSettings(w, r, i18n.Admin.Tf("en", "The colour scheme could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: web.ThemeCookie,
Value: theme,
Path: "/admin",
MaxAge: 365 * 24 * 3600,
HttpOnly: true,
Secure: a.cookieSecure(),
SameSite: http.SameSiteLaxMode,
})
data := a.settingsData(r)
data.Theme = theme
data.Notice = i18n.Admin.T(data.Lang, "The colour scheme is set.")
a.renderPage(w, r, "settings.html", data, http.StatusOK)
}
// cookieSecure reports whether the deployment serves over HTTPS or
// behind a trusted proxy, the condition the session cookie and the
// preference cookies take their Secure flag from.
func (a *Admin) cookieSecure() bool {
return a.deps.Config.Server.CookieSecure || a.deps.Config.Server.TrustProxy
}
// renderSettings renders the settings page with a flash message.
func (a *Admin) renderSettings(w http.ResponseWriter, r *http.Request, errorMsg, notice string, status int) {
data := a.settingsData(r)
data.Error = errorMsg
data.Notice = notice
a.renderPage(w, r, "settings.html", data, status)
}
// handleSettings renders the settings page.
func (a *Admin) handleSettings(w http.ResponseWriter, r *http.Request) {
a.renderSettings(w, r, "", "", http.StatusOK)
}
+820
View File
@@ -0,0 +1,820 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"bytes"
"maps"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/web"
"sourcedock.dev/petrbalvin/volumen/internal/webhooks"
)
func settingsForm(t *testing.T, f *fixture, path string, extra url.Values) *httptest.ResponseRecorder {
t.Helper()
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
form := url.Values{"_csrf": {csrf}}
maps.Copy(form, extra)
return postForm(t, f, path, form, cookie)
}
func TestSettingsPageRenders(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
"Settings", "Account", "Users", "Templates", "Backup",
"API tokens", "Webhooks", "admin",
} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q", want)
}
}
// Every field that sets a password carries the strength meter: the
// own-account change and the add-user form are on the page itself,
// the per-user reset form rides each non-self user row. The floor
// attribute rides the same inputs and nowhere else on the page.
meters := strings.Count(body, `data-min-length=`)
wantMeters := 2
for _, row := range f.admin.deps.Users.All() {
if row.Username != "admin" {
wantMeters++
}
}
if meters != wantMeters {
t.Fatalf("password meters = %d, want %d", meters, wantMeters)
}
if !strings.Contains(body, `class="pw-level__bar"`) {
t.Fatal("meter bar markup missing")
}
}
func TestPasswordChange(t *testing.T) {
f := newFixture(t)
// Wrong current password.
rec := settingsForm(t, f, "/admin/settings/password", url.Values{
"current_password": {"nope"},
"new_password": {"another-good-pass"},
})
if !strings.Contains(rec.Body.String(), "Current password is incorrect.") {
t.Fatal("wrong-password message missing")
}
// Weak new password.
rec = settingsForm(t, f, "/admin/settings/password", url.Values{
"current_password": {"correct-horse-9"},
"new_password": {"short"},
})
if !strings.Contains(rec.Body.String(), "at least") {
t.Fatal("policy message missing")
}
// Success.
rec = settingsForm(t, f, "/admin/settings/password", url.Values{
"current_password": {"correct-horse-9"},
"new_password": {"a-brand-new-passphrase"},
})
if !strings.Contains(rec.Body.String(), "Password updated.") {
t.Fatal("success message missing")
}
if f.users.Authenticate("admin", "a-brand-new-passphrase") == nil {
t.Fatal("new password does not authenticate")
}
}
func TestUsernameChangeUpdatesSession(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/settings/username",
url.Values{"_csrf": {csrf}, "username": {"bad name!"}}, cookie)
if !strings.Contains(rec.Body.String(), "letters, numbers") {
t.Fatal("format message missing")
}
rec = postForm(t, f, "/admin/settings/username",
url.Values{"_csrf": {csrf}, "username": {"petr"}}, cookie)
if !strings.Contains(rec.Body.String(), "Username updated.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Find("petr") == nil {
t.Fatal("rename not applied")
}
// The session cookie was re-signed with the new username.
newCookie := sessionCookie(t, rec)
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.AddCookie(newCookie)
rec = f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `value="petr"`) {
t.Fatalf("session lost after rename: code=%d", rec.Code)
}
}
func TestThemeChange(t *testing.T) {
f := newFixture(t)
rec := settingsForm(t, f, "/admin/settings/theme", url.Values{"theme": {"plasma"}})
if !strings.Contains(rec.Body.String(), "The colour scheme is set.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Find("admin").Theme != "plasma" {
t.Fatal("theme not stored on the account")
}
found := false
for _, c := range rec.Result().Cookies() {
if c.Name == web.ThemeCookie && c.Value == "plasma" {
found = true
}
}
if !found {
t.Fatal("theme cookie missing")
}
// The picker re-renders with the choice marked pressed.
if !strings.Contains(rec.Body.String(), `value="plasma" class="chip" aria-pressed="true"`) {
t.Fatal("picked scheme not marked active")
}
// An unknown scheme is refused and does not overwrite the choice.
rec = settingsForm(t, f, "/admin/settings/theme", url.Values{"theme": {"sepia"}})
if !strings.Contains(rec.Body.String(), "Unsupported colour scheme.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Find("admin").Theme != "plasma" {
t.Fatal("invalid scheme overwrote the stored choice")
}
}
func TestNameAndFediverseChange(t *testing.T) {
f := newFixture(t)
rec := settingsForm(t, f, "/admin/settings/name", url.Values{"name": {"Petr Balvín"}})
if !strings.Contains(rec.Body.String(), "Display name updated.") {
t.Fatal("name message missing")
}
rec = settingsForm(t, f, "/admin/settings/fediverse", url.Values{"fediverse_creator": {"nope"}})
if !strings.Contains(rec.Body.String(), "@user@host") {
t.Fatal("fediverse validation missing")
}
rec = settingsForm(t, f, "/admin/settings/fediverse", url.Values{"fediverse_creator": {"@petr@social"}})
if !strings.Contains(rec.Body.String(), "Fediverse handle updated.") {
t.Fatal("fediverse message missing")
}
rec = settingsForm(t, f, "/admin/settings/fediverse", url.Values{"fediverse_creator": {""}})
if !strings.Contains(rec.Body.String(), "Fediverse handle cleared.") {
t.Fatal("fediverse clear missing")
}
}
func TestOrcidChange(t *testing.T) {
f := newFixture(t)
// A malformed iD is refused and nothing is stored.
rec := settingsForm(t, f, "/admin/settings/orcid", url.Values{"orcid": {"0000-0002-1825-0098"}})
if !strings.Contains(rec.Body.String(), "ORCID must look like") {
t.Fatalf("orcid validation missing: %s", rec.Body.String())
}
if f.users.Find("admin").Orcid != "" {
t.Fatal("invalid orcid was stored")
}
// A valid iD is kept, normalised to upper case.
rec = settingsForm(t, f, "/admin/settings/orcid", url.Values{"orcid": {"0000-0002-1825-0097"}})
if !strings.Contains(rec.Body.String(), "ORCID updated.") {
t.Fatal("orcid message missing")
}
if got := f.users.Find("admin").Orcid; got != "0000-0002-1825-0097" {
t.Fatalf("stored orcid = %q", got)
}
// An empty value clears it.
rec = settingsForm(t, f, "/admin/settings/orcid", url.Values{"orcid": {""}})
if !strings.Contains(rec.Body.String(), "ORCID cleared.") {
t.Fatal("orcid clear missing")
}
if got := f.users.Find("admin").Orcid; got != "" {
t.Fatalf("orcid not cleared: %q", got)
}
}
// A password an admin sets keeps its edge spaces: only the emptiness
// check may trim, the stored value must not, or the trimmed form would
// work where the typed one does not.
func TestUserCreateKeepsPasswordSpaces(t *testing.T) {
f := newFixture(t)
rec := settingsForm(t, f, "/admin/settings/users", url.Values{
"username": {"joe"}, "password": {" padded-passphrase "}, "role": {"author"},
})
if !strings.Contains(rec.Body.String(), "User added.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Authenticate("joe", " padded-passphrase ") == nil {
t.Fatal("the exact password, spaces included, must authenticate")
}
if f.users.Authenticate("joe", "padded-passphrase") != nil {
t.Fatal("the trimmed password must not authenticate")
}
}
func TestUserManagement(t *testing.T) {
f := newFixture(t)
// Create a second user.
rec := settingsForm(t, f, "/admin/settings/users", url.Values{
"username": {"joe"}, "password": {"joes-good-passphrase"}, "role": {"author"},
})
if !strings.Contains(rec.Body.String(), "User added.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Find("joe") == nil {
t.Fatal("user not created")
}
// Duplicate rejected.
rec = settingsForm(t, f, "/admin/settings/users", url.Values{
"username": {"joe"}, "password": {"joes-good-passphrase"}, "role": {"author"},
})
if !strings.Contains(rec.Body.String(), "could not be added") {
t.Fatal("duplicate message missing")
}
// Role change.
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec = postForm(t, f, "/admin/settings/users/joe/role",
url.Values{"_csrf": {csrf}, "role": {"admin"}}, cookie)
if !strings.Contains(rec.Body.String(), "Role updated.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Find("joe").Role != "admin" {
t.Fatal("role not applied")
}
// Own role cannot change.
rec = postForm(t, f, "/admin/settings/users/admin/role",
url.Values{"_csrf": {csrf}, "role": {"author"}}, cookie)
if !strings.Contains(rec.Body.String(), "own role") {
t.Fatal("self role-change not blocked")
}
// Delete.
rec = postForm(t, f, "/admin/settings/users/joe/delete", url.Values{"_csrf": {csrf}}, cookie)
if !strings.Contains(rec.Body.String(), "User removed.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Find("joe") != nil {
t.Fatal("user not deleted")
}
// Own account cannot be deleted.
rec = postForm(t, f, "/admin/settings/users/admin/delete", url.Values{"_csrf": {csrf}}, cookie)
if !strings.Contains(rec.Body.String(), "own account") {
t.Fatal("self delete not blocked")
}
}
func TestUserManagementRequiresAdmin(t *testing.T) {
f := newFixture(t)
f.users.Add("joe", "joes-good-passphrase", "author")
cookie := login(t, f, "joe", "joes-good-passphrase")
csrf := csrfFromSession(t, f, cookie)
req := httptest.NewRequest(http.MethodPost, "/admin/settings/users",
strings.NewReader(url.Values{
"_csrf": {csrf}, "username": {"x"}, "password": {"good-enough-pass"},
}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403", rec.Code)
}
}
func TestTemplateCRUD(t *testing.T) {
f := newFixture(t)
rec := settingsForm(t, f, "/admin/settings/templates", url.Values{
"name": {"Review"}, "tags": {"review, opinion"}, "body": {"## Summary"},
})
if !strings.Contains(rec.Body.String(), "Template added.") {
t.Fatalf("body = %s", rec.Body.String())
}
if len(f.admin.deps.Templates.All()) != 1 {
t.Fatal("template not stored")
}
rec = settingsForm(t, f, "/admin/settings/templates", url.Values{"name": {"Review"}})
if !strings.Contains(rec.Body.String(), "could not be added") {
t.Fatal("duplicate message missing")
}
// A fields box pre-fills the scientific editor inputs; the values are
// TOML, so strings are quoted and a bare number arrives as text.
rec = settingsForm(t, f, "/admin/settings/templates", url.Values{
"name": {"Paper"}, "fields": {"series = \"tds\"\ndoi = \"10.5281/zenodo.1\"\nseries_order = 3\n"},
})
if !strings.Contains(rec.Body.String(), "Template added.") {
t.Fatalf("fields template rejected: %s", rec.Body.String())
}
var paperFields map[string]string
for _, tpl := range f.admin.deps.Templates.All() {
if tpl.Name == "Paper" {
paperFields = tpl.Fields
}
}
if paperFields["series"] != "tds" || paperFields["doi"] != "10.5281/zenodo.1" ||
paperFields["series_order"] != "3" {
t.Fatalf("stored fields = %v", paperFields)
}
// A key outside the editor's inputs is refused, so a typo cannot
// silently seed every new post with a dead key.
rec = settingsForm(t, f, "/admin/settings/templates", url.Values{
"name": {"Nope"}, "fields": {"journal = \"Nature\"\n"},
})
if !strings.Contains(rec.Body.String(), "Unknown template field") {
t.Fatal("unknown field accepted")
}
rec = settingsForm(t, f, "/admin/settings/templates", url.Values{
"name": {"Broken"}, "fields": {"series == tds\n\n("},
})
if !strings.Contains(rec.Body.String(), "must be key = value") {
t.Fatal("unparsable fields accepted")
}
// The new-post form embeds the templates with the lowercase keys its
// picker reads, fields included.
cookie := login(t, f, "admin", "correct-horse-9")
newReq := httptest.NewRequest(http.MethodGet, "/admin/posts/new", nil)
newReq.AddCookie(cookie)
rec = f.do(t, newReq)
if !strings.Contains(rec.Body.String(), `"fields":{`) ||
!strings.Contains(rec.Body.String(), `"series":"tds"`) {
t.Fatal("template fields missing from the editor payload")
}
csrf := csrfFromSession(t, f, cookie)
rec = postForm(t, f, "/admin/settings/templates/Review/delete",
url.Values{"_csrf": {csrf}}, cookie)
if !strings.Contains(rec.Body.String(), "Template deleted.") {
t.Fatalf("body = %s", rec.Body.String())
}
}
func TestTokenCRUD(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/settings/tokens",
url.Values{"_csrf": {csrf}, "name": {"ci"}}, cookie)
body := rec.Body.String()
if !strings.Contains(body, "Copy this token now") || !strings.Contains(body, "vol_") {
t.Fatalf("new token not shown: %s", body)
}
rec = postForm(t, f, "/admin/settings/tokens/ci/delete", url.Values{"_csrf": {csrf}}, cookie)
if !strings.Contains(rec.Body.String(), "Token revoked.") {
t.Fatalf("body = %s", rec.Body.String())
}
if len(f.admin.deps.Tokens.All()) != 0 {
t.Fatal("token not revoked")
}
}
func TestBackupExportImport(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "keep.md", "+++\nslug = \"keep\"\ntitle = \"Keep\"\n+++\nbody\n")
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/settings/export", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "application/gzip" {
t.Fatalf("code=%d type=%q", rec.Code, rec.Header().Get("Content-Type"))
}
archive := rec.Body.Bytes()
if len(archive) == 0 {
t.Fatal("empty archive")
}
// Wipe the content dir, then restore.
if err := os.Remove(filepath.Join(f.contentDir, "keep.md")); err != nil {
t.Fatalf("remove: %v", err)
}
csrf := csrfFromSession(t, f, cookie)
rec = multipartBytes(t, f, "/admin/settings/import", cookie, csrf, "backup", archive)
if !strings.Contains(rec.Body.String(), "Backup restored") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.storeObj.Find("keep", "") == nil {
t.Fatal("post not restored from backup")
}
}
func TestCheckUpdateWithoutWiring(t *testing.T) {
f := newFixture(t)
rec := settingsForm(t, f, "/admin/settings/check-update", nil)
if !strings.Contains(rec.Body.String(), "not available in this build") {
t.Fatalf("body = %s", rec.Body.String())
}
}
func TestMediaLibraryAndDelete(t *testing.T) {
f := newFixture(t)
webpData := append([]byte("RIFF"), 0, 0, 0, 0)
webpData = append(webpData, []byte("WEBPVP8 ")...)
uploaded, err := f.storeObj.StoreUpload("pic.webp", webpData)
if err != nil {
t.Fatalf("upload: %v", err)
}
name := strings.TrimPrefix(uploaded, "/media/")
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/media", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), name) {
t.Fatalf("code=%d", rec.Code)
}
csrf := csrfFromSession(t, f, cookie)
rec = postForm(t, f, "/admin/media/"+name+"/delete", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/media" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
if _, err := f.storeObj.MediaPath(name); err == nil {
t.Fatal("media not deleted")
}
rec = postForm(t, f, "/admin/media/ghost.webp/delete", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
}
// multipartBytes posts a binary file field.
func multipartBytes(t *testing.T, f *fixture, path string, cookie *http.Cookie, csrf, field string, data []byte) *httptest.ResponseRecorder {
t.Helper()
body, contentType := buildMultipart(t, csrf, field, "backup.tar.gz", data)
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("Content-Type", contentType)
req.AddCookie(cookie)
return f.do(t, req)
}
// buildMultipart renders a single-file multipart body.
func buildMultipart(t *testing.T, csrf, field, filename string, data []byte) (string, string) {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
_ = mw.WriteField("_csrf", csrf)
part, err := mw.CreateFormFile(field, filename)
if err != nil {
t.Fatalf("create form file: %v", err)
}
if _, err := part.Write(data); err != nil {
t.Fatalf("write part: %v", err)
}
_ = mw.Close()
return buf.String(), mw.FormDataContentType()
}
func TestPhotoUploadAndRemove(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
webpData := append([]byte("RIFF"), 0, 0, 0, 0)
webpData = append(webpData, []byte("WEBPVP8 ")...)
body, contentType := buildMultipart(t, csrf, "photo", "me.webp", webpData)
req := httptest.NewRequest(http.MethodPost, "/admin/settings/photo", strings.NewReader(body))
req.Header.Set("Content-Type", contentType)
req.AddCookie(cookie)
rec := f.do(t, req)
if !strings.Contains(rec.Body.String(), "Profile photo updated.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Find("admin").Photo == "" {
t.Fatal("photo not stored on the user")
}
rec = postForm(t, f, "/admin/settings/photo/remove", url.Values{"_csrf": {csrf}}, cookie)
if !strings.Contains(rec.Body.String(), "Profile photo removed.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Find("admin").Photo != "" {
t.Fatal("photo not cleared")
}
}
func TestWebhookTestDelivery(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&hits, 1)
}))
defer srv.Close()
f := newFixture(t)
f.admin.deps.Config.Webhooks = []config.Webhook{{URL: srv.URL}}
f.admin.deps.Webhooks = webhooks.NewManager(
[]webhooks.Webhook{{URL: srv.URL, Enabled: true}}, "0.0.0-test")
rec := settingsForm(t, f, "/admin/settings/webhooks/0/test", nil)
if !strings.Contains(rec.Body.String(), "Test delivery sent.") {
t.Fatalf("body = %s", rec.Body.String())
}
if atomic.LoadInt32(&hits) != 1 {
t.Fatalf("hits = %d", hits)
}
rec = settingsForm(t, f, "/admin/settings/webhooks/9/test", nil)
if !strings.Contains(rec.Body.String(), "Webhook not found.") {
t.Fatalf("body = %s", rec.Body.String())
}
}
func TestUpdateHooksFlow(t *testing.T) {
f := newFixture(t)
f.admin.SetUpdateHooks(func() (string, error) { return "9.9.9", nil },
func() (string, error) { return "9.9.9", nil })
// Banner appears on the dashboard.
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if !strings.Contains(rec.Body.String(), "is available") {
t.Fatal("update banner missing")
}
csrf := csrfFromSession(t, f, cookie)
rec = postForm(t, f, "/admin/settings/update", url.Values{"_csrf": {csrf}}, cookie)
// The version is printed as the toolchain recorded it, prefix included.
if !strings.Contains(rec.Body.String(), "9.9.9") {
t.Fatalf("update page body = %s", rec.Body.String())
}
f.admin.SetUpdateHooks(func() (string, error) { return "", nil }, nil)
rec = settingsForm(t, f, "/admin/settings/check-update", nil)
if !strings.Contains(rec.Body.String(), "already the latest release") {
t.Fatalf("body = %s", rec.Body.String())
}
}
func TestTokenCreateWithScopes(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
form := url.Values{"_csrf": {csrf}, "name": {"scoped"}, "scope": {"write", "delete"}}
rec := postForm(t, f, "/admin/settings/tokens", form, cookie)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "vol_") {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
list := f.admin.deps.Tokens.All()
if len(list) != 1 {
t.Fatalf("tokens = %v", list)
}
if len(list[0].Scopes) != 2 || !list[0].HasScope("write") || !list[0].HasScope("delete") {
t.Fatalf("scopes = %v", list[0].Scopes)
}
if list[0].HasScope("read") {
t.Fatal("the removed read scope was granted")
}
}
func TestEditorSaveKeepsUnknownMetadata(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "aliased.md", `+++
title = "Aliased"
slug = "aliased"
aliases = ["old-slug"]
custom_field = "keep me"
[translations]
en = "aliased-en"
+++
body
`)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/aliased", url.Values{
"_csrf": {csrf}, "title": {"Aliased v2"}, "slug": {"aliased"},
"lang": {"cs"}, "body": {"new body"},
}, cookie)
if rec.Code != http.StatusSeeOther {
t.Fatalf("code = %d", rec.Code)
}
p := f.storeObj.Find("aliased", "")
if p == nil {
t.Fatal("post lost")
}
if got := p.Aliases(); len(got) != 1 || got[0] != "old-slug" {
t.Fatalf("aliases lost: %v", got)
}
if got := p.Translations(); got["en"] != "aliased-en" {
t.Fatalf("translations lost: %v", got)
}
if _, ok := p.Metadata.Get("custom_field"); !ok {
t.Fatal("custom field lost")
}
if p.Title() != "Aliased v2" {
t.Fatalf("title = %q", p.Title())
}
}
// A webhook added in Settings lands in webhooks.toml and reaches the
// manager without a restart; a config-declared hook stays read-only.
func TestSettingsWebhookLifecycle(t *testing.T) {
f := newFixture(t)
f.admin.deps.WebhooksFile = filepath.Join(t.TempDir(), "webhooks.toml")
f.admin.deps.Webhooks = webhooks.NewManager(nil, "t")
f.admin.deps.StaticWebhooks = []webhooks.Webhook{{URL: "https://cfg.example/hook", Enabled: true}}
f.admin.deps.Webhooks.SetHooks(f.admin.deps.StaticWebhooks)
// A config hook renders as read-only: no toggle form for it.
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if !strings.Contains(rec.Body.String(), "https://cfg.example/hook") ||
strings.Contains(rec.Body.String(), "Remove this webhook?") {
t.Fatalf("config hook row wrong: %d", rec.Code)
}
// Add one.
rec = settingsForm(t, f, "/admin/settings/webhooks", url.Values{
"url": {"https://example.com/hook"},
"secret": {"s3cret"},
"events": {"post.created, post.updated"},
"enabled": {"on"},
})
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Webhook added.") {
t.Fatalf("add: %d %s", rec.Code, rec.Body.String())
}
stored, err := webhooks.LoadFile(f.admin.deps.WebhooksFile)
if err != nil || len(stored) != 1 || stored[0].URL != "https://example.com/hook" ||
stored[0].Secret != "s3cret" || !stored[0].Enabled || len(stored[0].Events) != 2 {
t.Fatalf("stored = %+v err = %v", stored, err)
}
hooks := f.admin.deps.Webhooks.Hooks()
if len(hooks) != 2 || hooks[0].URL != "https://cfg.example/hook" || hooks[1].URL != "https://example.com/hook" {
t.Fatalf("manager = %+v", hooks)
}
// A duplicate URL and a broken URL are refused.
rec = settingsForm(t, f, "/admin/settings/webhooks", url.Values{
"url": {"https://example.com/hook"}, "enabled": {"on"},
})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "already configured") {
t.Fatalf("duplicate: %d %s", rec.Code, rec.Body.String())
}
rec = settingsForm(t, f, "/admin/settings/webhooks", url.Values{
"url": {"ftp://example.com/hook"}, "enabled": {"on"},
})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "not a valid") {
t.Fatalf("invalid url: %d %s", rec.Code, rec.Body.String())
}
// Toggle flips the stored flag and the manager's.
rec = settingsForm(t, f, "/admin/settings/webhooks/toggle", url.Values{
"url": {"https://example.com/hook"},
})
if rec.Code != http.StatusOK {
t.Fatalf("toggle: %d %s", rec.Code, rec.Body.String())
}
stored, _ = webhooks.LoadFile(f.admin.deps.WebhooksFile)
if stored[0].Enabled {
t.Fatal("toggle did not disable the hook")
}
if f.admin.deps.Webhooks.Hooks()[1].Enabled {
t.Fatal("manager kept the hook enabled")
}
// A config-declared URL is not toggleable.
rec = settingsForm(t, f, "/admin/settings/webhooks/toggle", url.Values{
"url": {"https://cfg.example/hook"},
})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "Webhook not found.") {
t.Fatalf("config hook toggle: %d %s", rec.Code, rec.Body.String())
}
// Delete removes the hook from the file and the manager.
rec = settingsForm(t, f, "/admin/settings/webhooks/delete", url.Values{
"url": {"https://example.com/hook"},
})
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Webhook removed.") {
t.Fatalf("delete: %d %s", rec.Code, rec.Body.String())
}
stored, _ = webhooks.LoadFile(f.admin.deps.WebhooksFile)
if len(stored) != 0 {
t.Fatalf("store = %+v", stored)
}
if len(f.admin.deps.Webhooks.Hooks()) != 1 {
t.Fatalf("manager = %+v", f.admin.deps.Webhooks.Hooks())
}
}
func TestOversizedBodyRejected(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
huge := strings.Repeat("a", 1_048_577)
rec := postForm(t, f, "/admin/posts", url.Values{
"_csrf": {csrf}, "title": {"Big"}, "slug": {"big"}, "body": {huge},
}, cookie)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d, want 422", rec.Code)
}
if !strings.Contains(rec.Body.String(), "at most 1048576 bytes") {
t.Fatal("size message missing")
}
}
// A changed password retires every session issued before it: the cookie
// carries a fingerprint of the hash, and only the device the change was
// made on gets re-bound.
func TestPasswordChangeSignsOutOtherSessions(t *testing.T) {
f := newFixture(t)
first := login(t, f, "admin", "correct-horse-9")
second := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, first)
rec := postForm(t, f, "/admin/settings/password", url.Values{
"_csrf": {csrf}, "current_password": {"correct-horse-9"},
"new_password": {"new-good-passphrase"},
}, first)
if !strings.Contains(rec.Body.String(), "Password updated.") {
t.Fatalf("body = %s", rec.Body.String())
}
// The change re-signs this device.s session; the cookie to test
// with is the one the response just set.
first = sessionCookie(t, rec)
// The other device.s session is dead.
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(second)
if rec := f.do(t, req); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
t.Fatalf("other session survived: %d %s", rec.Code, rec.Header().Get("Location"))
}
// The device the change was made on stays signed in.
req = httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(first)
if rec := f.do(t, req); rec.Code != http.StatusOK {
t.Fatalf("current session died: %d", rec.Code)
}
// The old password no longer signs in; the new one does.
if f.users.Authenticate("admin", "correct-horse-9") != nil {
t.Fatal("the old password still authenticates")
}
if f.users.Authenticate("admin", "new-good-passphrase") == nil {
t.Fatal("the new password does not authenticate")
}
}
// An admin can reset another account's password; the account's sessions
// die with it, and the admin cannot shortcut their own current-password
// check through the route.
func TestAdminPasswordReset(t *testing.T) {
f := newFixture(t)
if _, err := f.users.Add("author", "authors-good-passphrase", "author"); err != nil {
t.Fatalf("author not created: %v", err)
}
authorCookie := login(t, f, "author", "authors-good-passphrase")
// Self-reset is refused.
rec := settingsForm(t, f, "/admin/settings/users/admin/password",
url.Values{"password": {"shortcut-passphrase"}})
if rec.Code != http.StatusUnprocessableEntity ||
!strings.Contains(rec.Body.String(), "own password") {
t.Fatalf("self reset not blocked: %d %s", rec.Code, rec.Body.String())
}
// Reset the author's password.
rec = settingsForm(t, f, "/admin/settings/users/author/password",
url.Values{"password": {"reset-passphrase-9"}})
if !strings.Contains(rec.Body.String(), "sessions were signed out") {
t.Fatalf("body = %s", rec.Body.String())
}
// The author's session is dead, and the new password works.
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(authorCookie)
if rec := f.do(t, req); rec.Code != http.StatusSeeOther {
t.Fatalf("author session survived the reset: %d", rec.Code)
}
if f.users.Authenticate("author", "reset-passphrase-9") == nil {
t.Fatal("the reset password does not authenticate")
}
}
+189
View File
@@ -0,0 +1,189 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"encoding/base32"
"html/template"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/qrcode"
"sourcedock.dev/petrbalvin/volumen/internal/session"
"sourcedock.dev/petrbalvin/volumen/internal/totp"
"sourcedock.dev/petrbalvin/volumen/internal/users"
)
// The enrolment state rides the session: the candidate secret lives
// there between the QR page and the verifying code, so the users file
// only ever holds secrets that were proven by a working application.
const (
totpEnrollKey = "totp_enroll"
totpEnrollAt = "totp_enroll_at"
)
// enrolWindow bounds how long a candidate secret stays answerable.
const enrolWindow = 10 * time.Minute
// totpURI builds the otpauth URI every application understands.
func totpURI(secret, username string) string {
u := url.URL{
Scheme: "otpauth",
Host: "totp",
Path: "/Volumen:" + username,
RawQuery: url.Values{"secret": {secret}, "issuer": {"Volumen"}, "algorithm": {"SHA1"}, "digits": {"6"}, "period": {"30"}}.Encode(),
}
return u.String()
}
// fillTotpState carries the second-factor state of the signed-in
// account and of an enrolment in flight onto the settings page.
func (a *Admin) fillTotpState(data *PageData, r *http.Request) {
record := a.deps.Users.Find(data.CurrentUser)
if record != nil && record.TotpSecret != "" {
data.TotpEnabled = true
return
}
sess := session.FromContext(r.Context())
secret := sess.Get(totpEnrollKey)
if secret == "" {
return
}
started, err := strconv.ParseInt(sess.Get(totpEnrollAt), 10, 64)
if err != nil || time.Since(time.Unix(started, 0)) > enrolWindow {
sess.Delete(totpEnrollKey)
sess.Delete(totpEnrollAt)
return
}
data.TotpPending = true
data.TotpSecret = secret
data.TotpURI = totpURI(secret, data.CurrentUser)
if svg, err := qrcode.SVG(data.TotpURI); err == nil {
data.TotpSVG = template.HTML(svg)
}
}
// decodeBase32Secret turns the stored candidate back into key bytes.
func decodeBase32Secret(encoded string) ([]byte, error) {
return base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(encoded))
}
// totpOK checks a candidate secret against the code the application
// shows; no replay floor applies, this is the first use.
func totpOK(secret []byte, code string) bool {
ok, _ := totp.Validate(secret, code, time.Now(), 0)
return ok
}
// handleTotpStart begins enrolment: a fresh candidate secret travels to
// the settings page inside the session, and nothing is stored yet.
func (a *Admin) handleTotpStart(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
sess := session.FromContext(r.Context())
if record := a.deps.Users.Find(sess.Get("user")); record != nil && record.TotpSecret != "" {
a.renderSettings(w, r, i18n.Admin.T(a.lang(r, nil), "Two-factor authentication is already on."), "", http.StatusUnprocessableEntity)
return
}
secret := users.GenerateTotpSecret()
sess.Set(totpEnrollKey, secret)
sess.Set(totpEnrollAt, strconv.FormatInt(time.Now().Unix(), 10))
http.Redirect(w, r, "/admin/settings#security", http.StatusSeeOther)
}
// handleTotpCancel drops an enrolment in flight.
func (a *Admin) handleTotpCancel(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
sess := session.FromContext(r.Context())
sess.Delete(totpEnrollKey)
sess.Delete(totpEnrollAt)
http.Redirect(w, r, "/admin/settings#security", http.StatusSeeOther)
}
// handleTotpVerify finishes enrolment: the code the application shows
// proves the candidate secret, which is stored together with a fresh
// set of recovery codes. The codes are shown exactly once, here.
func (a *Admin) handleTotpVerify(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
sess := session.FromContext(r.Context())
username := sess.Get("user")
secret := sess.Get(totpEnrollKey)
if secret == "" {
http.Redirect(w, r, "/admin/settings#security", http.StatusSeeOther)
return
}
code := r.PostFormValue("code")
decoded, err := decodeBase32Secret(secret)
if err != nil || !totpOK(decoded, code) {
sess.Delete(totpEnrollKey)
sess.Delete(totpEnrollAt)
a.renderSettings(w, r, i18n.Admin.T(a.lang(r, nil), "That code did not match; start again."), "", http.StatusUnprocessableEntity)
return
}
codes, hashes := users.GenerateRecoveryCodes(10)
if _, err := a.deps.Users.EnableTotp(username, secret, hashes); err != nil {
a.renderSettings(w, r, i18n.Admin.Tf(a.lang(r, nil), "Two-factor could not be enabled: %s", err.Error()), "", http.StatusInternalServerError)
return
}
sess.Delete(totpEnrollKey)
sess.Delete(totpEnrollAt)
a.record(r, "user.totp_enabled", username, nil)
data := a.settingsData(r)
data.RecoveryCodes = codes
data.RecoveryNotice = i18n.Admin.T(data.Lang, "Two-factor is on. Store these recovery codes now; they will not be shown again.")
a.renderPage(w, r, "settings.html", data, http.StatusOK)
}
// handleTotpDisable turns the second factor off; possession of a
// current code is the proof, so a stolen cookie alone cannot.
func (a *Admin) handleTotpDisable(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
sess := session.FromContext(r.Context())
username := sess.Get("user")
if !a.deps.Users.VerifyTotp(username, r.PostFormValue("code"), time.Now()) {
a.renderSettings(w, r, i18n.Admin.T(a.lang(r, nil), "Wrong or expired code."), "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Users.ClearTotp(username); err != nil {
a.renderSettings(w, r, i18n.Admin.Tf(a.lang(r, nil), "Two-factor could not be disabled: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.record(r, "user.totp_disabled", username, nil)
a.renderSettings(w, r, "", i18n.Admin.T(a.lang(r, nil), "Two-factor is off."), http.StatusOK)
}
// handleTotpCodes replaces the recovery codes; the old ones stop
// working, and the new ones are shown exactly once.
func (a *Admin) handleTotpCodes(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
sess := session.FromContext(r.Context())
username := sess.Get("user")
if !a.deps.Users.VerifyTotp(username, r.PostFormValue("code"), time.Now()) {
a.renderSettings(w, r, i18n.Admin.T(a.lang(r, nil), "Wrong or expired code."), "", http.StatusUnprocessableEntity)
return
}
codes, hashes := users.GenerateRecoveryCodes(10)
if _, err := a.deps.Users.ReplaceRecovery(username, hashes); err != nil {
a.renderSettings(w, r, i18n.Admin.Tf(a.lang(r, nil), "The codes could not be replaced: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.record(r, "user.totp_codes", username, nil)
data := a.settingsData(r)
data.RecoveryCodes = codes
data.RecoveryNotice = i18n.Admin.T(data.Lang, "New recovery codes. Store them now; they will not be shown again.")
a.renderPage(w, r, "settings.html", data, http.StatusOK)
}
+56
View File
@@ -0,0 +1,56 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"net/http"
)
func (a *Admin) handleSettingsCheckUpdate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
if a.deps.CheckUpdate == nil {
a.renderSettings(w, r, "", a.tr(r, "Update checks are not available in this build."), http.StatusOK)
return
}
latest, err := a.deps.CheckUpdate()
if err != nil {
a.renderSettings(w, r, a.trf(r, "Update check failed: %s", err.Error()), "", http.StatusOK)
return
}
// The hook returns "" when the running version is current, so the
// comparison has already been made by the one implementation that
// knows how to make it.
if latest == "" {
a.renderSettings(w, r, "",
a.trf(r, "volumen %s is already the latest release.", a.deps.Version), http.StatusOK)
return
}
a.renderSettings(w, r, "", a.trf(r, "volumen %s is available.", latest), http.StatusOK)
}
func (a *Admin) handleSettingsUpdate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
if a.deps.SelfUpdate == nil {
a.renderSettings(w, r, a.tr(r, "Self-update is not available in this build."), "", http.StatusUnprocessableEntity)
return
}
target, err := a.deps.SelfUpdate()
if err != nil {
message := a.trf(r, "The upgrade failed: %s", err.Error())
if target != "" {
message = a.trf2(r, "Upgrade to %s failed: %s", target, err.Error())
}
a.renderSettings(w, r, message, "", http.StatusInternalServerError)
return
}
data := a.pageData(r)
data.Target = target
a.renderPage(w, r, "update.html", data, http.StatusOK)
}
// --- webhooks and tokens ----------------------------------------------------
+129
View File
@@ -0,0 +1,129 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"net/http"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
)
func (a *Admin) handleSettingsUserCreate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
// A password keeps its edge spaces: the reset path stores them the
// same way, and trimming here would create a password only the
// trimmed form of which works.
password := r.PostFormValue("password")
role := r.PostFormValue("role")
if username == "" || strings.TrimSpace(password) == "" {
a.renderSettings(w, r, a.tr(r, "Username and password are required."), "", http.StatusUnprocessableEntity)
return
}
if !usernameRe.MatchString(username) {
a.renderSettings(w, r, a.tr(r, "Username may use letters, numbers, dot, dash, underscore."), "", http.StatusUnprocessableEntity)
return
}
minLen, maxLen := a.passwordPolicy()
if key, n := PasswordError(password, minLen, maxLen); key != "" {
msg := a.tr(r, key)
if n > 0 {
msg = i18n.Admin.N(a.langFor(r), key, n)
}
a.renderSettings(w, r, msg, "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Users.Add(username, password, role); err != nil {
a.renderSettings(w, r, a.trf(r, "That user could not be added: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
a.record(r, "user.created", username, nil)
a.renderSettings(w, r, "", a.tr(r, "User added."), http.StatusOK)
}
func (a *Admin) handleSettingsUserRole(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
target := r.PathValue("name")
if target == a.currentUser(r) {
a.renderSettings(w, r, a.tr(r, "You cannot change your own role."), "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Users.SetRole(target, r.PostFormValue("role")); err != nil {
a.renderSettings(w, r, a.trf(r, "The role could not be changed: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
a.record(r, "user.role_changed", target, nil)
a.renderSettings(w, r, "", a.tr(r, "Role updated."), http.StatusOK)
}
func (a *Admin) handleSettingsUserDelete(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
target := r.PathValue("name")
if target == a.currentUser(r) {
a.renderSettings(w, r, a.tr(r, "You cannot delete your own account."), "", http.StatusUnprocessableEntity)
return
}
photo := ""
if record := a.deps.Users.Find(target); record != nil {
photo = record.Photo
}
if _, err := a.deps.Users.Delete(target); err != nil {
a.renderSettings(w, r, a.trf(r, "The user could not be removed: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
if photo != "" {
a.deleteUnreferencedMedia(photo)
}
a.record(r, "user.deleted", target, nil)
a.renderSettings(w, r, "", a.tr(r, "User removed."), http.StatusOK)
}
// --- post templates ---------------------------------------------------------
// handleSettingsUserPassword resets another account's password. The
// account's sessions die with the change (the session fingerprint
// changes), which is the point: an admin resetting a password is
// remedying an account, and every cookie issued before must stop
// working.
func (a *Admin) handleSettingsUserPassword(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
target := r.PathValue("name")
if target == a.currentUser(r) {
a.renderSettings(w, r, a.tr(r, "You cannot reset your own password here."), "", http.StatusUnprocessableEntity)
return
}
if a.deps.Users.Find(target) == nil {
a.renderSettings(w, r, a.tr(r, "That user was not found."), "", http.StatusUnprocessableEntity)
return
}
newPassword := r.PostFormValue("password")
if strings.TrimSpace(newPassword) == "" {
a.renderSettings(w, r, a.tr(r, "New password cannot be empty."), "", http.StatusUnprocessableEntity)
return
}
minLen, maxLen := a.passwordPolicy()
if key, n := PasswordError(newPassword, minLen, maxLen); key != "" {
msg := a.tr(r, key)
if n > 0 {
msg = i18n.Admin.N(a.langFor(r), key, n)
}
a.renderSettings(w, r, msg, "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Users.UpdatePassword(target, newPassword); err != nil {
a.renderSettings(w, r, a.trf(r, "The new password could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.record(r, "user.password_reset", target, nil)
a.renderSettings(w, r, "", a.tr(r, "Password reset; that user's sessions were signed out."), http.StatusOK)
}
+182
View File
@@ -0,0 +1,182 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"fmt"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/store"
"sourcedock.dev/petrbalvin/volumen/internal/tokens"
"sourcedock.dev/petrbalvin/volumen/internal/users"
"sourcedock.dev/petrbalvin/volumen/internal/webhooks"
)
// roleOption is one <option> of a role select.
type roleOption struct {
Value string
Selected bool
}
// userRow is one entry of the users panel.
type userRow struct {
Username string
Display string
Initial string
Role string
Photo string
IsSelf bool
RoleOptions []roleOption
}
// hookRow is one configured webhook endpoint.
type hookRow struct {
Index string
URL string
// Managed is false for a hook declared in config.toml, which the
// settings forms may test but not change.
Managed bool
Enabled bool
Signed bool
EventsText string
}
// deliveryRow is one webhook delivery log entry.
type deliveryRow struct {
Timestamp string
Event string
HookURL string
OK bool
StatusCode int
Error string
Attempts int
// Result is the pre-formatted failure text ("failed (N attempts)"),
// translated by the caller that knows the request's language.
Result string
}
// tokenRow is one API token table row.
type tokenRow struct {
Name string
CreatedDay string
LastUsedDay string
}
// mediaRow is one media library tile.
type mediaRow struct {
Name string
URL string
SizeKB string
// Dimensions is the header-carried pixel size ("1920 × 1080"), or
// "" when the container did not yield one.
Dimensions string
}
func roleOptions(current string) []roleOption {
out := make([]roleOption, 0, len(users.Roles))
for _, role := range users.Roles {
out = append(out, roleOption{Value: role, Selected: role == current})
}
return out
}
func userRows(current string, list []*users.User) []userRow {
out := make([]userRow, 0, len(list))
for _, user := range list {
display := user.Name
if display == "" {
display = user.Username
}
out = append(out, userRow{
Username: user.Username,
Display: display,
Initial: firstUpper(display, "?"),
Role: user.Role,
Photo: user.Photo,
IsSelf: user.Username == current,
RoleOptions: roleOptions(user.Role),
})
}
return out
}
// hookRows renders the manager's merged hook list; the first static
// count came from config.toml and the rest are the admin's to manage.
func hookRows(hooks []webhooks.Webhook, staticCount int) []hookRow {
out := make([]hookRow, 0, len(hooks))
for i, hook := range hooks {
eventsText := "all"
if len(hook.Events) > 0 {
eventsText = strings.Join(hook.Events, ", ")
}
out = append(out, hookRow{
Index: fmt.Sprintf("%d", i),
URL: hook.URL,
Managed: i >= staticCount,
Enabled: hook.Enabled,
Signed: hook.Secret != "",
EventsText: eventsText,
})
}
return out
}
func deliveryRows(list []webhooks.Delivery) []deliveryRow {
out := make([]deliveryRow, 0, len(list))
for _, d := range list {
out = append(out, deliveryRow{
Timestamp: d.Timestamp,
Event: d.Event,
HookURL: d.HookURL,
OK: d.Status == "ok",
StatusCode: d.StatusCode,
Error: d.Error,
Attempts: d.Attempts,
})
}
return out
}
func tokenRows(list []tokens.Token) []tokenRow {
out := make([]tokenRow, 0, len(list))
for _, token := range list {
lastUsed := "never"
if len(token.LastUsed) >= 10 {
lastUsed = token.LastUsed[:10]
}
created := token.Created
if len(created) >= 10 {
created = created[:10]
}
out = append(out, tokenRow{
Name: token.Name,
CreatedDay: created,
LastUsedDay: lastUsed,
})
}
return out
}
func mediaRows(list []store.Media) []mediaRow {
out := make([]mediaRow, 0, len(list))
for _, item := range list {
out = append(out, mediaRow{
Name: item.Name,
URL: item.URL,
SizeKB: fmt.Sprintf("%.1f", float64(item.Size)/1024),
Dimensions: pixelSize(item.Width, item.Height),
})
}
return out
}
// pixelSize renders the header-carried pixel size, empty when the
// container did not yield one.
func pixelSize(width, height int) string {
if width <= 0 || height <= 0 {
return ""
}
return fmt.Sprintf("%d × %d", width, height)
}
+139
View File
@@ -0,0 +1,139 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"net/http"
"net/url"
"slices"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
"sourcedock.dev/petrbalvin/volumen/internal/webhooks"
)
// fileHooks reads the admin-managed hook store.
func (a *Admin) fileHooks() ([]webhooks.Webhook, error) {
if a.deps.WebhooksFile == "" {
return nil, nil
}
return webhooks.LoadFile(a.deps.WebhooksFile)
}
// refreshWebhooks re-saves the store and applies the merged hook set to
// the manager, so a settings change delivers without a restart.
func (a *Admin) refreshWebhooks(hooks []webhooks.Webhook) error {
if err := webhooks.SaveFile(a.deps.WebhooksFile, hooks); err != nil {
return err
}
merged := make([]webhooks.Webhook, 0, len(a.deps.StaticWebhooks)+len(hooks))
merged = append(merged, a.deps.StaticWebhooks...)
merged = append(merged, hooks...)
a.deps.Webhooks.SetHooks(merged)
return nil
}
// handleSettingsWebhookAdd adds one endpoint to the store. Config-declared
// hooks are the operator's business and stay read-only; this list is the
// admin's to manage.
func (a *Admin) handleSettingsWebhookAdd(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
file, err := a.fileHooks()
if err != nil {
a.renderSettings(w, r, a.trf(r, "The webhook store could not be read: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
hook, problem := validateHookInput(
r.PostFormValue("url"), strings.TrimSpace(r.PostFormValue("secret")),
r.PostFormValue("events"), r.PostFormValue("enabled") == "on",
append(slices.Clone(a.deps.StaticWebhooks), file...),
)
if problem != "" {
a.renderSettings(w, r, a.tr(r, problem), "", http.StatusUnprocessableEntity)
return
}
if err := a.refreshWebhooks(append(file, hook)); err != nil {
a.renderSettings(w, r, a.trf(r, "The webhook could not be saved: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
a.record(r, "webhook.added", hook.URL, nil)
a.renderSettings(w, r, "", a.tr(r, "Webhook added."), http.StatusOK)
}
// handleSettingsWebhookToggle flips one stored hook's enabled flag. The
// URL names the hook, because the row the form was rendered from may no
// longer be at its old index by the time the POST lands.
func (a *Admin) handleSettingsWebhookToggle(w http.ResponseWriter, r *http.Request) {
a.mutateStoredHook(w, r, "webhook.updated", "Webhook updated.",
func(hooks []webhooks.Webhook, url string) ([]webhooks.Webhook, bool) {
for i, hook := range hooks {
if hook.URL == url {
hooks[i].Enabled = !hook.Enabled
return hooks, true
}
}
return hooks, false
})
}
func (a *Admin) handleSettingsWebhookDelete(w http.ResponseWriter, r *http.Request) {
a.mutateStoredHook(w, r, "webhook.deleted", "Webhook removed.",
func(hooks []webhooks.Webhook, url string) ([]webhooks.Webhook, bool) {
for i, hook := range hooks {
if hook.URL == url {
return slices.Delete(hooks, i, i+1), true
}
}
return hooks, false
})
}
// mutateStoredHook applies a change to the stored hook the form's url
// field names, re-saves, and refreshes the manager.
func (a *Admin) mutateStoredHook(w http.ResponseWriter, r *http.Request, auditAction, notice string, apply func([]webhooks.Webhook, string) ([]webhooks.Webhook, bool)) {
if !a.requireCSRF(w, r) {
return
}
file, err := a.fileHooks()
if err != nil {
a.renderSettings(w, r, a.trf(r, "The webhook store could not be read: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
target := r.PostFormValue("url")
changed, ok := apply(file, target)
if !ok {
a.renderSettings(w, r, a.tr(r, "Webhook not found."), "", http.StatusUnprocessableEntity)
return
}
if err := a.refreshWebhooks(changed); err != nil {
a.renderSettings(w, r, a.trf(r, "The webhook could not be saved: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
a.record(r, auditAction, target, nil)
a.renderSettings(w, r, "", a.tr(r, notice), http.StatusOK)
}
// validateHookInput checks the add form: an absolute http(s) URL no
// configured hook already uses, an optional secret, and the event
// filter as a comma-separated list (empty delivers everything).
func validateHookInput(raw, secret, events string, enabled bool, existing []webhooks.Webhook) (webhooks.Webhook, string) {
raw = strings.TrimSpace(raw)
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return webhooks.Webhook{}, "That URL is not a valid http(s) endpoint."
}
for _, hook := range existing {
if hook.URL == raw {
return webhooks.Webhook{}, "That URL is already configured."
}
}
return webhooks.Webhook{
URL: raw,
Secret: secret,
Events: payloads.ParseTags(events),
Enabled: enabled,
}, ""
}
+240
View File
@@ -0,0 +1,240 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
json "encoding/json/v2"
"errors"
"html/template"
"net/http"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/session"
"sourcedock.dev/petrbalvin/volumen/internal/users"
"sourcedock.dev/petrbalvin/volumen/internal/web"
)
// setupNeeded reports whether the first-run wizard should serve: a
// readable users file that holds no accounts. A file that cannot be
// read answers through the second value: the wizard would refuse to
// write over it anyway, so the caller says so instead of offering a
// form that cannot work.
func (a *Admin) setupNeeded() (needed bool, broken error) {
if err := a.deps.Users.Health(); err != nil {
return false, err
}
return !a.deps.Users.Any(), nil
}
// registerSetupRoutes mounts the wizard. The routes are public in the
// same sense the login is public: they exist for the owner of the
// installation before any account does, and the wizard retires itself
// as soon as one account exists.
func (a *Admin) registerSetupRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /admin/setup", a.handleSetupForm)
mux.HandleFunc("POST /admin/setup", a.handleSetup)
}
// handleSetupForm serves the wizard while no account exists. Once one
// does, the route sends the browser back to the login, which is the
// same answer as deleting the route: the first run happens exactly
// once. The ?lang query re-renders the page in another shipped language:
// the language chips are real links, so the choice works without
// JavaScript too.
func (a *Admin) handleSetupForm(w http.ResponseWriter, r *http.Request) {
sess := session.FromContext(r.Context())
if sess.Get("user") != "" && a.deps.Users.Find(sess.Get("user")) != nil {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
needed, broken := a.setupNeeded()
if broken != nil {
http.Error(w, a.tr(r, "The users file cannot be read; repair it before setting up."), http.StatusServiceUnavailable)
return
}
if !needed {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
lang := i18n.Normalize(r.URL.Query().Get("lang"))
a.renderSetup(w, r, "", http.StatusOK, lang)
}
// renderSetup draws the wizard page in the given language ("" keeps the
// site default) and with the error the last attempt reported when there
// is one. The wizard always opens on the clean defaults: the site
// language and the shipped scheme, never on the anonymous preview
// cookies, which belong to the login screen after an account exists and
// here would only be a leftover from a half-finished or reset setup. The
// cookies are expired on the way so the next screen starts from the same
// truth the form shows. The page carries both languages of its own
// strings so the chips can swap the text without a reload.
func (a *Admin) renderSetup(w http.ResponseWriter, r *http.Request, errorMsg string, status int, lang string) {
if lang == "" {
lang = a.siteLanguage()
}
data := a.pageData(r)
data.IsSetup = true
data.Lang = lang
data.Theme = web.DefaultTheme
data.Error = errorMsg
data.SetupI18n = setupI18n(data.Config.Admin.MinPasswordLength)
expires := &http.Cookie{MaxAge: -1, Path: "/admin", HttpOnly: true}
c1 := *expires
c1.Name = i18n.Cookie
http.SetCookie(w, &c1)
c2 := *expires
c2.Name = web.ThemeCookie
http.SetCookie(w, &c2)
a.renderPage(w, r, "setup.html", data, status)
}
// setupI18nKeys are the wizard's own interface strings, keyed by their
// English source; the page swaps them client-side when a language chip
// is clicked, so the typed values survive. "password.hint" is added
// separately because it carries the configured length.
var setupI18nKeys = []string{
"Welcome to Volumen",
"Set up the administrator account to open this installation.",
"Account",
"Username",
"Display name",
"Your real name",
"Password",
"Show password",
"Hide password",
"Language",
"Colour scheme",
"The page takes the colours as you choose.",
"Create account",
}
// setupI18n builds the page's bilingual payload: every wizard string in
// both shipped languages, and the script catalogue per language, escaped
// for embedding in a script element the way the shared catalogue is.
func setupI18n(minLength int) template.JS {
ui := make(map[string]map[string]string, len(setupI18nKeys)+1)
for _, key := range setupI18nKeys {
ui[key] = map[string]string{
"en": i18n.Admin.T("en", key),
"cs": i18n.Admin.T("cs", key),
}
}
ui["password.hint"] = map[string]string{
"en": i18n.Admin.N("en", "password.min", minLength),
"cs": i18n.Admin.N("cs", "password.min", minLength),
}
payload := map[string]any{
"ui": ui,
"js": map[string]any{
"en": i18n.Admin.JS("en"),
"cs": i18n.Admin.JS("cs"),
},
}
b, err := json.Marshal(payload, json.Deterministic(true))
if err != nil {
return "{}"
}
return template.JS(strings.ReplaceAll(string(b), "<", `\u003c`))
}
// siteLanguage is the interface language a request falls back to when no
// account and no cookie decide it: the configured site language when the
// UI ships it, English otherwise.
func (a *Admin) siteLanguage() string {
if lang := i18n.Normalize(a.deps.Config.Site.Language); lang != "" {
return lang
}
return "en"
}
// handleSetup creates the first administrator account, stores the
// interface choices with it and signs the operator straight in. The
// password goes through the same server policy as every other password
// change; the page meter is advice, this is the gate.
func (a *Admin) handleSetup(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
needed, broken := a.setupNeeded()
if broken != nil {
http.Error(w, a.tr(r, "The users file cannot be read; repair it before setting up."), http.StatusServiceUnavailable)
return
}
if !needed {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
if username == "" {
username = "admin"
}
name := strings.TrimSpace(r.PostFormValue("name"))
language := i18n.Normalize(r.PostFormValue("language"))
if language == "" {
language = a.siteLanguage()
}
theme := r.PostFormValue("theme")
if !web.ValidTheme(theme) {
theme = web.DefaultTheme
}
secret := r.PostFormValue("password")
if !usernameRe.MatchString(username) {
a.renderSetup(w, r, i18n.Admin.T(language, "Username may use letters, numbers, dot, dash, underscore."), http.StatusUnprocessableEntity, language)
return
}
minLen, maxLen := a.passwordPolicy()
if key, n := PasswordError(secret, minLen, maxLen); key != "" {
msg := i18n.Admin.T(language, key)
if n > 0 {
msg = i18n.Admin.N(language, key, n)
}
a.renderSetup(w, r, msg, http.StatusUnprocessableEntity, language)
return
}
user, err := a.deps.Users.AddFirst(username, secret, language, theme, name)
switch {
case err == nil:
case errors.Is(err, users.ErrUsersExist):
// Another claim won the race a moment ago; the wizard is gone
// and the account is already there.
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
default:
a.renderSetup(w, r, i18n.Admin.Tf(language, "The account could not be created: %s", err.Error()), http.StatusInternalServerError, language)
return
}
// Sign the operator in with the same session shape the login uses,
// so the wizard ends where a first sign-in would: inside the
// dashboard, on one request.
sess := session.FromContext(r.Context())
sess.Set("user", user.Username)
sess.Set("pv", sessionFingerprint(user.PasswordHash))
a.record(r, "setup.first_user", user.Username, nil)
secure := a.cookieSecure()
http.SetCookie(w, &http.Cookie{
Name: i18n.Cookie,
Value: language,
Path: "/admin",
MaxAge: 365 * 24 * 3600,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
http.SetCookie(w, &http.Cookie{
Name: web.ThemeCookie,
Value: theme,
Path: "/admin",
MaxAge: 365 * 24 * 3600,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
}
+236
View File
@@ -0,0 +1,236 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/web"
)
// A stale anonymous preview cookie (a leftover of an abandoned or reset
// setup) must not greet the operator on the wizard: the first run opens
// on the clean defaults.
func TestSetupFormIgnoresPreviewCookies(t *testing.T) {
f := newFixtureSeeded(t, false)
req := httptest.NewRequest(http.MethodGet, "/admin/setup", nil)
req.AddCookie(&http.Cookie{Name: i18n.Cookie, Value: "cs"})
req.AddCookie(&http.Cookie{Name: web.ThemeCookie, Value: "magma"})
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, `<html lang="en" data-palette="viridis">`) {
t.Fatalf("wizard did not open on the clean defaults:\n%s", body[:min(len(body), 400)])
}
// The response expires both preview cookies so later screens are clean too.
var sawLang, sawTheme bool
for _, c := range rec.Result().Cookies() {
if c.Name == i18n.Cookie && c.MaxAge < 0 {
sawLang = true
}
if c.Name == web.ThemeCookie && c.MaxAge < 0 {
sawTheme = true
}
}
if !sawLang || !sawTheme {
t.Fatalf("preview cookies not expired on the wizard response: %v", rec.Result().Cookies())
}
}
// A deployment with no accounts shows the wizard in place of the login
// screen; the login URL itself redirects, so an old bookmark lands in
// the right place too.
func TestLoginFormRedirectsToWizard(t *testing.T) {
f := newFixtureSeeded(t, false)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/setup" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
}
func TestSetupFormServesWhileNoAccounts(t *testing.T) {
f := newFixtureSeeded(t, false)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"Welcome to Volumen", "name=\"password\"", `name="theme"`, `name="language"`} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q", want)
}
}
}
// The chips are real links, so the language is a server-side choice
// too: ?lang renders the whole page in it and the hidden field carries
// it into the account.
func TestSetupFormHonoursLangQuery(t *testing.T) {
f := newFixtureSeeded(t, false)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup?lang=cs", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{`<html lang="cs"`, "Účet", `name="language" id="setup-language" value="cs"`} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q", want)
}
}
// The bilingual bundle rides along for the client-side swap.
if !strings.Contains(body, "Vítejte ve Volumenu") {
t.Fatal("the language bundle is missing")
}
}
func TestSetupFormRetiresWhenAccountsExist(t *testing.T) {
f := newFixture(t)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
}
// The wizard creates the first account, keeps the language and the
// colour scheme with it, and signs the operator in on the same trip.
func TestSetupCreatesAndSignsIn(t *testing.T) {
f := newFixtureSeeded(t, false)
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
csrf := extractCSRF(t, get.Body.String())
cookie := sessionCookie(t, get)
form := url.Values{
"_csrf": {csrf},
"username": {"balvin"},
"name": {"Petr Balvín"},
"password": {"a-genuinely-unique-passphrase"},
"language": {"cs"},
"theme": {"plasma"},
}
req := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("code=%d location=%q body=%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
}
user := f.users.Find("balvin")
if user == nil || user.Role != "admin" {
t.Fatalf("first account missing: %v", user)
}
if user.Language != "cs" || user.Theme != "plasma" {
t.Fatalf("wizard choices not stored: lang=%q theme=%q", user.Language, user.Theme)
}
if user.Name != "Petr Balvín" {
t.Fatalf("display name = %q", user.Name)
}
// The session cookie from the wizard opens the dashboard: the
// operator is signed in, not sent back through the login.
authed := sessionCookie(t, rec)
dash := httptest.NewRequest(http.MethodGet, "/admin/", nil)
dash.AddCookie(authed)
if rec := f.do(t, dash); rec.Code != http.StatusOK {
t.Fatalf("dashboard after setup: code = %d", rec.Code)
}
// A second claim of the same wizard is refused and pointed at the
// login: the installation has exactly one first account. The CSRF
// token rides the same session, so the refusal comes from the
// accounts already existing, not from the form.
req2 := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req2.AddCookie(authed)
rec2 := f.do(t, req2)
if rec2.Code != http.StatusSeeOther || rec2.Header().Get("Location") != "/admin/login" {
t.Fatalf("second claim: code=%d location=%q", rec2.Code, rec2.Header().Get("Location"))
}
}
// The wizard POST validates with the same server-side rules every
// password change uses: the page meter is advice, this is the gate.
func TestSetupRejectsWeakPasswordAndBadCSRF(t *testing.T) {
f := newFixtureSeeded(t, false)
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
csrf := extractCSRF(t, get.Body.String())
cookie := sessionCookie(t, get)
form := url.Values{
"_csrf": {csrf},
"username": {"admin"},
"password": {"short"},
}
req := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("weak password: code = %d", rec.Code)
}
if f.users.Any() {
t.Fatal("a refused wizard still created an account")
}
noCSRF := url.Values{"username": {"admin"}, "password": {"a-genuinely-unique-passphrase"}}
req = httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(noCSRF.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusForbidden {
t.Fatalf("missing CSRF: code = %d", rec.Code)
}
}
func TestSetupRejectsBadUsername(t *testing.T) {
f := newFixtureSeeded(t, false)
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
csrf := extractCSRF(t, get.Body.String())
cookie := sessionCookie(t, get)
form := url.Values{
"_csrf": {csrf},
"username": {"not a name!"},
"password": {"a-genuinely-unique-passphrase"},
}
req := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("bad username: code = %d", rec.Code)
}
if f.users.Any() {
t.Fatal("a refused username still created an account")
}
}
// The default username is admin, and an empty field gets it: the form
// starts filled, a submit that cleared it still lands on a valid name.
func TestSetupDefaultsUsername(t *testing.T) {
f := newFixtureSeeded(t, false)
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
csrf := extractCSRF(t, get.Body.String())
cookie := sessionCookie(t, get)
form := url.Values{
"_csrf": {csrf},
"username": {""},
"password": {"a-genuinely-unique-passphrase"},
}
req := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("empty username: code = %d body = %s", rec.Code, rec.Body.String())
}
if f.users.Find("admin") == nil {
t.Fatal("the empty username field did not fall back to admin")
}
}
+237
View File
@@ -0,0 +1,237 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"encoding/base32"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/totp"
"sourcedock.dev/petrbalvin/volumen/internal/users"
)
func currentCode(t *testing.T, secret string) string {
t.Helper()
return currentCodeIn(t, secret, 0)
}
// currentCodeIn computes the code of a neighbouring time step, so a
// test can answer twice without tripping the replay floor.
func currentCodeIn(t *testing.T, secret string, steps int) string {
t.Helper()
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
if err != nil {
t.Fatalf("decode secret: %v", err)
}
return totp.Code(key, time.Now().Add(time.Duration(steps)*totp.Step))
}
// loginTo opens the first door and returns the session wherever it
// stands: the dashboard, or the second-factor step when the account
// has one.
func loginTo(t *testing.T, f *fixture, username, secret string) *http.Cookie {
t.Helper()
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
csrf := extractCSRF(t, get.Body.String())
cookie := sessionCookie(t, get)
form := url.Values{"_csrf": {csrf}, "username": {username}, "password": {secret}}
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("login failed: code=%d body=%s", rec.Code, rec.Body.String())
}
return sessionCookie(t, rec)
}
// TestLoginWithSecondFactor walks the whole door: password, code, in,
// and the recovery path when the application is lost.
func TestLoginWithSecondFactor(t *testing.T) {
f := newFixture(t)
secret := users.GenerateTotpSecret()
codes, hashes := users.GenerateRecoveryCodes(10)
if _, err := f.users.EnableTotp("admin", secret, hashes); err != nil {
t.Fatal(err)
}
cookie := loginTo(t, f, "admin", "correct-horse-9")
// The password alone no longer opens anything: the admin bounces
// to the login, which forwards a pending session to the step.
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
t.Fatalf("password step: code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
req = httptest.NewRequest(http.MethodGet, "/admin/login", nil)
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/twofactor" {
t.Fatalf("login form forwards pending session: code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
req = httptest.NewRequest(http.MethodGet, "/admin/twofactor", nil)
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Verification code") {
t.Fatalf("twofactor form: code=%d", rec.Code)
}
// The direct route redirects anonymous traffic to the first step.
req = httptest.NewRequest(http.MethodGet, "/admin/twofactor", nil)
if rec := f.do(t, req); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
t.Fatalf("anonymous twofactor: code=%d", rec.Code)
}
csrf := csrfFromSession(t, f, cookie)
// A wrong code is refused and changes nothing.
rec := postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {"000000"}}, cookie)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("wrong code: code=%d", rec.Code)
}
rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {currentCode(t, secret)}}, cookie)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("right code: code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
cookie = sessionCookie(t, rec)
req = httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusOK {
t.Fatalf("dashboard after second factor: %d", rec.Code)
}
// The recovery path: sign out, in again, spend one code; the same
// code never works twice.
postForm(t, f, "/admin/logout", url.Values{"_csrf": {csrf}}, cookie)
cookie = loginTo(t, f, "admin", "correct-horse-9")
csrf = csrfFromSession(t, f, cookie)
rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {codes[0]}}, cookie)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("recovery code: code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
cookie = sessionCookie(t, rec)
postForm(t, f, "/admin/logout", url.Values{"_csrf": {csrf}}, cookie)
cookie = loginTo(t, f, "admin", "correct-horse-9")
csrf = csrfFromSession(t, f, cookie)
rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {codes[0]}}, cookie)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("reused recovery code: code=%d", rec.Code)
}
// The typed shapes humans use still work.
rec = postForm(t, f, "/admin/twofactor",
url.Values{"_csrf": {csrf}, "code": {strings.ReplaceAll(codes[1], "-", " ")}}, cookie)
if rec.Code != http.StatusSeeOther {
t.Fatalf("spaced recovery code: code=%d", rec.Code)
}
}
// TestTotpEnrolment drives the settings flow: start, the QR page, the
// verifying code, the one-time recovery codes, and turning it off.
func TestTotpEnrolment(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
// Before anything, the settings page offers the setup.
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.AddCookie(cookie)
body := f.do(t, req).Body.String()
if !strings.Contains(body, "Set up two-factor") {
t.Fatal("setup offer missing")
}
// Start shows the QR and the secret, and stores nothing yet. The
// candidate rides the cookie, so the jar moves on with it.
rec := postForm(t, f, "/admin/settings/twofactor/start", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusSeeOther {
t.Fatalf("start: %d", rec.Code)
}
cookie = sessionCookie(t, rec)
req = httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.AddCookie(cookie)
body = f.do(t, req).Body.String()
if !strings.Contains(body, "totp__qr") || !strings.Contains(body, `<path fill="#000"`) {
t.Fatal("QR panel missing after start")
}
if f.users.Find("admin").TotpSecret != "" {
t.Fatal("start stored a secret before verification")
}
// A wrong verifying code clears the candidate.
rec = postForm(t, f, "/admin/settings/twofactor/verify", url.Values{"_csrf": {csrf}, "code": {"000000"}}, cookie)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("wrong verify: %d", rec.Code)
}
cookie = sessionCookie(t, rec)
req = httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.AddCookie(cookie)
if strings.Contains(f.do(t, req).Body.String(), "totp__qr") {
t.Fatal("candidate survived a wrong code")
}
// The honest path: start again, verify with the code the
// application shows, receive the recovery codes once.
rec = postForm(t, f, "/admin/settings/twofactor/start", url.Values{"_csrf": {csrf}}, cookie)
cookie = sessionCookie(t, rec)
req = httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.AddCookie(cookie)
body = f.do(t, req).Body.String()
i := strings.Index(body, `class="totp__secret"`)
if i < 0 {
t.Fatal("secret text missing")
}
rest := body[i:]
j := strings.Index(rest, ">")
k := strings.Index(rest[j:], "<")
secret := rest[j+1 : j+k]
if len(secret) < 26 {
t.Fatalf("secret looks wrong: %q", secret)
}
rec = postForm(t, f, "/admin/settings/twofactor/verify", url.Values{"_csrf": {csrf}, "code": {currentCode(t, secret)}}, cookie)
if rec.Code != http.StatusOK {
t.Fatalf("verify: %d body=%s", rec.Code, rec.Body.String()[:200])
}
page := rec.Body.String()
if !strings.Contains(page, "recovery__code") {
t.Fatal("recovery codes not shown once")
}
if f.users.Find("admin").TotpSecret == "" {
t.Fatal("enabled secret not stored")
}
// The next sign-in needs the second factor.
postForm(t, f, "/admin/logout", url.Values{"_csrf": {csrf}}, cookie)
cookie = loginTo(t, f, "admin", "correct-horse-9")
req = httptest.NewRequest(http.MethodGet, "/admin/login", nil)
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Header().Get("Location") != "/admin/twofactor" {
t.Fatalf("second factor not asked: %q", rec.Header().Get("Location"))
}
// Turning it off asks for a current code.
csrf = csrfFromSession(t, f, cookie)
rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {currentCode(t, secret)}}, cookie)
if rec.Code != http.StatusSeeOther {
t.Fatalf("sign-in code: %d", rec.Code)
}
cookie = sessionCookie(t, rec)
rec = postForm(t, f, "/admin/settings/twofactor/disable", url.Values{"_csrf": {csrf}, "code": {"000000"}}, cookie)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("disable with wrong code: %d", rec.Code)
}
// The sign-in already spent this window's code: the next window's
// code answers, the spent one must not.
rec = postForm(t, f, "/admin/settings/twofactor/disable",
url.Values{"_csrf": {csrf}, "code": {currentCodeIn(t, secret, 1)}}, cookie)
if rec.Code != http.StatusOK {
t.Fatalf("disable: %d", rec.Code)
}
if f.users.Find("admin").TotpSecret != "" {
t.Fatal("secret survived disable")
}
}
+426
View File
@@ -0,0 +1,426 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
json "encoding/json/v2"
"fmt"
"html/template"
"log/slog"
"maps"
"slices"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/post"
"sourcedock.dev/petrbalvin/volumen/internal/store"
"sourcedock.dev/petrbalvin/volumen/internal/templates"
)
// postVariant is one language version of a publication. The dashboard
// shows one card per publication and lists its variants here, so the card
// can switch what it names, links and shows between them.
type postVariant struct {
Lang string `json:"lang"`
Slug string `json:"slug"`
Title string `json:"title"`
Excerpt string `json:"excerpt"`
DateString string `json:"date"`
Timestamp int64 `json:"timestamp,omitempty"`
Status string `json:"status"`
Draft bool `json:"draft,omitempty"`
Scheduled bool `json:"scheduled,omitempty"`
Series string `json:"series,omitempty"`
HasOrder bool `json:"hasOrder,omitempty"`
SeriesOrder int `json:"seriesOrder,omitempty"`
Cover string `json:"cover,omitempty"`
}
// postCard is one dashboard card.
type postCard struct {
Slug string
Title string
Excerpt string
Lang string
Series string
HasOrder bool
SeriesOrder int
DateString string
Cover string
Draft bool
Scheduled bool
Tags []string
Timestamp int64
Status string
// Variants carries every language version of the publication;
// VariantsJSON is the same list prepared for the card's data
// attribute, so the page script can switch between them.
Variants []postVariant
VariantsJS template.JS
GroupSlugs string
}
// recentPost is one entry of the dashboard's recent strip.
type recentPost struct {
Slug string
Title string
DateString string
}
// scheduledPost is one entry of the dashboard's upcoming strip.
type scheduledPost struct {
Slug string
Title string
When string
}
// dashboardStats holds the counters shown above the post grid.
type dashboardStats struct {
Total int
Published int
Drafts int
Scheduled int
Recent []recentPost
Upcoming []scheduledPost
}
// tagCount is one tag-cloud chip.
type tagCount struct {
Name string
Count int
}
// editorPost carries the form-ready post fields for the editor.
type editorPost struct {
Slug string
Title string
Lang string
Author string
FediverseCreator string
DOI string
ORCID string
Date string
PublishAt string
TagsCSV string
Series string
SeriesOrder string
Excerpt string
ExcerptIsSet bool
ExcerptPlaceholder string
Cover string
CoverAlt string
CoverCaption string
Body string
Draft bool
AllLangs bool
Scheduled bool
// RefsJSON is the post's reference list as a JS literal for the
// bibliography card: the frontmatter tables as they are stored, so
// the editor edits what the file holds and no field is lost in a
// decode/encode cycle. RefsCount is the same list's length, shown in
// the card's head.
RefsJSON template.JS
RefsCount int
}
// revisionRow is one history table row.
type revisionRow struct {
Name string
When string
SizeKB string
}
// tplOption is one post-template dropdown entry.
type tplOption struct {
Name string `json:"name"`
Title string `json:"title"`
Slug string `json:"slug"`
Tags []string `json:"tags"`
Body string `json:"body"`
// Fields are the extra editor inputs the template pre-fills. The
// keys are the editor's own input names.
Fields map[string]string `json:"fields,omitzero"`
// FieldsText lists the same fields for the settings screen, sorted
// and joined, so the template row shows what it will pre-fill.
FieldsText string `json:"-"`
}
// newPostVariant maps one stored post onto one card variant.
func newPostVariant(p *post.Post) postVariant {
v := postVariant{
Lang: p.Lang(),
Slug: p.Slug(),
Title: p.Title(),
Excerpt: p.Excerpt(),
DateString: p.DateString(),
Status: statusKey(p),
Draft: p.Draft(),
Scheduled: p.Scheduled(),
Series: p.Series(),
Cover: p.Cover(),
}
if order, ok := p.SeriesOrder(); ok {
v.HasOrder = true
v.SeriesOrder = order
}
if ts, ok := p.PublishedTimestamp(); ok {
v.Timestamp = ts
}
return v
}
// statusKey names the post status with the filter vocabulary.
func statusKey(p *post.Post) string {
switch {
case p.Draft():
return "draft"
case p.Scheduled():
return "scheduled"
default:
return "published"
}
}
// groupPosts merges the posts whose frontmatter names each other in the
// translations map into one group: the dashboard then shows one card per
// publication instead of one per language file. The groups keep the order
// of their first appearance, and the posts arrive newest first, so the
// grid stays date-ordered.
func groupPosts(posts []*post.Post) [][]*post.Post {
parent := map[string]string{}
var find func(slug string) string
find = func(slug string) string {
root, ok := parent[slug]
if !ok {
parent[slug] = slug
return slug
}
if root == slug {
return slug
}
parent[slug] = find(root)
return parent[slug]
}
union := func(a, b string) {
ra, rb := find(a), find(b)
if ra != rb {
parent[rb] = ra
}
}
for _, p := range posts {
find(p.Slug())
}
for _, p := range posts {
for _, target := range p.Translations() {
if target != "" {
union(p.Slug(), target)
}
}
}
var order []string
members := map[string][]*post.Post{}
for _, p := range posts {
root := find(p.Slug())
if _, seen := members[root]; !seen {
order = append(order, root)
}
members[root] = append(members[root], p)
}
groups := make([][]*post.Post, 0, len(order))
for _, root := range order {
groups = append(groups, members[root])
}
return groups
}
// pickDisplay chooses the variant the card shows: the one in the
// interface language when the publication carries it, the newest one
// otherwise.
func pickDisplay(group []*post.Post, lang string) *post.Post {
if lang != "" {
for _, p := range group {
if p.Lang() == lang {
return p
}
}
}
return group[0]
}
// variantsJSON renders the language variants as a JS literal for the
// card's data attribute. The same script-embedding rule as templatesJSON
// applies: no literal "<" may reach the page.
func variantsJSON(variants []postVariant) template.JS {
raw, err := json.Marshal(variants)
if err != nil {
slog.Warn("admin: cannot encode post variants", "error", err)
return template.JS("[]")
}
return template.JS(strings.ReplaceAll(string(raw), "<", `\u003c`))
}
// newEditorPost maps a stored post onto the editor form fields.
func newEditorPost(p *post.Post) *editorPost {
view := &editorPost{
Slug: p.Slug(),
Title: p.Title(),
Lang: p.Lang(),
Author: p.Author(),
Date: p.DateString(),
TagsCSV: strings.Join(p.Tags(), ", "),
Series: p.Series(),
Excerpt: p.StoredExcerpt(),
Cover: p.Cover(),
CoverAlt: p.CoverAlt(),
CoverCaption: p.CoverCaption(),
Body: p.Body,
Draft: p.Draft(),
AllLangs: p.AllLangs(),
Scheduled: p.Scheduled(),
}
view.RefsJSON, view.RefsCount = refsJS(p)
if fc := p.FediverseCreator(); fc != "" {
view.FediverseCreator = fc
}
view.DOI = p.DOI()
view.ORCID = p.ORCID()
if d, ok := p.DueAt(); ok {
view.PublishAt = d.Format("2006-01-02")
}
if order, ok := p.SeriesOrder(); ok {
view.SeriesOrder = fmt.Sprintf("%d", order)
}
view.ExcerptIsSet = strings.TrimSpace(view.Excerpt) != ""
// An unset excerpt shows the derived text as a placeholder, so saving
// the form never writes text the author did not type.
view.ExcerptPlaceholder = "Short summary for listings and previews"
if !view.ExcerptIsSet {
if derived := p.Excerpt(); derived != "" {
view.ExcerptPlaceholder = derived
}
}
return view
}
// refsJS renders the post's stored reference tables as a JS literal for
// the bibliography card, with the list's length beside it. The same
// script-embedding rule as templatesJSON applies: no literal "<" may
// reach the page, and a post without refs still gets a literal the
// script can iterate.
func refsJS(p *post.Post) (template.JS, int) {
raw, _ := p.Metadata.Get("refs")
// A parsed file hands the tables over as []map[string]any while a
// freshly written list is []any; both shapes carry the same entries.
var list []any
switch tables := raw.(type) {
case []any:
list = tables
case []map[string]any:
list = make([]any, len(tables))
for i, table := range tables {
list[i] = table
}
}
if len(list) == 0 {
return template.JS("[]"), 0
}
encoded, err := json.Marshal(list)
if err != nil {
slog.Warn("admin: cannot encode the post references", "slug", p.Slug(), "error", err)
return template.JS("[]"), 0
}
return template.JS(strings.ReplaceAll(string(encoded), "<", `\u003c`)), len(list)
}
// newPostCard maps a stored post onto one dashboard card.
func newPostCard(p *post.Post) postCard {
card := postCard{
Slug: p.Slug(),
Title: p.Title(),
Excerpt: p.Excerpt(),
Lang: p.Lang(),
Series: p.Series(),
DateString: p.DateString(),
Cover: p.Cover(),
Draft: p.Draft(),
Scheduled: p.Scheduled(),
Tags: p.Tags(),
}
if order, ok := p.SeriesOrder(); ok {
card.HasOrder = true
card.SeriesOrder = order
}
if ts, ok := p.PublishedTimestamp(); ok {
card.Timestamp = ts
}
switch {
case card.Draft:
card.Status = "draft"
case card.Scheduled:
card.Status = "scheduled"
default:
card.Status = "published"
}
return card
}
// newRevisionRow formats one revision for the history table.
func newRevisionRow(rev store.Revision) revisionRow {
return revisionRow{
Name: rev.Name,
When: rev.When,
SizeKB: fmt.Sprintf("%.1f", float64(rev.Size)/1024),
}
}
// tplOptions converts stored post templates for the dropdown and the
// embedded JSON blob.
func tplOptions(list []templates.PostTemplate) []tplOption {
out := make([]tplOption, 0, len(list))
for _, tpl := range list {
tags := tpl.Tags
if tags == nil {
tags = []string{}
}
out = append(out, tplOption{
Name: tpl.Name,
Title: tpl.Title,
Slug: tpl.Slug,
Tags: tags,
Body: tpl.Body,
Fields: tpl.Fields,
FieldsText: fieldsText(tpl.Fields),
})
}
return out
}
// fieldsText renders a sorted "key = value" summary of template fields.
func fieldsText(fields map[string]string) string {
if len(fields) == 0 {
return ""
}
parts := make([]string, 0, len(fields))
for _, key := range slices.Sorted(maps.Keys(fields)) {
parts = append(parts, key+" = "+fields[key])
}
return strings.Join(parts, ", ")
}
// templatesJSON renders the template list as a JS literal.
func templatesJSON(list []tplOption) template.JS {
raw, err := json.Marshal(list)
if err != nil {
slog.Warn("admin: cannot encode post templates", "error", err)
return template.JS("[]")
}
// A template body is free-text admin content, and encoding/json/v2
// escapes only what JSON requires: a literal "</script>" inside the
// JSON would end the script element the literal is embedded in. "<"
// cannot occur outside a string in JSON, so escaping it as a unicode
// escape inside the literal closes the hole while staying valid JSON.
return template.JS(strings.ReplaceAll(string(raw), "<", `\u003c`))
}
+484
View File
@@ -0,0 +1,484 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package app assembles the volumen HTTP server: shared services,
// route registration, and the middleware chain.
package app
import (
"crypto/rand"
"encoding/hex"
json "encoding/json/v2"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"slices"
"strings"
"syscall"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/admin"
"sourcedock.dev/petrbalvin/volumen/internal/audit"
"sourcedock.dev/petrbalvin/volumen/internal/backup"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/httpapi"
"sourcedock.dev/petrbalvin/volumen/internal/imagefile"
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
"sourcedock.dev/petrbalvin/volumen/internal/post"
"sourcedock.dev/petrbalvin/volumen/internal/ratelimit"
"sourcedock.dev/petrbalvin/volumen/internal/session"
"sourcedock.dev/petrbalvin/volumen/internal/store"
"sourcedock.dev/petrbalvin/volumen/internal/templates"
"sourcedock.dev/petrbalvin/volumen/internal/tokens"
"sourcedock.dev/petrbalvin/volumen/internal/users"
"sourcedock.dev/petrbalvin/volumen/internal/version"
"sourcedock.dev/petrbalvin/volumen/internal/web"
"sourcedock.dev/petrbalvin/volumen/internal/webhooks"
)
// Server holds every long-lived service the handlers share.
type Server struct {
Config *config.Config
Store *store.Store
Users *users.Users
Templates *templates.Store
Tokens *tokens.Store
Audit *audit.Log
LoginLim *ratelimit.LoginLimiter
Sessions *session.Store
Webhooks *webhooks.Manager
Admin *admin.Admin
OnEvent func(event string, payload map[string]any)
// previewKey is the session secret New resolved, which the admin
// signs preview links with and the API verifies them against.
previewKey string
}
// New validates the configuration and builds the server with all
// file-backed stores derived from it.
func New(cfg *config.Config, st *store.Store) (*Server, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
secret, err := sessionSecret(cfg)
if err != nil {
return nil, err
}
cookieSecure := cfg.Server.CookieSecure || cfg.Server.TrustProxy
usersPath := cfg.UsersFile
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(),
})
}
staticHooks := slices.Clone(hooks)
// The admin-managed hooks live beside the users file and apply
// without a restart. A file that cannot be read is a real fault and
// is reported, but it does not take the server down: the configured
// hooks still deliver.
webhooksFile := filepath.Join(filepath.Dir(usersPath), "webhooks.toml")
fileHooks, err := webhooks.LoadFile(webhooksFile)
if err != nil {
slog.Warn("app: ignoring the webhook store", "path", webhooksFile, "error", err)
} else {
hooks = append(hooks, fileHooks...)
}
manager := webhooks.NewManager(hooks, version.Version())
srv := &Server{
Config: cfg,
Store: st,
Users: users.New(usersPath),
Templates: templates.New(cfg.TemplatesFile()),
Tokens: tokens.New(cfg.TokensFile()),
Audit: audit.New(cfg.AuditLog),
LoginLim: ratelimit.NewLoginLimiter(),
Sessions: session.New(secret, time.Duration(cfg.Admin.SessionTTL)*time.Second, cookieSecure),
Webhooks: manager,
previewKey: secret,
OnEvent: func(event string, payload map[string]any) {
manager.Fire(event, payload, false)
},
}
adminHandler, err := admin.New(admin.Deps{
Config: cfg,
Store: st,
Users: srv.Users,
Templates: srv.Templates,
Tokens: srv.Tokens,
Audit: srv.Audit,
LoginLim: srv.LoginLim,
Sessions: srv.Sessions,
Webhooks: manager,
PreviewKey: secret,
WebhooksFile: webhooksFile,
StaticWebhooks: staticHooks,
Version: version.Version(),
OnEvent: srv.OnEvent,
Backup: BackupOptions(cfg),
})
if err != nil {
return nil, fmt.Errorf("init admin UI: %w", err)
}
srv.Admin = adminHandler
return srv, nil
}
// Handler builds the full middleware chain and route tree.
//
// Uploaded media and the backup export are served on their own branches:
// the session middleware and the gzip wrapper buffer whole responses,
// which would hold entire files in memory. Everything else flows through
// the full chain.
func (s *Server) Handler() http.Handler {
secure := s.Config.Server.CookieSecure || s.Config.Server.TrustProxy
mediaMux := http.NewServeMux()
mediaMux.HandleFunc("GET /media/{name...}", s.handleMedia)
mediaHandler := web.SecurityHeaders(secure)(mediaMux)
adminHandler := s.Admin.Handler()
// The backup export streams: it keeps the admin authentication but
// bypasses the wrappers that hold a whole response in memory, the
// session recorder and the gzip wrapper, so the archive reaches the
// client as it is written instead of waiting in a second copy. The
// session attaches read-only; a GET never mutates it.
var exportHandler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess := s.Sessions.Load(r)
adminHandler.ServeHTTP(w, r.WithContext(session.WithContext(r.Context(), sess)))
})
exportHandler = web.CrossOrigin()(exportHandler)
exportHandler = web.SecurityHeaders(secure)(exportHandler)
mux := http.NewServeMux()
api := httpapi.New(httpapi.Deps{
Config: s.Config,
Store: s.Store,
Tokens: s.Tokens,
OnEvent: s.OnEvent,
PreviewKey: s.previewKey,
})
mux.Handle("/api/volumen/", api)
mux.Handle("/admin/", adminHandler)
mux.Handle("/admin", adminHandler)
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Location", "/admin/")
w.WriteHeader(http.StatusSeeOther)
})
mux.HandleFunc("GET /healthz", s.handleHealthz)
mux.HandleFunc("GET /robots.txt", s.handleRobots)
mux.HandleFunc("GET /sitemap.xml", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Location", "/api/volumen/sitemap.xml")
w.WriteHeader(http.StatusMovedPermanently)
})
mux.HandleFunc("GET /favicon.ico", s.handleFavicon)
mux.HandleFunc("/", s.handleNotFound)
var handler http.Handler = web.RequestLogger(mux)
handler = s.Sessions.Middleware(handler)
handler = s.apiRateLimit(handler)
handler = web.SecurityHeaders(secure)(handler)
handler = web.CrossOrigin()(handler)
handler = web.Gzip(500)(handler)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/admin/settings/export" {
exportHandler.ServeHTTP(w, r)
return
}
if strings.HasPrefix(r.URL.Path, "/media/") {
mediaHandler.ServeHTTP(w, r)
return
}
handler.ServeHTTP(w, r)
})
}
func (s *Server) apiRateLimit(next http.Handler) http.Handler {
if s.Config.API.RateLimit <= 0 {
return next
}
limiter := ratelimit.New(
s.Config.API.RateLimit,
time.Duration(s.Config.API.RateLimitWindow)*time.Second,
)
trusted, err := s.Config.TrustedProxyPrefixes()
if err != nil || !s.Config.Server.TrustProxy {
trusted = nil
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/volumen" && !strings.HasPrefix(r.URL.Path, "/api/volumen/") {
next.ServeHTTP(w, r)
return
}
allowed, remaining, retryAfter := limiter.Check(web.ClientIP(r, trusted))
if !allowed {
w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", limiter.Limit()))
w.Header().Set("X-RateLimit-Remaining", "0")
for key, value := range map[string]string{
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
} {
w.Header().Set(key, value)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
_ = json.MarshalWrite(w, map[string]any{
"error": "rate_limited",
"retry_after": retryAfter,
}, json.Deterministic(true))
return
}
w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", limiter.Limit()))
w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", remaining))
next.ServeHTTP(w, r)
})
}
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
checks := map[string]string{}
overall := "ok"
if info, err := os.Stat(s.Config.ContentDir); err == nil && info.IsDir() {
checks["content_dir"] = "ok"
} else {
checks["content_dir"] = "missing"
overall = "degraded"
}
switch err := s.Users.Health(); {
case err != nil:
// A file that cannot be read means nobody can sign in, which is
// not a healthy deployment: say so rather than reporting a count
// of zero accounts.
slog.Error("volumen: healthz cannot read the users file", "error", err)
checks["users_file"] = "unreadable"
overall = "degraded"
default:
if info, statErr := os.Stat(s.Config.UsersFile); statErr == nil && info.Mode().IsRegular() {
checks["users_file"] = "ok"
} else {
checks["users_file"] = "missing (no accounts yet; /admin runs the first-run wizard)"
}
}
if err := s.Tokens.Health(); err != nil {
slog.Error("volumen: healthz cannot read the tokens file", "error", err)
checks["tokens_file"] = "unreadable"
overall = "degraded"
}
if err := s.Templates.Health(); err != nil {
slog.Error("volumen: healthz cannot read the templates file", "error", err)
checks["templates_file"] = "unreadable"
overall = "degraded"
}
if skipped := s.Store.Unreadable(); len(skipped) > 0 {
checks["content_files"] = fmt.Sprintf("%d file(s) cannot be parsed", len(skipped))
overall = "degraded"
}
freeMB, err := freeDiskMB(s.Config.ContentDir)
switch {
case err != nil:
// The path and the OS error are logged, not published: this
// endpoint is anonymous.
slog.Warn("volumen: healthz cannot read the content directory", "error", err)
checks["disk"] = "error"
case freeMB < 100:
checks["disk"] = fmt.Sprintf("low: %.0f MB free", freeMB)
overall = "degraded"
default:
checks["disk"] = fmt.Sprintf("ok (%.0f MB free)", freeMB)
}
status := http.StatusOK
if overall != "ok" {
status = http.StatusServiceUnavailable
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.MarshalWrite(w, map[string]any{"status": overall, "checks": checks}, json.Deterministic(true))
}
func freeDiskMB(path string) (float64, error) {
var st syscall.Statfs_t
if err := syscall.Statfs(path, &st); err != nil {
return 0, err
}
return float64(st.Bavail) * float64(st.Bsize) / (1024 * 1024), nil
}
func (s *Server) handleRobots(w http.ResponseWriter, _ *http.Request) {
base := s.Config.Site.BaseURL
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintf(w, "User-agent: *\nAllow: /\nSitemap: %s/api/volumen/sitemap.xml\n", base)
}
func (s *Server) handleFavicon(w http.ResponseWriter, _ *http.Request) {
icon, err := web.StaticFile("volumen-icon.svg")
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(icon)
}
func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
mediaPath, err := s.Store.MediaPath(name)
if err != nil {
// A name that is not an allowed image, that would escape the
// media directory, or that names nothing, is a 404: the route is
// public, so it says nothing about why.
s.writeNotFound(w)
return
}
// The type is set from the name's extension rather than sniffed, so a
// file whose bytes do not match its extension is still served as an
// image and never as a document.
contentType := imagefile.ContentType(name)
w.Header().Set("Cache-Control", "public, max-age=604800")
w.Header().Set("Content-Type", contentType)
if contentType == imagefile.MIMESVG {
// An SVG is the one accepted image that is also a document:
// opened at its own URL it would run on this origin. The
// sandbox and the locked-down policy make that a dead
// document, while the <img> uses of the file ignore both.
w.Header().Set("Content-Security-Policy",
"default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; sandbox")
}
http.ServeFile(w, r, mediaPath)
}
func (s *Server) writeNotFound(w http.ResponseWriter) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
_ = json.MarshalWrite(w, map[string]any{"error": "not_found"}, json.Deterministic(true))
}
func (s *Server) handleNotFound(w http.ResponseWriter, _ *http.Request) {
s.writeNotFound(w)
}
// BackupOptions names the files an archive carries, for the admin UI and
// the CLI export and import.
func BackupOptions(cfg *config.Config) backup.Options {
return backup.Options{
ContentDir: cfg.ContentDir,
UsersFile: cfg.UsersFile,
TemplatesFile: cfg.TemplatesFile(),
TokensFile: cfg.TokensFile(),
}
}
// PublishEvent reports that a scheduled post went live, as the same
// post.published event the admin delivers, so a hook subscribed to it
// hears about a post the scheduler published.
func (s *Server) PublishEvent(p *post.Post) {
if s.OnEvent == nil || p == nil {
return
}
s.OnEvent("post.published", map[string]any{"post": payloads.BuildSummary(p)})
}
// sessionSecret resolves the cookie signing key. The [admin].session_key
// in config is an override; with nothing set the server keeps its own
// secret in secret.key beside the users file, generating one on first
// start so a fresh installation can sign in without the operator
// editing the config. Production refuses a key shorter than 64 bytes
// whatever its source; development falls back to the ephemeral secret
// of session.New when the file cannot be written.
func sessionSecret(cfg *config.Config) (string, error) {
if key := cfg.Admin.SessionKey; key != "" {
return checkedSecret(cfg, key, "[admin].session_key")
}
path := cfg.SecretKeyFile()
raw, err := os.ReadFile(path)
switch {
case err == nil:
if key := strings.TrimSpace(string(raw)); key != "" {
return checkedSecret(cfg, key, path)
}
// An empty file is treated as no file: one more start and the
// key is generated and written, so the state converges.
case !errors.Is(err, os.ErrNotExist):
if cfg.IsProduction() {
return "", fmt.Errorf("read %s: %w", path, err)
}
slog.Warn("app: cannot read the session secret file", "path", path, "error", err)
return "", nil
}
// 32 random bytes as 64 hex characters, which is the length
// production requires. A system failure here dies inside
// crypto/rand rather than signing sessions with less entropy than
// the key looks like.
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("generate the session secret: %w", err)
}
key := hex.EncodeToString(buf)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
if cfg.IsProduction() {
return "", fmt.Errorf("create %s: %w", filepath.Dir(path), err)
}
slog.Warn("app: cannot create the session secret directory; using an ephemeral secret", "error", err)
return "", nil
}
// O_EXCL so two servers racing on a fresh data directory cannot
// each write a different key: the loser reads the winner's file.
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if errors.Is(err, os.ErrExist) {
raw, err := os.ReadFile(path)
if err != nil {
if cfg.IsProduction() {
return "", fmt.Errorf("read %s: %w", path, err)
}
return "", nil
}
return checkedSecret(cfg, strings.TrimSpace(string(raw)), path)
}
if err != nil {
if cfg.IsProduction() {
return "", fmt.Errorf("write %s: %w (or set [admin].session_key)", path, err)
}
slog.Warn("app: cannot write the session secret file; using an ephemeral secret", "error", err)
return "", nil
}
if _, err := f.WriteString(key + "\n"); err != nil {
f.Close()
if cfg.IsProduction() {
return "", fmt.Errorf("write %s: %w", path, err)
}
return "", nil
}
if err := f.Close(); err != nil && cfg.IsProduction() {
return "", fmt.Errorf("write %s: %w", path, err)
}
slog.Info("app: generated the session secret", "path", path)
return key, nil
}
// checkedSecret applies the production length rule to a key named by
// its source, which is the config field or the secret file.
func checkedSecret(cfg *config.Config, key, source string) (string, error) {
if len(key) < 64 && cfg.IsProduction() {
return "", fmt.Errorf(
"%s must be at least 64 bytes in production (got %d). "+
"Generate one with: openssl rand -hex 32", source, len(key))
}
return key, nil
}
+617
View File
@@ -0,0 +1,617 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package app
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/preview"
"sourcedock.dev/petrbalvin/volumen/internal/store"
)
func newTestServer(t *testing.T) (*Server, http.Handler) {
t.Helper()
dir := t.TempDir()
content := filepath.Join(dir, "posts")
if err := os.MkdirAll(content, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
Port: -1,
ContentDir: content,
UsersFile: filepath.Join(dir, "users.toml"),
})
if err != nil {
t.Fatalf("config: %v", err)
}
cfg.Site.BaseURL = "https://site.example"
cfg.Server.Env = config.EnvProduction
cfg.Server.CookieSecure = true
cfg.Admin.SessionKey = strings.Repeat("s", 64)
st := store.New(store.Options{ContentDir: content, DefaultLang: "en", RevisionLimit: 10})
srv, err := New(cfg, st)
if err != nil {
t.Fatalf("New: %v", err)
}
return srv, srv.Handler()
}
// With no session_key in the config, production starts anyway: the
// server generates its secret into secret.key beside the users file,
// owner-only, and every later start reuses the same key so sessions
// survive a restart. An explicitly configured key still wins and is
// still length-checked.
func TestNewGeneratesSessionSecret(t *testing.T) {
dir := t.TempDir()
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
Port: -1,
ContentDir: filepath.Join(dir, "posts"),
UsersFile: filepath.Join(dir, "users.toml"),
})
if err != nil {
t.Fatalf("config: %v", err)
}
cfg.Server.Env = "production"
if err := os.MkdirAll(filepath.Join(dir, "posts"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if _, err := New(cfg, store.New(store.Options{ContentDir: filepath.Join(dir, "posts"), DefaultLang: "en", RevisionLimit: 10})); err != nil {
t.Fatalf("production with no session_key must generate the secret: %v", err)
}
raw, err := os.ReadFile(filepath.Join(dir, "secret.key"))
if err != nil {
t.Fatalf("read secret.key: %v", err)
}
key := strings.TrimSpace(string(raw))
if len(key) < 64 {
t.Fatalf("generated key length = %d", len(key))
}
info, err := os.Stat(filepath.Join(dir, "secret.key"))
if err != nil {
t.Fatalf("stat secret.key: %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("secret.key mode = %v, want 0600", info.Mode().Perm())
}
// The second start reads the file back and keeps the same key.
cfg2, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
Port: -1,
ContentDir: filepath.Join(dir, "posts"),
UsersFile: filepath.Join(dir, "users.toml"),
})
if err != nil {
t.Fatalf("config: %v", err)
}
cfg2.Server.Env = "production"
secret, err := sessionSecret(cfg2)
if err != nil {
t.Fatalf("sessionSecret on the second start: %v", err)
}
if secret != key {
t.Fatal("the generated secret changed between starts")
}
// A configured override wins, and production still rejects it short.
cfg2.Admin.SessionKey = "short"
if _, err := sessionSecret(cfg2); err == nil {
t.Fatal("want error for a short production override")
}
}
// The generated secret.key signs preview links too, the way the
// configuration documents admin.session_key: a default deployment, with
// no key in the config, honours a preview token minted from the file,
// through the wired handler the browser talks to.
func TestGeneratedSecretSignsPreviewLinks(t *testing.T) {
dir := t.TempDir()
content := filepath.Join(dir, "posts")
if err := os.MkdirAll(content, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
draft := "+++\ntitle = \"Draft\"\nslug = \"draft\"\ndate = 2026-08-18\ndraft = true\n+++\n\nBody.\n"
if err := os.WriteFile(filepath.Join(content, "draft.md"), []byte(draft), 0o644); err != nil {
t.Fatalf("write draft: %v", err)
}
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
Port: -1,
ContentDir: content,
UsersFile: filepath.Join(dir, "users.toml"),
})
if err != nil {
t.Fatalf("config: %v", err)
}
cfg.Server.Env = "production"
cfg.Site.BaseURL = "https://site.example"
st := store.New(store.Options{ContentDir: content, DefaultLang: "en", RevisionLimit: 10})
srv, err := New(cfg, st)
if err != nil {
t.Fatalf("New: %v", err)
}
raw, err := os.ReadFile(filepath.Join(dir, "secret.key"))
if err != nil {
t.Fatalf("read secret.key: %v", err)
}
key := strings.TrimSpace(string(raw))
if key == "" {
t.Fatal("no secret.key was generated")
}
handler := srv.Handler()
token := preview.Token("draft", key, time.Now())
if token == "" {
t.Fatal("the generated key cannot sign a preview token")
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet,
"/api/volumen/posts/draft?preview_token="+token, nil))
if rec.Code != http.StatusOK {
t.Fatalf("preview through the wired handler = %d %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "Draft") {
t.Fatalf("the draft body did not reach the response: %s", rec.Body.String())
}
}
// Development starts without a writable location for the file too:
// the ephemeral fallback keeps the server usable, with the warning as
// the only signal.
func TestSessionSecretOverrideInDevelopment(t *testing.T) {
dir := t.TempDir()
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
Port: -1,
ContentDir: filepath.Join(dir, "posts"),
UsersFile: filepath.Join(dir, "users.toml"),
})
if err != nil {
t.Fatalf("config: %v", err)
}
cfg.Admin.SessionKey = "a-development-key"
secret, err := sessionSecret(cfg)
if err != nil {
t.Fatalf("development accepts a short override: %v", err)
}
if secret != "a-development-key" {
t.Fatalf("secret = %q", secret)
}
}
func TestRootRedirectsToAdmin(t *testing.T) {
_, handler := newTestServer(t)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
}
func TestHealthz(t *testing.T) {
srv, handler := newTestServer(t)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
body := decode(t, rec)
if body["status"] != "ok" {
t.Fatalf("body = %v", body)
}
checks := body["checks"].(map[string]any)
if checks["content_dir"] != "ok" {
t.Fatalf("checks = %v", checks)
}
if checks["users_file"] == nil || checks["disk"] == nil {
t.Fatalf("checks = %v", checks)
}
if rec.Header().Get("Cache-Control") != "no-store" {
t.Fatal("healthz must not be cached")
}
// Missing content directory degrades the status.
if err := os.RemoveAll(srv.Config.ContentDir); err != nil {
t.Fatalf("remove: %v", err)
}
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("code = %d", rec.Code)
}
}
func decode(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("invalid JSON %q: %v", rec.Body.String(), err)
}
return out
}
func TestRobotsSitemapFavicon(t *testing.T) {
_, handler := newTestServer(t)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/robots.txt", nil))
if !strings.Contains(rec.Body.String(), "Sitemap: https://site.example/api/volumen/sitemap.xml") {
t.Fatalf("robots = %q", rec.Body.String())
}
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/sitemap.xml", nil))
if rec.Code != http.StatusMovedPermanently ||
rec.Header().Get("Location") != "/api/volumen/sitemap.xml" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/favicon.ico", nil))
if rec.Code != http.StatusOK ||
rec.Header().Get("Content-Type") != "image/svg+xml" {
t.Fatalf("code=%d type=%q", rec.Code, rec.Header().Get("Content-Type"))
}
if !strings.Contains(rec.Body.String(), "<svg") {
t.Fatal("favicon body is not SVG")
}
}
func TestMediaServing(t *testing.T) {
srv, handler := newTestServer(t)
mediaDir := filepath.Join(srv.Store.ContentDir, store.MediaDirName)
if err := os.MkdirAll(mediaDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(mediaDir, "pic.webp"), []byte("IIIIIIIIWEBP"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>`)
if err := os.WriteFile(filepath.Join(mediaDir, "figure.svg"), svg, 0o644); err != nil {
t.Fatalf("write: %v", err)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/media/pic.webp", nil))
if rec.Code != http.StatusOK || rec.Body.String() != "IIIIIIIIWEBP" {
t.Fatalf("code=%d body=%q", rec.Code, rec.Body.String())
}
if rec.Header().Get("Cache-Control") != "public, max-age=604800" {
t.Fatalf("cache-control = %q", rec.Header().Get("Cache-Control"))
}
// A raster image gets no document policy of its own.
if strings.Contains(rec.Header().Get("Content-Security-Policy"), "sandbox") {
t.Fatal("webp response carries a sandbox policy")
}
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/media/figure.svg", nil))
if rec.Code != http.StatusOK || rec.Body.String() != string(svg) {
t.Fatalf("svg code=%d body=%q", rec.Code, rec.Body.String())
}
if rec.Header().Get("Content-Type") != "image/svg+xml" {
t.Fatalf("svg content-type = %q", rec.Header().Get("Content-Type"))
}
// An SVG opened at its own URL is a document on this origin: the
// response must sandbox it.
csp := rec.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "sandbox") || !strings.Contains(csp, "default-src 'none'") {
t.Fatalf("svg content-security-policy = %q, want a sandboxed document", csp)
}
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/media/missing.webp", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
}
func TestNotFoundJSON(t *testing.T) {
_, handler := newTestServer(t)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/nope", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
if decode(t, rec)["error"] != "not_found" {
t.Fatalf("body = %s", rec.Body.String())
}
}
func TestSecurityHeadersOnAPI(t *testing.T) {
_, handler := newTestServer(t)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/volumen/site", nil))
if rec.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatal("security headers missing")
}
if strings.Contains(rec.Header().Get("Content-Security-Policy"), "nonce-") {
t.Fatal("nonce leaked onto API response")
}
if rec.Header().Get("Strict-Transport-Security") == "" {
t.Fatal("HSTS missing on secure deployment")
}
}
func TestAPIRateLimitHeaders(t *testing.T) {
srv, handler := newTestServer(t)
srv.Config.API.RateLimit = 2
srv.Config.API.RateLimitWindow = 60
handler = srv.Handler() // rebuild with the new limit
var limited bool
for range 5 {
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/volumen/site", nil))
if rec.Code == http.StatusTooManyRequests {
limited = true
body := decode(t, rec)
if body["error"] != "rate_limited" {
t.Fatalf("body = %v", body)
}
if rec.Header().Get("Retry-After") == "" {
t.Fatal("Retry-After missing")
}
break
}
if rec.Header().Get("X-RateLimit-Limit") != "2" {
t.Fatalf("limit header = %q", rec.Header().Get("X-RateLimit-Limit"))
}
}
if !limited {
t.Fatal("rate limit never triggered")
}
// Non-API routes are not limited.
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/robots.txt", nil))
if rec.Code != http.StatusOK {
t.Fatalf("robots blocked: %d", rec.Code)
}
}
func TestGzipThroughChain(t *testing.T) {
srv, handler := newTestServer(t)
// A large post body pushes the JSON response over the gzip threshold.
var b strings.Builder
b.WriteString("+++\ntitle = \"Big\"\nslug = \"big\"\ndate = 2026-01-01\nexcerpt = \"")
b.WriteString(strings.Repeat("x", 600))
b.WriteString("\"\n+++\nbody\n")
if err := os.WriteFile(filepath.Join(srv.Config.ContentDir, "big.md"), []byte(b.String()), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/volumen/posts/big", nil)
req.Header.Set("Accept-Encoding", "gzip")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Header().Get("Content-Encoding") != "gzip" {
t.Fatalf("content-encoding = %q (body %d bytes)",
rec.Header().Get("Content-Encoding"), rec.Body.Len())
}
if strconv.Itoa(rec.Body.Len()) == "0" {
t.Fatal("empty body")
}
}
func TestSessionCookieSecureFlag(t *testing.T) {
dir := t.TempDir()
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
Port: -1,
ContentDir: filepath.Join(dir, "posts"),
UsersFile: filepath.Join(dir, "users.toml"),
})
if err != nil {
t.Fatalf("config: %v", err)
}
if err := os.MkdirAll(filepath.Join(dir, "posts"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// trust_proxy implies secure cookies even without cookie_secure.
cfg.Server.TrustProxy = true
srv, err := New(cfg, store.New(store.Options{ContentDir: filepath.Join(dir, "posts"), DefaultLang: "en", RevisionLimit: 10}))
if err != nil {
t.Fatalf("New: %v", err)
}
if !srv.Sessions.Secure() {
t.Fatal("trust_proxy must imply secure session cookies")
}
}
func TestMediaServedWithoutGzipBuffering(t *testing.T) {
srv, handler := newTestServer(t)
mediaDir := filepath.Join(srv.Store.ContentDir, store.MediaDirName)
if err := os.MkdirAll(mediaDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// Repetitive content that gzip would shrink dramatically if applied.
payload := strings.Repeat("WEBPDATA", 2000)
if err := os.WriteFile(filepath.Join(mediaDir, "big.webp"), []byte(payload), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/media/big.webp", nil)
req.Header.Set("Accept-Encoding", "gzip")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
if enc := rec.Header().Get("Content-Encoding"); enc != "" {
t.Fatalf("media was compressed (%q); it must stream untouched", enc)
}
if rec.Body.Len() != len(payload) {
t.Fatalf("body length = %d, want %d", rec.Body.Len(), len(payload))
}
if rec.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatal("security headers missing on media responses")
}
}
// The backup export streams on its own branch: the archive arrives
// compressed by the backup writer alone, never re-wrapped by the gzip
// middleware, which also proves the response never waited in that
// wrapper's buffer.
func TestExportStreamsUnwrapped(t *testing.T) {
srv, handler := newTestServer(t)
if _, err := srv.Users.Add("admin", "correct-horse-battery", "admin"); err != nil {
t.Fatalf("Add: %v", err)
}
// Enough varied content that the archive crosses the gzip threshold
// and would engage the middleware were the export still flowing
// through it; repeated bytes compress away and prove nothing.
var b strings.Builder
b.WriteString("+++\ntitle = \"Big\"\nslug = \"big\"\ndate = 2026-01-01\nexcerpt = \"")
for i := range 400 {
b.WriteString(strconv.Itoa(i*7919+i*i) + " ")
}
b.WriteString("\"\n+++\nbody\n")
if err := os.WriteFile(filepath.Join(srv.Config.ContentDir, "big.md"), []byte(b.String()), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
login := func() *http.Cookie {
t.Helper()
form := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/login", nil)
handler.ServeHTTP(form, req)
csrf := extractCSRF(t, form.Body.String())
cookies := form.Result().Cookies()
body := strings.NewReader("_csrf=" + url.QueryEscape(csrf) + "&username=admin&password=correct-horse-battery")
req = httptest.NewRequest(http.MethodPost, "/admin/login", body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String())
}
for _, c := range rec.Result().Cookies() {
if c.Name == "volumen_session" {
return c
}
}
t.Fatal("no session cookie after login")
return nil
}
req := httptest.NewRequest(http.MethodGet, "/admin/settings/export", nil)
req.Header.Set("Accept-Encoding", "gzip")
req.AddCookie(login())
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != "application/gzip" {
t.Fatalf("content-type = %q", got)
}
if enc := rec.Header().Get("Content-Encoding"); enc != "" {
t.Fatalf("export re-compressed (%q); it must stream untouched", enc)
}
if rec.Body.Len() < 500 {
t.Fatalf("body %d bytes, too small to prove the bypass", rec.Body.Len())
}
if rec.Body.Bytes()[0] != 0x1f || rec.Body.Bytes()[1] != 0x8b {
t.Fatal("body does not start with the gzip magic bytes")
}
}
// Logout must clear the cookie through the handler built by app.New: the
// session services are wired there, and a hand-built Deps in a test would
// hide a missing one.
func TestLogoutThroughTheWiredHandler(t *testing.T) {
srv, handler := newTestServer(t)
if _, err := srv.Users.Add("admin", "correct-horse-battery", "admin"); err != nil {
t.Fatalf("Add: %v", err)
}
login := func() *http.Cookie {
t.Helper()
form := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/login", nil)
handler.ServeHTTP(form, req)
csrf := extractCSRF(t, form.Body.String())
cookies := form.Result().Cookies()
body := strings.NewReader("_csrf=" + url.QueryEscape(csrf) + "&username=admin&password=correct-horse-battery")
req = httptest.NewRequest(http.MethodPost, "/admin/login", body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String())
}
for _, c := range rec.Result().Cookies() {
if c.Name == "volumen_session" {
return c
}
}
t.Fatal("no session cookie after login")
return nil
}
session := login()
// The dashboard is reachable while signed in.
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(session)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("dashboard code = %d", rec.Code)
}
csrf := extractCSRF(t, rec.Body.String())
// Logging out answers, clears the cookie and refuses the next request.
rec = httptest.NewRecorder()
body := strings.NewReader("_csrf=" + url.QueryEscape(csrf))
req = httptest.NewRequest(http.MethodPost, "/admin/logout", body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(session)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("logout code = %d body=%s", rec.Code, rec.Body.String())
}
expired := false
for _, c := range rec.Result().Cookies() {
if c.Name == "volumen_session" && c.MaxAge < 0 {
expired = true
}
}
if !expired {
t.Fatal("logout did not expire the session cookie")
}
// A copy of the cookie taken before the logout stays valid until it
// expires, because the session lives entirely in the cookie. That is
// the documented trade-off of a signed cookie without server state,
// and why logging out expires the browser's copy rather than claiming
// to revoke it.
}
func extractCSRF(t *testing.T, body string) string {
t.Helper()
const marker = `name="_csrf" value="`
_, rest, ok := strings.Cut(body, marker)
if !ok {
t.Fatal("no CSRF token in the page")
}
token, _, ok := strings.Cut(rest, `"`)
if !ok {
t.Fatal("malformed CSRF token")
}
return token
}
+98
View File
@@ -0,0 +1,98 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package app
import (
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/store"
)
// BenchmarkServer drives the wired handler over real HTTP: a list, a
// single post, a tag feed and the sitemap, against a corpus the size of a
// site that has been running for a few years. It is also the workload the
// PGO profile is recorded from, which is why it exercises the whole chain
// rather than one function.
func BenchmarkServer(b *testing.B) {
const posts = 500
dir := b.TempDir()
content := filepath.Join(dir, "posts")
if err := os.MkdirAll(content, 0o755); err != nil {
b.Fatalf("mkdir: %v", err)
}
for i := range posts {
body := fmt.Sprintf(`+++
title = "Post %d"
slug = "post-%d"
date = 2026-01-%02d
lang = "en"
tags = ["go", "bench"]
series = "Bench"
series_order = %d
+++
## Section
A paragraph with **markup**, a [link](https://example.com) and enough
words to make the renderer do real work: %s
- one
- two
- three
`, i, i, i%28+1, i, strings.Repeat("lorem ipsum dolor sit amet ", 40))
path := filepath.Join(content, fmt.Sprintf("post-%d.md", i))
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
b.Fatalf("write: %v", err)
}
}
cfg := config.Defaults()
cfg.ContentDir = content
cfg.UsersFile = filepath.Join(dir, "users.toml")
cfg.Admin.SessionKey = strings.Repeat("k", 64)
cfg.Site.BaseURL = "https://site.example"
cfg.API.RateLimit = 0
srv, err := New(cfg, store.New(store.Options{ContentDir: content, DefaultLang: "en", RevisionLimit: 10}))
if err != nil {
b.Fatalf("New: %v", err)
}
// The access line each request writes is noise inside a benchmark.
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
b.Cleanup(func() { slog.SetDefault(previous) })
server := httptest.NewServer(srv.Handler())
defer server.Close()
targets := []string{
"/api/volumen/posts?limit=20",
"/api/volumen/posts/post-250",
"/api/volumen/tags",
"/api/volumen/tags/go/feed.json",
"/api/volumen/sitemap.xml",
}
client := server.Client()
b.ResetTimer()
for i := 0; b.Loop(); i++ {
resp, err := client.Get(server.URL + targets[i%len(targets)])
if err != nil {
b.Fatalf("get: %v", err)
}
if _, err := io.Copy(io.Discard, resp.Body); err != nil {
b.Fatalf("read: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b.Fatalf("%s: status %d", targets[i%len(targets)], resp.StatusCode)
}
}
}
+124
View File
@@ -0,0 +1,124 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package audit is a structured audit log for administrative actions.
//
// It writes JSON lines to a configurable file so operators can track who
// did what, when, and from which IP address. The lines are produced by a
// slog handler rather than assembled by hand: the handler owns the
// encoding and the escaping, and a key-renaming step keeps the field
// names this log has always used.
package audit
import (
"context"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
)
// Entry is one audit record. Only User and Action are always present.
type Entry struct {
User string
Action string
Resource string
Detail map[string]any
IP string
}
// Log is an append-only JSON-lines audit log. An empty path disables it.
type Log struct {
path string
mu sync.Mutex
file *os.File
logger *slog.Logger
}
// New creates a log writing to path; an empty path disables auditing.
func New(path string) *Log {
return &Log{path: path}
}
// Enabled reports whether entries are persisted.
func (l *Log) Enabled() bool {
return l != nil && l.path != ""
}
// Record appends one audit entry. Failures are logged, never raised: a
// line that cannot be written must not fail the action it records.
func (l *Log) Record(e Entry) {
if !l.Enabled() {
return
}
logger, err := l.handler()
if err != nil {
slog.Warn("audit: cannot open the log", "path", l.path, "error", err)
return
}
attrs := make([]slog.Attr, 0, 4)
if e.Resource != "" {
attrs = append(attrs, slog.String("resource", e.Resource))
}
if len(e.Detail) > 0 {
attrs = append(attrs, slog.Any("detail", e.Detail))
}
if e.IP != "" {
attrs = append(attrs, slog.String("ip", e.IP))
}
logger.LogAttrs(context.Background(), slog.LevelInfo, e.Action,
append([]slog.Attr{slog.String("user", e.User)}, attrs...)...)
}
// Close releases the file handle.
func (l *Log) Close() error {
l.mu.Lock()
defer l.mu.Unlock()
if l.file == nil {
return nil
}
err := l.file.Close()
l.file = nil
l.logger = nil
return err
}
// handler returns the logger backed by the audit file, opening it on
// first use. A failed open is retried on the next record rather than
// cached: a transient failure (a missing parent directory, descriptor
// exhaustion) must not silence the audit log for the life of the
// process, and records are rare enough that the retry costs nothing.
func (l *Log) handler() (*slog.Logger, error) {
l.mu.Lock()
defer l.mu.Unlock()
if l.logger != nil {
return l.logger, nil
}
if err := os.MkdirAll(filepath.Dir(l.path), 0o755); err != nil {
return nil, err
}
file, err := os.OpenFile(l.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return nil, err
}
l.file = file
l.logger = slog.New(slog.NewJSONHandler(file, &slog.HandlerOptions{
// The field names are the log's contract with the operator's
// tooling: a timestamp under "ts", the action as the message,
// and no level, which an audit line does not have.
ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr {
switch attr.Key {
case slog.TimeKey:
return slog.String("ts", attr.Value.Time().UTC().Format(time.RFC3339))
case slog.MessageKey:
attr.Key = "action"
case slog.LevelKey:
return slog.Attr{}
}
return attr
},
}))
return l.logger, nil
}
+149
View File
@@ -0,0 +1,149 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package audit
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestDisabledLogWritesNothing(t *testing.T) {
l := New("")
if l.Enabled() {
t.Fatal("Enabled = true for empty path")
}
l.Record(Entry{User: "admin", Action: "login"})
}
func TestRecordAppendsJSONLines(t *testing.T) {
path := filepath.Join(t.TempDir(), "sub", "audit.log")
l := New(path)
if !l.Enabled() {
t.Fatal("Enabled = false for a real path")
}
l.Record(Entry{User: "admin", Action: "post.created", Resource: "hello", IP: "127.0.0.1"})
l.Record(Entry{User: "admin", Action: "post.deleted", Detail: map[string]any{"slug": "hello"}})
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
if len(lines) != 2 {
t.Fatalf("got %d lines, want 2: %q", len(lines), data)
}
var first map[string]any
if err := json.Unmarshal([]byte(lines[0]), &first); err != nil {
t.Fatalf("line is not JSON: %v", err)
}
if first["user"] != "admin" || first["action"] != "post.created" ||
first["resource"] != "hello" || first["ip"] != "127.0.0.1" {
t.Fatalf("first entry = %v", first)
}
if _, ok := first["detail"]; ok {
t.Fatalf("unexpected detail in first entry: %v", first)
}
var second map[string]any
if err := json.Unmarshal([]byte(lines[1]), &second); err != nil {
t.Fatalf("line is not JSON: %v", err)
}
detail, ok := second["detail"].(map[string]any)
if !ok || detail["slug"] != "hello" {
t.Fatalf("second entry detail = %v", second)
}
ts, _ := first["ts"].(string)
if len(ts) < 19 || !strings.Contains(ts, "T") {
t.Fatalf("ts = %q, want ISO timestamp", ts)
}
}
func TestRecordSurvivesUnwritablePath(t *testing.T) {
dir := t.TempDir()
l := New(filepath.Join(dir, "file-as-dir", "x", "audit.log"))
if err := os.WriteFile(filepath.Join(dir, "file-as-dir"), []byte("x"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
l.Record(Entry{User: "admin", Action: "login"}) // must not panic
}
// The handler opens the file once and appends to it, and Close releases
// the handle.
func TestFileIsOpenedOnceAndClosed(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.log")
l := New(path)
for range 50 {
l.Record(Entry{User: "admin", Action: "post.updated", Resource: "hello"})
}
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if got := strings.Count(string(data), "\n"); got != 50 {
t.Fatalf("lines = %d, want 50", got)
}
// A write after Close reopens it rather than losing the line.
l.Record(Entry{User: "admin", Action: "post.deleted"})
data, err = os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if got := strings.Count(string(data), "\n"); got != 51 {
t.Fatalf("lines = %d, want 51", got)
}
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
}
// An unwritable destination is reported once and never fails the caller.
func TestUnwritableDestinationIsNotFatal(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.log")
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
l := New(path)
l.Record(Entry{User: "admin", Action: "post.created"})
l.Record(Entry{User: "admin", Action: "post.created"})
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
}
// A transient open failure must not silence the log for the life of the
// process: once the obstruction is gone, the next record writes.
func TestRecordRecoversAfterThePathBecomesWritable(t *testing.T) {
dir := t.TempDir()
// A regular file where the log's parent directory should be makes
// MkdirAll fail.
blocker := filepath.Join(dir, "blocked")
if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil {
t.Fatalf("write blocker: %v", err)
}
log := New(filepath.Join(blocker, "sub", "audit.log"))
log.Record(Entry{User: "u", Action: "first"})
if err := log.Close(); err != nil {
t.Fatalf("close: %v", err)
}
if err := os.Remove(blocker); err != nil {
t.Fatalf("remove blocker: %v", err)
}
log.Record(Entry{User: "u", Action: "second"})
if err := log.Close(); err != nil {
t.Fatalf("close: %v", err)
}
raw, err := os.ReadFile(filepath.Join(dir, "blocked", "sub", "audit.log"))
if err != nil {
t.Fatalf("the audit log never recovered: %v", err)
}
if !strings.Contains(string(raw), `"action":"second"`) {
t.Fatalf("recovered log = %s", raw)
}
}
+322
View File
@@ -0,0 +1,322 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package backup writes and restores the deployment's data as a
// gzip-compressed tar: the content directory under posts/, plus the
// users, templates and tokens files. The CLI and the admin UI both go
// through here, so an archive written by one restores in the other.
package backup
import (
"archive/tar"
"compress/gzip"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/imagefile"
"sourcedock.dev/petrbalvin/volumen/internal/store"
)
// Archive entry names. The users, templates and tokens files are stored
// under these names rather than under their configured paths, so a
// deployment that renamed them still restores into its own layout.
const (
postsPrefix = "posts/"
usersEntry = "users.toml"
templatesEntry = "templates.toml"
tokensEntry = "tokens.toml"
)
// MaxDecompressed bounds the total size a restore will decompress, so a
// small archive cannot expand until the process runs out of memory.
const MaxDecompressed = 512 << 20
// Options names the files and directories an archive carries.
type Options struct {
ContentDir string
UsersFile string
TemplatesFile string
TokensFile string
}
// Write writes the archive. A file that is absent is skipped; a file
// that exists but cannot be read aborts the backup, because an archive
// that silently omits data looks complete and is not.
func Write(w io.Writer, opts Options) error {
gz := gzip.NewWriter(w)
tw := tar.NewWriter(gz)
if err := writeTree(tw, opts.ContentDir); err != nil {
return err
}
for _, file := range []struct{ entry, source string }{
{usersEntry, opts.UsersFile},
{templatesEntry, opts.TemplatesFile},
{tokensEntry, opts.TokensFile},
} {
if file.source == "" {
continue
}
if err := writeFile(tw, file.entry, file.source); err != nil {
return err
}
}
if err := tw.Close(); err != nil {
return fmt.Errorf("finish archive: %w", err)
}
if err := gz.Close(); err != nil {
return fmt.Errorf("finish compression: %w", err)
}
return nil
}
func writeTree(tw *tar.Writer, contentDir string) error {
if contentDir == "" {
return nil
}
err := filepath.WalkDir(contentDir, func(filePath string, d os.DirEntry, err error) error {
if err != nil {
return fmt.Errorf("read %s: %w", filePath, err)
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(contentDir, filePath)
if err != nil {
return fmt.Errorf("locate %s: %w", filePath, err)
}
return writeFile(tw, postsPrefix+filepath.ToSlash(rel), filePath)
})
if err != nil {
return err
}
return nil
}
func writeFile(tw *tar.Writer, entry, source string) error {
info, err := os.Stat(source)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("read %s: %w", source, err)
}
if !info.Mode().IsRegular() {
return nil
}
file, err := os.Open(source)
if err != nil {
return fmt.Errorf("read %s: %w", source, err)
}
defer file.Close()
header := &tar.Header{
Name: entry,
Mode: int64(info.Mode().Perm()),
Size: info.Size(),
ModTime: info.ModTime(),
}
if err := tw.WriteHeader(header); err != nil {
return fmt.Errorf("archive %s: %w", entry, err)
}
if _, err := io.Copy(tw, file); err != nil {
return fmt.Errorf("archive %s: %w", source, err)
}
return nil
}
// Restore reads an archive and writes its entries into the deployment.
// It returns the number of files written. Entries the layout does not
// name are skipped; an entry that tries to escape its destination is
// refused outright.
func Restore(r io.Reader, opts Options) (int, error) {
gz, err := gzip.NewReader(io.LimitReader(r, MaxDecompressed))
if err != nil {
return 0, fmt.Errorf("the archive is not a gzip file: %w", err)
}
defer gz.Close()
// The limit applies to the decompressed bytes, which is what a
// compression bomb expands into.
tr := tar.NewReader(io.LimitReader(gz, MaxDecompressed))
root, err := openContentRoot(opts.ContentDir)
if err != nil {
return 0, err
}
defer root.Close()
written := 0
for {
header, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return written, fmt.Errorf("read the archive: %w", err)
}
if header.Typeflag != tar.TypeReg {
continue
}
name := path.Clean(strings.TrimPrefix(header.Name, "./"))
switch {
case name == usersEntry:
if err := writeTarget(opts.UsersFile, tr, header.Size); err != nil {
return written, err
}
case name == templatesEntry:
if err := writeTarget(opts.TemplatesFile, tr, header.Size); err != nil {
return written, err
}
case name == tokensEntry:
if err := writeTarget(opts.TokensFile, tr, header.Size); err != nil {
return written, err
}
case strings.HasPrefix(name, postsPrefix):
rel := strings.TrimPrefix(name, postsPrefix)
if !restorablePath(rel) {
continue
}
if err := writeInRoot(root, rel, tr, header.Size); err != nil {
return written, err
}
default:
continue
}
written++
}
return written, nil
}
// restorablePath reports whether a path inside posts/ may be written
// back. Only posts, revision archives and media files with an allowed
// image extension are accepted: the media directory is served from a
// public route, so an archive must not be able to plant a document
// there that a browser would execute.
func restorablePath(rel string) bool {
if rel == "" || strings.HasPrefix(rel, "..") || path.IsAbs(rel) {
return false
}
switch {
case strings.HasPrefix(rel, store.MediaDirName+"/"):
return imagefile.Allowed(path.Base(rel))
case rel == store.MediaDirName:
return false
}
return strings.HasSuffix(rel, ".md")
}
func openContentRoot(contentDir string) (*os.Root, error) {
if contentDir == "" {
return nil, errors.New("no content directory is configured")
}
if err := os.MkdirAll(contentDir, 0o755); err != nil {
return nil, fmt.Errorf("create the content directory: %w", err)
}
root, err := os.OpenRoot(contentDir)
if err != nil {
return nil, fmt.Errorf("open the content directory: %w", err)
}
return root, nil
}
// writeInRoot writes rel inside the content directory. os.Root resolves
// every operation inside that directory, so a name that escapes one,
// through .. or through a symlink, is refused by construction.
func writeInRoot(root *os.Root, rel string, r io.Reader, size int64) error {
clean := path.Clean("/" + rel)
rel = strings.TrimPrefix(clean, "/")
if dir := path.Dir(rel); dir != "." {
if err := root.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create %s: %w", dir, err)
}
}
data, err := readEntry(r, size)
if err != nil {
return fmt.Errorf("read %s: %w", rel, err)
}
if err := atomicWriteInRoot(root, rel, data); err != nil {
return fmt.Errorf("write %s: %w", rel, err)
}
return nil
}
// atomicWriteInRoot replaces rel through a temp file and a rename, the
// same shape the post store writes with: a crash mid-restore leaves the
// previous file intact rather than a truncated one.
func atomicWriteInRoot(root *os.Root, rel string, data []byte) error {
dir, base := path.Split(rel)
tmp := path.Join(dir, "."+base+".tmp")
handle, err := root.OpenFile(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(tmp)
return fmt.Errorf("write temp file: %w", err)
}
if err := handle.Close(); err != nil {
root.Remove(tmp)
return fmt.Errorf("close temp file: %w", err)
}
if err := root.Rename(tmp, rel); err != nil {
root.Remove(tmp)
return fmt.Errorf("replace: %w", err)
}
return nil
}
// writeTarget writes one of the standalone TOML files. The destination
// is the configured path, never a path from the archive.
func writeTarget(target string, r io.Reader, size int64) error {
if target == "" {
return errors.New("no destination is configured for that file")
}
data, err := readEntry(r, size)
if err != nil {
return fmt.Errorf("read %s: %w", filepath.Base(target), err)
}
dir := filepath.Dir(target)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create the directory for %s: %w", target, err)
}
tmp, err := os.CreateTemp(dir, "."+filepath.Base(target)+".*.tmp")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
tmpPath := tmp.Name()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("write temp file: %w", err)
}
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("chmod temp file: %w", err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("close temp file: %w", err)
}
if err := os.Rename(tmpPath, target); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("write %s: %w", target, err)
}
return nil
}
// readEntry reads one entry, refusing a declared size beyond the budget.
func readEntry(r io.Reader, size int64) ([]byte, error) {
if size > MaxDecompressed {
return nil, fmt.Errorf("entry declares %d bytes, over the %d byte limit", size, int64(MaxDecompressed))
}
data, err := io.ReadAll(io.LimitReader(r, MaxDecompressed))
if err != nil {
return nil, err
}
return data, nil
}
+289
View File
@@ -0,0 +1,289 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package backup
import (
"archive/tar"
"bytes"
"compress/gzip"
"os"
"path/filepath"
"strings"
"testing"
"sourcedock.dev/petrbalvin/volumen/internal/store"
)
// layout builds a deployment on disk and returns its options.
func layout(t *testing.T) Options {
t.Helper()
dir := t.TempDir()
content := filepath.Join(dir, "posts")
media := filepath.Join(content, store.MediaDirName)
revisions := filepath.Join(content, ".revisions", "hello")
for _, d := range []string{content, media, revisions} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
}
write := func(path, body string) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
write(filepath.Join(content, "hello.md"), "+++\nslug = \"hello\"\n+++\nbody\n")
write(filepath.Join(content, "cs", "ahoj.md"), "+++\nslug = \"ahoj\"\n+++\ntelo\n")
write(filepath.Join(media, "abcd1234-upload.webp"), "RIFF....WEBPVP8 ")
write(filepath.Join(revisions, "20260102T030405Z.md"), "old body")
return Options{
ContentDir: content,
UsersFile: filepath.Join(dir, "users.toml"),
TemplatesFile: filepath.Join(dir, "templates.toml"),
TokensFile: filepath.Join(dir, "tokens.toml"),
}
}
func withSecrets(t *testing.T, opts Options) Options {
t.Helper()
writeFixture(t, opts.UsersFile, "[[users]]\nusername = \"admin\"\nrole = \"admin\"\n")
writeFixture(t, opts.TemplatesFile, "[[templates]]\nname = \"Review\"\n")
writeFixture(t, opts.TokensFile, "[[tokens]]\nname = \"ci\"\ntoken_hash = \"abc\"\n")
return opts
}
func writeFixture(t *testing.T, path, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func TestRoundTrip(t *testing.T) {
opts := withSecrets(t, layout(t))
var buf bytes.Buffer
if err := Write(&buf, opts); err != nil {
t.Fatalf("Write: %v", err)
}
// A fresh deployment restores what the archive carries.
fresh := layout(t)
fresh.UsersFile = filepath.Join(t.TempDir(), "renamed-users.toml")
fresh.TemplatesFile = filepath.Join(t.TempDir(), "renamed-templates.toml")
fresh.TokensFile = filepath.Join(t.TempDir(), "renamed-tokens.toml")
for _, path := range []string{
filepath.Join(fresh.ContentDir, "hello.md"),
filepath.Join(fresh.ContentDir, "cs", "ahoj.md"),
filepath.Join(fresh.ContentDir, store.MediaDirName, "abcd1234-upload.webp"),
filepath.Join(fresh.ContentDir, ".revisions", "hello", "20260102T030405Z.md"),
} {
if err := os.Remove(path); err != nil {
t.Fatalf("remove %s: %v", path, err)
}
}
written, err := Restore(&buf, fresh)
if err != nil {
t.Fatalf("Restore: %v", err)
}
// Four files inside posts/ plus the three standalone files.
if written != 7 {
t.Fatalf("written = %d, want 7", written)
}
for _, path := range []string{
filepath.Join(fresh.ContentDir, "hello.md"),
filepath.Join(fresh.ContentDir, "cs", "ahoj.md"),
filepath.Join(fresh.ContentDir, store.MediaDirName, "abcd1234-upload.webp"),
filepath.Join(fresh.ContentDir, ".revisions", "hello", "20260102T030405Z.md"),
fresh.UsersFile,
fresh.TemplatesFile,
fresh.TokensFile,
} {
if _, err := os.Stat(path); err != nil {
t.Errorf("missing after restore: %s (%v)", path, err)
}
}
// The renamed files receive their contents under the configured
// names, not under the archive's names.
raw, err := os.ReadFile(fresh.UsersFile)
if err != nil || !strings.Contains(string(raw), "admin") {
t.Fatalf("users file = %q, %v", raw, err)
}
}
func TestRestoreRefusesHostileEntries(t *testing.T) {
opts := layout(t)
archive := buildArchive(t, []archiveEntry{
{name: "../escape.md", body: "pwned"},
{name: "posts/../../escape.md", body: "pwned"},
{name: "/etc/passwd", body: "pwned"},
{name: "posts/media/evil.html", body: "<script>alert(1)</script>"},
{name: "posts/media/evil.js", body: "alert(1)"},
{name: "posts/notes.txt", body: "not a post"},
{name: "posts/keep.md", body: "+++\nslug = \"keep\"\n+++\nok\n"},
{name: "posts/media/abcd1234-upload.webp", body: "RIFF....WEBPVP8 "},
{name: "unrelated.toml", body: "x = 1"},
{name: "config.toml", body: "session_key = \"secret\""},
})
written, err := Restore(bytes.NewReader(archive), opts)
if err != nil {
t.Fatalf("Restore: %v", err)
}
if written != 2 {
t.Fatalf("written = %d, want 2 (keep.md and the image)", written)
}
if _, err := os.Stat(filepath.Join(opts.ContentDir, "keep.md")); err != nil {
t.Errorf("valid post not restored: %v", err)
}
if _, err := os.Stat(filepath.Join(opts.ContentDir, store.MediaDirName, "abcd1234-upload.webp")); err != nil {
t.Errorf("valid image not restored: %v", err)
}
for _, bad := range []string{
filepath.Join(filepath.Dir(opts.ContentDir), "escape.md"),
filepath.Join(opts.ContentDir, "media", "evil.html"),
filepath.Join(opts.ContentDir, "media", "evil.js"),
filepath.Join(opts.ContentDir, "notes.txt"),
} {
if _, err := os.Stat(bad); err == nil {
t.Errorf("hostile entry was written: %s", bad)
}
}
}
func TestRestoreRejectsNonArchives(t *testing.T) {
opts := layout(t)
if _, err := Restore(strings.NewReader("this is not a tar.gz"), opts); err == nil {
t.Fatal("a text file was accepted as an archive")
}
empty := buildArchive(t, nil)
if _, err := Restore(bytes.NewReader(empty), opts); err != nil {
t.Fatalf("an empty archive is not an error: %v", err)
}
}
func TestRestoreRefusesAnOversizedEntry(t *testing.T) {
opts := layout(t)
// The header declares more than the budget; the body is short, which
// is exactly the shape a compression bomb has.
archive := buildArchiveRaw(t, []archiveEntry{
{name: "posts/huge.md", body: "x", size: MaxDecompressed + 1},
}, false)
if _, err := Restore(bytes.NewReader(archive), opts); err == nil {
t.Fatal("an entry declaring more than the budget was accepted")
}
if _, err := os.Stat(filepath.Join(opts.ContentDir, "huge.md")); err == nil {
t.Fatal("the oversized entry was written")
}
}
func TestWriteRefusesAMissingDirectory(t *testing.T) {
opts := layout(t)
opts.ContentDir = filepath.Join(t.TempDir(), "absent", "posts")
if err := Write(&bytes.Buffer{}, opts); err == nil {
t.Fatal("a missing content directory was archived as if it were empty")
}
}
type archiveEntry struct {
name string
body string
size int64
}
// buildArchive writes a tar.gz with the given entries, taking sizes from
// the body unless a size is given.
func buildArchive(t *testing.T, entries []archiveEntry) []byte {
t.Helper()
return buildArchiveRaw(t, entries, true)
}
// buildArchiveRaw writes the entries; complete controls whether a
// declared size larger than the body is padded to match.
func buildArchiveRaw(t *testing.T, entries []archiveEntry, complete bool) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for _, entry := range entries {
size := int64(len(entry.body))
if entry.size > 0 {
size = entry.size
}
if err := tw.WriteHeader(&tar.Header{
Name: entry.name, Mode: 0o644, Size: size, Typeflag: tar.TypeReg,
}); err != nil {
t.Fatalf("header: %v", err)
}
if _, err := tw.Write([]byte(entry.body)); err != nil {
t.Fatalf("body: %v", err)
}
if !complete {
// The reader stops at the declared size, so the remaining
// bytes are never needed.
_ = tw.Flush()
_ = gz.Close()
return buf.Bytes()
}
}
if err := tw.Close(); err != nil {
t.Fatalf("close tar: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("close gzip: %v", err)
}
return buf.Bytes()
}
// A restore leaves no temp files behind and writes the standalone files
// owner-only, the same mode a direct write always used.
func TestRestoreLeavesNoTempFiles(t *testing.T) {
dir := t.TempDir()
content := filepath.Join(dir, "posts")
if err := os.MkdirAll(content, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
users := filepath.Join(dir, "users.toml")
archive := filepath.Join(dir, "backup.tar.gz")
data := buildArchive(t, []archiveEntry{
{name: "users.toml", body: "[[users]]\n"},
{name: "posts/a.md", body: "+++\nslug = \"a\"\ntitle = \"A\"\n+++\nx\n"},
})
if err := os.WriteFile(archive, data, 0o600); err != nil {
t.Fatalf("write archive: %v", err)
}
file, err := os.Open(archive)
if err != nil {
t.Fatalf("open: %v", err)
}
defer file.Close()
if _, err := Restore(file, Options{
ContentDir: content, UsersFile: users,
TemplatesFile: filepath.Join(dir, "templates.toml"),
TokensFile: filepath.Join(dir, "tokens.toml"),
}); err != nil {
t.Fatalf("restore: %v", err)
}
for _, list := range []string{dir, content} {
entries, err := os.ReadDir(list)
if err != nil {
t.Fatalf("read %s: %v", list, err)
}
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".") && strings.HasSuffix(e.Name(), ".tmp") {
t.Fatalf("temp file %s left behind in %s", e.Name(), list)
}
}
}
info, err := os.Stat(users)
if err != nil {
t.Fatalf("stat users: %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("users.toml mode = %v, want 0600", info.Mode().Perm())
}
}
+497
View File
@@ -0,0 +1,497 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package biblio turns the structured `refs` frontmatter of a post into
// a rendered reference list, machine-readable citations, and the
// resolver links a scholar expects. It is a leaf: it reads plain
// metadata values and writes HTML and JSON shapes, importing no other
// domain package.
//
// The shape is measured on the author's own volumes: numbered `[n]`
// entries, inline `[n]` citations, and `doi:` and `arXiv:` identifiers
// embedded in free text. The engine keeps that convention and makes it
// live: each entry gets a target the inline citations point at, and
// every identifier becomes a link to its resolver.
package biblio
import (
stdhtml "html"
"regexp"
"strconv"
"strings"
)
// Author is one cited author: a name, and the author's ORCID when the
// reference records one.
type Author struct {
Name string `json:"name"`
ORCID string `json:"orcid,omitempty"`
}
// Entry is one reference. A hand-written entry that does not break down
// into the structured fields keeps its verbatim text in Raw, which the
// renderer prints as given, with any identifier inside it linked: a
// reference is never dropped or rewritten into something the author did
// not write.
type Entry struct {
Num int `json:"num"`
Authors []Author `json:"authors,omitempty"`
Title string `json:"title,omitempty"`
Venue string `json:"venue,omitempty"`
Year string `json:"year,omitempty"`
Volume string `json:"volume,omitempty"`
Pages string `json:"pages,omitempty"`
DOI string `json:"doi,omitempty"`
ArXiv string `json:"arxiv,omitempty"`
URL string `json:"url,omitempty"`
Raw string `json:"raw,omitempty"`
// Internal is the same-instance link: the API URL of the post whose
// DOI this entry cites, set by the caller that knows the instance
// (post.RefsLinked annotates each post's entries). Empty when the cited
// work is not published here, or is the citing post itself.
Internal string `json:"internal,omitempty"`
}
// href is the address the rendered link points at: the internal post
// when the entry cites a work published in this instance, otherwise
// the external resolver.
func (e Entry) href() string {
if e.Internal != "" {
return e.Internal
}
return e.Link()
}
// Marker is the token an author places where the reference list should
// be rendered; a body without it gets the list appended.
const Marker = "[[refs]]"
var (
markerRe = regexp.MustCompile(`(?s)<p>\s*\Q` + Marker + `\E\s*</p>`)
doiTokenRe = regexp.MustCompile(`(?i)\bdoi:\s*(10\.[0-9]{4,9}/[^\s]+)`)
arxivTokenRe = regexp.MustCompile(`(?i)\barXiv:\s*([A-Za-z0-9][A-Za-z0-9./:-]*)`)
yearRe = regexp.MustCompile(`\b(1[89][0-9]{2}|20[0-9]{2})\b`)
inlineRefRe = regexp.MustCompile(`\[(\d{1,3})\]`)
)
// Parse reads the refs array out of flattened frontmatter tables. Each
// element may carry authors, title, venue, year, volume, pages, doi,
// arxiv, url or raw; authors is a list of strings or of {name, orcid}
// tables. Entries without an explicit number are numbered by position.
func Parse(refs []map[string]any) []Entry {
var out []Entry
for i, raw := range refs {
entry := Entry{
Num: numOr(raw["num"], i+1),
Title: strings.TrimSpace(str(raw["title"])),
Venue: strings.TrimSpace(str(raw["venue"])),
Year: strings.TrimSpace(str(raw["year"])),
Volume: strings.TrimSpace(str(raw["volume"])),
Pages: strings.TrimSpace(str(raw["pages"])),
DOI: cleanDOI(str(raw["doi"])),
ArXiv: cleanArxiv(str(raw["arxiv"])),
URL: strings.TrimSpace(str(raw["url"])),
Raw: strings.TrimSpace(str(raw["raw"])),
}
entry.Authors = parseAuthors(raw["authors"])
entry.enrichFromText()
out = append(out, entry)
}
return out
}
func parseAuthors(v any) []Author {
list, ok := v.([]any)
if !ok {
return nil
}
var out []Author
for _, item := range list {
switch value := item.(type) {
case string:
if name := strings.TrimSpace(value); name != "" {
out = append(out, Author{Name: name})
}
case map[string]any:
author := Author{
Name: strings.TrimSpace(str(value["name"])),
ORCID: strings.TrimSpace(str(value["orcid"])),
}
if author.Name != "" {
out = append(out, author)
}
}
}
return out
}
func numOr(v any, fallback int) int {
switch n := v.(type) {
case int64:
return int(n)
case int:
return n
case float64:
return int(n)
case string:
if parsed, err := strconv.Atoi(strings.TrimSpace(n)); err == nil {
return parsed
}
}
return fallback
}
func str(v any) string {
s, _ := v.(string)
return s
}
// cleanDOI accepts a bare identifier, a doi: prefix or a resolver URL
// and returns the bare form.
func cleanDOI(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
if m := doiTokenRe.FindStringSubmatch(s); m != nil {
return strings.TrimRight(m[1], ".,;)")
}
s = strings.TrimPrefix(strings.TrimPrefix(s, "https://doi.org/"), "doi:")
return strings.TrimRight(strings.TrimSpace(s), ".,;)")
}
func cleanArxiv(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
if m := arxivTokenRe.FindStringSubmatch(s); m != nil {
s = m[1]
}
s = strings.TrimRight(strings.TrimPrefix(strings.TrimPrefix(s, "arXiv:"), "https://arxiv.org/abs/"), ".,;)")
if !strings.HasPrefix(s, "arXiv:") && s != "" {
// Already a bare id; accept anything identifier-shaped.
if strings.ContainsAny(s, " \t<\"") {
return ""
}
return s
}
return ""
}
// stripTokens removes the identifier tokens from the entry's own text,
// used once an entry is structured: the renderer puts the identifier at
// the end as a link, and it must not also sit in the title as prose.
func (e *Entry) stripTokens() {
if e.Title == "" && len(e.Authors) == 0 {
return // a raw entry prints its tokens inline as links instead
}
e.Title = tidyTokens(e.Title)
e.Venue = tidyTokens(e.Venue)
}
func tidyTokens(s string) string {
s = doiTokenRe.ReplaceAllString(s, "")
s = arxivTokenRe.ReplaceAllString(s, "")
s = strings.ReplaceAll(s, ", ,", ",")
s = strings.TrimSpace(s)
s = strings.TrimRight(s, ",;")
return strings.TrimSpace(s)
}
// enrichFromText recovers identifiers and a year a hand-written entry
// kept in its free text, so a migrated volume gains live links without
// every field being split by hand.
func (e *Entry) enrichFromText() {
joined := strings.Join([]string{e.Title, e.Venue, e.Raw}, " ")
if e.DOI == "" {
if m := doiTokenRe.FindStringSubmatch(joined); m != nil {
e.DOI = strings.TrimRight(m[1], ".,;)")
e.stripTokens()
}
}
if e.ArXiv == "" {
if m := arxivTokenRe.FindStringSubmatch(joined); m != nil {
e.ArXiv = m[1]
e.stripTokens()
}
}
// The year is a structured field only when the entry is structured:
// a raw entry already prints its year inside its own text.
if e.Year == "" && (e.Title != "" || len(e.Authors) > 0) {
if m := yearRe.FindStringSubmatch(joined); m != nil {
e.Year = m[1]
}
}
}
// Link returns the resolver URL for this entry: DOI first, then arXiv,
// then a plain URL. Empty when the entry carries no identifier.
func (e Entry) Link() string {
switch {
case e.DOI != "":
return "https://doi.org/" + e.DOI
case e.ArXiv != "":
return "https://arxiv.org/abs/" + e.ArXiv
case e.URL != "":
return e.URL
default:
return ""
}
}
// ListHTML renders the numbered reference list. Each entry is anchored
// so an inline citation can point at it.
func ListHTML(entries []Entry) string {
if len(entries) == 0 {
return ""
}
var b strings.Builder
b.WriteString(`<section class="refs" id="references">` + "\n<ol>\n")
for _, e := range entries {
b.WriteString(`<li id="ref-` + strconv.Itoa(e.Num) + `">`)
b.WriteString(e.line())
b.WriteString("</li>\n")
}
b.WriteString("</ol>\n</section>\n")
return b.String()
}
// line renders one entry: authors, title, the venue tail, and the
// identifier link. A purely raw entry prints its verbatim text with
// embedded identifiers made clickable, and nothing else: the author's
// own wording already carries the year and venue.
func (e Entry) line() string {
if e.Raw != "" && e.Title == "" && len(e.Authors) == 0 {
return e.identifierLinks()
}
var b strings.Builder
if len(e.Authors) > 0 {
names := make([]string, 0, len(e.Authors))
for _, a := range e.Authors {
name := stdhtml.EscapeString(a.Name)
if a.ORCID != "" {
name += " " + link("https://orcid.org/"+a.ORCID, "orcid:"+a.ORCID)
}
names = append(names, name)
}
// An author string that already ends in a period ("Riess, A. G.
// a kol.") must not gain a second one at the segment boundary.
authorText := strings.Join(names, ", ")
b.WriteString(authorText)
if !strings.HasSuffix(authorText, ".") {
b.WriteString(".")
}
b.WriteString(" ")
}
if e.Title != "" {
b.WriteString(stdhtml.EscapeString(e.Title) + ".")
}
tail := make([]string, 0, 4)
if e.Venue != "" {
tail = append(tail, stdhtml.EscapeString(e.Venue))
}
if e.Volume != "" {
tail = append(tail, "vol. "+stdhtml.EscapeString(e.Volume))
}
if e.Pages != "" {
tail = append(tail, "pp. "+stdhtml.EscapeString(e.Pages))
}
if e.Year != "" {
tail = append(tail, stdhtml.EscapeString(e.Year))
}
if len(tail) > 0 {
if e.Title != "" {
b.WriteString(" ")
}
b.WriteString(strings.Join(tail, ", "))
b.WriteString(".")
}
if url := e.href(); url != "" {
b.WriteString(" " + link(url, label(e)))
}
return b.String()
}
// label names the identifier link by whichever resolver it uses.
func label(e Entry) string {
switch {
case e.DOI != "":
return "doi:" + e.DOI
case e.ArXiv != "":
return "arXiv:" + e.ArXiv
default:
return "url"
}
}
// identifierLinks prints a raw entry with each doi:/arXiv: token it
// embeds replaced by a live link, keeping the author's own wording
// around it untouched. A token equal to the entry's cited DOI takes the
// internal link when the entry has one; every other token keeps its
// resolver.
func (e Entry) identifierLinks() string {
raw, doi, arxiv := e.Raw, e.DOI, e.ArXiv
html := stdhtml.EscapeString(raw)
if doi != "" {
html = doiTokenRe.ReplaceAllStringFunc(html, func(match string) string {
id := strings.TrimRight(doiTokenRe.FindStringSubmatch(match)[1], ".,;)")
trail := match[len("doi:"):]
trail = trail[strings.Index(trail, id)+len(id):]
href := "https://doi.org/" + id
if e.Internal != "" && id == doi {
// The slug comes from the store, so it is escaped like
// every other href: a hand-edited file whose slug carries
// a quote must not open an attribute here.
href = stdhtml.EscapeString(e.Internal)
}
return `<a class="refs-link" href="` + href + `">doi:` + id + `</a>` + trail
})
}
if arxiv != "" {
html = arxivTokenRe.ReplaceAllStringFunc(html, func(match string) string {
id := arxivTokenRe.FindStringSubmatch(match)[1]
body := "arXiv:" + id
trail := match[len(body):]
return `<a class="refs-link" href="https://arxiv.org/abs/` + id + `">arXiv:` + id + `</a>` + trail
})
}
return html
}
// Citations renders the entries as schema.org citation objects for the
// post's JSON-LD block, so a machine reading the article also reads the
// works it cites, with each author and identifier resolved.
func Citations(entries []Entry) []map[string]any {
var out []map[string]any
for _, e := range entries {
name := e.Title
if name == "" {
name = e.Raw
}
if name == "" {
continue
}
c := map[string]any{"@type": "ScholarlyArticle", "position": e.Num, "name": name}
if len(e.Authors) > 0 {
persons := make([]map[string]any, 0, len(e.Authors))
for _, a := range e.Authors {
person := map[string]any{"@type": "Person", "name": a.Name}
if a.ORCID != "" {
person["identifier"] = "https://orcid.org/" + a.ORCID
}
persons = append(persons, person)
}
c["author"] = persons
}
if e.Venue != "" {
c["isPartOf"] = map[string]any{"@type": "PublicationJournal", "name": e.Venue}
}
if e.Year != "" {
c["datePublished"] = e.Year
}
if url := e.Link(); url != "" {
c["identifier"] = url
}
if e.Internal != "" {
c["url"] = e.Internal
}
out = append(out, c)
}
return out
}
func link(href, text string) string {
return `<a class="refs-link" href="` + stdhtml.EscapeString(href) + `">` +
stdhtml.EscapeString(text) + "</a>"
}
// Place splices the list into the rendered body: at the marker
// paragraph when the author wrote one, otherwise appended.
func Place(bodyHTML string, entries []Entry) string {
list := ListHTML(entries)
if list == "" {
return bodyHTML
}
if markerRe.MatchString(bodyHTML) {
return markerRe.ReplaceAllString(bodyHTML, list)
}
return strings.TrimRight(bodyHTML, "\n") + "\n" + list
}
// LinkCitations rewrites inline `[n]` markers in the rendered HTML into
// links to the numbered entries. It walks the markup and touches text
// only: tags and attribute values are copied verbatim, and the inside
// of <code>, <pre> and <a> elements is left alone, so code that holds
// bracketed numbers and the reference list itself keep their text.
func LinkCitations(html string, entries []Entry) string {
if len(entries) == 0 {
return html
}
highest := 0
for _, e := range entries {
if e.Num > highest {
highest = e.Num
}
}
var b strings.Builder
b.Grow(len(html) + 2*len(html)/8)
i := 0
skipping := "" // non-empty while inside a protected element
for i < len(html) {
if html[i] != '<' {
j := i
for j < len(html) && html[j] != '<' {
j++
}
b.WriteString(citeText(html[i:j], highest, skipping != ""))
i = j
continue
}
end := strings.IndexByte(html[i:], '>')
if end < 0 {
b.WriteString(html[i:])
break
}
tag := html[i : i+end+1]
b.WriteString(tag)
switch {
case skipping != "":
// Inside a protected element: wait for its close.
closing := "</" + skipping + ">"
if pos := strings.Index(strings.ToLower(tag), closing); pos >= 0 {
skipping = ""
}
case strings.HasPrefix(strings.ToLower(tag), "<pre"),
strings.HasPrefix(strings.ToLower(tag), "<code"),
strings.HasPrefix(strings.ToLower(tag), "<a"):
if !strings.HasSuffix(tag, "/>") {
kind := "a"
switch {
case strings.HasPrefix(strings.ToLower(tag), "<pre"):
kind = "pre"
case strings.HasPrefix(strings.ToLower(tag), "<code"):
kind = "code"
}
skipping = kind
}
}
i += end + 1
}
return b.String()
}
// citeText replaces the bracketed numbers within one text run when
// linking is allowed there.
func citeText(text string, highest int, protected bool) string {
if protected || highest == 0 {
return text
}
return inlineRefRe.ReplaceAllStringFunc(text, func(tok string) string {
n, err := strconv.Atoi(strings.Trim(tok, "[]"))
if err != nil || n < 1 || n > highest {
return tok
}
return `<a class="ref-cite" href="#ref-` + strconv.Itoa(n) + `">` + tok + "</a>"
})
}
+207
View File
@@ -0,0 +1,207 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package biblio
import (
"strings"
"testing"
)
func TestParseStructuredAndRaw(t *testing.T) {
entries := Parse([]map[string]any{
{
"title": "Observational evidence from supernovae",
"authors": []any{
map[string]any{"name": "Riess, A. G.", "orcid": "0000-0002-1825-0097"},
"Filippenko, A. V.",
},
"venue": "The Astronomical Journal",
"year": "1998",
"doi": "doi:10.1103/PhysRevD.59.103502.",
},
{
"raw": "Goldhaber, G. a kol., arXiv:astro-ph/0408076, doi:10.1088/1475-7516/2004/12/010.",
},
})
if len(entries) != 2 {
t.Fatalf("entries = %d", len(entries))
}
first := entries[0]
if first.Num != 1 || first.Title == "" || len(first.Authors) != 2 {
t.Fatalf("first = %+v", first)
}
if first.Authors[0].ORCID != "0000-0002-1825-0097" {
t.Fatalf("author orcid lost: %+v", first.Authors[0])
}
if first.DOI != "10.1103/PhysRevD.59.103502" {
t.Fatalf("doi not cleaned: %q", first.DOI)
}
second := entries[1]
if second.Num != 2 || second.DOI != "10.1088/1475-7516/2004/12/010" ||
second.ArXiv != "astro-ph/0408076" {
t.Fatalf("raw entry not enriched: %+v", second)
}
}
func TestListHTMLAndPlace(t *testing.T) {
entries := Parse([]map[string]any{
{"title": "Alpha", "doi": "10.1000/alpha", "authors": []any{"A. Author"}},
})
list := ListHTML(entries)
for _, want := range []string{
`<section class="refs" id="references">`, `<li id="ref-1">`,
`href="https://doi.org/10.1000/alpha"`, "orcid", "A. Author.",
} {
if want == "orcid" {
if strings.Contains(list, want) {
t.Fatalf("unexpected orcid: %s", list)
}
continue
}
if !strings.Contains(list, want) {
t.Fatalf("list missing %q:\n%s", want, list)
}
}
// The marker is replaced in place; without it the list is appended.
body := "<p>Intro.</p>\n<p>" + Marker + "</p>\n<p>After.</p>\n"
placed := Place(body, entries)
if !strings.Contains(placed, "Intro.") || !strings.Contains(placed, "After.") ||
strings.Contains(placed, Marker) {
t.Fatalf("marker placement wrong:\n%s", placed)
}
if !strings.HasSuffix(Place("<p>Only.</p>", entries), "</section>\n") {
t.Fatal("append mode missing")
}
if Place("<p>x</p>", nil) != "<p>x</p>" {
t.Fatal("empty entries should not change the body")
}
}
func TestLinkCitationsRewritesTextOnly(t *testing.T) {
entries := Parse([]map[string]any{{"title": "A"}, {"title": "B"}})
html := "<p>Work [1] and [2], even [3] out of range.</p>" +
"<p>Code stays: <code>list[1] = 2</code>.</p>" +
"<pre>array[2]=x</pre>" +
`<a href="/x#ref-1">text [1] inside link</a>` +
`<a href="/y" data-q="[2]">href untouched</a>`
out := LinkCitations(html, entries)
if !strings.Contains(out, `<a class="ref-cite" href="#ref-1">[1]</a>`) {
t.Fatalf("inline [1] not linked:\n%s", out)
}
if strings.Contains(out, `href="#ref-3"`) {
t.Fatal("out-of-range [3] was linked")
}
if !strings.Contains(out, "<code>list[1] = 2</code>") {
t.Fatal("code content was rewritten")
}
if !strings.Contains(out, "<pre>array[2]=x</pre>") {
t.Fatal("pre content was rewritten")
}
// Inside an existing anchor the marker stays text; no nesting.
if !strings.Contains(out, ">text [1] inside link</a>") {
t.Fatalf("anchor text was rewritten:\n%s", out)
}
if !strings.Contains(out, `data-q="[2]"`) {
t.Fatal("attribute value was rewritten")
}
}
func TestStructuredEntryStripsTokensFromText(t *testing.T) {
entries := Parse([]map[string]any{{
"title": "Timescale stretch parameterization, doi:10.1088/1475-7516/2004/12/010.",
"authors": []any{"Goldhaber, G. a kol."},
}})
e := entries[0]
if e.DOI != "10.1088/1475-7516/2004/12/010" {
t.Fatalf("doi not pulled out: %q", e.DOI)
}
line := ListHTML(entries)
if got := strings.Count(line, "refs-link"); got != 1 {
t.Fatalf("expected one identifier link, got %d:\n%s", got, line)
}
if before := line[:strings.Index(line, `<a `)]; strings.Contains(before, "10.1088") {
t.Fatalf("identifier left as prose before the link:\n%s", before)
}
if !strings.Contains(line, `href="https://doi.org/10.1088/1475-7516/2004/12/010"`) {
t.Fatalf("resolver link missing:\n%s", line)
}
}
func TestCitationsJSONLDShape(t *testing.T) {
entries := Parse([]map[string]any{
{
"title": "Alpha",
"authors": []any{map[string]any{"name": "A. Author", "orcid": "0000-0002-1825-0097"}},
"venue": "Journal", "year": "2020", "doi": "10.1000/a",
},
})
cits := Citations(entries)
if len(cits) != 1 {
t.Fatalf("citations = %v", cits)
}
c := cits[0]
if c["@type"] != "ScholarlyArticle" || c["identifier"] != "https://doi.org/10.1000/a" {
t.Fatalf("citation shape wrong: %v", c)
}
persons, ok := c["author"].([]map[string]any)
if !ok || persons[0]["identifier"] != "https://orcid.org/0000-0002-1825-0097" {
t.Fatalf("author orcid missing: %v", c["author"])
}
}
func TestInternalCrossLinks(t *testing.T) {
entries := Parse([]map[string]any{
{"num": int64(1), "title": "Teorie deterministického substrátu", "doi": "10.5555/tdssc.2026"},
{"num": int64(2), "raw": "Balvín, P.: TDSSC, viz doi:10.5555/tdssc.2026; externí doi:10.9999/other (2020)."},
{"num": int64(3), "title": "Externí práce", "doi": "10.9999/other"},
{"num": int64(4), "title": "Bez identifikátoru"},
})
entries[0].Internal = "/api/volumen/posts/tdssc"
entries[1].Internal = "/api/volumen/posts/tdssc"
list := ListHTML(entries)
for _, want := range []string{
`href="/api/volumen/posts/tdssc">doi:10.5555/tdssc.2026<`,
`href="https://doi.org/10.9999/other">doi:10.9999/other<`,
} {
if !strings.Contains(list, want) {
t.Fatalf("list missing %q:\n%s", want, list)
}
}
if strings.Count(list, `href="/api/volumen/posts/tdssc"`) != 2 {
t.Fatalf("internal link count wrong:\n%s", list)
}
cits := Citations(entries)
if cits[0]["url"] != "/api/volumen/posts/tdssc" {
t.Fatalf("citation url not internal: %v", cits[0])
}
// The canonical identifier survives unchanged next to the internal link.
if cits[0]["identifier"] != "https://doi.org/10.5555/tdssc.2026" {
t.Fatalf("citation identifier changed: %v", cits[0])
}
if _, ok := cits[2]["url"]; ok {
t.Fatalf("external citation gained a url: %v", cits[2])
}
if _, ok := cits[3]["identifier"]; ok {
t.Fatalf("bare citation gained an identifier: %v", cits[3])
}
}
// A raw entry's internal link is escaped like every other href: the slug
// it names comes from the store, and a hand-edited file could carry a
// quote that must not open an attribute in the rendered list.
func TestRawEntryEscapesInternalLink(t *testing.T) {
entries := Parse([]map[string]any{
{"raw": "Viz doi:10.5555/hostile.2026 (2020).", "doi": "10.5555/hostile.2026"},
})
entries[0].Internal = `/api/volumen/posts/x" onmouseover="alert(1)`
out := entries[0].identifierLinks()
if strings.Contains(out, `" onmouseover=`) {
t.Fatalf("the internal link broke out of its attribute: %q", out)
}
if !strings.Contains(out, "href=\"/api/volumen/posts/x&#34; onmouseover=&#34;alert(1)\"") {
t.Fatalf("the internal link lost its target: %q", out)
}
}
+523
View File
@@ -0,0 +1,523 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package config reads config.toml into a typed value, applies the
// command-line overrides, and validates it. The commented template it
// ships, config.toml.example, is the file an operator copies.
//
// There is no configuration map: every key has a field, the decoder
// rejects a key whose TOML type does not match its field, and a key the
// decoder does not know is ignored, so a file written for a newer release
// still loads.
package config
import (
"errors"
"fmt"
"log/slog"
"net/netip"
"net/url"
"os"
"path/filepath"
"strings"
"sourcedock.dev/petrbalvin/interpres/v2"
"sourcedock.dev/petrbalvin/volumen/internal/fediverse"
"sourcedock.dev/petrbalvin/volumen/internal/password"
)
// DefaultPath is the system-wide configuration file location.
const DefaultPath = "/etc/volumen/config.toml"
// UserConfigPath returns the per-user configuration file location,
// resolved from XDG_CONFIG_HOME or ~/.config. It is "" when neither can
// be named.
func UserConfigPath() string {
if d := os.Getenv("XDG_CONFIG_HOME"); d != "" {
return filepath.Join(d, "volumen", "config.toml")
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".config", "volumen", "config.toml")
}
// ResolveConfigPath picks the configuration file `serve` reads when
// --config was not given: the system path if it exists, otherwise the
// per-user path if it exists, otherwise the system path, which does not
// exist and so loads the built-in defaults. A plain `volumen serve` with
// no file anywhere therefore runs on per-user state paths.
func ResolveConfigPath() string {
if _, err := os.Stat(DefaultPath); err == nil {
return DefaultPath
}
if p := UserConfigPath(); p != "" {
if _, err := os.Stat(p); err == nil {
return p
}
}
return DefaultPath
}
// UserStatePaths returns the per-user content and users file paths used
// as the defaults when no configuration file exists, so the server can
// run and write its state under the user's home without root. They are
// resolved from XDG_DATA_HOME or ~/.local/share; ok is false when the
// platform cannot name one.
func UserStatePaths() (contentDir, usersFile string, ok bool) {
base := os.Getenv("XDG_DATA_HOME")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", "", false
}
base = filepath.Join(home, ".local", "share")
}
dir := filepath.Join(base, "volumen")
return filepath.Join(dir, "posts"), filepath.Join(dir, "users.toml"), true
}
// The built-in defaults, applied to every key the file leaves out.
const (
DefaultHost = "::"
DefaultPort = 9091
DefaultContentDir = "/var/lib/volumen/posts"
DefaultUsersFile = "/var/lib/volumen/users.toml"
DefaultSiteTitle = "Volumen"
DefaultSiteDescription = "Powered by Volumen."
DefaultBaseURL = "https://example.com"
DefaultLanguage = "en"
DefaultAuthor = "Anonymous"
DefaultSessionTTL = 86400
DefaultMinPasswordLength = 10
DefaultMaxPasswordLength = 1024
DefaultMaxUploadBytes = 10 * 1024 * 1024
DefaultAPIRateLimit = 60
DefaultAPIRateLimitWindow = 60
DefaultRevisionLimit = 10
DefaultSchedulerInterval = 300
// MaxSessionTTL bounds [admin].session_ttl so that it cannot overflow
// a time.Duration when converted to seconds, and so that a session
// cannot outlive a year.
MaxSessionTTL = 365 * 24 * 60 * 60
// MaxRateLimitWindow bounds [api].rate_limit_window for the same
// reason.
MaxRateLimitWindow = 24 * 60 * 60
// MaxUploadBytesCeiling bounds [admin].max_upload_bytes, so that a
// mistyped value cannot be read into memory in one piece.
MaxUploadBytesCeiling = 1 << 30
// PortUnset is the Overrides.Port sentinel meaning "do not override".
PortUnset = -1
EnvProduction = "production"
EnvDevelopment = "development"
LogFormatText = "text"
LogFormatJSON = "json"
)
// ConfigError reports a configuration value the program refuses to run
// with. Validate returns it, and the caller prints it and exits.
type ConfigError struct {
msg string
}
func (e *ConfigError) Error() string { return e.msg }
func errorf(format string, args ...any) *ConfigError {
return &ConfigError{msg: fmt.Sprintf(format, args...)}
}
// Server is the [server] table.
type Server struct {
Host string `toml:"host"`
Port int `toml:"port"`
Env string `toml:"env"`
TrustProxy bool `toml:"trust_proxy"`
// TrustedProxies lists the addresses whose X-Forwarded-For may be
// believed, as addresses or CIDR prefixes. An empty list means the
// header is never read and the connection address is always used;
// list the proxy so its clients each rate-limit under their own
// address.
TrustedProxies []string `toml:"trusted_proxies"`
CookieSecure bool `toml:"cookie_secure"`
LogFormat string `toml:"log_format"`
}
// Site is the [site] table.
type Site struct {
Title string `toml:"title"`
Description string `toml:"description"`
BaseURL string `toml:"base_url"`
Language string `toml:"language"`
Author string `toml:"author"`
FediverseCreator string `toml:"fediverse_creator"`
}
// Admin is the [admin] table.
type Admin struct {
SessionKey string `toml:"session_key"`
SessionTTL int `toml:"session_ttl"`
MinPasswordLength int `toml:"min_password_length"`
MaxPasswordLength int `toml:"max_password_length"`
MaxUploadBytes int `toml:"max_upload_bytes"`
}
// API is the [api] table.
type API struct {
RateLimit int `toml:"rate_limit"`
RateLimitWindow int `toml:"rate_limit_window"`
}
// Scheduler is the [scheduler] table.
type Scheduler struct {
Enabled bool `toml:"enabled"`
Interval int `toml:"interval"`
}
// Webhook is one [[webhooks]] entry.
type Webhook struct {
URL string `toml:"url"`
Secret string `toml:"secret"`
Events []string `toml:"events"`
// Enabled is a pointer so that an omitted key means enabled: a hook
// written without the key is one the operator wants delivered, and
// only an explicit false turns it off.
Enabled *bool `toml:"enabled"`
}
// Delivers reports whether the hook is on.
func (w Webhook) Delivers() bool { return w.Enabled == nil || *w.Enabled }
// Config is the merged configuration: the built-in defaults with the file
// decoded over them and the command-line overrides applied.
type Config struct {
Server Server `toml:"server"`
Site Site `toml:"site"`
Admin Admin `toml:"admin"`
API API `toml:"api"`
Scheduler Scheduler `toml:"scheduler"`
ContentDir string `toml:"content_dir"`
UsersFile string `toml:"users_file"`
RevisionLimit int `toml:"revision_limit"`
AuditLog string `toml:"audit_log"`
Webhooks []Webhook `toml:"webhooks"`
}
// Overrides carries the command-line overrides of the `serve` subcommand.
// An empty string means unset; Port uses PortUnset rather than zero,
// because port 0 is a value a caller could mean to set.
type Overrides struct {
Host string
Port int
ContentDir string
UsersFile string
}
// Defaults returns the built-in configuration.
func Defaults() *Config {
return &Config{
Server: Server{
Host: DefaultHost,
Port: DefaultPort,
Env: EnvDevelopment,
LogFormat: LogFormatText,
},
Site: Site{
Title: DefaultSiteTitle,
Description: DefaultSiteDescription,
BaseURL: DefaultBaseURL,
Language: DefaultLanguage,
Author: DefaultAuthor,
},
Admin: Admin{
SessionTTL: DefaultSessionTTL,
MinPasswordLength: DefaultMinPasswordLength,
MaxPasswordLength: DefaultMaxPasswordLength,
MaxUploadBytes: DefaultMaxUploadBytes,
},
API: API{
RateLimit: DefaultAPIRateLimit,
RateLimitWindow: DefaultAPIRateLimitWindow,
},
Scheduler: Scheduler{
Interval: DefaultSchedulerInterval,
},
ContentDir: DefaultContentDir,
UsersFile: DefaultUsersFile,
RevisionLimit: DefaultRevisionLimit,
}
}
// Load reads path, decodes it over the built-in defaults, applies the
// overrides, and returns the configuration. A missing file is not an
// error: the defaults are used and one line says so. With no file, the
// data paths move under the user's home so a server started without any
// configuration can still write its state and run the first-run wizard;
// an explicit --content or --users-file override wins over that.
func Load(path string, ov Overrides) (*Config, error) {
cfg := Defaults()
raw, err := os.ReadFile(path)
switch {
case err == nil:
if err := decode(raw, cfg); err != nil {
return nil, fmt.Errorf("parse config %s: %w", path, err)
}
case errors.Is(err, os.ErrNotExist):
if content, users, ok := UserStatePaths(); ok {
cfg.ContentDir = content
cfg.UsersFile = users
slog.Info("volumen: configuration file not found, using built-in defaults",
"path", path, "example", "config.toml.example", "content_dir", content)
} else {
slog.Info("volumen: configuration file not found, using built-in defaults",
"path", path, "example", "config.toml.example")
}
default:
return nil, fmt.Errorf("read config %s: %w", path, err)
}
cfg.apply(ov)
return cfg, nil
}
// decode fills cfg from a TOML document, and reports a root key that a
// table header swallowed: TOML puts a key written below [site] inside
// that table, where nothing reads it.
func decode(raw []byte, cfg *Config) error {
tree, err := interpres.ParseMap(raw)
if err != nil {
return err
}
for name, value := range tree {
// Only tables can swallow a root key; [[webhooks]] is an array
// of tables and parses as a slice, so the type check skips it.
sub, ok := value.(map[string]any)
if !ok {
continue
}
for _, key := range foldedKeys {
if _, present := sub[key]; present {
return fmt.Errorf(
"%s is written below the [%s] header, so it belongs to that table; move it above the first [table] header",
key, name)
}
}
}
return interpres.Unmarshal(raw, cfg)
}
// foldedKeys are the keys that sit at the root of the document and are
// silently captured by a preceding table header if they are written below
// one.
var foldedKeys = []string{"content_dir", "users_file", "revision_limit", "audit_log"}
func (c *Config) apply(ov Overrides) {
if ov.Host != "" {
c.Server.Host = ov.Host
}
if ov.Port != PortUnset {
c.Server.Port = ov.Port
}
if ov.ContentDir != "" {
c.ContentDir = ov.ContentDir
}
if ov.UsersFile != "" {
c.UsersFile = ov.UsersFile
}
}
// TemplatesFile returns the templates.toml path, next to users_file.
func (c *Config) TemplatesFile() string {
return filepath.Join(filepath.Dir(c.UsersFile), "templates.toml")
}
// TokensFile returns the tokens.toml path, next to users_file.
func (c *Config) TokensFile() string {
return filepath.Join(filepath.Dir(c.UsersFile), "tokens.toml")
}
// IsProduction reports whether the environment label is production.
func (c *Config) IsProduction() bool { return c.Server.Env == EnvProduction }
// ListenAddr returns the address the server binds, as host:port.
func (c *Config) ListenAddr() (netip.AddrPort, error) {
addr, err := netip.ParseAddr(c.Server.Host)
if err != nil {
return netip.AddrPort{}, errorf("[server].host must be an IP address (got %q)", c.Server.Host)
}
return netip.AddrPortFrom(addr, uint16(c.Server.Port)), nil
}
// TrustedProxyPrefixes parses [server].trusted_proxies. An entry may be a
// single address, which is read as a /32 or /128 prefix.
func (c *Config) TrustedProxyPrefixes() ([]netip.Prefix, error) {
out := make([]netip.Prefix, 0, len(c.Server.TrustedProxies))
for _, entry := range c.Server.TrustedProxies {
if prefix, err := netip.ParsePrefix(entry); err == nil {
out = append(out, prefix.Masked())
continue
}
addr, err := netip.ParseAddr(entry)
if err != nil {
return nil, errorf("[server].trusted_proxies entry %q is not an address or a CIDR prefix", entry)
}
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
}
return out, nil
}
// Validate checks the configuration and returns a *ConfigError naming the
// first problem. A key the decoder could not read is already an error by
// then, so this covers the values a wrong type cannot catch.
func (c *Config) Validate() error {
if c.Server.Host == "" {
return errorf("[server].host must be a non-empty string")
}
if _, err := netip.ParseAddr(c.Server.Host); err != nil {
return errorf("[server].host must be an IP address (got %q)", c.Server.Host)
}
if c.Server.Port < 1 || c.Server.Port > 65535 {
return errorf("[server].port must be an integer in 1..65535 (got %d)", c.Server.Port)
}
if c.Server.Env != EnvDevelopment && c.Server.Env != EnvProduction {
return errorf("[server].env must be one of [development production] (got %q)", c.Server.Env)
}
if c.Server.LogFormat != LogFormatText && c.Server.LogFormat != LogFormatJSON {
return errorf("[server].log_format must be one of [text json] (got %q)", c.Server.LogFormat)
}
if _, err := c.TrustedProxyPrefixes(); err != nil {
return err
}
if c.Server.TrustProxy && !c.IsProduction() {
slog.Warn("config: [server].trust_proxy is on outside production; " +
"only a proxy you control must be able to reach the listener")
}
if c.RevisionLimit < 0 {
return errorf("revision_limit must be >= 0 (got %d)", c.RevisionLimit)
}
if c.Admin.SessionTTL <= 0 {
return errorf("[admin].session_ttl must be > 0 (got %d)", c.Admin.SessionTTL)
}
if c.Admin.SessionTTL > MaxSessionTTL {
return errorf("[admin].session_ttl must be <= %d seconds (got %d)", MaxSessionTTL, c.Admin.SessionTTL)
}
if c.Admin.MinPasswordLength < 1 || c.Admin.MinPasswordLength > c.Admin.MaxPasswordLength {
return errorf("[admin].min_password_length must be >= 1 and <= max_password_length")
}
// The hashing layer refuses anything longer whatever this value
// says; catching it here turns a password-change 500 into a startup
// error the operator can read.
if c.Admin.MaxPasswordLength > password.MaxPasswordLength {
return errorf("[admin].max_password_length must be <= %d (got %d)",
password.MaxPasswordLength, c.Admin.MaxPasswordLength)
}
if c.Admin.MaxUploadBytes <= 0 {
return errorf("[admin].max_upload_bytes must be > 0")
}
if c.Admin.MaxUploadBytes > MaxUploadBytesCeiling {
return errorf("[admin].max_upload_bytes must be <= %d (got %d)", MaxUploadBytesCeiling, c.Admin.MaxUploadBytes)
}
if c.API.RateLimit < 0 {
return errorf("[api].rate_limit must be >= 0 (got %d)", c.API.RateLimit)
}
if c.API.RateLimit > 0 {
if c.API.RateLimitWindow <= 0 {
return errorf("[api].rate_limit_window must be > 0 when rate limiting is enabled")
}
if c.API.RateLimitWindow > MaxRateLimitWindow {
return errorf("[api].rate_limit_window must be <= %d seconds (got %d)", MaxRateLimitWindow, c.API.RateLimitWindow)
}
}
if c.Scheduler.Enabled && c.Scheduler.Interval < 1 {
return errorf("[scheduler].interval must be >= 1 second (got %d)", c.Scheduler.Interval)
}
for _, hook := range c.Webhooks {
parsed, err := url.Parse(hook.URL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return errorf("[[webhooks]].url must be an http(s) URL (got %q)", hook.URL)
}
}
if c.Site.BaseURL == "" {
return errorf("[site].base_url must be a non-empty string")
}
parsed, err := url.Parse(c.Site.BaseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return errorf("[site].base_url must be an absolute URL (got %q)", c.Site.BaseURL)
}
if c.Site.FediverseCreator != "" && !fediverse.Valid(c.Site.FediverseCreator) {
return errorf("[site].fediverse_creator must look like @user@host when set")
}
if err := probeWritable(c.ContentDir, "[content_dir]"); err != nil {
return err
}
if err := probeWritable(c.UsersFile, "[users_file]"); err != nil {
return err
}
return nil
}
// probeWritable reports whether the directory holding target can be
// written to. It never creates anything: a read-only command validates a
// configuration, and turning a mistyped path into a directory would make
// the mistake harder to see. The directory itself is created when the
// first post or account is written.
func probeWritable(target, label string) error {
parent := filepath.Dir(target)
probeDir := nearestExisting(parent)
probe, err := os.CreateTemp(probeDir, ".volumen-write-probe-*")
if err != nil {
return errorf("%s is not writable at %q: %v", label, target, err)
}
name := probe.Name()
probe.Close()
os.Remove(name)
return nil
}
// nearestExisting returns the closest existing ancestor of path, which is
// where writability is probed.
func nearestExisting(path string) string {
for dir := path; ; {
if info, err := os.Stat(dir); err == nil && info.IsDir() {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
return dir
}
dir = parent
}
}
// TemplatesDir returns the directory that holds templates.toml and
// tokens.toml, beside users_file.
func (c *Config) TemplatesDir() string {
return filepath.Dir(c.UsersFile)
}
// SecretKeyFile returns the path of the session secret the server
// generates for itself, kept beside the state files. [admin].session_key
// overrides it.
func (c *Config) SecretKeyFile() string {
return filepath.Join(filepath.Dir(c.UsersFile), "secret.key")
}
// TrimmedBaseURL returns [site].base_url without a trailing slash.
func (c *Config) TrimmedBaseURL() string {
return strings.TrimRight(c.Site.BaseURL, "/")
}
+388
View File
@@ -0,0 +1,388 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package config
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func writableConfig(t *testing.T) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
content := filepath.Join(dir, "posts")
users := filepath.Join(dir, "users.toml")
body := "content_dir = \"" + content + "\"\n" +
"users_file = \"" + users + "\"\n" +
"\n[server]\n" +
"host = \"::1\"\n" +
"port = 8080\n" +
"env = \"production\"\n" +
"trust_proxy = true\n" +
"\n[site]\n" +
"base_url = \"https://site.example\"\n" +
"language = \"cs\"\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
return path
}
func TestLoadMergesDefaults(t *testing.T) {
cfg, err := Load(writableConfig(t), Overrides{Port: -1})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Host != "::1" || cfg.Server.Port != 8080 || cfg.Server.Env != EnvProduction {
t.Fatalf("server section wrong: %s %d %s", cfg.Server.Host, cfg.Server.Port, cfg.Server.Env)
}
if !cfg.Server.TrustProxy {
t.Fatal("trust_proxy not loaded")
}
if cfg.Site.Title != DefaultSiteTitle {
t.Fatalf("default title missing: %q", cfg.Site.Title)
}
if cfg.Site.Language != "cs" {
t.Fatalf("language = %q", cfg.Site.Language)
}
if cfg.Admin.SessionTTL != DefaultSessionTTL {
t.Fatalf("session_ttl = %d", cfg.Admin.SessionTTL)
}
if cfg.RevisionLimit != DefaultRevisionLimit {
t.Fatalf("revision_limit = %d", cfg.RevisionLimit)
}
}
func TestLoadMissingFileUsesDefaults(t *testing.T) {
cfg, err := Load(filepath.Join(t.TempDir(), "nope.toml"), Overrides{Port: -1})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Host != DefaultHost || cfg.Server.Port != DefaultPort {
t.Fatalf("defaults not applied: %s %d", cfg.Server.Host, cfg.Server.Port)
}
}
func TestLoadRejectsInvalidToml(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)
}
if _, err := Load(path, Overrides{Port: -1}); err == nil {
t.Fatal("want parse error")
}
}
// A root key written below [server] belongs to that table, where nothing
// reads it. The loader names the key and the fix rather than silently
// falling back to the default path.
func TestLoadRejectsAFoldedRootKey(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
body := "[server]\nhost = \"::1\"\ncontent_dir = \"" + filepath.Join(dir, "posts") + "\"\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
_, err := Load(path, Overrides{Port: -1})
if err == nil {
t.Fatal("want an error for a root key below a table header")
}
if !strings.Contains(err.Error(), "content_dir") ||
!strings.Contains(err.Error(), "[table] header") {
t.Fatalf("error does not name the key and the fix: %v", err)
}
}
// The shipped template is a valid configuration as written: loading it
// and validating it (with the data paths pointed at a writable directory)
// is what every deployment does after copying config.toml.example.
func TestShippedTemplateLoads(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
if err := os.WriteFile(path, []byte(Template), 0o600); err != nil {
t.Fatalf("write template: %v", err)
}
cfg, err := Load(path, Overrides{
Port: -1,
ContentDir: filepath.Join(dir, "posts"),
UsersFile: filepath.Join(dir, "users.toml"),
})
if err != nil {
t.Fatalf("Load: %v", err)
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
// The template's site block must carry the same defaults the code
// serves, so a deployment with no config file and one that copies the
// example present the same site.
if cfg.Site.Title != DefaultSiteTitle || cfg.Site.Description != DefaultSiteDescription {
t.Fatalf("template defaults = %q, %q; want %q, %q",
cfg.Site.Title, cfg.Site.Description, DefaultSiteTitle, DefaultSiteDescription)
}
// A fresh template leaves the session key empty: the server generates
// its own secret rather than the config carrying one.
if cfg.Admin.SessionKey != "" {
t.Fatalf("template session_key should default empty, got %q", cfg.Admin.SessionKey)
}
}
// A missing configuration file moves the state under the user's home:
// that is what lets a plain `volumen serve` on a fresh machine run
// without root, and the overrides still win over it.
func TestMissingFileUsesUserStatePaths(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_DATA_HOME", "")
t.Setenv("XDG_CONFIG_HOME", "")
cfg, err := Load(filepath.Join(home, "nope", "config.toml"), Overrides{Port: -1})
if err != nil {
t.Fatalf("Load: %v", err)
}
want := filepath.Join(home, ".local", "share", "volumen")
if cfg.ContentDir != filepath.Join(want, "posts") || cfg.UsersFile != filepath.Join(want, "users.toml") {
t.Fatalf("paths = %q %q, want under %q", cfg.ContentDir, cfg.UsersFile, want)
}
// XDG_DATA_HOME wins over ~/.local/share when set.
data := t.TempDir()
t.Setenv("XDG_DATA_HOME", data)
cfg, err = Load(filepath.Join(home, "nope", "config.toml"), Overrides{Port: -1})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.ContentDir != filepath.Join(data, "volumen", "posts") {
t.Fatalf("XDG_DATA_HOME ignored: %q", cfg.ContentDir)
}
// An explicit override beats the user default.
cfg, err = Load(filepath.Join(home, "nope", "config.toml"), Overrides{
Port: -1,
ContentDir: "/srv/posts",
UsersFile: "/srv/users.toml",
})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.ContentDir != "/srv/posts" || cfg.UsersFile != "/srv/users.toml" {
t.Fatalf("override lost: %q %q", cfg.ContentDir, cfg.UsersFile)
}
}
// Without --config the file is chosen in order: /etc, then the per-user
// path, then /etc again so a fresh machine loads the defaults.
func TestResolveConfigPathOrder(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", "")
userCfg := filepath.Join(home, ".config", "volumen", "config.toml")
// Nothing exists: the system path is named so Load reports defaults.
if got := ResolveConfigPath(); got != DefaultPath {
t.Fatalf("with no files = %q, want %q", got, DefaultPath)
}
if err := os.MkdirAll(filepath.Dir(userCfg), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(userCfg, []byte("port = 9091\n"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if got := ResolveConfigPath(); got != userCfg {
t.Fatalf("user config not found: %q", got)
}
}
func TestOverrides(t *testing.T) {
cfg, err := Load(filepath.Join(t.TempDir(), "x.toml"), Overrides{
Host: "127.0.0.1",
Port: 1234,
ContentDir: "/srv/posts",
UsersFile: "/srv/users.toml",
})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Host != "127.0.0.1" || cfg.Server.Port != 1234 {
t.Fatalf("host/port override failed: %s %d", cfg.Server.Host, cfg.Server.Port)
}
if cfg.ContentDir != "/srv/posts" || cfg.UsersFile != "/srv/users.toml" {
t.Fatal("path overrides failed")
}
}
func TestValidateOK(t *testing.T) {
cfg, err := Load(writableConfig(t), Overrides{Port: -1})
if err != nil {
t.Fatalf("Load: %v", err)
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
}
func TestValidateFailures(t *testing.T) {
dir := t.TempDir()
base := func(mutate func(*Config)) *Config {
cfg, err := Load(filepath.Join(dir, "none.toml"), Overrides{Port: -1})
if err != nil {
t.Fatalf("Load: %v", err)
}
if mutate != nil {
mutate(cfg)
}
return cfg
}
cases := []struct {
name string
cfg *Config
frag string
}{
{"host", base(func(c *Config) { c.Server.Host = "" }), "[server].host"},
{"host_not_an_address", base(func(c *Config) { c.Server.Host = "localhost" }), "[server].host"},
{"port", base(func(c *Config) { c.Server.Port = 0 }), "[server].port"},
{"env", base(func(c *Config) { c.Server.Env = "staging" }), "[server].env"},
{"log_format", base(func(c *Config) { c.Server.LogFormat = "xml" }), "log_format"},
{"trusted_proxies", base(func(c *Config) { c.Server.TrustedProxies = []string{"not a prefix"} }), "trusted_proxies"},
{"base_url", base(func(c *Config) { c.Site.BaseURL = "notaurl" }), "base_url"},
{"fediverse", base(func(c *Config) { c.Site.FediverseCreator = "nope" }), "fediverse_creator"},
{"webhook", base(func(c *Config) { c.Webhooks = []Webhook{{URL: "ftp://x"}} }), "webhooks"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.cfg.Validate()
if err == nil {
t.Fatal("want error")
}
if !strings.Contains(err.Error(), tc.frag) {
t.Fatalf("error %q does not mention %q", err, tc.frag)
}
})
}
t.Run("session_ttl", func(t *testing.T) {
cfg := base(func(c *Config) { c.Admin.SessionTTL = 0 })
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "session_ttl") {
t.Fatalf("err = %v", err)
}
})
t.Run("password lengths", func(t *testing.T) {
cfg := base(func(c *Config) {
c.Admin.MinPasswordLength = 20
c.Admin.MaxPasswordLength = 10
})
if err := cfg.Validate(); err == nil {
t.Fatal("want error")
}
})
t.Run("rate limit", func(t *testing.T) {
cfg := base(func(c *Config) { c.API.RateLimit = -1 })
if err := cfg.Validate(); err == nil {
t.Fatal("want error")
}
})
}
// The config file an operator writes for a reverse proxy decodes its
// trusted proxy list, empty list included: this is the shape documented
// in the template and what a production deployment relies on.
func TestTrustedProxiesParse(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
body := "content_dir = \"" + filepath.Join(dir, "posts") + "\"\n" +
"users_file = \"" + filepath.Join(dir, "users.toml") + "\"\n" +
"\n[server]\n" +
"host = \"::1\"\n" +
"trust_proxy = true\n" +
"trusted_proxies = [\"::1\", \"127.0.0.1\"]\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
cfg, err := Load(path, Overrides{Port: PortUnset})
if err != nil {
t.Fatalf("Load: %v", err)
}
want := []string{"::1", "127.0.0.1"}
if !slices.Equal(cfg.Server.TrustedProxies, want) {
t.Fatalf("trusted_proxies = %v, want %v", cfg.Server.TrustedProxies, want)
}
emptyPath := filepath.Join(dir, "empty.toml")
emptyBody := "content_dir = \"" + filepath.Join(dir, "posts") + "\"\n" +
"users_file = \"" + filepath.Join(dir, "users.toml") + "\"\n" +
"\n[server]\n" +
"host = \"::1\"\n" +
"trusted_proxies = []\n"
if err := os.WriteFile(emptyPath, []byte(emptyBody), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
empty, err := Load(emptyPath, Overrides{Port: PortUnset})
if err != nil {
t.Fatalf("Load empty: %v", err)
}
if len(empty.Server.TrustedProxies) != 0 {
t.Fatalf("empty list parsed as %v", empty.Server.TrustedProxies)
}
}
// The example shipped in the repository is the embedded template, so the
// two cannot drift: the documentation points at both.
func TestExampleMatchesTemplate(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "config.toml.example"))
if err != nil {
t.Fatalf("read config.toml.example: %v", err)
}
if string(raw) != Template {
t.Fatal("config.toml.example differs from config.Template")
}
}
func TestAuditLogPathAndScheduler(t *testing.T) {
cfg, err := Load(filepath.Join(t.TempDir(), "x.toml"), Overrides{Port: -1})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.AuditLog != "" {
t.Fatal("audit log should default to disabled")
}
if cfg.Scheduler.Enabled || cfg.Scheduler.Interval != DefaultSchedulerInterval {
t.Fatal("scheduler defaults wrong")
}
cfg.AuditLog = "/var/log/volumen-audit.log"
cfg.Scheduler = Scheduler{Enabled: true, Interval: 60}
if cfg.AuditLog != "/var/log/volumen-audit.log" {
t.Fatalf("audit log = %q", cfg.AuditLog)
}
if !cfg.Scheduler.Enabled || cfg.Scheduler.Interval != 60 {
t.Fatal("scheduler config wrong")
}
}
// A hook is on unless the file turns it off, which is the reason the
// field is a pointer: an omitted key must not read as false.
func TestWebhookDefaults(t *testing.T) {
path := filepath.Join(t.TempDir(), "hooks.toml")
body := "[[webhooks]]\nurl = \"https://example.com/one\"\n\n" +
"[[webhooks]]\nurl = \"https://example.com/two\"\nenabled = false\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
cfg, err := Load(path, Overrides{Port: -1})
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.Webhooks) != 2 {
t.Fatalf("webhooks = %v", cfg.Webhooks)
}
if !cfg.Webhooks[0].Delivers() {
t.Fatal("an omitted enabled key disabled the hook")
}
if cfg.Webhooks[1].Delivers() {
t.Fatal("an explicit false left the hook enabled")
}
}
+107
View File
@@ -0,0 +1,107 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package config
// Template is the commented configuration file every deployment starts
// from: an operator copies it to the config path and edits it. The
// committed config.toml.example is this constant, unchanged, and a test
// asserts that; the release pipeline ships that file as an asset.
//
// The root keys come first, before any table header: a key written below
// a header belongs to that table, and the loader refuses a root key that
// a header swallowed rather than silently falling back to the default.
// Every key this file accepts is listed here, and docs/CONFIGURATION.md
// is the reference for its type, default and rules.
const Template = `# volumen configuration.
#
# Every key this file accepts is listed here. Copy it to
# /etc/volumen/config.toml (or ~/.config/volumen/config.toml for a
# per-user installation), then edit.
#
# Keys that belong to no table come first, because a key written below a
# [table] header belongs to that table.
# Directory of the Markdown posts (.md with TOML frontmatter).
content_dir = "/var/lib/volumen/posts"
# File holding the admin accounts (managed from the admin Settings page).
users_file = "/var/lib/volumen/users.toml"
# How many previous versions of each post to keep in .revisions/
# (0 keeps none, which also makes deleting a post permanent).
revision_limit = 10
# Where the audit log is appended, or "" to disable auditing. Records
# who changed what, and when, in JSON lines.
audit_log = ""
[server]
# Address to bind, as an IP address: "::" is every interface, "::1" is
# loopback only, which is what a reverse proxy needs.
host = "::"
port = 9091
# Environment label: "development" or "production". It decides the
# startup safety checks (session key length, cookie flags, password
# policy).
env = "development"
# Set true ONLY when a trusted reverse proxy terminates TLS in front of
# volumen. Client addresses are then taken from X-Forwarded-For and
# cookies are marked Secure.
trust_proxy = false
# Addresses whose X-Forwarded-For may be believed, as addresses or CIDR
# prefixes. An empty list never reads the header and always uses the
# connection address; list the proxy so its clients each rate-limit
# under their own address.
trusted_proxies = []
# Set true in production to force the Secure flag on session cookies.
cookie_secure = false
# Log output format: "text" (human readable) or "json" (structured).
log_format = "text"
[site]
title = "Volumen"
description = "Powered by Volumen."
# Absolute URL of the public site, without a trailing slash.
base_url = "https://example.com"
language = "en"
author = "Anonymous"
# Fediverse handle surfaced as the author in feeds and meta tags.
# Leave empty to disable.
fediverse_creator = ""
[admin]
# Secret that signs session cookies (at least 64 bytes in production).
# Leave empty: the server generates one and keeps it in secret.key next
# to users.toml. A value here overrides that file.
session_key = ""
# Session lifetime in seconds (24 hours by default).
session_ttl = 86400
# Minimum password length enforced when a password is set in the admin UI.
min_password_length = 10
# Maximum password length, to bound the scrypt work.
max_password_length = 1024
# Maximum upload size in bytes (10 MB by default).
max_upload_bytes = 10485760
[api]
# Public API rate limit: requests allowed per window per client address.
# 0 disables rate limiting.
rate_limit = 60
# Rate-limit window length in seconds.
rate_limit_window = 60
# Scheduled publishing, for a post whose frontmatter carries publish_at.
# [scheduler]
# enabled = false
# interval = 300
# Outgoing webhooks: POST a signed JSON payload on post changes so a
# front-end can rebuild its cache or static pages. Repeat the block for
# more endpoints; events may be omitted to receive every event.
# [[webhooks]]
# url = "https://example.com/hooks/rebuild"
# secret = "a-long-random-string" # HMAC-SHA256 signing key
# events = ["post.created", "post.updated", "post.deleted", "post.published"]
# enabled = true
`
+192
View File
@@ -0,0 +1,192 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package diff renders a line-based difference between two texts. The
// classic longest-common-subsequence dynamic program is enough here:
// post-sized inputs are small, and inputs beyond the cell budget fall
// back to an honest full replacement rather than a slow or hungry walk.
package diff
import "strings"
// Op classifies one line of the result.
type Op uint8
const (
// Equal marks a line both texts share.
Equal Op = iota
// Removed marks a line only the old text carries.
Removed
// Added marks a line only the new text carries.
Added
// Skipped marks equal lines the context collapse hid.
Skipped
)
// Is reports whether the operation matches the name a template asks
// for: "equal", "removed", "added" or "skipped".
func (o Op) Is(name string) bool {
switch name {
case "equal":
return o == Equal
case "removed":
return o == Removed
case "added":
return o == Added
case "skipped":
return o == Skipped
}
return false
}
// Chunk is a run of consecutive lines with one operation.
type Chunk struct {
Op Op
Lines []string
Skipped int // Skipped only: how many equal lines the collapse hid
}
// maxCells bounds the dynamic-programming table. Past it, the diff
// degrades to a whole-text replacement: rare, and never wrong.
const maxCells = 4_000_000
// Chunks diffs the old text against the new one line by line and
// collapses long equal runs to context lines around each change. The
// texts are split on newlines; a trailing newline does not create an
// empty final line.
func Chunks(oldText, newText string, context int) []Chunk {
oldLines := split(oldText)
newLines := split(newText)
ops := make([]Op, 0, len(oldLines)+len(newLines))
if len(oldLines)*len(newLines) > maxCells {
ops = appendAll(ops, Removed, oldLines)
ops = appendAll(ops, Added, newLines)
} else {
ops = lcs(oldLines, newLines)
}
return collapse(ops, oldLines, newLines, context)
}
// split breaks a text into lines without a phantom empty line for the
// trailing newline.
func split(text string) []string {
text = strings.TrimSuffix(text, "\n")
if text == "" {
return nil
}
return strings.Split(text, "\n")
}
func appendAll(ops []Op, op Op, lines []string) []Op {
for range lines {
ops = append(ops, op)
}
return ops
}
// lcs walks the dynamic-programming table backwards and yields the
// per-line operations.
func lcs(a, b []string) []Op {
n, m := len(a), len(b)
// table[i][j] holds the LCS length of a[i:] and b[j:].
table := make([]int32, (n+1)*(m+1))
at := func(i, j int) *int32 { return &table[i*(m+1)+j] }
for i := n - 1; i >= 0; i-- {
for j := m - 1; j >= 0; j-- {
if a[i] == b[j] {
*at(i, j) = *at(i+1, j+1) + 1
} else {
down, right := *at(i+1, j), *at(i, j+1)
*at(i, j) = max(down, right)
}
}
}
ops := make([]Op, 0, n+m)
i, j := 0, 0
for i < n && j < m {
switch {
case a[i] == b[j]:
ops = append(ops, Equal)
i++
j++
case *at(i+1, j) >= *at(i, j+1):
ops = append(ops, Removed)
i++
default:
ops = append(ops, Added)
j++
}
}
ops = appendAll(ops, Removed, a[i:])
ops = appendAll(ops, Added, b[j:])
return ops
}
// collapse groups the operations into chunks and trims equal runs to
// context lines around the changes.
func collapse(ops []Op, a, b []string, context int) []Chunk {
// Position each operation over its source line.
type line struct {
op Op
text string
}
out := make([]line, 0, len(ops))
ai, bi := 0, 0
for _, op := range ops {
switch op {
case Removed:
out = append(out, line{Removed, a[ai]})
ai++
case Added:
out = append(out, line{Added, b[bi]})
bi++
default:
out = append(out, line{Equal, a[ai]})
ai++
bi++
}
}
var chunks []Chunk
for start := 0; start < len(out); {
op := out[start].op
end := start
for end < len(out) && out[end].op == op {
end++
}
lines := make([]string, 0, end-start)
for _, l := range out[start:end] {
lines = append(lines, l.text)
}
chunks = append(chunks, Chunk{Op: op, Lines: lines})
start = end
}
// Collapse an equal chunk only when another change follows it; the
// leading context of the first change stays whole.
if context < 0 {
context = 0
}
final := make([]Chunk, 0, len(chunks))
for _, chunk := range chunks {
final = append(final, chunk)
}
for i := 0; i < len(final)-1; i++ {
c := &final[i]
if c.Op != Equal || len(c.Lines) <= 2*context+4 {
continue
}
hidden := len(c.Lines) - 2*context
collapsed := Chunk{Op: Skipped, Skipped: hidden}
with := make([]Chunk, 0, len(final)+1)
with = append(with, final[:i]...)
with = append(with, Chunk{Op: Equal, Lines: c.Lines[:context]})
with = append(with, collapsed)
with = append(with, Chunk{Op: Equal, Lines: c.Lines[len(c.Lines)-context:]})
with = append(with, final[i+1:]...)
final = with
i++
}
return final
}
+129
View File
@@ -0,0 +1,129 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package diff
import (
"strings"
"testing"
)
func render(chunks []Chunk) string {
var b strings.Builder
for _, c := range chunks {
switch c.Op {
case Equal:
for _, l := range c.Lines {
b.WriteString(" " + l + "\n")
}
case Removed:
for _, l := range c.Lines {
b.WriteString("- " + l + "\n")
}
case Added:
for _, l := range c.Lines {
b.WriteString("+ " + l + "\n")
}
case Skipped:
b.WriteString("… " + strings.Repeat("#", c.Skipped) + "\n")
}
}
return b.String()
}
func TestIdentical(t *testing.T) {
got := render(Chunks("alpha\nbeta\n", "alpha\nbeta\n", 3))
if got != " alpha\n beta\n" {
t.Errorf("identical diff = %q", got)
}
}
func TestSingleChange(t *testing.T) {
got := render(Chunks("alpha\nbeta\ngamma\n", "alpha\nbeta+\ngamma\n", 3))
want := " alpha\n- beta\n+ beta+\n gamma\n"
if got != want {
t.Errorf("change diff = %q, want %q", got, want)
}
}
func TestInsertAndDelete(t *testing.T) {
got := render(Chunks("one\ntwo\n", "one\nthree\nfour\ntwo\n", 3))
want := " one\n+ three\n+ four\n two\n"
if got != want {
t.Errorf("insert diff = %q, want %q", got, want)
}
got = render(Chunks("one\nthree\ntwo\n", "one\ntwo\n", 3))
want = " one\n- three\n two\n"
if got != want {
t.Errorf("delete diff = %q, want %q", got, want)
}
}
func TestEmptySides(t *testing.T) {
got := render(Chunks("", "alpha\n", 3))
if got != "+ alpha\n" {
t.Errorf("empty old = %q", got)
}
got = render(Chunks("alpha\n", "", 3))
if got != "- alpha\n" {
t.Errorf("empty new = %q", got)
}
got = render(Chunks("", "", 3))
if got != "" {
t.Errorf("both empty = %q", got)
}
}
func TestContextCollapse(t *testing.T) {
// Twelve equal lines between two changes: with context 3 the middle
// six collapse into one skipped chunk.
old := "x1\nx2\nx3\nx4\nx5\nx6\nx7\nx8\nx9\nx10\nx11\nx12\nx13\nx14\n"
new := "A1\nx2\nx3\nx4\nx5\nx6\nx7\nx8\nx9\nx10\nx11\nx12\nx13\nA14\n"
got := render(Chunks(old, new, 3))
want := "- x1\n+ A1\n" +
" x2\n x3\n x4\n" +
"… ######\n" +
" x11\n x12\n x13\n" +
"- x14\n+ A14\n"
if got != want {
t.Errorf("collapse diff = %q, want %q", got, want)
}
}
func TestTrailingNewlineIsNotALine(t *testing.T) {
with := render(Chunks("alpha\n", "alpha\n", 3))
without := render(Chunks("alpha", "alpha", 3))
if with != without {
t.Errorf("trailing newline changed the diff: %q vs %q", with, without)
}
}
func TestCellBudgetFallsBackToReplace(t *testing.T) {
// 2500 equal lines on both sides exceeds the cell budget; the diff
// must degrade to a full replacement instead of hanging.
var b strings.Builder
for range 2500 {
b.WriteString(strings.Repeat("x", 40) + "\n")
}
big := b.String()
chunks := Chunks(big, big, 3)
var removed, added bool
for _, c := range chunks {
switch c.Op {
case Removed:
removed = true
case Added:
added = true
}
}
if !removed || !added {
t.Errorf("oversized input did not degrade to replacement (removed=%v added=%v)", removed, added)
}
}
func TestOpIs(t *testing.T) {
if !Removed.Is("removed") || Added.Is("removed") || Skipped.Is("skipped") == false {
t.Error("Op.Is misclassifies")
}
}
+17
View File
@@ -0,0 +1,17 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package fediverse validates the fediverse handle that posts, users and
// the site configuration carry. It is a leaf so that the configuration
// and the domain object can share one rule without either importing the
// other.
package fediverse
import "regexp"
var handleRe = regexp.MustCompile(`\A@[^@\s]+@[^@\s]+\z`)
// Valid reports whether handle looks like @user@host.
func Valid(handle string) bool {
return handleRe.MatchString(handle)
}
+21
View File
@@ -0,0 +1,21 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package fediverse
import "testing"
func TestValid(t *testing.T) {
good := []string{"@user@host.social", "@a@b", "@petr@social.example.org"}
for _, handle := range good {
if !Valid(handle) {
t.Errorf("Valid(%q) = false, want true", handle)
}
}
bad := []string{"", "user@host", "@user", "@@host", "@user@", "@user @host", "@user@ho st", "u@h"}
for _, handle := range bad {
if Valid(handle) {
t.Errorf("Valid(%q) = true, want false", handle)
}
}
}
+300
View File
@@ -0,0 +1,300 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package feeds renders the RSS 2.0, Atom 1.0, JSON Feed 1.1 and
// sitemap documents for the public API.
package feeds
import (
"bytes"
json "encoding/json/v2"
"fmt"
"html"
"log/slog"
"os"
"strings"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/post"
)
// FeedItemLimit caps the number of items in a feed.
const FeedItemLimit = 20
// SitemapURLLimit caps the URLs in one sitemap document: the sitemaps.org
// protocol defines 50 000 as the maximum a single document may carry, so
// a larger site truncates to the newest posts rather than shipping a
// document consumers may refuse whole.
const SitemapURLLimit = 50_000
// language returns the site language, defaulting to English.
func language(site config.Site) string {
if site.Language != "" {
return site.Language
}
return DefaultLanguage
}
// DefaultLanguage is the feed language when [site].language is empty.
const DefaultLanguage = "en"
// RenderRSSFeed renders an RSS 2.0 XML feed for the given posts.
// selfPath is the path of the feed being rendered, so a reader can see
// which document it fetched.
func RenderRSSFeed(posts []*post.Post, site config.Site, baseURL, selfPath string) string {
items := make([]string, 0, min(len(posts), FeedItemLimit))
for _, p := range posts[:min(len(posts), FeedItemLimit)] {
items = append(items, rssItem(p, baseURL))
}
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>%s</title>
<link>%s</link>
<description>%s</description>
<language>%s</language>
<atom:link rel="self" type="application/rss+xml" href="%s"/>
%s
</channel>
</rss>`,
html.EscapeString(site.Title),
html.EscapeString(baseURL),
html.EscapeString(site.Description),
html.EscapeString(language(site)),
html.EscapeString(baseURL+selfPath),
strings.Join(items, "\n"))
}
func rssItem(p *post.Post, baseURL string) string {
permalink := baseURL + "/" + p.Slug()
var extra strings.Builder
if dateStr := p.DateString(); dateStr != "" {
fmt.Fprintf(&extra, "\n <pubDate>%s</pubDate>", html.EscapeString(rfc822Date(dateStr)))
}
if creator := p.FediverseCreator(); creator != "" {
fmt.Fprintf(&extra, "\n <dc:creator>%s</dc:creator>", html.EscapeString(creator))
}
if lang := p.Lang(); lang != "" {
fmt.Fprintf(&extra, "\n <dc:language>%s</dc:language>", html.EscapeString(lang))
}
return fmt.Sprintf(` <item>
<title>%s</title>
<link>%s</link>
<guid>%s</guid>
<description>%s</description>%s
</item>`,
html.EscapeString(p.Title()),
html.EscapeString(permalink),
html.EscapeString(permalink),
html.EscapeString(p.Excerpt()),
extra.String())
}
// rfc822Date formats a YYYY-MM-DD string as an RFC 822 timestamp, or
// returns it unchanged when unparseable.
func rfc822Date(value string) string {
t, err := time.Parse("2006-01-02", value)
if err != nil {
return value
}
return t.UTC().Format("Mon, 02 Jan 2006 15:04:05") + " GMT"
}
// rfc3339Date formats a YYYY-MM-DD string as RFC 3339 at UTC midnight,
// or returns it unchanged when it cannot be parsed.
func rfc3339Date(value string) string {
t, err := time.Parse("2006-01-02", value)
if err != nil {
return value
}
return t.UTC().Format("2006-01-02T15:04:05-07:00")
}
// RenderAtomFeed renders an Atom 1.0 XML feed for the given posts.
// selfPath is the path of the feed being rendered, so the rel=self link
// names the document the client actually fetched rather than always the
// site-wide feed.
func RenderAtomFeed(posts []*post.Post, site config.Site, baseURL, selfPath string) string {
title := html.EscapeString(site.Title)
link := html.EscapeString(baseURL)
feedURL := html.EscapeString(baseURL + selfPath)
description := html.EscapeString(site.Description)
updated := atomUpdated(posts)
items := make([]string, 0, min(len(posts), FeedItemLimit))
for _, p := range posts[:min(len(posts), FeedItemLimit)] {
items = append(items, atomEntry(p, baseURL))
}
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>%s</title>
<link rel="alternate" type="text/html" href="%s"/>
<link rel="self" type="application/atom+xml" href="%s"/>
<id>%s/</id>
<updated>%s</updated>
<subtitle>%s</subtitle>
%s
</feed>`,
title, link, feedURL, link,
html.EscapeString(updated), description,
strings.Join(items, "\n"))
}
func atomUpdated(posts []*post.Post) string {
for _, p := range posts {
if dateStr := p.DateString(); dateStr != "" {
return rfc3339Date(dateStr)
}
}
// No post carries a date, so the feed has no update time of its own.
// The epoch is used rather than the current time, because a document
// that changes on every request defeats every cache in front of it.
return "1970-01-01T00:00:00Z"
}
func atomEntry(p *post.Post, baseURL string) string {
permalink := baseURL + "/" + p.Slug()
updated, published := "", ""
if dateStr := p.DateString(); dateStr != "" {
updated = rfc3339Date(dateStr)
published = updated
}
authorTag := ""
if creator := p.FediverseCreator(); creator != "" {
authorTag = fmt.Sprintf("\n <author><name>%s</name></author>", html.EscapeString(creator))
}
langAttr := ""
if lang := p.Lang(); lang != "" {
langAttr = fmt.Sprintf(` xml:lang="%s"`, html.EscapeString(lang))
}
return fmt.Sprintf(` <entry>
<title%s>%s</title>
<link rel="alternate" type="text/html" href="%s"/>
<id>%s</id>
<updated>%s</updated>
<published>%s</published>
<summary>%s</summary>%s
</entry>`,
langAttr, html.EscapeString(p.Title()),
html.EscapeString(permalink), html.EscapeString(permalink),
updated, published,
html.EscapeString(p.Excerpt()), authorTag)
}
// JSONFeed is a JSON Feed 1.1 document. Empty optional fields are
// omitted, matching the feed specification.
type JSONFeed struct {
Version string `json:"version"`
Title string `json:"title"`
HomePageURL string `json:"home_page_url"`
FeedURL string `json:"feed_url"`
Description string `json:"description"`
Language string `json:"language"`
Items []JSONItem `json:"items,omitempty"`
}
// JSONItem is one JSON Feed item. Empty optional fields are omitted.
type JSONItem struct {
ID string `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
ContentHTML string `json:"content_html"`
Summary string `json:"summary,omitempty"`
DatePublished string `json:"date_published,omitempty"`
Tags []string `json:"tags,omitempty"`
Authors []JSONAuthor `json:"authors,omitempty"`
}
// JSONAuthor is the author entry of a JSON Feed item.
type JSONAuthor struct {
Name string `json:"name"`
}
// RenderJSONFeed builds a JSON Feed 1.1 document for the given posts.
// selfPath is the path of the feed being rendered, for feed_url.
func RenderJSONFeed(posts []*post.Post, site config.Site, baseURL, selfPath string) JSONFeed {
limit := min(len(posts), FeedItemLimit)
items := make([]JSONItem, 0, limit)
for _, p := range posts[:limit] {
items = append(items, jsonFeedItem(p, baseURL, site))
}
return JSONFeed{
Version: "https://jsonfeed.org/version/1.1",
Title: site.Title,
HomePageURL: baseURL,
FeedURL: baseURL + selfPath,
Description: site.Description,
Language: language(site),
Items: items,
}
}
func jsonFeedItem(p *post.Post, baseURL string, site config.Site) JSONItem {
permalink := baseURL + "/" + p.Slug()
htmlOut, err := p.HTML()
if err != nil {
// The item still goes out (a feed with a body-less entry beats a
// feed that 500s for one bad post), but not silently: the detail
// endpoint fails loudly for the same post, and the feed should
// leave the same trace.
slog.Warn("feeds: cannot render post for the JSON feed", "slug", p.Slug(), "error", err)
htmlOut = ""
}
item := JSONItem{
ID: permalink,
URL: permalink,
Title: p.Title(),
ContentHTML: htmlOut,
Summary: p.Excerpt(),
DatePublished: p.DateString(),
Tags: p.Tags(),
}
author := p.FediverseCreator()
if author == "" {
author = site.FediverseCreator
}
if author != "" {
item.Authors = []JSONAuthor{{Name: author}}
}
return item
}
// MarshalJSONFeed serialises a feed. encoding/json/v2 escapes only what
// JSON requires, so the HTML inside content_html reaches the client as the
// post was rendered rather than with every angle bracket escaped.
func MarshalJSONFeed(feed JSONFeed) ([]byte, error) {
var buf bytes.Buffer
if err := json.MarshalWrite(&buf, feed, json.Deterministic(true)); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// RenderSitemap renders an XML sitemap, preferring the file
// modification time for <lastmod>. At most SitemapURLLimit URLs are
// included, newest posts first (the caller lists them that way).
func RenderSitemap(posts []*post.Post, baseURL string) string {
var urls []string
for _, p := range posts[:min(len(posts), SitemapURLLimit)] {
entry := " <url><loc>" + html.EscapeString(baseURL+"/"+p.Slug()) + "</loc>"
if lastmod := lastmodFor(p); lastmod != "" {
entry += "<lastmod>" + html.EscapeString(lastmod) + "</lastmod>"
}
entry += "</url>"
urls = append(urls, entry)
}
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
` + strings.Join(urls, "\n") + `
</urlset>`
}
func lastmodFor(p *post.Post) string {
if p.Path != "" {
if info, err := os.Stat(p.Path); err == nil {
return info.ModTime().UTC().Format("2006-01-02")
}
}
return p.DateString()
}
+228
View File
@@ -0,0 +1,228 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package feeds
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
"sourcedock.dev/petrbalvin/volumen/internal/post"
)
var testSite = config.Site{
Title: "Můj blog",
Description: "Testovací blog",
BaseURL: "https://site.example",
Language: "cs",
Author: "Petr",
}
func samplePosts(t *testing.T) []*post.Post {
t.Helper()
p1 := parsePost(t, "+++\ntitle = \"První & <pos>\"\nslug = \"prvni\"\ndate = 2026-08-18\nlang = \"cs\"\ntags = [\"go\"]\nfediverse_creator = \"@petr@social\"\n+++\nTělo **jedna**.\n")
p2 := parsePost(t, "+++\ntitle = \"Druhý\"\nslug = \"druhy\"\n+++\nTělo dva.\n")
p1.Path = filepath.Join(t.TempDir(), "prvni.md")
return []*post.Post{p1, p2}
}
func parsePost(t *testing.T, content string) *post.Post {
t.Helper()
p, err := post.Parse(content)
if err != nil {
t.Fatalf("parse: %v", err)
}
return p
}
func TestRenderRSSFeed(t *testing.T) {
posts := samplePosts(t)
out := RenderRSSFeed(posts, testSite, "https://site.example", "/api/volumen/feed.xml")
for _, want := range []string{
`<?xml version="1.0" encoding="UTF-8"?>`,
`<rss version="2.0"`,
`<title>Můj blog</title>`,
`<title>První &amp; &lt;pos&gt;</title>`,
`<link>https://site.example/prvni</link>`,
`<guid>https://site.example/prvni</guid>`,
`<pubDate>Tue, 18 Aug 2026 00:00:00 GMT</pubDate>`,
`<dc:creator>@petr@social</dc:creator>`,
`<dc:language>cs</dc:language>`,
`<language>cs</language>`,
} {
if !strings.Contains(out, want) {
t.Fatalf("rss missing %q:\n%s", want, out)
}
}
// Post without a date has no pubDate.
if strings.Contains(out, "druhý</title>") && strings.Count(out, "<pubDate>") != 1 {
t.Fatalf("unexpected pubDate count:\n%s", out)
}
}
func TestRenderRSSFeedItemLimit(t *testing.T) {
var posts []*post.Post
for range FeedItemLimit + 5 {
p := parsePost(t, "+++\nslug = \"p\"\ntitle = \"t\"\n+++\nx\n")
posts = append(posts, p)
}
out := RenderRSSFeed(posts, testSite, "https://site.example", "/api/volumen/feed.xml")
if got := strings.Count(out, "<item>"); got != FeedItemLimit {
t.Fatalf("items = %d, want %d", got, FeedItemLimit)
}
}
func TestRenderAtomFeed(t *testing.T) {
posts := samplePosts(t)
out := RenderAtomFeed(posts, testSite, "https://site.example", "/api/volumen/feed.atom")
for _, want := range []string{
`<feed xmlns="http://www.w3.org/2005/Atom">`,
`<link rel="self" type="application/atom+xml" href="https://site.example/api/volumen/feed.atom"/>`,
`<id>https://site.example/</id>`,
`<updated>2026-08-18T00:00:00+00:00</updated>`,
`<title xml:lang="cs">První &amp; &lt;pos&gt;</title>`,
`<published>2026-08-18T00:00:00+00:00</published>`,
`<author><name>@petr@social</name></author>`,
`<summary>Tělo jedna.</summary>`,
} {
if !strings.Contains(out, want) {
t.Fatalf("atom missing %q:\n%s", want, out)
}
}
}
func TestRenderAtomFeedWithoutDates(t *testing.T) {
p := parsePost(t, "+++\nslug = \"x\"\ntitle = \"X\"\n+++\nb\n")
out := RenderAtomFeed([]*post.Post{p}, testSite, "https://site.example", "/api/volumen/feed.atom")
// An entry without a date emits empty updated and published
// elements, which consumers rely on.
if !strings.Contains(out, "<updated></updated>") {
t.Fatalf("empty updated element missing:\n%s", out)
}
if !strings.Contains(out, "<published></published>") {
t.Fatalf("empty published element missing:\n%s", out)
}
}
func TestRenderJSONFeed(t *testing.T) {
posts := samplePosts(t)
out := RenderJSONFeed(posts, testSite, "https://site.example", "/api/volumen/feed.json")
if out.Version != "https://jsonfeed.org/version/1.1" {
t.Fatalf("version = %v", out.Version)
}
if out.Title != "Můj blog" || out.Language != "cs" {
t.Fatalf("feed = %+v", out)
}
if out.FeedURL != "https://site.example/api/volumen/feed.json" {
t.Fatalf("feed_url = %v", out.FeedURL)
}
if len(out.Items) != 2 {
t.Fatalf("items = %v", out.Items)
}
first := out.Items[0]
if first.ID != "https://site.example/prvni" {
t.Fatalf("item = %+v", first)
}
if !strings.Contains(first.ContentHTML, "<strong>jedna</strong>") {
t.Fatalf("content_html = %v", first.ContentHTML)
}
if first.DatePublished != "2026-08-18" {
t.Fatalf("date_published = %v", first.DatePublished)
}
if len(first.Authors) != 1 || first.Authors[0].Name != "@petr@social" {
t.Fatalf("authors = %v", first.Authors)
}
if second := out.Items[1]; second.DatePublished != "" {
t.Fatalf("date_published should be empty: %+v", second)
}
}
func TestRenderJSONFeedSiteAuthorFallback(t *testing.T) {
p := parsePost(t, "+++\nslug = \"x\"\ntitle = \"X\"\n+++\nb\n")
site := config.Site{Title: "T", FediverseCreator: "@site@host"}
out := RenderJSONFeed([]*post.Post{p}, site, "https://site.example", "/api/volumen/feed.json")
if len(out.Items) != 1 || out.Items[0].Authors[0].Name != "@site@host" {
t.Fatalf("authors = %v", out.Items)
}
}
func TestRenderSitemap(t *testing.T) {
posts := samplePosts(t)
out := RenderSitemap(posts, "https://site.example")
for _, want := range []string{
`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`,
`<loc>https://site.example/prvni</loc>`,
`<lastmod>`,
`<loc>https://site.example/druhy</loc>`,
} {
if !strings.Contains(out, want) {
t.Fatalf("sitemap missing %q:\n%s", want, out)
}
}
}
func TestSitemapLastmodFallsBackToDate(t *testing.T) {
p := parsePost(t, "+++\nslug = \"x\"\ndate = 2026-01-02\n+++\nb\n")
out := RenderSitemap([]*post.Post{p}, "https://site.example")
if !strings.Contains(out, "<lastmod>2026-01-02</lastmod>") {
t.Fatalf("sitemap = %s", out)
}
}
func TestUnparseableDatePassesThrough(t *testing.T) {
if got := rfc822Date("not-a-date"); got != "not-a-date" {
t.Fatalf("rfc822Date = %q", got)
}
if got := rfc3339Date("not-a-date"); got != "not-a-date" {
t.Fatalf("rfc3339Date = %q", got)
}
}
func TestSitemapLastmodFromRealFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "x.md")
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
p := parsePost(t, "+++\nslug = \"x\"\n+++\nb\n")
p.Path = path
out := RenderSitemap([]*post.Post{p}, "https://site.example")
if !strings.Contains(out, "<lastmod>") {
t.Fatalf("sitemap = %s", out)
}
}
func TestMarshalJSONFeedNoHTMLEscaping(t *testing.T) {
posts := samplePosts(t)
feed := RenderJSONFeed(posts, testSite, "https://site.example", "/api/volumen/feed.json")
out, err := MarshalJSONFeed(feed)
if err != nil {
t.Fatalf("MarshalJSONFeed: %v", err)
}
if strings.Contains(string(out), `\u0026`) || strings.Contains(string(out), `\u003c`) {
t.Fatalf("HTML escaping leaked in: %s", out)
}
if !strings.Contains(string(out), `"title":"První & <pos>"`) {
t.Fatalf("literal characters missing: %s", out)
}
}
// One sitemap document carries at most SitemapURLLimit URLs, the
// sitemaps.org protocol ceiling.
func TestRenderSitemapCapsURLs(t *testing.T) {
posts := make([]*post.Post, SitemapURLLimit+250)
for i := range posts {
meta := frontmatter.NewMeta()
meta.Set("slug", fmt.Sprintf("post-%d", i))
posts[i] = post.New(meta, "body")
}
out := RenderSitemap(posts, "https://site.example")
if got := strings.Count(out, "<url>"); got != SitemapURLLimit {
t.Fatalf("sitemap holds %d URLs, want %d", got, SitemapURLLimit)
}
}
+316
View File
@@ -0,0 +1,316 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package frontmatter parses and writes TOML frontmatter in Markdown
// post files.
//
// Post files start with a TOML table between "+++" delimiter lines. The
// TOML is carried by an interpres Document, so a save keeps what the
// author wrote: the key order at every level, the comments, and whether
// a table was written as a header section or an inline table.
package frontmatter
import (
"bytes"
"fmt"
"maps"
"slices"
"strings"
"sourcedock.dev/petrbalvin/interpres/v2"
)
// Delimiter fences the TOML frontmatter block.
const Delimiter = "+++"
// Meta is an order-preserving TOML table. Keys iterate in written
// order; values keep their interpres-decoded Go types (map[string]any,
// []any, string, int64, float64, bool, interpres.LocalDate and
// friends). A Meta parsed from a file keeps the file's comments and
// nested table shapes through a save.
type Meta struct {
doc *interpres.Document
}
// NewMeta returns an empty Meta.
func NewMeta() *Meta {
// Parsing nothing always succeeds and yields a document with a
// root table, which is what an empty Meta needs; a nil doc (if the
// parser ever failed on nothing) leaves an inert Meta whose methods
// are all no-ops.
doc, _ := interpres.Parse(nil)
return &Meta{doc: doc}
}
// MetaFromMap builds a Meta from a plain map with keys in the given
// order. Keys missing from order are appended sorted, so output stays
// deterministic.
func MetaFromMap(m map[string]any, order []string) *Meta {
meta := NewMeta()
seen := map[string]bool{}
for _, k := range order {
if v, ok := m[k]; ok {
meta.Set(k, v)
seen[k] = true
}
}
rest := make([]string, 0, len(m))
for k := range m {
if !seen[k] {
rest = append(rest, k)
}
}
slices.Sort(rest)
for _, k := range rest {
meta.Set(k, m[k])
}
return meta
}
// Len returns the number of keys.
func (m *Meta) Len() int { return len(m.Keys()) }
// Keys returns the keys in written order.
func (m *Meta) Keys() []string {
if m.doc == nil {
return nil
}
return m.doc.Root().Keys()
}
// Get returns the value for key.
func (m *Meta) Get(key string) (any, bool) {
if m.doc == nil {
return nil, false
}
entry, ok := m.doc.Get(key)
if !ok {
return nil, false
}
return entry.Value(), true
}
// Set assigns key, appending it when new so the written order is
// stable. A []string is stored as the []any the parser produces, so a
// set value and a parsed one leave a save in the same shape; a map
// value becomes a sub-table whose keys are written sorted, because a
// plain map carries no order to keep.
func (m *Meta) Set(key string, value any) {
if list, ok := value.([]string); ok {
anyList := make([]any, len(list))
for i, s := range list {
anyList[i] = s
}
value = anyList
}
m.doc.Set(key, value)
}
// Delete removes key and its position in the order.
func (m *Meta) Delete(key string) {
m.doc.Delete(key)
}
// Map returns a plain copy of the metadata.
func (m *Meta) Map() map[string]any {
if m.doc == nil {
return nil
}
out := make(map[string]any, len(m.doc.Map()))
maps.Copy(out, m.doc.Map())
return out
}
// Clone returns a deep copy that shares no value with the original.
// The document is re-marshalled and re-parsed, which copies every value
// and carries the comments, the key order and the table shapes with
// them. A Meta holding a value no parse could produce (a nil set by
// hand) falls back to a plain value copy without comments.
func (m *Meta) Clone() *Meta {
if m.doc != nil {
if raw, err := interpres.Marshal(m.doc); err == nil {
if doc, err := interpres.Parse(raw); err == nil {
return &Meta{doc: doc}
}
}
}
out := NewMeta()
for _, key := range m.Keys() {
value, _ := m.Get(key)
out.Set(key, deepCopyValue(value))
}
return out
}
// deepCopyValue copies the containers so a clone shares no mutable
// value with its original.
func deepCopyValue(value any) any {
switch v := value.(type) {
case map[string]any:
out := make(map[string]any, len(v))
for key, item := range v {
out[key] = deepCopyValue(item)
}
return out
case []any:
out := make([]any, len(v))
for i, item := range v {
out[i] = deepCopyValue(item)
}
return out
case []map[string]any:
out := make([]map[string]any, len(v))
for i, item := range v {
out[i] = deepCopyValue(item).(map[string]any)
}
return out
case []string:
return slices.Clone(v)
}
return value
}
// Parse splits content into metadata and body. Without frontmatter it
// returns empty metadata and the full content as body. Line endings are
// normalised to \n. Invalid frontmatter TOML is an error.
func Parse(content string) (*Meta, string, error) {
text := normaliseFile(content)
meta, bodyStart, err := ParseMetadata(text)
if err != nil {
return nil, "", err
}
if bodyStart < 0 {
return meta, text, nil
}
return meta, text[bodyStart:], nil
}
// normaliseFile strips a leading byte-order mark and normalises line
// endings. A BOM before the opening delimiter would otherwise make the
// whole frontmatter (draft and publish_at included) silently count as
// body.
func normaliseFile(content string) string {
text := strings.TrimPrefix(content, "\ufeff")
return strings.ReplaceAll(text, "\r\n", "\n")
}
// ParseMetadata parses only the frontmatter, returning the metadata and
// the byte offset where the body begins, or -1 when there is no
// frontmatter. Line endings are normalised to \n before parsing.
// Invalid frontmatter TOML is an error.
func ParseMetadata(content string) (*Meta, int, error) {
text := normaliseFile(content)
lines := strings.Split(text, "\n")
if len(lines) == 0 || !isDelimiter(lines[0]) {
return NewMeta(), -1, nil
}
closing := closingIndex(lines)
if closing < 0 {
return NewMeta(), -1, nil
}
tomlText := strings.Join(lines[1:closing], "\n")
meta := NewMeta()
if strings.TrimSpace(tomlText) != "" {
doc, err := interpres.Parse([]byte(tomlText))
if err != nil {
return nil, -1, fmt.Errorf("frontmatter: %w", err)
}
meta = &Meta{doc: doc}
}
bodyStart := 0
for i := 0; i <= closing; i++ {
bodyStart += len(lines[i]) + 1
}
// A file that ends exactly on the closing delimiter has no trailing
// newline; clamp so the slice below can never run past the text.
if bodyStart > len(text) {
bodyStart = len(text)
}
if bodyStart < len(text) && text[bodyStart] == '\n' {
bodyStart++
}
return meta, bodyStart, nil
}
// Dump serialises metadata and body back into a post file string. The
// body is written verbatim so indented code blocks and leading blank
// lines survive a save round-trip; only a trailing newline is added.
// The frontmatter is written from the parsed document, so the author's
// comments, key order and table shapes survive a save.
func Dump(meta *Meta, body string) (string, error) {
var b strings.Builder
b.WriteString(Delimiter)
b.WriteByte('\n')
if meta.Len() > 0 {
tomlText, err := interpres.Marshal(meta.doc)
if err != nil {
return "", err
}
b.Write(tomlText)
if !bytes.HasSuffix(tomlText, []byte{'\n'}) {
b.WriteByte('\n')
}
}
b.WriteString(Delimiter)
b.WriteString("\n\n")
b.WriteString(body)
if !strings.HasSuffix(body, "\n") {
b.WriteByte('\n')
}
return b.String(), nil
}
func isDelimiter(line string) bool {
return strings.TrimSpace(line) == Delimiter
}
// multiline tracks whether the TOML scanner sits inside a multi-line
// basic string (three double quotes) or a literal one (three single
// quotes), where a line reading "+++" is content, not a delimiter, and
// a line reading "key =" is not a key.
type multiline struct {
basic bool
literal bool
}
// step consumes one line and reports whether that line sits inside a
// multi-line string.
func (m *multiline) step(line string) bool {
if m.basic || m.literal {
closer := `"""`
if m.literal {
closer = "'''"
}
if strings.Contains(line, closer) {
m.basic, m.literal = false, false
}
return true
}
if eq := strings.Index(line, "="); eq > 0 {
rest := strings.TrimSpace(line[eq+1:])
switch {
case rest == `"""`:
m.basic = true
case rest == `'''`:
m.literal = true
case strings.HasPrefix(rest, `"""`) && len(rest) > 5 && !strings.HasSuffix(rest, `"""`):
m.basic = true
case strings.HasPrefix(rest, `'''`) && len(rest) > 5 && !strings.HasSuffix(rest, `'''`):
m.literal = true
}
}
return false
}
func closingIndex(lines []string) int {
var state multiline
for i := 1; i < len(lines); i++ {
if state.step(lines[i]) {
continue
}
if isDelimiter(lines[i]) {
return i
}
}
return -1
}
+426
View File
@@ -0,0 +1,426 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package frontmatter
import (
"fmt"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/interpres/v2"
"sourcedock.dev/petrbalvin/volumen/internal/tomlfile"
)
const sample = `+++
title = "Ahoj"
slug = "ahoj"
tags = ["go", "blog"]
draft = false
date = 2026-08-18
[translations]
en = "hello"
+++
# Nadpis
Tělo článku.
`
func TestParse(t *testing.T) {
meta, body, _ := Parse(sample)
if got, _ := meta.Get("title"); got != "Ahoj" {
t.Fatalf("title = %v", got)
}
if got, _ := meta.Get("draft"); got != false {
t.Fatalf("draft = %v", got)
}
if !strings.HasPrefix(body, "# Nadpis") {
t.Fatalf("body = %q", body)
}
trans, ok := meta.Get("translations")
if !ok {
t.Fatal("translations missing")
}
tr := trans.(map[string]any)
if tr["en"] != "hello" {
t.Fatalf("translations = %v", tr)
}
}
func TestParseKeyOrderPreserved(t *testing.T) {
meta, _, _ := Parse(sample)
want := []string{"title", "slug", "tags", "draft", "date", "translations"}
got := meta.Keys()
if len(got) != len(want) {
t.Fatalf("keys = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("keys = %v, want %v", got, want)
}
}
}
func TestParseWithoutFrontmatter(t *testing.T) {
meta, body, _ := Parse("just text\n")
if meta.Len() != 0 {
t.Fatalf("metadata = %v, want empty", meta.Map())
}
if body != "just text\n" {
t.Fatalf("body = %q", body)
}
}
func TestParseUnclosedFrontmatter(t *testing.T) {
meta, body, _ := Parse("+++\ntitle = \"x\"\nno closing\n")
if meta.Len() != 0 {
t.Fatalf("metadata = %v, want empty", meta.Map())
}
if body != "+++\ntitle = \"x\"\nno closing\n" {
t.Fatalf("body = %q", body)
}
}
func TestParseInvalidToml(t *testing.T) {
meta, body, err := Parse("+++\nnot valid = = =\n+++\nbody\n")
if err == nil {
t.Fatalf("want error for invalid TOML, got metadata=%v body=%q", meta.Map(), body)
}
}
func TestParseCRLF(t *testing.T) {
crlf := strings.ReplaceAll(sample, "\n", "\r\n")
meta, body, _ := Parse(crlf)
if got, _ := meta.Get("title"); got != "Ahoj" {
t.Fatalf("title = %v", got)
}
if !strings.HasPrefix(body, "# Nadpis") {
t.Fatalf("body = %q", body)
}
}
func TestParseMetadataBodyOffset(t *testing.T) {
_, bodyStart, _ := ParseMetadata("+++\ntitle = \"x\"\n+++\n\ntext")
if bodyStart < 0 {
t.Fatal("bodyStart < 0")
}
rest := ("+++\ntitle = \"x\"\n+++\n\ntext")[bodyStart:]
if rest != "text" {
t.Fatalf("rest = %q, want %q", rest, "text")
}
}
func TestDumpRoundTrip(t *testing.T) {
meta, body, _ := Parse(sample)
out, err := Dump(meta, body)
if err != nil {
t.Fatalf("Dump: %v", err)
}
meta2, body2, _ := Parse(out)
if body2 != body {
t.Fatalf("body changed:\n%q\nvs\n%q", body2, body)
}
for _, key := range []string{"title", "slug", "draft"} {
a, _ := meta.Get(key)
b, _ := meta2.Get(key)
if a != b {
t.Fatalf("key %q: %v -> %v", key, a, b)
}
}
tags1, _ := meta.Get("tags")
tags2, _ := meta2.Get("tags")
if len(tags1.([]any)) != len(tags2.([]any)) {
t.Fatalf("tags changed: %v -> %v", tags1, tags2)
}
tr1 := meta.Map()["translations"].(map[string]any)
tr2 := meta2.Map()["translations"].(map[string]any)
if tr1["en"] != tr2["en"] {
t.Fatalf("translations changed: %v -> %v", tr1, tr2)
}
// Round-trip must be stable: dumping again yields identical bytes.
out2, err := Dump(meta2, body2)
if err != nil {
t.Fatalf("Dump 2: %v", err)
}
if out != out2 {
t.Fatalf("round-trip not stable:\n%s\nvs\n%s", out, out2)
}
}
func TestDumpEmptyMetadata(t *testing.T) {
out, err := Dump(NewMeta(), "body")
if err != nil {
t.Fatalf("Dump: %v", err)
}
if out != "+++\n+++\n\nbody\n" {
t.Fatalf("out = %q", out)
}
}
func TestDumpAddsTrailingNewline(t *testing.T) {
out, err := Dump(NewMeta(), "body without newline")
if err != nil {
t.Fatalf("Dump: %v", err)
}
if !strings.HasSuffix(out, "body without newline\n") {
t.Fatalf("out = %q", out)
}
}
func TestDumpValueTypes(t *testing.T) {
meta := NewMeta()
meta.Set("s", "řetězec s \"")
meta.Set("n", int64(42))
meta.Set("f", 3.5)
meta.Set("fint", 3.0)
meta.Set("b", true)
meta.Set("d", interpres.LocalDate{Time: time.Date(2026, 8, 18, 0, 0, 0, 0, time.UTC)})
meta.Set("arr", []any{"a", "b"})
out, err := Dump(meta, "x")
if err != nil {
t.Fatalf("Dump: %v", err)
}
for _, want := range []string{
`s = "řetězec s \""`,
"n = 42",
"f = 3.5",
"fint = 3.0",
"b = true",
"d = 2026-08-18",
`arr = ["a", "b"]`,
} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in:\n%s", want, out)
}
}
}
func TestDumpRejectsNil(t *testing.T) {
meta := NewMeta()
meta.Set("bad", nil)
if _, err := Dump(meta, "x"); err == nil {
t.Fatal("want error for nil value")
}
}
func TestDumpQuotedKeys(t *testing.T) {
meta := NewMeta()
meta.Set("with space", "v")
out, err := Dump(meta, "x")
if err != nil {
t.Fatalf("Dump: %v", err)
}
if !strings.Contains(out, `"with space" = "v"`) {
t.Fatalf("out = %q", out)
}
}
func TestMetaOperations(t *testing.T) {
meta := NewMeta()
meta.Set("a", int64(1))
meta.Set("b", int64(2))
meta.Set("a", int64(3))
if meta.Len() != 2 {
t.Fatalf("Len = %d", meta.Len())
}
if got, _ := meta.Get("a"); got != int64(3) {
t.Fatalf("a = %v", got)
}
if _, ok := meta.Get("b"); !ok {
t.Fatal("Has(b) = false")
}
meta.Delete("a")
meta.Delete("missing")
if keys := meta.Keys(); len(keys) != 1 || keys[0] != "b" {
t.Fatalf("keys = %v", keys)
}
}
func TestMetaFromMapUnknownOrderKeysSorted(t *testing.T) {
m := map[string]any{"b": int64(2), "a": int64(1), "c": int64(3)}
meta := MetaFromMap(m, []string{"c"})
keys := meta.Keys()
if keys[0] != "c" || keys[1] != "a" || keys[2] != "b" {
t.Fatalf("keys = %v", keys)
}
}
func TestParseClosingDelimiterWithoutTrailingNewline(t *testing.T) {
// A file ending exactly on the closing delimiter must not panic.
meta, body, err := Parse("+++\nslug = \"x\"\n+++")
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, _ := meta.Get("slug"); got != "x" {
t.Fatalf("slug = %v", got)
}
if body != "" {
t.Fatalf("body = %q, want empty", body)
}
// The metadata-only variant behaves the same.
if _, offset, err := ParseMetadata("+++\n+++"); err != nil || offset > len("+++\n+++") {
t.Fatalf("offset = %d, err = %v", offset, err)
}
}
// A byte-order mark must not demote the frontmatter to body: draft and
// publish_at live there, and a file saved as UTF-8 with BOM keeps its
// meaning.
func TestParseStripsByteOrderMark(t *testing.T) {
meta, body, err := Parse("\ufeff+++\ntitle = \"BOM\"\nslug = \"bom\"\ndraft = true\n+++\n\nbody text\n")
if err != nil {
t.Fatalf("Parse: %v", err)
}
if title, _ := meta.Get("title"); title != "BOM" {
t.Fatalf("title = %v, want BOM", title)
}
if !strings.Contains(body, "body text") || strings.Contains(body, "+++") {
t.Fatalf("body = %q", body)
}
if v, present := meta.Get("draft"); !present || v != true {
t.Fatal("draft flag lost behind the BOM")
}
}
// An array of tables in the frontmatter survives a save: it parses, so
// it must also serialise, or the post can never be edited again.
func TestDumpArraysOfTables(t *testing.T) {
src := "+++\ntitle = \"T\"\nslug = \"t\"\ninline = [{a = \"b\", n = 3}]\n\n[[chapters]]\nx = 1\n\n[[chapters]]\nx = 2\n+++\n\nbody\n"
meta, _, err := Parse(src)
if err != nil {
t.Fatalf("Parse: %v", err)
}
out, err := Dump(meta, "body\n")
if err != nil {
t.Fatalf("Dump rejected an array of tables: %v", err)
}
// The array of tables keeps its header form instead of being
// flattened into inline tables.
if !strings.Contains(out, "[[chapters]]") {
t.Fatalf("chapters header form lost:\n%s", out)
}
meta2, body2, err := Parse(out)
if err != nil {
t.Fatalf("round-trip parse: %v\n%s", err, out)
}
if body2 != "body\n" {
t.Fatalf("body = %q", body2)
}
chapters, _ := meta2.Get("chapters")
if got := len(tomlfile.Tables(chapters)); got != 2 {
t.Fatalf("chapters hold %d tables, want 2:\n%s", got, out)
}
inline, _ := meta2.Get("inline")
if got := len(tomlfile.Tables(inline)); got != 1 {
t.Fatalf("inline holds %d tables, want 1:\n%s", got, out)
}
}
// Comments in the frontmatter survive a save: they sit above the key
// the author explained, and a save has no business deleting them.
func TestDumpKeepsComments(t *testing.T) {
src := "+++\n# the visible name\ntitle = \"T\"\nslug = \"c\"\n\n# where it also lives\n[translations]\nen = \"hello\"\n+++\n\nbody\n"
meta, body, err := Parse(src)
if err != nil {
t.Fatalf("Parse: %v", err)
}
out, err := Dump(meta, body)
if err != nil {
t.Fatalf("Dump: %v", err)
}
for _, want := range []string{"# the visible name", "# where it also lives"} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in:\n%s", want, out)
}
}
// Round-trip must be stable: dumping again yields identical bytes.
meta2, _, err := Parse(out)
if err != nil {
t.Fatalf("round-trip parse: %v\n%s", err, out)
}
out2, err := Dump(meta2, body)
if err != nil {
t.Fatalf("Dump 2: %v", err)
}
if out != out2 {
t.Fatalf("round-trip not stable:\n%s\nvs\n%s", out, out2)
}
}
// A nested table keeps the order its keys were written in, not the
// sorted order a map would give.
func TestDumpKeepsNestedKeyOrder(t *testing.T) {
src := "+++\ntitle = \"T\"\nslug = \"o\"\n[translations]\nzz = \"last\"\naa = \"first\"\n+++\n\nbody\n"
meta, body, err := Parse(src)
if err != nil {
t.Fatalf("Parse: %v", err)
}
out, err := Dump(meta, body)
if err != nil {
t.Fatalf("Dump: %v", err)
}
zz := strings.Index(out, "zz = ")
aa := strings.Index(out, "aa = ")
if zz < 0 || aa < 0 || zz > aa {
t.Fatalf("nested order re-sorted:\n%s", out)
}
}
// A clone is a deep copy: it carries the comments with it, and mutating
// it leaves the original untouched.
func TestCloneKeepsCommentsAndIsolates(t *testing.T) {
src := "+++\ntitle = \"T\"\nslug = \"c\"\n\n# the visible name\ntitle_note = \"x\"\n[translations]\nen = \"hello\"\n+++\n\nbody\n"
meta, _, err := Parse(src)
if err != nil {
t.Fatalf("Parse: %v", err)
}
clone := meta.Clone()
clone.Set("title", "Changed")
tr, _ := clone.Get("translations")
tr.(map[string]any)["en"] = "mutated"
orig, _ := meta.Get("title")
if orig != "T" {
t.Fatalf("original title = %v, want T", orig)
}
origTr, _ := meta.Get("translations")
if origTr.(map[string]any)["en"] != "hello" {
t.Fatalf("original translations mutated: %v", origTr)
}
out, err := Dump(clone, "body\n")
if err != nil {
t.Fatalf("Dump: %v", err)
}
if !strings.Contains(out, "# the visible name") || !strings.Contains(out, `title = "Changed"`) {
t.Fatalf("clone lost a comment or the new value:\n%s", out)
}
}
// A line reading "+++" inside a multi-line string is content, not the
// closing delimiter; the frontmatter ends at the real one.
func TestParseDelimiterInsideMultilineString(t *testing.T) {
src := "+++\ntitle = \"T\"\nslug = \"ml\"\nbody = \"\"\"\n+++\nnot the end\n\"\"\"\n+++\nreal body\n"
meta, body, err := Parse(src)
if err != nil {
t.Fatalf("Parse: %v", err)
}
value, _ := meta.Get("body")
if got := fmt.Sprintf("%v", value); !strings.Contains(got, "not the end") {
t.Fatalf("multiline value = %q", got)
}
if !strings.HasPrefix(body, "real body") {
t.Fatalf("body = %q", body)
}
literal := "+++\ntitle = \"T\"\nslug = \"ml\"\nnote = '''\n+++\nliteral content\n'''\n+++\nreal body\n"
_, body2, err := Parse(literal)
if err != nil {
t.Fatalf("Parse literal: %v", err)
}
if !strings.HasPrefix(body2, "real body") {
t.Fatalf("literal body = %q", body2)
}
}
+937
View File
@@ -0,0 +1,937 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package httpapi serves the public JSON API under /api/volumen:
// site metadata, paginated posts, tags, series, feeds, the sitemap,
// and token-authenticated write endpoints.
package httpapi
import (
"crypto/sha256"
"encoding/hex"
json "encoding/json/v2"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"sync"
"time"
"sourcedock.dev/petrbalvin/interpres/v2"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/feeds"
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
"sourcedock.dev/petrbalvin/volumen/internal/post"
"sourcedock.dev/petrbalvin/volumen/internal/preview"
"sourcedock.dev/petrbalvin/volumen/internal/tokens"
"sourcedock.dev/petrbalvin/volumen/internal/web"
)
// Content is the content surface the API uses.
type Content interface {
All() []*post.Post
Find(slug, lang string) *post.Post
ResolveAlias(alias string) string
Save(p *post.Post) (*post.Post, error)
Delete(slug, lang string) (*post.Post, bool, error)
CacheKey() string
}
// Deps are the shared services the API needs.
type Deps struct {
Config *config.Config
Store Content
Tokens *tokens.Store
OnEvent func(event string, payload map[string]any)
// PreviewKey verifies the shareable preview links: the session
// secret the app layer resolved, from [admin].session_key or from
// the secret.key file it generated, which is the same key the admin
// signs the links with. Empty refuses every token.
PreviewKey string
}
// API routes /api/volumen requests.
type API struct {
deps Deps
sitemapMu sync.Mutex
sitemapKey string
sitemapXML string
}
// New builds the API handler.
func New(deps Deps) http.Handler {
api := &API{deps: deps}
mux := http.NewServeMux()
mux.HandleFunc("OPTIONS /api/volumen/{rest...}", api.handleOptions)
mux.HandleFunc("GET /api/volumen/site", api.handleSite)
mux.HandleFunc("GET /api/volumen/posts", api.handlePosts)
mux.HandleFunc("GET /api/volumen/posts/batch", api.handleBatch)
mux.HandleFunc("GET /api/volumen/posts/{slug}", api.handleSingle)
mux.HandleFunc("POST /api/volumen/posts", api.handleCreatePost)
mux.HandleFunc("PUT /api/volumen/posts/{slug}", api.handleUpdatePost)
mux.HandleFunc("DELETE /api/volumen/posts/{slug}", api.handleDeletePost)
mux.HandleFunc("GET /api/volumen/tags", api.handleTags)
mux.HandleFunc("GET /api/volumen/tags/{tag}", api.handleTagPosts)
mux.HandleFunc("GET /api/volumen/tags/{tag}/feed.xml", api.handleTagRSS)
mux.HandleFunc("GET /api/volumen/tags/{tag}/feed.atom", api.handleTagAtom)
mux.HandleFunc("GET /api/volumen/tags/{tag}/feed.json", api.handleTagJSON)
mux.HandleFunc("GET /api/volumen/series", api.handleSeries)
mux.HandleFunc("GET /api/volumen/series/{name}", api.handleSeriesDetail)
mux.HandleFunc("GET /api/volumen/series/{name}/feed.xml", api.handleSeriesRSS)
mux.HandleFunc("GET /api/volumen/series/{name}/feed.atom", api.handleSeriesAtom)
mux.HandleFunc("GET /api/volumen/series/{name}/feed.json", api.handleSeriesJSON)
mux.HandleFunc("GET /api/volumen/feed.xml", api.handleRSS)
mux.HandleFunc("GET /api/volumen/feed.atom", api.handleAtom)
mux.HandleFunc("GET /api/volumen/feed.json", api.handleJSONFeed)
mux.HandleFunc("GET /api/volumen/sitemap.xml", api.handleSitemap)
return mux
}
var corsHeaders = map[string]string{
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
}
const cacheHeader = "public, max-age=60, stale-while-revalidate=21600"
// pageSizeLimit bounds the page size: the documented limit of the list
// endpoints, used both by the clamp and by the error message so the two
// cannot drift.
const pageSizeLimit = 100
// maxWriteBody bounds a write payload.
const maxWriteBody = 10 << 20
// maxPage bounds the page number so offset arithmetic stays far inside
// 32-bit int range.
const maxPage = 1_000_000
func (a *API) fire(event string, payload map[string]any) {
if a.deps.OnEvent != nil {
a.deps.OnEvent(event, payload)
}
}
func writeCORS(w http.ResponseWriter) {
for key, value := range corsHeaders {
w.Header().Set(key, value)
}
w.Header().Set("Cache-Control", cacheHeader)
}
// writeJSON writes a JSON response with CORS and cache headers.
func writeJSON(w http.ResponseWriter, r *http.Request, status int, v any) {
writeJSONWithHeaders(w, r, status, v, nil)
}
// writeJSONWithHeaders applies extra headers last, so write endpoints
// can override the public CORS defaults.
func writeJSONWithHeaders(w http.ResponseWriter, r *http.Request, status int, v any, extra map[string]string) {
writeCORS(w)
for key, value := range extra {
w.Header().Set(key, value)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
writeJSONBody(w, r, v, "cannot encode response")
}
// writeJSONBody writes one JSON document and the newline a line-oriented
// client expects. encoding/json/v2 escapes only what JSON requires, so a
// body keeps the characters the author wrote.
func writeJSONBody(w io.Writer, r *http.Request, v any, what string) {
if err := json.MarshalWrite(w, v, json.Deterministic(true)); err != nil {
web.Logger(r.Context()).Warn("httpapi: "+what, "error", err)
return
}
if _, err := io.WriteString(w, "\n"); err != nil {
web.Logger(r.Context()).Warn("httpapi: "+what, "error", err)
}
}
// writeError writes the uniform error envelope:
//
// {"error": "<code>", "message": "<human text>", …extras}
//
// Every failure, in the API and in the middleware, uses this shape with
// CORS headers so cross-origin clients can read it. An error is never
// publicly cacheable.
func writeError(w http.ResponseWriter, r *http.Request, status int, body map[string]any) {
writeCORS(w)
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
writeJSONBody(w, r, body, "cannot encode error")
}
func writeXML(w http.ResponseWriter, r *http.Request, contentType, body string) {
writeCORS(w)
w.Header().Set("Content-Type", contentType)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(body))
}
// etagFor hashes the canonical serialisation of a payload, so two equal
// payloads always produce one tag: without Deterministic a map inside the
// payload could serialise in a different order on the next request and
// change the tag for the same content.
func etagFor(v any) string {
raw, err := json.Marshal(v, json.Deterministic(true))
if err != nil {
return `""`
}
sum := sha256.Sum256(raw)
return `"` + hex.EncodeToString(sum[:8]) + `"`
}
func etagMatches(header, etag string) bool {
opaque := func(tag string) string {
tag = strings.TrimSpace(tag)
tag = strings.TrimPrefix(tag, "W/")
return strings.Trim(tag, `"`)
}
stored := opaque(etag)
for tag := range strings.SplitSeq(header, ",") {
if strings.TrimSpace(tag) == "*" || opaque(tag) == stored {
return true
}
}
return false
}
func maybeNotModified(w http.ResponseWriter, r *http.Request, etag string) bool {
inm := r.Header.Get("If-None-Match")
if inm == "" || !etagMatches(inm, etag) {
return false
}
writeCORS(w)
w.Header().Set("ETag", etag)
w.WriteHeader(http.StatusNotModified)
return true
}
func writeJSONWithETag(w http.ResponseWriter, r *http.Request, v any) {
etag := etagFor(v)
if maybeNotModified(w, r, etag) {
return
}
w.Header().Set("ETag", etag)
writeJSON(w, r, http.StatusOK, v)
}
func (a *API) baseURL() string {
return strings.TrimRight(a.deps.Config.Site.BaseURL, "/")
}
func (a *API) handleOptions(w http.ResponseWriter, r *http.Request) {
// The preflight answers for the whole subtree, so it advertises the
// write methods as well; a browser preflight for a cross-origin POST
// fails when the response lists only GET.
for key, value := range writeCORSHeaders(r, a.deps.Config) {
w.Header().Set(key, value)
}
w.Header().Set("Access-Control-Max-Age", "600")
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
}
func (a *API) handleSite(w http.ResponseWriter, r *http.Request) {
writeJSONWithETag(w, r, payloads.BuildSite(a.deps.Config))
}
func queryInt(r *http.Request, name string, def, lo, hi int) (int, bool) {
raw := r.URL.Query().Get(name)
if raw == "" {
return def, true
}
n, err := strconv.Atoi(raw)
if err != nil {
return 0, false
}
if n < lo || n > hi {
return 0, false
}
return n, true
}
func writeQueryValidationError(w http.ResponseWriter, r *http.Request, name, msg string) {
writeError(w, r, http.StatusUnprocessableEntity, map[string]any{
"error": "validation",
"message": msg,
"field": name,
})
}
func (a *API) handlePosts(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
page, ok := queryInt(r, "page", 1, 1, maxPage)
if !ok {
writeQueryValidationError(w, r, "page", "Input should be between 1 and "+strconv.Itoa(maxPage))
return
}
limit, ok := queryInt(r, "limit", 20, 1, pageSizeLimit)
if !ok {
writeQueryValidationError(w, r, "limit", "Input should be between 1 and "+strconv.Itoa(pageSizeLimit))
return
}
payload := payloads.PostsPayload(
a.deps.Store,
q.Get("lang"), q.Get("tag"), q.Get("q"),
page, limit, q.Get("cursor"),
)
writeJSONWithETag(w, r, payload)
}
func (a *API) handleBatch(w http.ResponseWriter, r *http.Request) {
slugsParam := r.URL.Query().Get("slugs")
if slugsParam == "" {
writeJSON(w, r, http.StatusOK, map[string]any{"posts": []any{}})
return
}
var slugs []string
for slug := range strings.SplitSeq(slugsParam, ",") {
if trimmed := strings.TrimSpace(slug); trimmed != "" {
slugs = append(slugs, trimmed)
}
}
if len(slugs) > pageSizeLimit {
slugs = slugs[:pageSizeLimit]
}
// One listing serves the whole batch: Find re-walks the content
// directory per call, so a hundred slugs would walk it a hundred
// times. The map keeps Find(slug, "") semantics: the first post in
// listing order that carries the slug.
posts := a.deps.Store.All()
first := make(map[string]*post.Post, len(posts))
for _, p := range posts {
if _, ok := first[p.Slug()]; !ok {
first[p.Slug()] = p
}
}
base := a.baseURL()
results := make([]payloads.Detail, 0, len(slugs))
for _, slug := range slugs {
p := first[slug]
if p == nil || !p.Published() {
continue
}
detail, err := payloads.BuildDetail(p, base)
if err != nil {
web.Logger(r.Context()).Warn("httpapi: cannot render post", "slug", slug, "error", err)
continue
}
results = append(results, detail)
}
etag := etagFor(results)
if maybeNotModified(w, r, etag) {
return
}
w.Header().Set("ETag", etag)
writeJSON(w, r, http.StatusOK, payloads.Batch{Posts: results})
}
func (a *API) validPreviewToken(token, slug string) bool {
return preview.Valid(token, slug, a.deps.PreviewKey, time.Now())
}
func (a *API) handleSingle(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
lang := r.URL.Query().Get("lang")
p := a.deps.Store.Find(slug, lang)
if p == nil {
if canonical := a.deps.Store.ResolveAlias(slug); canonical != "" {
w.Header().Set("Location", "/api/volumen/posts/"+canonical)
w.WriteHeader(http.StatusMovedPermanently)
return
}
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
if !p.Published() && !a.validPreviewToken(r.URL.Query().Get("preview_token"), slug) {
// A draft and a scheduled post are distinguishable on purpose:
// the client knows the slug already, and the two states need
// different handling on the other side.
if p.Draft() {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "draft"})
return
}
writeError(w, r, http.StatusNotFound, map[string]any{"error": "scheduled"})
return
}
detail, err := payloads.BuildDetail(p, a.baseURL())
if err != nil {
writeError(w, r, http.StatusInternalServerError, map[string]any{"error": "render_failed"})
return
}
if !p.Published() {
// The URL is only valid with the preview token, but a shared cache
// would still be allowed to hold the unpublished content for the
// header's lifetime; a draft or a scheduled post is not
// publicly cacheable.
writeJSONWithHeaders(w, r, http.StatusOK, detail,
map[string]string{"Cache-Control": "no-store"})
return
}
writeJSONWithETag(w, r, detail)
}
func (a *API) handleTags(w http.ResponseWriter, r *http.Request) {
writeJSONWithETag(w, r, payloads.TagList{Tags: payloads.BuildTagCounts(a.publishedPosts())})
}
func (a *API) handleTagPosts(w http.ResponseWriter, r *http.Request) {
tag := r.PathValue("tag")
q := r.URL.Query()
page, ok := queryInt(r, "page", 1, 1, maxPage)
if !ok {
writeQueryValidationError(w, r, "page", "Input should be between 1 and "+strconv.Itoa(maxPage))
return
}
limit, ok := queryInt(r, "limit", 20, 1, 100)
if !ok {
writeQueryValidationError(w, r, "limit", "Input should be between 1 and "+strconv.Itoa(pageSizeLimit))
return
}
payload := payloads.PostsPayload(a.deps.Store, q.Get("lang"), tag, "", page, limit, q.Get("cursor"))
if payloads.IsEmpty(payload) {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
writeJSONWithETag(w, r, payload)
}
func (a *API) handleSeries(w http.ResponseWriter, r *http.Request) {
writeJSON(w, r, http.StatusOK, payloads.SeriesList{Series: payloads.BuildSeriesList(a.deps.Store)})
}
func (a *API) handleSeriesDetail(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
posts := payloads.SeriesPosts(a.deps.Store, name)
if len(posts) == 0 {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
summaries := make([]payloads.Summary, 0, len(posts))
for _, p := range posts {
summaries = append(summaries, payloads.BuildSummary(p))
}
writeJSON(w, r, http.StatusOK, payloads.SeriesDetail{
Name: name,
Count: len(posts),
Posts: summaries,
})
}
func (a *API) publishedPosts() []*post.Post {
return payloads.PublishedPosts(a.deps.Store)
}
func (a *API) postsWithTag(tag string) []*post.Post {
var out []*post.Post
for _, p := range a.publishedPosts() {
if slices.Contains(p.Tags(), tag) {
out = append(out, p)
}
}
return out
}
func (a *API) handleTagRSS(w http.ResponseWriter, r *http.Request) {
tag := r.PathValue("tag")
posts := a.postsWithTag(tag)
if len(posts) == 0 {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
writeXML(w, r, "application/rss+xml",
feeds.RenderRSSFeed(posts, a.deps.Config.Site, a.baseURL(), tagFeedPath(tag, "xml")))
}
func (a *API) handleTagAtom(w http.ResponseWriter, r *http.Request) {
tag := r.PathValue("tag")
posts := a.postsWithTag(tag)
if len(posts) == 0 {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
writeXML(w, r, "application/atom+xml",
feeds.RenderAtomFeed(posts, a.deps.Config.Site, a.baseURL(), tagFeedPath(tag, "atom")))
}
func (a *API) handleTagJSON(w http.ResponseWriter, r *http.Request) {
tag := r.PathValue("tag")
posts := a.postsWithTag(tag)
if len(posts) == 0 {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
a.writeJSONFeed(w, r, posts, tagFeedPath(tag, "json"))
}
func (a *API) handleSeriesRSS(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
posts := payloads.SeriesPosts(a.deps.Store, name)
if len(posts) == 0 {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
writeXML(w, r, "application/rss+xml",
feeds.RenderRSSFeed(posts, a.deps.Config.Site, a.baseURL(), seriesFeedPath(name, "xml")))
}
func (a *API) handleSeriesAtom(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
posts := payloads.SeriesPosts(a.deps.Store, name)
if len(posts) == 0 {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
writeXML(w, r, "application/atom+xml",
feeds.RenderAtomFeed(posts, a.deps.Config.Site, a.baseURL(), seriesFeedPath(name, "atom")))
}
func (a *API) handleSeriesJSON(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
posts := payloads.SeriesPosts(a.deps.Store, name)
if len(posts) == 0 {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
a.writeJSONFeed(w, r, posts, seriesFeedPath(name, "json"))
}
func (a *API) handleRSS(w http.ResponseWriter, r *http.Request) {
writeXML(w, r, "application/rss+xml",
feeds.RenderRSSFeed(a.publishedPosts(), a.deps.Config.Site, a.baseURL(), siteFeedPath("xml")))
}
func (a *API) handleAtom(w http.ResponseWriter, r *http.Request) {
writeXML(w, r, "application/atom+xml",
feeds.RenderAtomFeed(a.publishedPosts(), a.deps.Config.Site, a.baseURL(), siteFeedPath("atom")))
}
func (a *API) handleJSONFeed(w http.ResponseWriter, r *http.Request) {
a.writeJSONFeed(w, r, a.publishedPosts(), siteFeedPath("json"))
}
// Feed paths, used for the self link and feed_url of each document.
func siteFeedPath(format string) string { return "/api/volumen/feed." + format }
func tagFeedPath(tag, format string) string {
return "/api/volumen/tags/" + url.PathEscape(tag) + "/feed." + format
}
func seriesFeedPath(name, format string) string {
return "/api/volumen/series/" + url.PathEscape(name) + "/feed." + format
}
func (a *API) writeJSONFeed(w http.ResponseWriter, r *http.Request, posts []*post.Post, selfPath string) {
body, err := feeds.MarshalJSONFeed(feeds.RenderJSONFeed(posts, a.deps.Config.Site, a.baseURL(), selfPath))
if err != nil {
writeError(w, r, http.StatusInternalServerError, map[string]any{"error": "render_failed"})
return
}
writeCORS(w)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(append(body, '\n'))
}
func (a *API) handleSitemap(w http.ResponseWriter, r *http.Request) {
// The key is the store's snapshot, which changes whenever a post file
// is added, edited, touched or removed: the sitemap's lastmod comes
// from the file's modification time, so keying on the path and date
// alone would serve a stale lastmod for the life of the process.
// The key is taken before the posts: content changing between the two
// reads would pin XML rendered from the older snapshot under the newer
// key, and the cache would serve it until the next change. Key-first,
// the worst case is XML newer than its key, which recomputes.
key := a.deps.Store.CacheKey()
posts := a.publishedPosts()
a.sitemapMu.Lock()
if a.sitemapXML != "" && a.sitemapKey == key {
xml := a.sitemapXML
a.sitemapMu.Unlock()
writeXML(w, r, "application/xml", xml)
return
}
a.sitemapMu.Unlock()
xml := feeds.RenderSitemap(posts, a.baseURL())
a.sitemapMu.Lock()
a.sitemapKey = key
a.sitemapXML = xml
a.sitemapMu.Unlock()
writeXML(w, r, "application/xml", xml)
}
// --- token-authenticated writes ---------------------------------------------
var writeFields = []string{
"title", "slug", "lang", "author", "fediverse_creator",
"excerpt", "cover", "cover_alt", "cover_caption", "series",
}
func (a *API) requireToken(w http.ResponseWriter, r *http.Request, scope string) (*tokens.Token, bool) {
header := r.Header.Get("Authorization")
scheme, raw, found := strings.Cut(header, " ")
if !found || !strings.EqualFold(scheme, "Bearer") || strings.TrimSpace(raw) == "" {
writeUnauthorized(w, r)
return nil, false
}
token := a.deps.Tokens.Authenticate(strings.TrimSpace(raw))
if token == nil {
writeUnauthorized(w, r)
return nil, false
}
a.deps.Tokens.Touch(token.Name)
if !token.HasScope(scope) {
writeError(w, r, http.StatusForbidden, map[string]any{
"error": "forbidden",
"message": fmt.Sprintf("Token lacks '%s' scope", scope),
})
return nil, false
}
return token, true
}
// writeUnauthorized answers a missing or invalid token. The challenge
// goes out with the status line: a header set after WriteHeader never
// reaches the client.
func writeUnauthorized(w http.ResponseWriter, r *http.Request) {
writeJSONWithHeaders(w, r, http.StatusUnauthorized,
map[string]any{"error": "unauthorized"},
map[string]string{"WWW-Authenticate": "Bearer", "Cache-Control": "no-store"})
}
// writeCORSHeaders builds the restrictive CORS header set for
// token-authenticated write endpoints; only the configured base_url is
// allowed as an origin (with a wildcard fallback for setups without
// one).
func writeCORSHeaders(r *http.Request, cfg *config.Config) map[string]string {
base := strings.TrimRight(cfg.Site.BaseURL, "/")
origin := r.Header.Get("Origin")
allowed := "*"
if base != "" && origin != "" {
allowed = base
}
return map[string]string{
"Access-Control-Allow-Origin": allowed,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Cache-Control": "no-store",
}
}
// readJSONBody decodes a write payload. The decoder rejects a duplicate
// object member and invalid UTF-8, so a body that a JSON parser could
// read two ways is a 400 rather than a silent choice. A body over the
// limit is a 413, not a parse failure.
func readJSONBody(w http.ResponseWriter, r *http.Request) (map[string]any, bool) {
var data map[string]any
if err := json.UnmarshalRead(http.MaxBytesReader(w, r.Body, maxWriteBody), &data); err != nil {
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
writeError(w, r, http.StatusRequestEntityTooLarge, map[string]any{"error": "payload_too_large"})
return nil, false
}
writeError(w, r, http.StatusBadRequest, map[string]any{"error": "invalid_json"})
return nil, false
}
return data, true
}
// postFromJSON merges a JSON write payload into a post: omitted fields
// keep their values on update, an explicit null or empty string clears a
// field, and a malformed type is rejected with a validation message
// rather than coerced.
func postFromJSON(data map[string]any, existing *post.Post) (*post.Post, error) {
meta := frontmatterMetaFrom(existing)
body := ""
if existing != nil {
body = existing.Body
}
if rawBody, present := data["body"]; present {
// An explicit null clears the body, as it does every metadata
// field; keeping the stored text would contradict the merge
// contract the comment above documents.
if rawBody == nil {
body = ""
} else {
text, ok := rawBody.(string)
if !ok {
return nil, &payloads.ValidationError{Message: "body must be a string"}
}
body = text
}
}
bad := func(message string) (*post.Post, error) { return nil, &payloads.ValidationError{Message: message} }
for _, field := range writeFields {
value, present := data[field]
if !present {
continue
}
switch v := value.(type) {
case string:
if strings.TrimSpace(v) != "" {
meta.Set(field, strings.TrimSpace(v))
} else {
meta.Delete(field)
}
case nil:
meta.Delete(field)
default:
return bad(fmt.Sprintf("%s must be a string", field))
}
}
for _, dateField := range []string{"date", "publish_at"} {
value, present := data[dateField]
if !present {
continue
}
if parsed, ok := payloads.ParseDate(value); ok {
meta.Set(dateField, interpres.LocalDate{Time: parsed})
} else if value == nil || value == "" {
meta.Delete(dateField)
} else {
return bad(fmt.Sprintf("%s must be an ISO 8601 date", dateField))
}
}
if value, present := data["tags"]; present {
switch v := value.(type) {
case []any:
var cleaned []string
for _, item := range v {
// A malformed item is rejected, not coerced: fmt.Sprintf
// would turn null into the tag "<nil>".
s, ok := item.(string)
if !ok {
return bad("tags must be a list of strings")
}
if trimmed := strings.TrimSpace(s); trimmed != "" {
cleaned = append(cleaned, trimmed)
}
}
if len(cleaned) > 0 {
meta.Set("tags", cleaned)
} else {
meta.Delete("tags")
}
case string:
if strings.TrimSpace(v) != "" {
meta.Set("tags", payloads.ParseTags(v))
} else {
meta.Delete("tags")
}
case nil:
meta.Delete("tags")
default:
return bad("tags must be a list of strings")
}
}
for _, boolField := range []string{"draft", "all_langs"} {
value, present := data[boolField]
if !present {
continue
}
b, ok := value.(bool)
if !ok {
return bad(fmt.Sprintf("%s must be a boolean", boolField))
}
if b {
meta.Set(boolField, true)
} else {
meta.Delete(boolField)
}
}
if value, present := data["series_order"]; present {
switch v := value.(type) {
case nil:
meta.Delete("series_order")
case bool:
return bad("series_order must be an integer")
case float64:
if v != float64(int64(v)) {
return bad("series_order must be an integer")
}
meta.Set("series_order", int64(v))
case string:
if strings.TrimSpace(v) == "" {
meta.Delete("series_order")
break
}
n, ok := payloads.ParseInt(v)
if !ok {
return bad("series_order must be an integer")
}
meta.Set("series_order", int64(n))
default:
return bad("series_order must be an integer")
}
}
p := post.New(meta, body)
if existing != nil {
p.Path = existing.Path
}
return p, nil
}
func frontmatterMetaFrom(existing *post.Post) *frontmatter.Meta {
meta := frontmatter.NewMeta()
if existing != nil {
for _, key := range existing.Metadata.Keys() {
value, _ := existing.Metadata.Get(key)
meta.Set(key, value)
}
}
return meta
}
func (a *API) handleCreatePost(w http.ResponseWriter, r *http.Request) {
if _, ok := a.requireToken(w, r, "write"); !ok {
return
}
data, ok := readJSONBody(w, r)
if !ok {
return
}
p, err := postFromJSON(data, nil)
if err != nil {
writeError(w, r, http.StatusBadRequest, map[string]any{"error": "validation", "message": err.Error()})
return
}
if err := payloads.CreationError(p, a.deps.Store, nil); err != nil {
writeError(w, r, http.StatusBadRequest, map[string]any{"error": "validation", "message": err.Error()})
return
}
saved, err := a.deps.Store.Save(p)
if err != nil {
writeError(w, r, http.StatusInternalServerError, map[string]any{"error": "save_failed"})
return
}
summary := payloads.BuildSummary(saved)
a.fire("post.created", map[string]any{"post": summary})
headers := writeCORSHeaders(r, a.deps.Config)
// The ETag names the resource state the single-post GET serves, so a
// client can chain the create straight into an If-Match write.
if detail, err := payloads.BuildDetail(saved, a.baseURL()); err == nil {
headers["ETag"] = etagFor(detail)
}
writeJSONWithHeaders(w, r, http.StatusCreated, summary, headers)
}
// preconditionHolds checks the request's If-Match against the ETag the
// single-post GET serves for the same resource, so a client that read
// the post, edited it and writes it back fails instead of overwriting a
// change it never saw. No header is unconditional; `*` demands the post
// exists, which the caller has already established.
func (a *API) preconditionHolds(w http.ResponseWriter, r *http.Request, existing *post.Post) bool {
header := r.Header.Get("If-Match")
if header == "" {
return true
}
detail, err := payloads.BuildDetail(existing, a.baseURL())
if err != nil {
writeError(w, r, http.StatusInternalServerError, map[string]any{"error": "render_failed"})
return false
}
if etagMatches(header, etagFor(detail)) {
return true
}
writeError(w, r, http.StatusPreconditionFailed, map[string]any{"error": "precondition_failed"})
return false
}
func (a *API) handleUpdatePost(w http.ResponseWriter, r *http.Request) {
if _, ok := a.requireToken(w, r, "write"); !ok {
return
}
slug := r.PathValue("slug")
existing := a.deps.Store.Find(slug, "")
if existing == nil {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
if !a.preconditionHolds(w, r, existing) {
return
}
data, ok := readJSONBody(w, r)
if !ok {
return
}
p, err := postFromJSON(data, existing)
if err != nil {
writeError(w, r, http.StatusBadRequest, map[string]any{"error": "validation", "message": err.Error()})
return
}
if p.Slug() == "" {
p.Metadata.Set("slug", slug)
}
if err := payloads.CreationError(p, a.deps.Store, existing); err != nil {
writeError(w, r, http.StatusBadRequest, map[string]any{"error": "validation", "message": err.Error()})
return
}
// A post whose language came from its directory (not the frontmatter)
// keeps it through a rename: the payload set no lang, so the new
// default path would otherwise drop the language subdirectory and
// silently move the post into the default language.
if p.Lang() == "" {
p.SetFileLocation(existing.Slug(), existing.Lang())
}
// SavePost moves the file when the slug changed and archives the old
// one under its own language, the same way the admin editor does, so
// a rename behaves alike from either entry point.
saved, err := payloads.SavePost(a.deps.Store, p, existing)
if err != nil {
writeError(w, r, http.StatusInternalServerError, map[string]any{"error": "save_failed"})
return
}
summary := payloads.BuildSummary(saved)
a.fire("post.updated", map[string]any{"post": summary})
headers := writeCORSHeaders(r, a.deps.Config)
if detail, err := payloads.BuildDetail(saved, a.baseURL()); err == nil {
headers["ETag"] = etagFor(detail)
}
writeJSONWithHeaders(w, r, http.StatusOK, summary, headers)
}
func (a *API) handleDeletePost(w http.ResponseWriter, r *http.Request) {
if _, ok := a.requireToken(w, r, "delete"); !ok {
return
}
slug := r.PathValue("slug")
existing := a.deps.Store.Find(slug, "")
if existing == nil {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
if !a.preconditionHolds(w, r, existing) {
return
}
deleted, _, err := a.deps.Store.Delete(slug, "")
if err != nil {
web.Logger(r.Context()).Error("httpapi: cannot delete post", "slug", slug, "error", err)
writeError(w, r, http.StatusInternalServerError, map[string]any{"error": "delete_failed"})
return
}
if deleted == nil {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
a.fire("post.deleted", map[string]any{"post": map[string]any{
"slug": deleted.Slug(), "title": deleted.Title(),
}})
for key, value := range writeCORSHeaders(r, a.deps.Config) {
w.Header().Set(key, value)
}
w.WriteHeader(http.StatusNoContent)
}
+951
View File
@@ -0,0 +1,951 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package httpapi
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/preview"
"sourcedock.dev/petrbalvin/volumen/internal/store"
"sourcedock.dev/petrbalvin/volumen/internal/tokens"
)
type fixture struct {
handler http.Handler
store *store.Store
tokens *tokens.Store
events []string
}
func newFixture(t *testing.T, files map[string]string) *fixture {
t.Helper()
dir := t.TempDir()
content := filepath.Join(dir, "posts")
if err := os.MkdirAll(content, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
for name, body := range files {
path := filepath.Join(content, name)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
}
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
Host: "",
Port: -1,
ContentDir: content,
UsersFile: filepath.Join(dir, "users.toml"),
})
if err != nil {
t.Fatalf("config: %v", err)
}
cfg.Site.BaseURL = "https://site.example"
cfg.Admin.SessionKey = strings.Repeat("k", 64)
st := store.New(store.Options{ContentDir: content, DefaultLang: "en", RevisionLimit: 10})
f := &fixture{store: st, tokens: tokens.New(filepath.Join(dir, "tokens.toml"))}
f.handler = New(Deps{
Config: cfg,
Store: st,
Tokens: f.tokens,
PreviewKey: cfg.Admin.SessionKey,
OnEvent: func(event string, _ map[string]any) {
f.events = append(f.events, event)
},
})
return f
}
func (f *fixture) do(t *testing.T, req *http.Request) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
f.handler.ServeHTTP(rec, req)
return rec
}
func decodeJSON(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("invalid JSON %q: %v", rec.Body.String(), err)
}
return out
}
const helloFile = `+++
title = "Hello"
slug = "hello"
date = 2026-08-18
lang = "cs"
tags = ["go"]
+++
Hello **body**.
`
const draftFile = `+++
title = "Draft"
slug = "draft"
draft = true
+++
draft body
`
const scheduledFile = `+++
title = "Future"
slug = "future"
publish_at = 2999-01-01
+++
future body
`
func TestSiteAndETag(t *testing.T) {
f := newFixture(t, nil)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/site", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := decodeJSON(t, rec)
if body["title"] != config.DefaultSiteTitle || body["base_url"] != "https://site.example" {
t.Fatalf("site = %v", body)
}
if rec.Header().Get("Access-Control-Allow-Origin") != "*" {
t.Fatal("CORS missing")
}
if !strings.Contains(rec.Header().Get("Cache-Control"), "max-age=60") {
t.Fatalf("cache-control = %q", rec.Header().Get("Cache-Control"))
}
etag := rec.Header().Get("ETag")
if etag == "" {
t.Fatal("ETag missing")
}
req2 := httptest.NewRequest(http.MethodGet, "/api/volumen/site", nil)
req2.Header.Set("If-None-Match", etag)
rec2 := f.do(t, req2)
if rec2.Code != http.StatusNotModified {
t.Fatalf("code = %d, want 304", rec2.Code)
}
if rec2.Header().Get("ETag") != etag {
t.Fatal("ETag lost on 304")
}
// Weak comparison: W/ prefix and lists still match.
req3 := httptest.NewRequest(http.MethodGet, "/api/volumen/site", nil)
req3.Header.Set("If-None-Match", "W/"+etag+", \"other\"")
if rec3 := f.do(t, req3); rec3.Code != http.StatusNotModified {
t.Fatalf("weak compare failed: %d", rec3.Code)
}
}
func TestPostsPaginationAndFilters(t *testing.T) {
f := newFixture(t, map[string]string{
"hello.md": helloFile,
"draft.md": draftFile,
"scheduled.md": scheduledFile,
})
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts", nil))
body := decodeJSON(t, rec)
if body["total"] != float64(1) {
t.Fatalf("total = %v", body["total"])
}
posts := body["posts"].([]any)
first := posts[0].(map[string]any)
if first["slug"] != "hello" {
t.Fatalf("post = %v", first)
}
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts?lang=cs&tag=go&q=hello", nil))
if decodeJSON(t, rec)["total"] != float64(1) {
t.Fatal("filters wrong")
}
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts?lang=en", nil))
if decodeJSON(t, rec)["total"] != float64(0) {
t.Fatal("lang filter wrong")
}
}
func TestPostsQueryValidation(t *testing.T) {
f := newFixture(t, nil)
for _, path := range []string{
"/api/volumen/posts?page=0",
"/api/volumen/posts?page=abc",
"/api/volumen/posts?limit=0",
"/api/volumen/posts?limit=101",
} {
rec := f.do(t, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("%s: code = %d, want 422", path, rec.Code)
}
body := decodeJSON(t, rec)
if body["error"] != "validation" || body["field"] == nil {
t.Fatalf("%s: body = %v", path, body)
}
}
}
func TestBatch(t *testing.T) {
f := newFixture(t, map[string]string{"hello.md": helloFile, "draft.md": draftFile})
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/batch", nil))
if decodeJSON(t, rec)["posts"] == nil {
t.Fatal("empty batch wrong")
}
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/batch?slugs=hello,draft,missing", nil))
body := decodeJSON(t, rec)
posts := body["posts"].([]any)
if len(posts) != 1 {
t.Fatalf("posts = %v", posts)
}
first := posts[0].(map[string]any)
if first["slug"] != "hello" || first["html"] == nil || first["meta"] == nil {
t.Fatalf("detail = %v", first)
}
if rec.Header().Get("ETag") == "" {
t.Fatal("ETag missing on batch")
}
}
func TestSinglePostAndAliases(t *testing.T) {
f := newFixture(t, map[string]string{
"hello.md": "+++\ntitle = \"Hi\"\nslug = \"new\"\naliases = [\"old\"]\n+++\nbody\n",
})
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/new", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
if decodeJSON(t, rec)["title"] != "Hi" {
t.Fatal("wrong post")
}
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/old", nil))
if rec.Code != http.StatusMovedPermanently ||
rec.Header().Get("Location") != "/api/volumen/posts/new" {
t.Fatalf("alias redirect: %d %q", rec.Code, rec.Header().Get("Location"))
}
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/nope", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
if decodeJSON(t, rec)["error"] != "not_found" {
t.Fatalf("body = %s", rec.Body.String())
}
}
func TestDraftAndScheduledHiddenUnlessPreview(t *testing.T) {
f := newFixture(t, map[string]string{"draft.md": draftFile, "scheduled.md": scheduledFile})
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/draft", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("draft visible: %d", rec.Code)
}
if decodeJSON(t, rec)["error"] != "draft" {
t.Fatalf("body = %s", rec.Body.String())
}
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/future", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("scheduled visible: %d", rec.Code)
}
// Valid preview token reveals the draft.
token := preview.Token("draft", strings.Repeat("k", 64), time.Now())
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/draft?preview_token="+token, nil))
if rec.Code != http.StatusOK {
t.Fatalf("preview failed: %d %s", rec.Code, rec.Body.String())
}
// Garbage token does not.
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/draft?preview_token=bogus", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("bogus token accepted: %d", rec.Code)
}
}
func TestTagsAndSeries(t *testing.T) {
f := newFixture(t, map[string]string{
"a.md": "+++\nslug = \"a\"\ntags = [\"go\"]\nseries = \"S\"\nseries_order = 1\ndate = 2026-01-01\n+++\nx\n",
"b.md": "+++\nslug = \"b\"\ntags = [\"go\"]\nseries = \"S\"\nseries_order = 2\ndate = 2026-01-02\n+++\nx\n",
})
body := decodeJSON(t, f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/tags", nil)))
tags := body["tags"].([]any)
if len(tags) != 1 || tags[0].(map[string]any)["name"] != "go" {
t.Fatalf("tags = %v", tags)
}
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/tags/go", nil))
if decodeJSON(t, rec)["total"] != float64(2) {
t.Fatal("tag posts wrong")
}
if rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/tags/none", nil)); rec.Code != http.StatusNotFound {
t.Fatalf("unknown tag: %d", rec.Code)
}
body = decodeJSON(t, f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/series", nil)))
series := body["series"].([]any)
if len(series) != 1 || series[0].(map[string]any)["name"] != "S" {
t.Fatalf("series = %v", series)
}
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/series/S", nil))
body = decodeJSON(t, rec)
if body["count"] != float64(2) {
t.Fatalf("series detail = %v", body)
}
if rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/series/none", nil)); rec.Code != http.StatusNotFound {
t.Fatalf("unknown series: %d", rec.Code)
}
}
const seriesFile = `+++
title = "Second"
slug = "second"
date = 2026-08-19
series = "S"
series_order = 1
tags = ["go"]
+++
Second body.`
func TestFeedsAndSitemap(t *testing.T) {
f := newFixture(t, map[string]string{"hello.md": helloFile, "second.md": seriesFile})
cases := map[string]string{
"/api/volumen/feed.xml": "application/rss+xml",
"/api/volumen/feed.atom": "application/atom+xml",
"/api/volumen/feed.json": "application/json",
"/api/volumen/sitemap.xml": "application/xml",
"/api/volumen/tags/go/feed.xml": "application/rss+xml",
"/api/volumen/tags/go/feed.atom": "application/atom+xml",
"/api/volumen/tags/go/feed.json": "application/json",
}
for path, contentType := range cases {
rec := f.do(t, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Fatalf("%s: code = %d", path, rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != contentType {
t.Fatalf("%s: content-type = %q, want %q", path, got, contentType)
}
}
// JSON feed keeps literal characters.
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/feed.json", nil))
if strings.Contains(rec.Body.String(), `&`) {
t.Fatal("JSON feed HTML-escaped")
}
// Series feeds render the series, not the whole site, and name
// themselves in the self link.
for path, contentType := range map[string]string{
"/api/volumen/series/S/feed.xml": "application/rss+xml",
"/api/volumen/series/S/feed.atom": "application/atom+xml",
"/api/volumen/series/S/feed.json": "application/json",
} {
rec := f.do(t, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Fatalf("%s: code = %d", path, rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != contentType {
t.Fatalf("%s: content-type = %q, want %q", path, got, contentType)
}
body := rec.Body.String()
if !strings.Contains(body, "second") {
t.Fatalf("%s does not carry the series post:\n%s", path, body)
}
if strings.Contains(body, "hello") {
t.Fatalf("%s carries a post outside the series:\n%s", path, body)
}
if !strings.Contains(body, "/api/volumen/series/S/feed.") {
t.Fatalf("%s does not name itself:\n%s", path, body)
}
}
// A tag feed carries the tagged posts and names itself; a tag feed
// for an unknown tag is a 404.
tagFeed := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/tags/go/feed.json", nil))
if body := tagFeed.Body.String(); !strings.Contains(body, "hello") ||
!strings.Contains(body, "/api/volumen/tags/go/feed.json") {
t.Fatalf("tag json feed = %s", body)
}
tagAtom := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/tags/go/feed.atom", nil))
if body := tagAtom.Body.String(); !strings.Contains(body, "/api/volumen/tags/go/feed.atom") {
t.Fatalf("tag atom feed does not name itself: %s", body)
}
for _, path := range []string{
"/api/volumen/series/none/feed.xml",
"/api/volumen/series/none/feed.atom",
"/api/volumen/series/none/feed.json",
"/api/volumen/tags/none/feed.json",
"/api/volumen/tags/none/feed.atom",
} {
if rec := f.do(t, httptest.NewRequest(http.MethodGet, path, nil)); rec.Code != http.StatusNotFound {
t.Fatalf("%s: code = %d", path, rec.Code)
}
}
}
func TestOptionsPreflight(t *testing.T) {
f := newFixture(t, nil)
rec := f.do(t, httptest.NewRequest(http.MethodOptions, "/api/volumen/posts", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
if rec.Header().Get("Access-Control-Allow-Origin") != "*" ||
!strings.Contains(rec.Header().Get("Access-Control-Allow-Methods"), "GET") {
t.Fatalf("headers = %v", rec.Header())
}
}
// An If-Match write is refused with 412 when the etag the client holds
// no longer names the stored state, and accepted when it does. The
// guard is opt-in: a write without the header stays unconditional.
// Frontmatter keys the engine does not consume pass through to the
// detail payload's fields object; a post without any omits the member.
func TestCustomFieldsPassThrough(t *testing.T) {
files := map[string]string{
"hello.md": helloFile,
"custom.md": `+++
title = "Custom"
slug = "custom"
date = 2026-08-18
[colour]
accent = "#0f0"
depths = [1, 2, 3]
[ratings]
good = 5
[[items]]
n = 1
+++`,
}
f := newFixture(t, files)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/custom", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := decodeJSON(t, rec)
fields, ok := body["fields"].(map[string]any)
if !ok {
t.Fatalf("fields missing: %s", rec.Body.String())
}
colour, ok := fields["colour"].(map[string]any)
if !ok || colour["accent"] != "#0f0" {
t.Fatalf("colour = %v", fields["colour"])
}
if depths, _ := colour["depths"].([]any); len(depths) != 3 {
t.Fatalf("depths = %v", colour["depths"])
}
if ratings, _ := fields["ratings"].(map[string]any); ratings["good"] != float64(5) {
t.Fatalf("ratings = %v", fields["ratings"])
}
if items, _ := fields["items"].([]any); len(items) != 1 {
t.Fatalf("items = %v", fields["items"])
}
// A known key is never duplicated into fields.
if _, present := fields["title"]; present {
t.Fatalf("known key leaked into fields: %v", fields)
}
// A post without custom frontmatter carries no fields member.
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/hello", nil))
if strings.Contains(rec.Body.String(), `"fields"`) {
t.Fatalf("empty fields leaked: %s", rec.Body.String())
}
}
// A search ranks by relevance: a title hit leads, a tag that contains
// the query is now found at all, and a body-only mention trails; the
// date order alone would read the other way round.
func TestSearchRanksByRelevance(t *testing.T) {
searchPost := func(slug, title, tags, date, body string) string {
return fmt.Sprintf(`+++
title = %q
slug = %q
lang = "en"
date = %s
tags = [%q]
+++
%s
`, title, slug, date, tags, body)
}
files := map[string]string{
"title-hit.md": searchPost("title-hit", "WebP guide", "images", "2026-08-01", "nothing relevant here"),
"tag-hit.md": searchPost("tag-hit", "Unrelated one", "webp", "2026-08-02", "nothing relevant here"),
"body-hit.md": searchPost("body-hit", "Unrelated two", "images", "2026-08-03", "the webp format is lovely"),
}
f := newFixture(t, files)
req := httptest.NewRequest(http.MethodGet, "/api/volumen/posts?q=webp", nil)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
posts, ok := decodeJSON(t, rec)["posts"].([]any)
if !ok || len(posts) != 3 {
t.Fatalf("posts = %v", posts)
}
want := []string{"title-hit", "tag-hit", "body-hit"}
for i, slug := range want {
if got := posts[i].(map[string]any)["slug"]; got != slug {
t.Fatalf("rank %d = %v, want %q", i, got, slug)
}
}
}
func TestIfMatchGuardsWrites(t *testing.T) {
f := newFixture(t, map[string]string{"hello.md": helloFile})
_, raw, err := f.tokens.Create("full", nil)
if err != nil {
t.Fatalf("Create: %v", err)
}
auth := "Bearer " + raw
servedETag := func() string {
req := httptest.NewRequest(http.MethodGet, "/api/volumen/posts/hello", nil)
return f.do(t, req).Header().Get("ETag")
}
fresh := servedETag()
if fresh == "" {
t.Fatal("GET served no ETag")
}
put := func(ifMatch string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPut, "/api/volumen/posts/hello", strings.NewReader(`{"title":"Fresh"}`))
req.Header.Set("Authorization", auth)
if ifMatch != "" {
req.Header.Set("If-Match", ifMatch)
}
return f.do(t, req)
}
if rec := put(`"0000000000000000"`); rec.Code != http.StatusPreconditionFailed {
t.Fatalf("stale etag: code = %d body = %s", rec.Code, rec.Body.String())
}
if body := decodeJSON(t, put(`"0000000000000000"`)); body["error"] != "precondition_failed" {
t.Fatalf("body = %v", body)
}
rec := put(fresh)
if rec.Code != http.StatusOK {
t.Fatalf("fresh etag: code = %d body = %s", rec.Code, rec.Body.String())
}
stored := servedETag()
if rec.Header().Get("ETag") != stored {
t.Fatalf("response ETag %q does not name the stored state %q", rec.Header().Get("ETag"), stored)
}
if rec.Header().Get("ETag") == fresh {
t.Fatal("the etag survived an edit")
}
if rec := put("*"); rec.Code != http.StatusOK {
t.Fatalf("star etag: code = %d", rec.Code)
}
if rec := put(""); rec.Code != http.StatusOK {
t.Fatalf("no header: code = %d", rec.Code)
}
}
func TestIfMatchGuardsDelete(t *testing.T) {
f := newFixture(t, map[string]string{"hello.md": helloFile})
_, raw, err := f.tokens.Create("full", nil)
if err != nil {
t.Fatalf("Create: %v", err)
}
auth := "Bearer " + raw
req := httptest.NewRequest(http.MethodDelete, "/api/volumen/posts/hello", nil)
req.Header.Set("Authorization", auth)
req.Header.Set("If-Match", `"0000000000000000"`)
if rec := f.do(t, req); rec.Code != http.StatusPreconditionFailed {
t.Fatalf("stale etag: code = %d", rec.Code)
}
get := httptest.NewRequest(http.MethodGet, "/api/volumen/posts/hello", nil)
fresh := f.do(t, get).Header().Get("ETag")
req = httptest.NewRequest(http.MethodDelete, "/api/volumen/posts/hello", nil)
req.Header.Set("Authorization", auth)
req.Header.Set("If-Match", fresh)
if rec := f.do(t, req); rec.Code != http.StatusNoContent {
t.Fatalf("fresh etag: code = %d", rec.Code)
}
}
func TestWriteEndpointsRequireToken(t *testing.T) {
f := newFixture(t, map[string]string{"hello.md": helloFile})
rec := f.do(t, httptest.NewRequest(http.MethodPost, "/api/volumen/posts", strings.NewReader(`{}`)))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("code = %d", rec.Code)
}
if rec.Header().Get("WWW-Authenticate") != "Bearer" {
t.Fatal("WWW-Authenticate missing")
}
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts", strings.NewReader(`{}`))
req.Header.Set("Authorization", "Bearer vol_bogus")
if rec := f.do(t, req); rec.Code != http.StatusUnauthorized {
t.Fatalf("code = %d", rec.Code)
}
}
func TestWriteEndpointsScopeEnforced(t *testing.T) {
f := newFixture(t, nil)
// A token that carries only the delete scope may not write.
_, raw, err := f.tokens.Create("scoped", []string{"delete"})
if err != nil {
t.Fatalf("Create: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts", strings.NewReader(`{}`))
req.Header.Set("Authorization", "Bearer "+raw)
rec := f.do(t, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("code = %d", rec.Code)
}
body := decodeJSON(t, rec)
if message, _ := body["message"].(string); !strings.Contains(message, "write") {
t.Fatalf("body = %v", body)
}
}
func TestCreateUpdateDeletePost(t *testing.T) {
f := newFixture(t, nil)
_, raw, err := f.tokens.Create("full", nil)
if err != nil {
t.Fatalf("Create: %v", err)
}
// Invalid payload rejected.
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts",
strings.NewReader(`{"slug": "Upper"}`))
req.Header.Set("Authorization", "Bearer "+raw)
rec := f.do(t, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
// Valid create.
req = httptest.NewRequest(http.MethodPost, "/api/volumen/posts",
strings.NewReader(`{"slug": "created", "title": "Created", "body": "hello", "tags": ["go", "blog"]}`))
req.Header.Set("Authorization", "Bearer "+raw)
rec = f.do(t, req)
if rec.Code != http.StatusCreated {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
if decodeJSON(t, rec)["title"] != "Created" {
t.Fatal("wrong payload")
}
if len(f.events) != 1 || f.events[0] != "post.created" {
t.Fatalf("events = %v", f.events)
}
if f.store.Find("created", "") == nil {
t.Fatal("post not saved")
}
// Partial update keeps omitted fields.
req = httptest.NewRequest(http.MethodPut, "/api/volumen/posts/created",
strings.NewReader(`{"title": "Updated"}`))
req.Header.Set("Authorization", "Bearer "+raw)
rec = f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
p := f.store.Find("created", "")
// The body keeps the trailing newline the writer normalises, exactly
// a second round-trip must produce the same document.
if p.Title() != "Updated" || len(p.Tags()) != 2 || p.Body != "hello\n" {
t.Fatalf("title=%q tags=%v body=%q", p.Title(), p.Tags(), p.Body)
}
// Clearing a field with null.
req = httptest.NewRequest(http.MethodPut, "/api/volumen/posts/created",
strings.NewReader(`{"title": null}`))
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
if f.store.Find("created", "").Title() != "" {
t.Fatal("title not cleared")
}
// Malformed types rejected.
req = httptest.NewRequest(http.MethodPut, "/api/volumen/posts/created",
strings.NewReader(`{"draft": "yes"}`))
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusBadRequest {
t.Fatalf("code = %d", rec.Code)
}
// Unknown slug.
req = httptest.NewRequest(http.MethodPut, "/api/volumen/posts/ghost",
strings.NewReader(`{"title": "x"}`))
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
// Invalid JSON body.
req = httptest.NewRequest(http.MethodPost, "/api/volumen/posts", strings.NewReader(`not json`))
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusBadRequest {
t.Fatalf("code = %d", rec.Code)
}
// Delete.
req = httptest.NewRequest(http.MethodDelete, "/api/volumen/posts/created", nil)
req.Header.Set("Authorization", "Bearer "+raw)
rec = f.do(t, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("code = %d", rec.Code)
}
if f.store.Find("created", "") != nil {
t.Fatal("post not deleted")
}
if f.events[len(f.events)-1] != "post.deleted" {
t.Fatalf("events = %v", f.events)
}
req = httptest.NewRequest(http.MethodDelete, "/api/volumen/posts/created", nil)
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
}
func TestUpdateRenameSoftDeletesOldFile(t *testing.T) {
f := newFixture(t, map[string]string{
"old.md": "+++\ntitle = \"Old\"\nslug = \"old\"\n+++\nbody\n",
})
_, raw, err := f.tokens.Create("full", nil)
if err != nil {
t.Fatalf("Create: %v", err)
}
req := httptest.NewRequest(http.MethodPut, "/api/volumen/posts/old",
strings.NewReader(`{"slug": "new-name"}`))
req.Header.Set("Authorization", "Bearer "+raw)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
if f.store.Find("new-name", "") == nil {
t.Fatal("renamed post missing")
}
if f.store.Find("old", "") != nil {
t.Fatal("old slug still resolves")
}
if f.store.TombstonePath("old") == "" {
t.Fatal("old file not soft-deleted")
}
if restored := f.store.Undelete("old"); restored == nil {
t.Fatal("rename not undoable")
}
}
func TestPreviewTokenShape(t *testing.T) {
now := time.Now()
// 32 hex characters plus an expiry stamp, stable for the same slug,
// secret and day.
token := preview.Token("hello", "secret", now)
if len(token) != 32+1+10 {
t.Fatalf("token = %q", token)
}
if token != preview.Token("hello", "secret", now) {
t.Fatal("token not stable")
}
if token == preview.Token("other", "secret", now) {
t.Fatal("token ignores slug")
}
if !preview.Valid(token, "hello", "secret", now) {
t.Fatal("fresh token rejected")
}
if preview.Valid(token, "hello", "secret", now.Add(preview.TTL+time.Hour)) {
t.Fatal("expired token accepted")
}
if preview.Valid(token, "hello", "other", now) {
t.Fatal("token accepted with the wrong key")
}
if preview.Token("hello", "", now) != "" {
t.Fatal("a token was minted without a session key")
}
}
func TestSeriesOrderBooleanRejected(t *testing.T) {
f := newFixture(t, map[string]string{"a.md": "+++\nslug = \"a\"\n+++\nx\n"})
_, raw, err := f.tokens.Create("full", nil)
if err != nil {
t.Fatalf("Create: %v", err)
}
req := httptest.NewRequest(http.MethodPut, "/api/volumen/posts/a",
strings.NewReader(`{"series_order": true}`))
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusBadRequest {
t.Fatalf("code = %d", rec.Code)
}
}
func TestWriteEndpointsKeepRestrictiveCORS(t *testing.T) {
f := newFixture(t, nil)
_, raw, err := f.tokens.Create("full", nil)
if err != nil {
t.Fatalf("Create: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts",
strings.NewReader(`{"slug": "cors-check", "title": "T"}`))
req.Header.Set("Authorization", "Bearer "+raw)
req.Header.Set("Origin", "https://evil.example")
rec := f.do(t, req)
if rec.Code != http.StatusCreated {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://site.example" {
t.Fatalf("allow-origin = %q, want the configured base_url", got)
}
if methods := rec.Header().Get("Access-Control-Allow-Methods"); !strings.Contains(methods, "POST") {
t.Fatalf("allow-methods = %q", methods)
}
}
// An explicit null body clears the stored text, matching the merge
// contract every other field follows.
func TestUpdateClearsBodyWithNull(t *testing.T) {
f := newFixture(t, nil)
_, raw, _ := f.tokens.Create("full", nil)
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts",
strings.NewReader(`{"slug": "body-test", "title": "B", "body": "text"}`))
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusCreated {
t.Fatalf("create code = %d, body = %s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodPut, "/api/volumen/posts/body-test",
strings.NewReader(`{"body": null}`))
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusOK {
t.Fatalf("update code = %d, body = %s", rec.Code, rec.Body.String())
}
// The writer always ends the file with one newline, so an empty
// body reads back as exactly that.
if body := f.store.Find("body-test", "").Body; body != "\n" {
t.Fatalf("body = %q, want cleared", body)
}
}
// A tag list item that is not a string is rejected rather than coerced
// into a made-up tag such as "<nil>".
func TestUpdateRejectsNonStringTags(t *testing.T) {
f := newFixture(t, nil)
_, raw, _ := f.tokens.Create("full", nil)
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts",
strings.NewReader(`{"slug": "tag-test", "title": "T", "body": "x"}`))
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusCreated {
t.Fatalf("create code = %d, body = %s", rec.Code, rec.Body.String())
}
for _, body := range []string{`{"tags": [null]}`, `{"tags": [3]}`, `{"tags": [true]}`} {
req = httptest.NewRequest(http.MethodPut, "/api/volumen/posts/tag-test", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+raw)
rec := f.do(t, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("body %s: code = %d", body, rec.Code)
}
if decodeJSON(t, rec)["error"] != "validation" {
t.Fatalf("body %s: envelope = %s", body, rec.Body.String())
}
}
if tags := f.store.Find("tag-test", "").Tags(); len(tags) != 0 {
t.Fatalf("tags = %v, want untouched", tags)
}
}
// A body over the limit is a 413, not a parse failure.
func TestWriteBodyOverTheLimitIs413(t *testing.T) {
f := newFixture(t, nil)
_, raw, _ := f.tokens.Create("full", nil)
big := strings.Repeat("x", maxWriteBody+1)
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts",
strings.NewReader(`{"slug": "big", "body": "`+big+`"}`))
req.Header.Set("Authorization", "Bearer "+raw)
rec := f.do(t, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
if decodeJSON(t, rec)["error"] != "payload_too_large" {
t.Fatalf("envelope = %s", rec.Body.String())
}
}
// A preview response is not publicly cacheable: the URL is only valid
// with the token, and a shared cache must not keep unpublished content.
func TestPreviewResponseIsNotPubliclyCacheable(t *testing.T) {
f := newFixture(t, map[string]string{"draft.md": draftFile})
token := preview.Token("draft", strings.Repeat("k", 64), time.Now())
req := httptest.NewRequest(http.MethodGet, "/api/volumen/posts/draft?preview_token="+token, nil)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("preview failed: %d %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control = %q, want no-store", got)
}
// The published detail stays publicly cacheable.
f2 := newFixture(t, map[string]string{"hello.md": helloFile})
rec = f2.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/hello", nil))
if got := rec2CacheControl(rec); got == "no-store" {
t.Fatalf("published detail carries %q", got)
}
}
func rec2CacheControl(rec *httptest.ResponseRecorder) string {
return rec.Header().Get("Cache-Control")
}
// Renaming through the API keeps a language that came from the file's
// directory: the new file lands in the same language subtree rather
// than in the content root.
func TestRenameKeepsDirectoryLanguage(t *testing.T) {
f := newFixture(t, map[string]string{
// No lang in the frontmatter: cs comes from the directory.
"cs/hello.md": "+++\ntitle = \"Hello\"\nslug = \"hello\"\n+++\nbody\n",
})
_, raw, _ := f.tokens.Create("full", nil)
req := httptest.NewRequest(http.MethodPut, "/api/volumen/posts/hello",
strings.NewReader(`{"slug": "hi"}`))
req.Header.Set("Authorization", "Bearer "+raw)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("rename code = %d, body = %s", rec.Code, rec.Body.String())
}
renamed := f.store.Find("hi", "")
if renamed == nil {
t.Fatal("renamed post missing")
}
if renamed.Lang() != "cs" {
t.Fatalf("lang = %q, want cs", renamed.Lang())
}
if want := filepath.Join(f.store.ContentDir, "cs", "hi.md"); renamed.Path != want {
t.Fatalf("path = %q, want %q", renamed.Path, want)
}
}
+145
View File
@@ -0,0 +1,145 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package httpapi
import (
"encoding/json"
"flag"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"testing"
)
var updateContract = flag.Bool("update-contract", false, "rewrite the API contract goldens")
// contractRequests pin the public JSON API contract. Any change to a
// response body shows up as a golden diff, which is exactly the point:
// the API serves the front-end, so its shape is a contract.
var contractRequests = map[string]string{
"site": "/api/volumen/site",
"posts": "/api/volumen/posts",
"posts_page2": "/api/volumen/posts?limit=1&page=2",
"posts_cursor": "/api/volumen/posts?cursor=alpha&limit=1",
"posts_filtered": "/api/volumen/posts?tag=go&q=alpha&lang=cs",
"posts_batch": "/api/volumen/posts/batch?slugs=alpha,draft,missing",
"post_detail": "/api/volumen/posts/alpha",
"post_not_found": "/api/volumen/posts/ghost",
"post_draft": "/api/volumen/posts/draft",
"tags": "/api/volumen/tags",
"tag_posts": "/api/volumen/tags/go",
"series": "/api/volumen/series",
"series_detail": "/api/volumen/series/Series",
"feed_json": "/api/volumen/feed.json",
}
const contractPost = `+++
title = "Alpha"
slug = "alpha"
date = 2026-08-18
lang = "cs"
author = "Petr"
tags = ["go", "research"]
series = "Series"
series_order = 1
fediverse_creator = "@petr@social"
cover = "/media/c.webp"
cover_alt = "alt"
cover_caption = "caption"
aliases = ["old-alpha"]
[translations]
en = "alpha-en"
+++
Alpha **body** with a [link](https://example.com).
`
const contractSecond = `+++
title = "Beta"
slug = "beta"
date = 2026-07-01
tags = ["go"]
+++
Beta body.
`
// TestAPIContract snapshots every public JSON response. Regenerate with
// `go test ./internal/httpapi -update-contract` after an intentional
// contract change, and record it in the CHANGELOG.
func TestAPIContract(t *testing.T) {
f := newFixture(t, map[string]string{
"alpha.md": contractPost,
"beta.md": contractSecond,
"draft.md": "+++\nslug = \"draft\"\ntitle = \"Draft\"\ndraft = true\n+++\nDraft body.\n",
})
for name, path := range contractRequests {
t.Run(name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := f.do(t, req)
got := rec.Body.Bytes()
golden := filepath.Join("testdata", "contract", name+".json")
if *updateContract {
if err := os.MkdirAll(filepath.Dir(golden), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(golden, got, 0o644); err != nil {
t.Fatalf("write golden: %v", err)
}
return
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("read golden (run with -update-contract): %v", err)
}
// JSON objects are unordered, so compare parsed values
// rather than bytes; keys and values must match exactly.
var gotJSON, wantJSON any
if err := json.Unmarshal(got, &gotJSON); err != nil {
t.Fatalf("response is not JSON: %v\n%s", err, got)
}
if err := json.Unmarshal(want, &wantJSON); err != nil {
t.Fatalf("golden is not JSON: %v", err)
}
if !reflect.DeepEqual(gotJSON, wantJSON) {
t.Fatalf("contract changed for %s:\n--- got ---\n%s\n--- want ---\n%s",
path, got, want)
}
})
}
}
// TestContractStatusCodes pins the status codes that accompany the
// bodies above.
func TestContractStatusCodes(t *testing.T) {
f := newFixture(t, map[string]string{
"alpha.md": contractPost,
"draft.md": "+++\nslug = \"draft\"\ndraft = true\n+++\nx\n",
})
want := map[string]int{
"/api/volumen/site": http.StatusOK,
"/api/volumen/posts": http.StatusOK,
"/api/volumen/posts/alpha": http.StatusOK,
"/api/volumen/posts/ghost": http.StatusNotFound,
"/api/volumen/posts/draft": http.StatusNotFound,
"/api/volumen/posts/old-alpha": http.StatusMovedPermanently,
"/api/volumen/tags/go": http.StatusOK,
"/api/volumen/tags/none": http.StatusNotFound,
"/api/volumen/series/None": http.StatusNotFound,
"/api/volumen/posts?page=0": http.StatusUnprocessableEntity,
"/api/volumen/posts?limit=101": http.StatusUnprocessableEntity,
"/api/volumen/posts?page=1000001": http.StatusUnprocessableEntity,
}
for path, code := range want {
req := httptest.NewRequest(http.MethodGet, path, nil)
if rec := f.do(t, req); rec.Code != code {
t.Fatalf("%s: code = %d, want %d", path, rec.Code, code)
}
}
}
+1
View File
@@ -0,0 +1 @@
{"version":"https://jsonfeed.org/version/1.1","title":"Volumen","home_page_url":"https://site.example","feed_url":"https://site.example/api/volumen/feed.json","description":"Powered by Volumen.","language":"en","items":[{"id":"https://site.example/alpha","url":"https://site.example/alpha","title":"Alpha","content_html":"<p>Alpha <strong>body</strong> with a <a rel=\"noopener noreferrer\" href=\"https://example.com\">link</a>.</p>\n","summary":"Alpha body with a [link](https://example.com).","date_published":"2026-08-18","tags":["go","research"],"authors":[{"name":"@petr@social"}]},{"id":"https://site.example/beta","url":"https://site.example/beta","title":"Beta","content_html":"<p>Beta body.</p>\n","summary":"Beta body.","date_published":"2026-07-01","tags":["go"]}]}
+1
View File
@@ -0,0 +1 @@
{"slug":"alpha","title":"Alpha","excerpt":"Alpha body with a [link](https://example.com).","date":"2026-08-18","lang":"cs","tags":["go","research"],"author":"Petr","fediverse_creator":"@petr@social","cover":"/media/c.webp","cover_alt":"alt","cover_caption":"caption","reading_time":1,"translations":{"en":"alpha-en"},"series":"Series","series_order":1,"url":"/api/volumen/posts/alpha","body":"Alpha **body** with a [link](https://example.com).\n","html":"<p>Alpha <strong>body</strong> with a <a rel=\"noopener noreferrer\" href=\"https://example.com\">link</a>.</p>\n","toc":"<div class=\"toc\">\n<ul></ul>\n</div>\n","meta":{"url":"https://site.example/alpha","json_ld":"{\"@context\":\"https://schema.org\",\"@type\":\"Article\",\"author\":{\"@type\":\"Person\",\"name\":\"Petr\"},\"creator\":{\"@type\":\"Person\",\"name\":\"@petr@social\"},\"dateModified\":\"2026-08-18\",\"datePublished\":\"2026-08-18\",\"description\":\"Alpha body with a [link](https://example.com).\",\"headline\":\"Alpha\",\"image\":[\"/media/c.webp\"],\"inLanguage\":\"cs\",\"keywords\":[\"go\",\"research\"],\"mainEntityOfPage\":{\"@id\":\"https://site.example/alpha\",\"@type\":\"WebPage\"},\"url\":\"https://site.example/alpha\"}","og":{"article:author":"Petr","article:published_time":"2026-08-18","article:tag":["go","research"],"og:description":"Alpha body with a [link](https://example.com).","og:image":"/media/c.webp","og:locale":"cs","og:title":"Alpha","og:type":"article","og:url":"https://site.example/alpha"},"twitter":{"twitter:card":"summary_large_image","twitter:creator":"@petr@social","twitter:description":"Alpha body with a [link](https://example.com).","twitter:image":"/media/c.webp","twitter:title":"Alpha"}}}
+1
View File
@@ -0,0 +1 @@
{"error":"draft"}
@@ -0,0 +1 @@
{"error":"not_found"}
+1
View File
@@ -0,0 +1 @@
{"page_size":20,"total":2,"posts":[{"slug":"alpha","title":"Alpha","excerpt":"Alpha body with a [link](https://example.com).","date":"2026-08-18","lang":"cs","tags":["go","research"],"author":"Petr","fediverse_creator":"@petr@social","cover":"/media/c.webp","cover_alt":"alt","cover_caption":"caption","reading_time":1,"translations":{"en":"alpha-en"},"series":"Series","series_order":1,"url":"/api/volumen/posts/alpha"},{"slug":"beta","title":"Beta","excerpt":"Beta body.","date":"2026-07-01","lang":"en","tags":["go"],"reading_time":1,"url":"/api/volumen/posts/beta"}],"page":1,"has_next":false,"has_prev":false}
+1
View File
@@ -0,0 +1 @@
{"posts":[{"slug":"alpha","title":"Alpha","excerpt":"Alpha body with a [link](https://example.com).","date":"2026-08-18","lang":"cs","tags":["go","research"],"author":"Petr","fediverse_creator":"@petr@social","cover":"/media/c.webp","cover_alt":"alt","cover_caption":"caption","reading_time":1,"translations":{"en":"alpha-en"},"series":"Series","series_order":1,"url":"/api/volumen/posts/alpha","body":"Alpha **body** with a [link](https://example.com).\n","html":"<p>Alpha <strong>body</strong> with a <a rel=\"noopener noreferrer\" href=\"https://example.com\">link</a>.</p>\n","toc":"<div class=\"toc\">\n<ul></ul>\n</div>\n","meta":{"url":"https://site.example/alpha","json_ld":"{\"@context\":\"https://schema.org\",\"@type\":\"Article\",\"author\":{\"@type\":\"Person\",\"name\":\"Petr\"},\"creator\":{\"@type\":\"Person\",\"name\":\"@petr@social\"},\"dateModified\":\"2026-08-18\",\"datePublished\":\"2026-08-18\",\"description\":\"Alpha body with a [link](https://example.com).\",\"headline\":\"Alpha\",\"image\":[\"/media/c.webp\"],\"inLanguage\":\"cs\",\"keywords\":[\"go\",\"research\"],\"mainEntityOfPage\":{\"@id\":\"https://site.example/alpha\",\"@type\":\"WebPage\"},\"url\":\"https://site.example/alpha\"}","og":{"article:author":"Petr","article:published_time":"2026-08-18","article:tag":["go","research"],"og:description":"Alpha body with a [link](https://example.com).","og:image":"/media/c.webp","og:locale":"cs","og:title":"Alpha","og:type":"article","og:url":"https://site.example/alpha"},"twitter":{"twitter:card":"summary_large_image","twitter:creator":"@petr@social","twitter:description":"Alpha body with a [link](https://example.com).","twitter:image":"/media/c.webp","twitter:title":"Alpha"}}}]}
+1
View File
@@ -0,0 +1 @@
{"page_size":1,"total":2,"posts":[{"slug":"beta","title":"Beta","excerpt":"Beta body.","date":"2026-07-01","lang":"en","tags":["go"],"reading_time":1,"url":"/api/volumen/posts/beta"}],"next_cursor":null}
@@ -0,0 +1 @@
{"page_size":20,"total":1,"posts":[{"slug":"alpha","title":"Alpha","excerpt":"Alpha body with a [link](https://example.com).","date":"2026-08-18","lang":"cs","tags":["go","research"],"author":"Petr","fediverse_creator":"@petr@social","cover":"/media/c.webp","cover_alt":"alt","cover_caption":"caption","reading_time":1,"translations":{"en":"alpha-en"},"series":"Series","series_order":1,"url":"/api/volumen/posts/alpha"}],"page":1,"has_next":false,"has_prev":false}
+1
View File
@@ -0,0 +1 @@
{"page_size":1,"total":2,"posts":[{"slug":"beta","title":"Beta","excerpt":"Beta body.","date":"2026-07-01","lang":"en","tags":["go"],"reading_time":1,"url":"/api/volumen/posts/beta"}],"page":2,"has_next":false,"has_prev":true}
+1
View File
@@ -0,0 +1 @@
{"series":[{"name":"Series","count":1}]}
+1
View File
@@ -0,0 +1 @@
{"name":"Series","count":1,"posts":[{"slug":"alpha","title":"Alpha","excerpt":"Alpha body with a [link](https://example.com).","date":"2026-08-18","lang":"cs","tags":["go","research"],"author":"Petr","fediverse_creator":"@petr@social","cover":"/media/c.webp","cover_alt":"alt","cover_caption":"caption","reading_time":1,"translations":{"en":"alpha-en"},"series":"Series","series_order":1,"url":"/api/volumen/posts/alpha"}]}
+1
View File
@@ -0,0 +1 @@
{"title":"Volumen","description":"Powered by Volumen.","base_url":"https://site.example","language":"en","author":"Anonymous","fediverse_creator":""}
+1
View File
@@ -0,0 +1 @@
{"page_size":20,"total":2,"posts":[{"slug":"alpha","title":"Alpha","excerpt":"Alpha body with a [link](https://example.com).","date":"2026-08-18","lang":"cs","tags":["go","research"],"author":"Petr","fediverse_creator":"@petr@social","cover":"/media/c.webp","cover_alt":"alt","cover_caption":"caption","reading_time":1,"translations":{"en":"alpha-en"},"series":"Series","series_order":1,"url":"/api/volumen/posts/alpha"},{"slug":"beta","title":"Beta","excerpt":"Beta body.","date":"2026-07-01","lang":"en","tags":["go"],"reading_time":1,"url":"/api/volumen/posts/beta"}],"page":1,"has_next":false,"has_prev":false}
+1
View File
@@ -0,0 +1 @@
{"tags":[{"name":"go","count":2},{"name":"research","count":1}]}
+831
View File
@@ -0,0 +1,831 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package i18n holds the admin interface strings in every language the
// UI ships. Simple messages are keyed by their English text, so the
// template keeps its meaning and an untranslated key falls back to
// English rather than an identifier. Messages with a numeral use an
// explicit id and per-language plural forms, because Czech declines
// the counted noun where English does not.
//
// The catalogue never stores markup: sentences with <code>, <strong> and
// friends stay in the template, split into fragments, so every string
// the funcs return can take the template engine's escaping. The one
// exception is trh, for static sentences that only make sense whole.
//
// Some keys never appear literally outside this file: the editor
// matches payloads validation errors against the catalogue at runtime
// ("Slug is required." and friends), and the page scripts read their
// strings from the JSON dump, so a key being absent from the templates
// does not mean it is dead.
package i18n
import (
"slices"
"strconv"
"strings"
)
// Languages the admin interface ships, in the order the settings
// switch lists them.
var Languages = []string{"en", "cs"}
// Cookie carries the interface language across the login screen, where
// no account is known yet.
const Cookie = "volumen_admin_lang"
// Valid reports whether the value is a shipped language.
func Valid(lang string) bool {
return slices.Contains(Languages, lang)
}
// Normalize maps a language tag, which may carry a region ("cs-CZ"),
// onto a shipped language, or "" when nothing matches.
func Normalize(tag string) string {
tag = strings.ToLower(strings.TrimSpace(tag))
for _, l := range Languages {
if tag == l || strings.HasPrefix(tag, l+"-") {
return l
}
}
return ""
}
// pluralSet holds the counted forms of one message: one for exactly
// one, few for the Czech "2 to 4" class (22 to 24 included), other for
// the rest. English never uses few.
type pluralSet struct {
one, few, other string
}
// pluralForms are the messages that carry a numeral. Every language
// defines one and other; few exists for Czech alone.
var pluralForms = map[string]map[string]pluralSet{
"posts.total": {
"en": {one: "%d post in total", other: "%d posts in total"},
"cs": {one: "Celkem %d publikace", few: "Celkem %d publikace", other: "Celkem %d publikací"},
},
"media.count": {
"en": {one: "%d file uploaded", other: "%d files uploaded"},
"cs": {one: "Nahraný %d soubor", few: "Nahrané %d soubory", other: "Nahraných %d souborů"},
},
"revisions.count": {
"en": {one: "%d archived version", other: "%d archived versions"},
"cs": {one: "Archivovaná %d verze", few: "Archivované %d verze", other: "Archivovaných %d verzí"},
},
"posts.published": {
"en": {one: "%d post published.", other: "%d posts published."},
"cs": {one: "Zveřejněna %d publikace.", few: "Zveřejněny %d publikace.", other: "Zveřejněno %d publikací."},
},
"posts.drafted": {
"en": {one: "%d post moved to draft.", other: "%d posts moved to draft."},
"cs": {one: "Přesunuta %d publikace do konceptu.", few: "Přesunuty %d publikace do konceptu.", other: "Přesunuto %d publikací do konceptu."},
},
"posts.deleted": {
"en": {one: "%d post deleted.", other: "%d posts deleted."},
"cs": {one: "Smazána %d publikace.", few: "Smazány %d publikace.", other: "Smazáno %d publikací."},
},
"diff.unchanged": {
"en": {one: "%d unchanged line", other: "%d unchanged lines"},
"cs": {one: "%d nezměněný řádek", few: "%d nezměněné řádky", other: "%d nezměněných řádků"},
},
"password.min": {
"en": {one: "Password must be at least %d character long.", other: "Password must be at least %d characters long."},
"cs": {one: "Heslo musí mít nejméně %d znak.", few: "Heslo musí mít nejméně %d znaky.", other: "Heslo musí mít nejméně %d znaků."},
},
"password.max": {
"en": {one: "Password must be at most %d character long.", other: "Password must be at most %d characters long."},
"cs": {one: "Heslo musí mít nejvýše %d znak.", few: "Heslo musí mít nejvýše %d znaky.", other: "Heslo musí mít nejvýše %d znaků."},
},
"editor.words": {
"en": {one: "%d word", other: "%d words"},
"cs": {one: "%d slovo", few: "%d slova", other: "%d slov"},
},
"refs.count": {
"en": {one: "%d reference", other: "%d references"},
"cs": {one: "%d reference", few: "%d reference", other: "%d referencí"},
},
"editor.chars": {
"en": {one: "%d char", other: "%d chars"},
"cs": {one: "%d znak", few: "%d znaky", other: "%d znaků"},
},
"editor.reading": {
"en": {one: "%d min read", other: "%d min read"},
"cs": {one: "%d min čtení", few: "%d min čtení", other: "%d min čtení"},
},
"bulk.selected": {
"en": {one: "%d selected", other: "%d selected"},
"cs": {one: "Vybráno %d", few: "Vybráno %d", other: "Vybráno %d"},
},
"login.seconds": {
"en": {one: "Too many login attempts. Try again in %d second.", other: "Too many login attempts. Try again in %d seconds."},
"cs": {one: "Příliš mnoho pokusů o přihlášení. Zkuste to znovu za %d s.", few: "Příliš mnoho pokusů o přihlášení. Zkuste to znovu za %d s.", other: "Příliš mnoho pokusů o přihlášení. Zkuste to znovu za %d s."},
},
"deliveries.attempts": {
"en": {one: "failed (%d attempt)", other: "failed (%d attempts)"},
"cs": {one: "selhalo (%d pokus)", few: "selhalo (%d pokusy)", other: "selhalo (%d pokusů)"},
},
"posts.count": {
"en": {one: "%d post", other: "%d posts"},
"cs": {one: "%d publikace", few: "%d publikace", other: "%d publikací"},
},
"backup.files": {
"en": {one: "Backup restored (%d file).", other: "Backup restored (%d files)."},
"cs": {one: "Záloha obnovena (%d soubor).", few: "Záloha obnovena (%d soubory).", other: "Záloha obnovena (%d souborů)."},
},
}
// cs translates the simple messages, keyed by the English text the
// templates and handlers use. Markup stays out: where the template
// carries <code> or <strong>, the sentence is split into fragments.
var cs = map[string]string{
// Layout: sidebar, topbar, mobile sheet, shared dialogs.
"Content": "Obsah",
"Configure": "Nastavení",
"Posts": "Publikace",
"New post": "Nová publikace",
"Import": "Import",
"Media": "Média",
"Settings": "Nastavení",
"Log out": "Odhlásit se",
"Menu": "Nabídka",
"Main menu": "Hlavní nabídka",
"Collapse sidebar": "Sbalit postranní panel",
"Expand sidebar": "Rozbalit postranní panel",
"administration": "administrace",
"Skip to content": "Přejít na obsah",
"Breadcrumb": "Navigace",
"Colour mode": "Režim barev",
"Follow system": "Podle systému",
"Light": "Světlý",
"Dark": "Tmavý",
"Update now": "Aktualizovat",
"Details": "Podrobnosti",
"is available; you are running": "je k dispozici; provozujete",
"Update to %s now?": "Aktualizovat na %s hned?",
"The server will restart.": "Server se restartuje.",
"Are you sure?": "Opravdu?",
"Continue": "Pokračovat",
"URL": "URL",
"OK": "Budiž",
// The admin 404 page.
"Page not found": "Stránka nenalezena",
"The address does not exist; it may have been moved or deleted.": "Tato adresa neexistuje; možná byla přesunuta nebo zrušena.",
"Back to dashboard": "Zpět na přehled",
// Login page.
"Volumen · administration": "Volumen · administrace",
"Sign in": "Přihlásit se",
"Welcome back. Sign in to manage your posts.": "Vítejte zpět. Přihlaste se ke správě publikací.",
"Username": "Uživatel",
"Password": "Heslo",
"Show password": "Zobrazit heslo",
"Hide password": "Skrýt heslo",
"Invalid username or password.": "Neplatné uživatelské jméno nebo heslo.",
"Auto-unlock in {} s.": "Odemčení za {} s.",
" You can try again now.": " Můžete to zkusit znovu.",
// First-run wizard.
"Welcome to Volumen": "Vítejte ve Volumenu",
"Set up Volumen": "Nastavení Volumenu",
"Set up the administrator account to open this installation.": "Nastavte účet správce, tím se instalace otevře.",
"The page takes the colours as you choose.": "Stránka mění barvy podle vaší volby.",
"Create account": "Vytvořit účet",
"The users file cannot be read; repair it before setting up.": "Soubor uživatelů nelze přečíst; před nastavením ho opravte.",
"The account could not be created: %s": "Účet se nepodařilo vytvořit: %s",
"Weak": "Slabé",
"Fair": "Dostatečné",
"Good": "Dobré",
"Strong": "Silné",
// Posts list.
"Published": "Publikované",
"Drafts": "Koncepty",
"Scheduled": "Naplánované",
"Total": "Celkem",
"Recent published": "Nedávno publikované",
"Search title, slug, tag...": "Hledat v titulku, slagu, značce…",
"Select all posts": "Vybrat všechny publikace",
"All": "Vše",
"Filter by status": "Filtrovat podle stavu",
"Filter by tag": "Filtrovat podle značky",
"Publish": "Publikovat",
"Move to draft": "Přesunout do konceptu",
"Delete": "Smazat",
"Cancel": "Zrušit",
"Two-factor authentication": "Dvoufázové přihlášení",
"Two-factor code": "Kód dvoufázového přihlášení",
"The password is accepted; answer the second question.": "Heslo přijato; odpovězte na druhou otázku.",
"Verification code": "Ověřovací kód",
"six digits, or a recovery code": "šest číslic, nebo záložní kód",
"Six digits from your application, or one of your recovery codes.": "Šest číslic z vaší aplikace, nebo jeden ze záložních kódů.",
"Back to sign in": "Zpět na přihlášení",
"Your sign-in asks for a code from your application after the password.": "Po přihlášení heslem se žádá ještě kód z vaší aplikace.",
"Current code": "Současný kód",
"Regenerate recovery codes": "Vygenerovat záložní kódy znovu",
"Turn two-factor off? Your application will no longer be asked.": "Vypnout dvoufázové přihlášení? Vaše aplikace se už nebude dotazovat.",
"Turn off": "Vypnout",
"Scan the code with your authenticator application, or enter the secret by hand, then confirm with the code it shows.": "Naskenujte kód svou aplikací pro ověřování, nebo zadejte tajný klíč ručně, a potvrďte kódem, který ukáže.",
"Secret": "Tajný klíč",
"Verify and enable": "Ověřit a zapnout",
"Ask for a code from your authenticator application after the password. Voluntary: nothing changes until you finish the setup.": "Po hesle se bude žádat o kód z vaší ověřovací aplikace. Dobrovolné: nic se nezmění, dokud nastavení nedokončíte.",
"Set up two-factor": "Nastavit dvoufázové přihlášení",
"Wrong or expired code.": "Nesprávný nebo propadlý kód.",
"That code did not match; start again.": "Kód nesouhlasil; začněte znovu.",
"Two-factor is already on.": "Dvoufázové přihlášení už je zapnuté.",
"Two-factor could not be enabled: %s": "Dvoufázové přihlášení nešlo zapnout: %s",
"Two-factor could not be disabled: %s": "Dvoufázové přihlášení nešlo vypnout: %s",
"The codes could not be replaced: %s": "Záložní kódy nešlo vyměnit: %s",
"Two-factor is off.": "Dvoufázové přihlášení je vypnuté.",
"Two-factor is on. Store these recovery codes now; they will not be shown again.": "Dvoufázové přihlášení je zapnuté. Uschovejte si tyto záložní kódy; znovu se neukážou.",
"New recovery codes. Store them now; they will not be shown again.": "Nové záložní kódy. Uschovejte si je; znovu se neukážou.",
"No posts yet": "Zatím žádné publikace",
"Create your first post to get started.": "Začněte vytvořením první publikace.",
"Create post": "Vytvořit publikaci",
"No posts match your search": "Žádná publikace neodpovídá hledání",
"Try a different search term or status filter.": "Zkuste jiné hledané slovo nebo jiný filtr stavu.",
"Clear filters": "Vymazat filtry",
"Draft": "Koncept",
"Edit": "Upravit",
"Delete post": "Smazat publikaci",
"Delete %s?": "Smazat %s?",
"Post created.": "Publikace vytvořena.",
"Post saved.": "Publikace uložena.",
"Post deleted.": "Publikace smazána.",
"Post duplicated.": "Publikace zkopírována.",
"Post restored.": "Publikace obnovena.",
"Could not restore the post; it may already be back.": "Publikaci se nepodařilo obnovit; možná už je zpět.",
"The post could not be duplicated.": "Publikaci se nepodařilo zdvojit.",
"Undo": "Zpět",
"Undo failed.": "Obnovení se nezdařilo.",
"Delete the selected posts?": "Smazat vybrané publikace?",
"Delete this post?": "Smazat tuto publikaci?",
// Editor.
"Draft a new article in Markdown.": "Napište nový článek v Markdownu.",
"Edit post": "Úprava publikace",
"Update and publish this post.": "Upravte a zveřejněte tuto publikaci.",
"Draft, not visible to the public": "Koncept, veřejnosti nezobrazovaný",
"Scheduled for %s": "Naplánováno na %s",
"Scheduled for later": "Naplánováno na později",
"Preview": "Náhled",
"Download": "Stáhnout",
"History": "Historie",
"Duplicate": "Zkopírovat",
"Duplicate as draft": "Zkopírovat jako koncept",
"Duplicate this post as a draft?": "Zkopírovat tuto publikaci jako koncept?",
"Save": "Uložit",
"Save (Ctrl+S)": "Uložit (Ctrl+S)",
"Revision restored. The previous content was archived; see": "Verze obnovena. Předchozí obsah byl archivován; viz",
"Duplicated as draft. The new slug is": "Zkopírováno jako koncept. Nový slug je",
", adjust the title and body, then publish.": ", upravte titulek a obsah a publikujte.",
"Template": "Šablona",
"Blank": "Prázdná",
"Restore": "Obnovit",
"Discard": "Zahodit",
"Title, slug, and publishing options": "Titulek, slug a volby publikování",
"Title": "Titulek",
"A clear, descriptive title": "Výstižný, popisný titulek",
"Slug": "Slug",
"my-post-slug": "moje-publikace",
"Language": "Jazyk",
"Author": "Autor",
"Author name": "Jméno autora",
"Fediverse creator": "Autor na fediverse",
"@user@instance.tld": "@uzivatel@instance.tld",
"DOI": "DOI",
"Author ORCID": "ORCID autora",
"10.5281/zenodo.1234567": "10.5281/zenodo.1234567",
"0000-0002-1825-0097": "0000-0002-1825-0097",
"Digital Object Identifier; a doi.org URL is stored as the bare 10.… form.": "Digital Object Identifier; URL na doi.org se uloží jako holý tvar 10.…",
"The author's Open Researcher and Contributor ID, checked against its own digit.": "Open Researcher and Contributor ID autora, kontroluje se i kontrolní číslice.",
"DOI must look like 10.xxxx/suffix.": "DOI musí mít tvar 10.xxxx/sufix.",
"Bibliography": "Bibliografie",
"The numbered reference list; cite entries as [n] in the body": "Číslovaný seznam literatury; v textu citujte jako [n]",
"No references yet.": "Zatím žádné reference.",
"Add reference": "Přidat referenci",
"Insert [[refs]] marker into the body": "Vložit do textu značku [[refs]]",
"Fill the verbatim citation line, or the structured fields; the marker places the list in the body and without it the list is appended.": "Vyplňte doslovný citovaný řádek nebo strukturovaná pole; značka umístí seznam do textu a bez něj se seznam připojí na konec.",
"Move up": "Posunout výš",
"Move down": "Posunout níž",
"Remove reference": "Odebrat referenci",
"Verbatim citation": "Doslovná citace",
"Structured fields": "Strukturovaná pole",
"One author per line, optionally with [orcid:0000-0002-1825-0097]": "Každý autor na vlastním řádku, volitelně s [orcid:0000-0002-1825-0097]",
"Authors": "Autoři",
"Journal or proceedings": "Časopis nebo sborník",
"Venue": "Místo publikace",
"Year": "Rok",
"Volume": "Ročník",
"Pages": "Strany",
"arXiv": "arXiv",
"Author, title, venue, year": "Autor, titul, kde vyšlo, rok",
"Publish date": "Datum publikace",
"Schedule for": "Naplánovat na",
"Pick a date": "Vyberte datum",
"Leave empty to publish immediately. Date is interpreted as your local timezone.": "Necháte-li prázdné, publikuje se hned. Datum se interpretuje v místním časovém pásmu.",
"Markdown": "Markdown",
"Visual": "Vizuální",
"Tags": "Značky",
"comma, separated": "čárkami, oddělené",
"Series": "Série",
"e.g. rust-tutorial": "např. rust-tutorial",
"Group multi-part posts; leave empty for standalone posts": "Sdružuje díly publikací; pro samostatné nechte prázdné",
"Part number": "Číslo dílu",
"Order within the series": "Pořadí v sérii",
"Excerpt": "Perex",
"Auto-generated from the first paragraph if left empty": "Nevyplněno: vygeneruje se z prvního odstavce",
"Available in all languages": "Dostupné ve všech jazycích",
"Cover image": "Úvodní obrázek",
"Header image shown on the article page": "Obrázek v záhlaví stránky článku",
"/media/… or https://…": "/media/… nebo https://…",
"Upload": "Nahrát",
"Clear": "Vymazat",
"Today": "Dnes",
"ALT text (for accessibility)": "ALT text (pro přístupnost)",
"Caption (e.g. 'Generated by AI')": "Popisek (např. „Vygenerováno AI“)",
"Write your post in Markdown…": "Pište publikaci v Markdownu…",
"Drop image to upload": "Pusťte obrázek a nahrajte",
"Bold (Ctrl+B)": "Tučně (Ctrl+B)",
"Italic (Ctrl+I)": "Kurzíva (Ctrl+I)",
"Heading 2": "Nadpis 2",
"Heading 3": "Nadpis 3",
"Link (Ctrl+K)": "Odkaz (Ctrl+K)",
"Bullet list": "Odrážky",
"Numbered list": "Číslovaný seznam",
"Quote": "Citace",
"Code": "Kód",
"Image": "Obrázek",
"Toggle preview": "Přepnout náhled",
"✓ saved": "✓ uloženo",
"Keyboard shortcuts": "Klávesové zkratky",
"Bold": "Tučně",
"Italic": "Kurzíva",
"Link": "Odkaz",
"Save post": "Uložit publikaci",
"Show this help": "Zobrazit tuto nápovědu",
"Close modal": "Zavřít okno",
"Editor": "Editor",
"Unsaved changes from %s are available.": "K dispozici jsou neuložené změny z %s.",
"Slug is available.": "Slug je volný.",
"Already used by “%s”.": "Už ho používá „%s“.",
"Will be published immediately (date is today or earlier).": "Publikuje se okamžitě (datum je dnešní nebo dřívější).",
"Link URL": "Adresa odkazu",
"Only WebP, AVIF and SVG images are accepted (got “%s”).": "Přijímají se jen obrázky WebP, AVIF a SVG (obdrženo „%s“).",
"Upload failed:\n\n%s": "Nahrání selhalo:\n\n%s",
// History.
"Back to editor": "Zpět do editoru",
"No revisions yet": "Zatím žádné verze",
"Every time this post is saved, the previous version is archived here.": "Při každém uložení publikace se předchozí verze archivuje tady.",
"Saved": "Uloženo",
"Size": "Velikost",
"Actions": "Akce",
"Restore this revision? The current content will be archived first.": "Obnovit tuto verzi? Současný obsah se nejdřív archivuje.",
"Compare": "Porovnat",
"Back to history": "Zpět do historie",
"Changes": "Změny",
"Archived version": "Archivovaná verze",
"compared with the current content.": "srovnáno se současným obsahem.",
"No differences from the current content.": "Žádné rozdíly oproti současnému obsahu.",
"removed": "odebráno",
"added": "přidáno",
"Upcoming": "Nadcházející",
// Import.
"Import post": "Import publikace",
"frontmatter": "hlavička",
"Bring a post written anywhere: a Markdown file with optional TOML frontmatter.": "Přineste publikaci psanou kdekoliv: soubor Markdown s volitelnou TOML hlavičkou.",
"Drop your post here": "Pusťte sem svou publikaci",
"or click to browse": "nebo klikněte a vyberte",
"Remove": "Odebrat",
"No frontmatter found; the slug will be derived from the file name.": "Hlavička nenalezena; slug se odvodí z názvu souboru.",
"Only .md files are accepted, got “%s”": "Přijímají se jen soubory .md, obdrženo „%s“",
// Media.
"No media yet": "Zatím žádná média",
"Upload an image to use as a cover or inline content. WebP, AVIF and SVG are supported.": "Nahrajte obrázek jako úvodní nebo do obsahu. Podporovány jsou WebP, AVIF a SVG.",
"You can also paste or drop an image directly into the editor body.": "Obrázek můžete také vložit nebo přetáhnout přímo do těla editoru.",
"Upload images": "Nahrát obrázky",
"Drop images here, or click to browse": "Pusťte sem obrázky, nebo klikněte a vyberte",
"WebP, AVIF or SVG, up to 10 MB each.": "WebP, AVIF nebo SVG, každý nejvýše 10 MB.",
"Search files...": "Hledat soubory…",
"No files match your search.": "Žádný soubor neodpovídá hledání.",
"Copy link": "Kopírovat odkaz",
"Open in new tab": "Otevřít v nové kartě",
"Link copied.": "Odkaz zkopírován.",
"Copy the link:": "Zkopírujte odkaz:",
"Upload failed: %s": "Nahrání selhalo: %s",
"Upload complete.": "Nahrání dokončeno.",
"Uploading %s…": "Nahrávám %s…",
// Settings, shared.
"Manage your account and users": "Spravujte svůj účet a uživatele",
"Account": "Účet",
"Users": "Uživatelé",
"Templates": "Šablony",
"Backup": "Záloha",
"Version": "Verze",
"Webhooks": "Webhooky",
"API tokens": "API tokeny",
"Signed in as": "Přihlášen jako",
// Account section.
"Profile photo": "Profilová fotka",
"WebP, AVIF or SVG, max 10 MB. Shown in the sidebar.": "WebP, AVIF nebo SVG, nejvýše 10 MB. Zobrazuje se v postranní liště.",
"Identity": "Identita",
"Display name": "Zobrazované jméno",
"Your real name": "Vaše skutečné jméno",
"Save name": "Uložit jméno",
"Fediverse handle": "Fediverse účet",
"Save handle": "Uložit účet",
"Security": "Zabezpečení",
"Change your password and username. You can also lock yourself out.": "Změňte heslo i uživatelské jméno. Můžete si i sami zablokovat přístup.",
"Current password": "Současné heslo",
"New password": "Nové heslo",
"Update password": "Změnit heslo",
"Update username": "Změnit uživatele",
"Language version": "Jazyková verze",
"The interface language of your account. The login screen follows your last choice.": "Jazyk rozhraní vašeho účtu. Přihlašovací stránka se řídí poslední volbou.",
"Unsupported language.": "Nepodporovaný jazyk.",
"The language could not be saved: %s": "Jazyk nešlo uložit: %s",
"Colour scheme": "Barevné schéma",
"The colour scheme of your account. The login screen follows your last choice.": "Barevné schéma vašeho účtu. Přihlašovací stránka se řídí poslední volbou.",
"Unsupported colour scheme.": "Nepodporované barevné schéma.",
"The colour scheme could not be saved: %s": "Barevné schéma nešlo uložit: %s",
"The colour scheme is set.": "Barevné schéma je nastaveno.",
// Users section.
"Add or remove admin and editor accounts": "Přidávejte a odstraňujte účty správců a autorů",
"you": "vy",
"Remove this user?": "Odebrat tohoto uživatele?",
"Add user": "Přidat uživatele",
"Role": "Role",
"jane-doe": "jana-novakova",
// Templates section.
"Post templates": "Šablony publikací",
"Pre-fill new posts with a reusable structure": "Předvyplňují nové publikace opakovaně použitelnou strukturou",
"No templates yet. Templates pre-fill the new-post form so you can keep your favourite structure on hand.": "Zatím žádné šablony. Předvyplňují formulář nové publikace, abyste měli svou oblíbenou strukturu po ruce.",
"Name": "Název",
"Fields to pre-fill": "Předvyplněná pole",
"Extra editor inputs as TOML key = value lines, one per line, strings quoted: author, lang, doi, orcid, series, series_order, cover, excerpt.": "Doplňující hodnoty editoru jako řádky TOML key = value, jeden na řádek, řetězce v uvozovkách: author, lang, doi, orcid, series, series_order, cover, excerpt.",
"Template fields must be key = value TOML lines.": "Pole šablony musí být řádky TOML key = value.",
"Unknown template field %s.": "Neznámé pole šablony %s.",
"Default title": "Výchozí titulek",
"Optional": "Volitelné",
"Default slug": "Výchozí slug",
"Tags (comma-separated)": "Značky (oddělené čárkou)",
"Body template (Markdown)": "Šablona obsahu (Markdown)",
"e.g. Review": "např. Recenze",
"Add template": "Přidat šablonu",
// Backup section.
"Backup & restore": "Záloha a obnova",
"Export or import all posts, media, users, and templates": "Exportujte nebo importujte všechny publikace, média, uživatele i šablony",
"Export": "Export",
"Download backup": "Stáhnout zálohu",
"Restore from a previously exported archive. Overwrites existing data.": "Obnova z dříve exportovaného archivu. Přepíše existující data.",
"This will overwrite existing posts and users. Continue?": "Tím se přepíšou existující publikace i uživatelé. Pokračovat?",
"Restore backup": "Obnovit ze zálohy",
// Version section.
"Keep Volumen up to date": "Udržujte Volumen aktuální",
"Installed version": "Nainstalovaná verze",
"Running": "Provozuji",
"latest": "nejnovější",
"Update to %s": "Aktualizovat na %s",
"Check for updates": "Zkontrolovat aktualizace",
// Webhooks section.
"Notify external services when posts change": "Upozorněte externí služby na změny publikací",
"Endpoints": "Koncové body",
"Signed JSON payloads are POSTed on post changes. Changes apply without a restart.": "Podepsané zprávy JSON se posílají při změnách publikací. Změny se projeví bez restartu.",
"Events": "Události",
"Send test": "Poslat test",
"disabled": "vypnuto",
"signed": "podepsáno",
"from config": "z konfigurace",
"Add webhook": "Přidat webhook",
"Endpoint URL": "URL koncového bodu",
"Signing secret (optional)": "Podpisový tajný klíč (nepovinný)",
"Events (comma-separated, empty for all)": "Události (oddělené čárkou, prázdné znamená všechny)",
"Enabled": "Zapnuto",
"Add": "Přidat",
"Enable": "Zapnout",
"Disable": "Vypnout",
"Remove this webhook?": "Odebrat tento webhook?",
"Webhook added.": "Webhook přidán.",
"Webhook updated.": "Webhook uložen.",
"Webhook removed.": "Webhook odebrán.",
"That URL is not a valid http(s) endpoint.": "Tato URL není platný koncový bod http(s).",
"That URL is already configured.": "Tato URL už je nastavená.",
"The webhook store could not be read: %s": "Soubor webhooků se nepodařilo přečíst: %s",
"The webhook could not be saved: %s": "Webhook se nepodařilo uložit: %s",
"Recent deliveries": "Nedávná doručení",
"Kept in memory; cleared on restart.": "Uchovaná v paměti; po restartu smazána.",
"When": "Kdy",
"Event": "Událost",
"Endpoint": "Kam",
"Result": "Výsledek",
// Tokens section.
"Programmatic write access to the public API": "Programový zápis do veřejného API",
"Access tokens": "Přístupové tokeny",
"Copy this token now; it will never be shown again.": "Token si zkopírujte hned; znovu se nezobrazí.",
"No tokens yet. Create one to publish posts from scripts or CI.": "Zatím žádné tokeny. Vytvořte si jeden pro publikování ze skriptů nebo CI.",
"Created": "Vytvořeno",
"Last used": "Naposledy",
"Revoke token “%s”? Scripts using it will stop working.": "Odvolat token „%s“? Skripty, které ho používají, přestanou fungovat.",
"Revoke": "Odvolat",
"Token name": "Název tokenu",
"e.g. deploy-script": "např. deploy-script",
"Scopes": "Oprávnění",
"Write": "Zápis",
"Write creates and edits posts; delete removes them. Read endpoints are public, so no scope is needed for them. Selecting neither grants full access.": "Zápis vytváří a upravuje publikace; mazání je odstraňuje. Čtení je veřejné, oprávnění nepotřebuje. Bez výběru platí plný přístup.",
"Create token": "Vytvořit token",
// Update page.
"The server is restarting. This page will reload automatically.": "Server se restartuje. Tato stránka se obnoví sama.",
"Server is back; reloading.": "Server je zpět; obnovuji.",
"The server did not come back within 3 minutes. Check the service manually.": "Server se do 3 minut nevrátil. Zkontrolujte službu ručně.",
// Date picker.
"Previous month": "Předchozí měsíc",
"Next month": "Další měsíc",
// Editor page actions and crumbs.
"Select %s": "Vybrat: %s",
"Updated to": "Aktualizováno na",
"Short summary for listings and previews": "Krátké shrnutí pro výčty a náhledy",
"Volumen admin": "Volumen administrace",
// Editor crumbs and roles.
"Untitled": "Bez názvu",
"%d of %d": "%d z %d",
"admin": "administrátor",
"author": "autor",
"date must be an ISO 8601 date.": "Datum musí být ve tvaru ISO 8601 (RRRR-MM-DD).",
"publish_at must be an ISO 8601 date.": "Datum publikace musí být ve tvaru ISO 8601 (RRRR-MM-DD).",
// Go handler messages: posts.
"No file selected.": "Nevybrán žádný soubor.",
"No file was uploaded.": "Nevybrán žádný soubor.",
"The upload could not be stored.": "Nahraný soubor nešlo uložit.",
"Only .md files are accepted.": "Přijímají se jen soubory .md.",
"File is too large.": "Soubor je příliš velký.",
"The file could not be read as a post: %s": "Soubor nešlo přečíst jako publikaci: %s",
"Import failed.": "Import selhal.",
"The file could not be read (limit %s bytes).": "Soubor nešlo přečíst (limit %s bajtů).",
"Only WebP, AVIF and SVG images are supported.": "Podporovány jsou jen obrázky WebP, AVIF a SVG.",
"The post could not be saved: %s": "Publikaci nešlo uložit: %s",
"The post could not be deleted: %s": "Publikaci nešlo smazat: %s",
"Slug is required.": "Slug je povinný.",
"Invalid slug.": "Neplatný slug.",
"Invalid language.": "Neplatný jazyk.",
"A post with that slug already exists.": "Publikace s tímto slugem už existuje.",
"Fediverse creator must look like @user@host.": "Autor fediverse musí mít tvar @uzivatel@hostitel.",
// Go handler messages: login and security.
"Invalid CSRF token": "Neplatný CSRF token",
// Go handler messages: settings, account.
"Current password is incorrect.": "Současné heslo není správné.",
"New password cannot be empty.": "Nové heslo nesmí být prázdné.",
"The new password could not be saved: %s": "Nové heslo nešlo uložit: %s",
"Password updated.": "Heslo změněno.",
"Username cannot be empty.": "Uživatelské jméno nesmí být prázdné.",
"Username may use letters, numbers, dot, dash, underscore.": "Uživatelské jméno smí používat písmena, čísla, tečku, pomlčku a podtržítko.",
"Username unchanged.": "Uživatelské jméno nezměněno.",
"The username could not be changed: %s": "Uživatelské jméno nešlo změnit: %s",
"Username updated.": "Uživatelské jméno změněno.",
"The display name could not be saved: %s": "Zobrazované jméno nešlo uložit: %s",
"Display name cleared.": "Zobrazované jméno vymazáno.",
"Display name updated.": "Zobrazované jméno uloženo.",
"The handle could not be saved: %s": "Fediverse účet nešlo uložit: %s",
"Fediverse handle cleared.": "Fediverse účet vymazán.",
"Fediverse handle must look like @user@host.": "Fediverse účet musí mít tvar @uzivatel@hostitel.",
"Fediverse handle updated.": "Fediverse účet uložen.",
"ORCID iD": "ORCID iD",
"Save ORCID": "Uložit ORCID",
"ORCID updated.": "ORCID uložen.",
"ORCID cleared.": "ORCID vymazán.",
"The ORCID could not be saved: %s": "ORCID nešlo uložit: %s",
"ORCID must look like 0000-0002-1825-0097.": "ORCID musí mít tvar 0000-0002-1825-0097.",
"Your Open Researcher and Contributor ID; it pre-fills the author field of new publications.": "Váš Open Researcher and Contributor ID; předvyplňuje pole autora nových publikací.",
"Display name pre-fills the author field. Fediverse handle pre-fills the creator, and the ORCID pre-fills the author identifier of new publications.": "Zobrazované jméno předvyplňuje pole autora, fediverse účet tvůrce a ORCID identifikátor autora nových publikací.",
"The photo could not be stored.": "Fotku nešlo uložit.",
"The profile photo could not be saved: %s": "Profilovou fotku nešlo uložit: %s",
"Profile photo updated.": "Profilová fotka uložena.",
"The profile photo could not be removed: %s": "Profilovou fotku nešlo odstranit: %s",
"Profile photo removed.": "Profilová fotka odstraněna.",
"The interface language is set.": "Jazyk rozhraní je nastaven.",
// Go handler messages: settings, users.
"Username and password are required.": "Uživatelské jméno a heslo jsou povinné.",
"That user could not be added: %s": "Uživatele nešlo přidat: %s",
"User added.": "Uživatel přidán.",
"You cannot change your own role.": "Vlastní roli nelze změnit.",
"The role could not be changed: %s": "Roli nešlo změnit: %s",
"Role updated.": "Role uložena.",
"You cannot delete your own account.": "Vlastní účet nelze smazat.",
"The user could not be removed: %s": "Uživatele nešlo odebrat: %s",
"User removed.": "Uživatel odebrán.",
"Reset password": "Resetovat heslo",
"You cannot reset your own password here.": "Vlastní heslo tady resetovat nemůžete.",
"That user was not found.": "Takový uživatel nebyl nalezen.",
"Password reset; that user's sessions were signed out.": "Heslo resetováno; relace toho uživatele byly odhlášeny.",
"Reset this user's password? Their sessions will be signed out.": "Resetovat heslo tohoto uživatele? Jeho relace budou odhlášeny.",
// Go handler messages: settings, templates and tokens.
"Template name is required.": "Název šablony je povinný.",
"That template could not be added: %s": "Šablonu nešlo přidat: %s",
"Template added.": "Šablona přidána.",
"The template could not be deleted: %s": "Šablonu nešlo smazat: %s",
"Template deleted.": "Šablona smazána.",
"The token could not be created: %s": "Token nešlo vytvořit: %s",
"Token revoked.": "Token odvolán.",
"That token was not found.": "Token nenalezen.",
// Go handler messages: settings, webhooks, backup and version.
"No webhooks configured.": "Žádné webhooky nenastaveny.",
"Webhook not found.": "Webhook nenalezen.",
"Test delivery sent.": "Testovací doručení odesláno.",
"Test delivery failed.": "Testovací doručení selhalo.",
"No backup file selected.": "Nevybrán žádný soubor zálohy.",
"Could not restore backup: %s": "Zálohu nešlo obnovit: %s",
"The archive holds no files this deployment recognises.": "Archiv neobsahuje žádné soubory, které by tahle instalace poznala.",
"Update checks are not available in this build.": "Kontrola aktualizací není v tomto buildu dostupná.",
"Update check failed: %s": "Kontrola aktualizací selhala: %s",
"volumen %s is already the latest release.": "Volumen %s je už nejnovější vydání.",
"volumen %s is available.": "Volumen %s je k dispozici.",
"Self-update is not available in this build.": "Automatická aktualizace není v tomto buildu dostupná.",
"The upgrade failed: %s": "Aktualizace selhala: %s",
"Upgrade to %s failed: %s": "Aktualizace na %s selhala: %s",
}
// csHTML translates the static sentences that only make sense with
// their markup whole. They never carry an interpolated value, so
// returning them unescaped is safe.
var csHTML = map[string]string{
"On macOS use <kbd>Cmd</kbd> instead of <kbd>Ctrl</kbd>.": "Na macOS použijte <kbd>Cmd</kbd> místo <kbd>Ctrl</kbd>.",
"Download a <code>.tar.gz</code> archive of your entire site.": "Stáhněte <code>.tar.gz</code> archiv celého webu.",
"No webhooks yet. Add an endpoint below, or declare <code>[[webhooks]]</code> blocks in <code>config.toml</code> (those are read-only here):": "Žádné webhooky. Přidejte koncový bod níže, nebo deklarujte bloky <code>[[webhooks]]</code> v <code>config.toml</code> (ty tady jsou jen ke čtení):",
"Send a token as <code>Authorization: Bearer &lt;token&gt;</code> on <code>POST/PUT/DELETE /api/volumen/posts</code>.": "Token posílejte jako <code>Authorization: Bearer &lt;token&gt;</code> na <code>POST/PUT/DELETE /api/volumen/posts</code>.",
}
// Catalog translates the admin interface.
type Catalog struct{}
// Admin is the catalogue the binary ships.
var Admin = Catalog{}
// T translates a simple message. The key is the English text; an
// unknown key or the "en" language returns the key itself, so English
// needs no table and a missing translation degrades to English rather
// than to an identifier.
func (Catalog) T(lang, s string) string {
if lang != "cs" {
return s
}
if out, ok := cs[s]; ok && out != "" {
return out
}
return s
}
// TH translates a static sentence that carries its own markup. The
// sentences are authored here, never interpolated, so returning them
// unescaped cannot smuggle request data into the page.
func (Catalog) TH(lang, s string) string {
if lang != "cs" {
return s
}
if out, ok := csHTML[s]; ok {
return out
}
return s
}
// Tf translates a simple message with one %s value. The result stays a
// plain string, so the template engine escapes the interpolated value.
func (c Catalog) Tf(lang, s, arg string) string {
t := c.T(lang, s)
if before, after, ok := strings.Cut(t, "%s"); ok {
return before + arg + after
}
return t
}
// Tf2 translates a simple message with two %s values.
func (c Catalog) Tf2(lang, s, first, second string) string {
t := c.T(lang, s)
for _, arg := range []string{first, second} {
if i := strings.Index(t, "%s"); i >= 0 {
t = t[:i] + arg + t[i+2:]
}
}
return t
}
// N translates a numbered message, picking the form the language's
// cardinal rule asks for. Czech follows CLDR: one for 1, few for the
// 2 to 4 class except 12 to 14, other otherwise.
func (c Catalog) N(lang, id string, n int) string {
forms, ok := pluralForms[id]
if !ok {
return id
}
set := forms["en"]
if lang != "en" {
if l := forms[lang]; l.one != "" || l.other != "" {
set = l
}
}
var form string
switch {
case n == 1:
form = set.one
case lang == "cs" && n%10 >= 2 && n%10 <= 4 && (n%100 < 12 || n%100 > 14):
form = set.few
default:
form = set.other
}
// A plural set without a few form (an id added with English forms
// only) would otherwise render an empty string for the 2 to 4 class.
if form == "" {
form = set.other
}
num := strconv.Itoa(n)
if i := strings.Index(form, "%d"); i >= 0 {
return form[:i] + num + form[i+2:]
}
return form
}
// JS returns the strings the page scripts need, as a map ready for
// JSON encoding into the page. Plural messages carry their three
// forms; the client picks one with the same cardinal rule as N.
func (c Catalog) JS(lang string) map[string]any {
if lang == "" {
lang = "en"
}
out := map[string]any{}
for _, id := range []string{
"editor.words", "editor.chars", "editor.reading",
"bulk.selected", "posts.count", "refs.count",
} {
set := pluralForms[id]["en"]
if lang != "en" {
if l := pluralForms[id][lang]; l.one != "" || l.other != "" {
set = l
}
}
out[id] = []string{set.one, set.few, set.other}
}
plain := map[string]string{
"post.created": c.T(lang, "Post created."),
"post.updated": c.T(lang, "Post saved."),
"post.deleted": c.T(lang, "Post deleted."),
"post.duplicated": c.T(lang, "Post duplicated."),
"post.undone": c.T(lang, "Post restored."),
"post.undelete_failed": c.T(lang, "Could not restore the post; it may already be back."),
"post.duplicate_failed": c.T(lang, "The post could not be duplicated."),
"undo": c.T(lang, "Undo"),
"undo.failed": c.T(lang, "Undo failed."),
"autosave.available": c.T(lang, "Unsaved changes from %s are available."),
"slug.ok": c.T(lang, "Slug is available."),
"slug.taken": c.T(lang, "Already used by “%s”."),
"upload.prefix": c.T(lang, "Upload failed:\n\n%s"),
"upload.rejected": c.T(lang, "Only WebP, AVIF and SVG images are accepted (got “%s”)."),
"link.prompt": c.T(lang, "Link URL"),
"Show password": c.T(lang, "Show password"),
"Hide password": c.T(lang, "Hide password"),
"Collapse sidebar": c.T(lang, "Collapse sidebar"),
"Expand sidebar": c.T(lang, "Expand sidebar"),
"login.unlock": c.T(lang, "Auto-unlock in {} s."),
"login.retry": c.T(lang, " You can try again now."),
"update.reload": c.T(lang, "Server is back; reloading."),
"update.gaveup": c.T(lang, "The server did not come back within 3 minutes. Check the service manually."),
"datepicker.prev": c.T(lang, "Previous month"),
"datepicker.next": c.T(lang, "Next month"),
"Pick a date": c.T(lang, "Pick a date"),
"Clear": c.T(lang, "Clear"),
"Today": c.T(lang, "Today"),
"filter.of": c.T(lang, "%d of %d"),
"import.rejected": c.T(lang, "Only .md files are accepted, got “%s”"),
"media.uploading": c.T(lang, "Uploading %s…"),
"media.upload_failed": c.T(lang, "Upload failed: %s"),
"media.copied": c.T(lang, "Link copied."),
"media.copy_prompt": c.T(lang, "Copy the link:"),
"upload.complete": c.T(lang, "Upload complete."),
"pw.weak": c.T(lang, "Weak"),
"pw.fair": c.T(lang, "Fair"),
"pw.good": c.T(lang, "Good"),
"pw.strong": c.T(lang, "Strong"),
}
for id, v := range plain {
out[id] = v
}
return out
}
+144
View File
@@ -0,0 +1,144 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package i18n
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
func TestNormalize(t *testing.T) {
cases := map[string]string{
"cs": "cs",
"cs-CZ": "cs",
"cs-CZ-x-praha": "cs",
"EN": "en",
"en-GB": "en",
"de": "",
"": "",
}
for in, want := range cases {
if got := Normalize(in); got != want {
t.Errorf("Normalize(%q) = %q, want %q", in, got, want)
}
}
}
func TestSimpleFallback(t *testing.T) {
// English is the key: it always renders as itself.
if got := Admin.T("en", "New post"); got != "New post" {
t.Errorf("en simple = %q", got)
}
// A key the Czech table misses falls back to English, not to noise.
if got := Admin.T("cs", "No such string in the catalogue"); got != "No such string in the catalogue" {
t.Errorf("cs fallback = %q", got)
}
if got := Admin.T("cs", "New post"); got != "Nová publikace" {
t.Errorf("cs New post = %q", got)
}
}
func TestPlural(t *testing.T) {
csCases := []struct {
n int
want string
}{
{1, "Celkem 1 publikace"},
{2, "Celkem 2 publikace"},
{4, "Celkem 4 publikace"},
{5, "Celkem 5 publikací"},
{11, "Celkem 11 publikací"},
{12, "Celkem 12 publikací"},
{14, "Celkem 14 publikací"},
{22, "Celkem 22 publikace"},
{24, "Celkem 24 publikace"},
{25, "Celkem 25 publikací"},
{111, "Celkem 111 publikací"},
{124, "Celkem 124 publikace"},
}
for _, c := range csCases {
if got := Admin.N("cs", "posts.total", c.n); got != c.want {
t.Errorf("cs posts.total(%d) = %q, want %q", c.n, got, c.want)
}
}
if got := Admin.N("en", "posts.total", 1); got != "1 post in total" {
t.Errorf("en posts.total(1) = %q", got)
}
if got := Admin.N("en", "posts.total", 3); got != "3 posts in total" {
t.Errorf("en posts.total(3) = %q", got)
}
if got := Admin.N("cs", "no.such.id", 2); got != "no.such.id" {
t.Errorf("missing plural id = %q", got)
}
}
func TestFormat(t *testing.T) {
if got := Admin.Tf("cs", "Scheduled for %s", "2026-09-19"); got != "Naplánováno na 2026-09-19" {
t.Errorf("cs Tf = %q", got)
}
if got := Admin.Tf("en", "No such string %s", "x"); got != "No such string x" {
t.Errorf("en Tf fallback = %q", got)
}
if got := Admin.Tf2("cs", "Upgrade to %s failed: %s", "v1.1", "boom"); got != "Aktualizace na v1.1 selhala: boom" {
t.Errorf("cs Tf2 = %q", got)
}
}
func TestHTML(t *testing.T) {
en := Admin.TH("en", "Download a <code>.tar.gz</code> archive of your entire site.")
if en != "Download a <code>.tar.gz</code> archive of your entire site." {
t.Errorf("en TH = %q", en)
}
csOut := Admin.TH("cs", "Download a <code>.tar.gz</code> archive of your entire site.")
if !strings.Contains(csOut, "<code>.tar.gz</code>") {
t.Errorf("cs TH lost the markup: %q", csOut)
}
}
// TestTemplatesAreTranslated walks the admin templates and asserts every
// tr, trh and trf key exists in the Czech catalogue, and every trn id is a
// known plural message. A string edited in a template without its Czech
// counterpart fails here rather than silently rendering English.
func TestTemplatesAreTranslated(t *testing.T) {
root := filepath.Join("..", "..", "internal", "web", "templates")
entries, err := os.ReadDir(root)
if err != nil {
t.Fatalf("read templates: %v", err)
}
callRe := regexp.MustCompile(`\b(tr|trh|trf)\s+"((?:[^"\\]|\\.)*)"`)
// trn takes the count first: "trn .Count \"id\"" or
// "trn (len .Posts) \"id\"", so the id is the first quoted string
// after a run of non-quote characters on the same line.
idRe := regexp.MustCompile(`\btrn\s+[^\n"]*"((?:[^"\\]|\\.)*)"`)
for _, entry := range entries {
if !strings.HasSuffix(entry.Name(), ".html") {
continue
}
src, err := os.ReadFile(filepath.Join(root, entry.Name()))
if err != nil {
t.Fatalf("read %s: %v", entry.Name(), err)
}
for _, m := range callRe.FindAllStringSubmatch(string(src), -1) {
fn, key := m[1], m[2]
switch fn {
case "tr", "trf":
if _, ok := cs[key]; !ok {
t.Errorf("%s: tr key %q has no Czech entry", entry.Name(), key)
}
case "trh":
if _, ok := csHTML[key]; !ok {
t.Errorf("%s: trh key %q has no Czech entry", entry.Name(), key)
}
}
}
for _, m := range idRe.FindAllStringSubmatch(string(src), -1) {
if _, ok := pluralForms[m[1]]; !ok {
t.Errorf("%s: trn id %q is not a plural message", entry.Name(), m[1])
}
}
}
}
+94
View File
@@ -0,0 +1,94 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package identifiers validates the scholarly identifiers a post or an
// account can carry: a DOI names the work, an ORCID names an author. It
// is a leaf so the configuration, the domain object, the payload
// builders and the admin share one rule without importing one another.
//
// The rules are syntactic on purpose. A DOI resolves through doi.org and
// an ORCID through orcid.org, and both publish registries; asking those
// services here would tie a save to the network, leak who is writing to
// a third party, and make the offline binary wait on someone else's
// uptime. Syntax plus the ORCID check digit catches every realistic
// typo; resolution stays the consumer's job.
package identifiers
import (
"regexp"
"strings"
)
var doiRe = regexp.MustCompile(`\A10\.[0-9]{4,9}/\S+\z`)
// orcidRe is the display form: four groups of four digits, the last
// character a digit or the uppercase X that stands for a checksum of 10.
var orcidRe = regexp.MustCompile(`\A[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X]\z`)
// NormalizeDOI accepts the bare form, a doi: scheme and a doi.org URL,
// and returns the bare identifier; an empty result means the input
// carries no DOI at all.
func NormalizeDOI(text string) string {
text = strings.TrimSpace(text)
for _, prefix := range []string{
"https://doi.org/", "http://doi.org/", "doi.org/", "doi:",
} {
if len(text) > len(prefix) && strings.EqualFold(text[:len(prefix)], prefix) {
text = strings.TrimSpace(text[len(prefix):])
break
}
}
return text
}
// ValidDOI reports whether text is a syntactically valid bare DOI: the
// 10. prefix, a registrant needle of four to nine digits, a slash, and
// a non-empty suffix without spaces.
func ValidDOI(text string) bool {
return doiRe.MatchString(NormalizeDOI(text))
}
// NormalizeORCID trims and lower-cases to upper; it fixes no body.
func NormalizeORCID(text string) string {
return strings.ToUpper(strings.TrimSpace(text))
}
// ValidORCID reports whether text is an ORCID iD in display form with a
// correct ISO 7064 (MOD 11-2) check digit.
func ValidORCID(text string) bool {
id := NormalizeORCID(text)
if !orcidRe.MatchString(id) {
return false
}
digits := strings.ReplaceAll(id, "-", "")
total := 0
for _, r := range digits[:15] {
total = (total + int(r-'0')) * 2
}
remainder := (12 - total%11) % 11
want := byte('0' + remainder)
if remainder == 10 {
want = 'X'
}
return digits[15] == want
}
// DOIURL names the resolver for a bare or already-prefixed DOI; an
// invalid input yields "".
func DOIURL(text string) string {
doi := NormalizeDOI(text)
if !doiRe.MatchString(doi) {
return ""
}
return "https://doi.org/" + doi
}
// ORCIDURL names the public record for a valid iD; an invalid input
// yields "".
func ORCIDURL(text string) string {
id := NormalizeORCID(text)
if !ValidORCID(id) {
return ""
}
return "https://orcid.org/" + id
}
+84
View File
@@ -0,0 +1,84 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package identifiers
import "testing"
func TestNormalizeDOI(t *testing.T) {
cases := map[string]string{
"10.5281/zenodo.1234567": "10.5281/zenodo.1234567",
" https://doi.org/10.5281/zenodo.1234567": "10.5281/zenodo.1234567",
"DOI: 10.1234/abcd": "10.1234/abcd",
"http://dx.doi.org/10.1/x": "http://dx.doi.org/10.1/x", // unknown host stays as it is
"": "",
}
for in, want := range cases {
if got := NormalizeDOI(in); got != want {
t.Errorf("NormalizeDOI(%q) = %q, want %q", in, got, want)
}
}
}
func TestValidDOI(t *testing.T) {
for _, ok := range []string{
"10.1234/x", "10.5281/zenodo.1234567", "https://doi.org/10.1000/18.2011.01",
"10.1109/5.77101",
} {
if !ValidDOI(ok) {
t.Errorf("ValidDOI(%q) = false, want true", ok)
}
}
for _, bad := range []string{
"", "10.123/x", // needle too short
"10./x", // empty needle
"20.1234/x", // not the 10. prefix
"10.1234", // no suffix
"10.1234/ spaced suffix", // whitespace in the suffix
"doi:10.1234/", // empty suffix after the slash
} {
if ValidDOI(bad) {
t.Errorf("ValidDOI(%q) = true, want false", bad)
}
}
}
func TestValidORCID(t *testing.T) {
// A real public iD, and its computed X-check sibling.
for _, ok := range []string{
"0000-0002-1825-0097",
"0000-0000-0000-001X",
"0000-0000-0000-001x", // lower-case x is upper-cased first
" 0000-0002-1825-0097 ",
} {
if !ValidORCID(ok) {
t.Errorf("ValidORCID(%q) = false, want true", ok)
}
}
for _, bad := range []string{
"",
"0000-0002-1825-0098", // wrong check digit
"0000-0002-1825-009x", // right length, wrong check digit
"0000-0002-1825-009", // too short
"0000000218250097", // dashes missing
} {
if ValidORCID(bad) {
t.Errorf("ValidORCID(%q) = true, want false", bad)
}
}
}
func TestURLs(t *testing.T) {
if got := DOIURL("https://doi.org/10.1234/x"); got != "https://doi.org/10.1234/x" {
t.Errorf("DOIURL = %q", got)
}
if got := DOIURL("nonsense"); got != "" {
t.Errorf("DOIURL(nonsense) = %q, want empty", got)
}
if got := ORCIDURL("0000-0002-1825-0097"); got != "https://orcid.org/0000-0002-1825-0097" {
t.Errorf("ORCIDURL = %q", got)
}
if got := ORCIDURL("0000-0002-1825-0098"); got != "" {
t.Errorf("ORCIDURL(bad checksum) = %q, want empty", got)
}
}
+463
View File
@@ -0,0 +1,463 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package imagefile identifies the image formats volumen accepts, from
// the bytes rather than from a name or a declared type.
//
// Only the three formats below are stored, and the media route serves
// nothing else: a browser is never handed a document from a directory
// that an archive or an upload can write to. An SVG is a document of
// sorts, so the media route serves it sandboxed and the signature test
// here demands the root element really be <svg>.
package imagefile
import (
"bytes"
"encoding/binary"
"path/filepath"
"strconv"
"strings"
)
// The canonical extensions and the Content-Type each is served with.
const (
ExtWebP = ".webp"
ExtAVIF = ".avif"
ExtSVG = ".svg"
MIMEWebP = "image/webp"
MIMEAVIF = "image/avif"
MIMESVG = "image/svg+xml"
)
var mimeTypes = map[string]string{
ExtWebP: MIMEWebP,
ExtAVIF: MIMEAVIF,
ExtSVG: MIMESVG,
}
// ContentType returns the Content-Type for an allowed image name, or ""
// when the name does not carry an allowed extension.
func ContentType(name string) string {
return mimeTypes[strings.ToLower(filepath.Ext(filepath.Base(name)))]
}
// Allowed reports whether name carries an allowed extension.
func Allowed(name string) bool { return ContentType(name) != "" }
// SignatureMatches reports whether data carries the signature of the
// format the extension names.
func SignatureMatches(data []byte, ext string) bool {
switch strings.ToLower(ext) {
case ExtWebP:
// RIFF....WEBP, twelve bytes of magic.
return len(data) >= 12 &&
bytes.Equal(data[:4], []byte("RIFF")) &&
bytes.Equal(data[8:12], []byte("WEBP"))
case ExtAVIF:
// ISO BMFF: bytes 4..7 are the box size, 8..11 are "ftyp",
// followed by the major brand.
if len(data) < 16 || !bytes.Equal(data[4:8], []byte("ftyp")) {
return false
}
brand := data[8:12]
return bytes.Equal(brand, []byte("avif")) || bytes.Equal(brand, []byte("avis"))
case ExtSVG:
// The root element must be <svg>: XML text that opens with
// another document (an XHTML page, an SVGZ masquerading as a
// plain .svg) is not an accepted image.
return svgRootTag(data) != nil
}
return false
}
// Detect returns the canonical extension for data, or "" when the bytes
// carry no accepted signature.
func Detect(data []byte) string {
if SignatureMatches(data, ExtWebP) {
return ExtWebP
}
if SignatureMatches(data, ExtAVIF) {
return ExtAVIF
}
if SignatureMatches(data, ExtSVG) {
return ExtSVG
}
return ""
}
// Dimensions reports the pixel size of a WebP, AVIF or SVG image, reading
// only the container headers or the root element, and ok=false when the
// bytes carry no size it can trust. A caller that holds a whole file can
// pass it whole; a 64 KiB prefix of a media file carries every header
// this reads.
func Dimensions(data []byte) (width, height int, ok bool) {
if w, h, ok := webpDimensions(data); ok {
return w, h, true
}
if w, h, ok := avifDimensions(data); ok {
return w, h, true
}
return svgDimensions(data)
}
// webpDimensions reads the size out of the three chunk shapes WebP
// uses: VP8 (lossy), VP8L (lossless) and VP8X (extended canvas).
func webpDimensions(data []byte) (int, int, bool) {
if len(data) < 20 || !bytes.Equal(data[:4], []byte("RIFF")) ||
!bytes.Equal(data[8:12], []byte("WEBP")) {
return 0, 0, false
}
switch string(data[12:16]) {
case "VP8 ":
// After the frame tag sit the three sync bytes 0x9d 0x01 0x2a,
// then the width and the height as 16-bit little-endian values
// whose top two bits carry a scale code.
if len(data) < 30 || data[23] != 0x9d || data[24] != 0x01 || data[25] != 0x2a {
return 0, 0, false
}
w := int(binary.LittleEndian.Uint16(data[26:28]) & 0x3fff)
h := int(binary.LittleEndian.Uint16(data[28:30]) & 0x3fff)
return w, h, w > 0 && h > 0
case "VP8L":
// The payload opens with 0x2f and packs width-1 into 14 bits
// followed by height-1 into 14 more, least significant first.
if len(data) < 25 || data[20] != 0x2f {
return 0, 0, false
}
bits := uint32(data[21]) | uint32(data[22])<<8 | uint32(data[23])<<16 | uint32(data[24])<<24
w := int(bits&0x3fff) + 1
h := int((bits>>14)&0x3fff) + 1
return w, h, true
case "VP8X":
// The canvas size sits as two 24-bit little-endian minus-one
// values after the flags and three reserved bytes.
if len(data) < 30 {
return 0, 0, false
}
w := int(uint32(data[24])|uint32(data[25])<<8|uint32(data[26])<<16) + 1
h := int(uint32(data[27])|uint32(data[28])<<8|uint32(data[29])<<16) + 1
return w, h, true
}
return 0, 0, false
}
// avifDimensions walks the ISO-BMFF boxes of an AVIF: the meta box
// names the primary item (pitm) and associates it with properties
// (ipma inside iprp); the ispe property it points at carries the image
// extent. Anything the walk cannot certify is reported as unknown
// rather than guessed.
func avifDimensions(data []byte) (int, int, bool) {
if len(data) < 16 || !bytes.Equal(data[4:8], []byte("ftyp")) {
return 0, 0, false
}
var meta []byte
for _, b := range readBoxes(data) {
if b.typ == "meta" {
meta = b.body
break
}
}
if meta == nil || len(meta) < 4 {
return 0, 0, false
}
// meta is a full box: four bytes of version and flags precede the
// children.
children := readBoxes(meta[4:])
var primary uint64
var properties []box
var associations []box
for _, b := range children {
switch b.typ {
case "pitm":
if len(b.body) < 1 {
return 0, 0, false
}
// The bounds name the whole item id: a box whose body stops
// short of it is refused rather than read past, because a
// truncated box at the end of the buffer has no bytes left
// to read and the slice would run out of range.
if b.body[0] == 0 {
if len(b.body) < 6 {
return 0, 0, false
}
primary = uint64(binary.BigEndian.Uint16(b.body[4:6]))
} else {
if len(b.body) < 8 {
return 0, 0, false
}
primary = uint64(binary.BigEndian.Uint32(b.body[4:8]))
}
case "iprp":
for _, inner := range readBoxes(b.body) {
switch inner.typ {
case "ipco":
properties = readBoxes(inner.body)
case "ipma":
associations = append(associations, inner)
}
}
}
}
if primary == 0 || properties == nil {
return 0, 0, false
}
for _, assoc := range associations {
for _, propertyIndex := range readAssociations(assoc) {
if propertyIndex.item != primary {
continue
}
// Property indices are one-based over ipco's children.
if propertyIndex.index == 0 || propertyIndex.index > len(properties) {
continue
}
boxed := properties[propertyIndex.index-1]
// ispe is a full box: version and flags, then the width and
// the height as big-endian 32-bit values.
if boxed.typ != "ispe" || len(boxed.body) < 12 {
continue
}
w := int(binary.BigEndian.Uint32(boxed.body[4:8]))
h := int(binary.BigEndian.Uint32(boxed.body[8:12]))
return w, h, w > 0 && h > 0
}
}
return 0, 0, false
}
// boxAssociation pairs an item id with the one-based property index one
// of its associations names.
type boxAssociation struct {
item uint64
index int
}
// readAssociations decodes an ipma box's entries into item/property
// pairs, honouring both the 16- and 32-bit item id sizes and the
// 7- and 15-bit index sizes the flags select.
func readAssociations(b box) []boxAssociation {
if len(b.body) < 12 {
return nil
}
flags := uint32(b.body[1])<<16 | uint32(b.body[2])<<8 | uint32(b.body[3])
wideItems := flags&0b10 != 0
wideIndexes := flags&0b01 != 0
idSize, indexSize := 2, 1
if wideItems {
idSize = 4
}
if wideIndexes {
indexSize = 2
}
count := int(binary.BigEndian.Uint32(b.body[4:8]))
out := make([]boxAssociation, 0, count)
pos := 8
for range count {
if pos+idSize+1 > len(b.body) {
return out
}
var item uint64
if wideItems {
item = uint64(binary.BigEndian.Uint32(b.body[pos : pos+4]))
} else {
item = uint64(binary.BigEndian.Uint16(b.body[pos : pos+2]))
}
pos += idSize
assocCount := int(b.body[pos])
pos++
for range assocCount {
if pos+indexSize > len(b.body) {
return out
}
var index int
if wideIndexes {
// The essential bit rides the top bit of a 15-bit index.
index = int(binary.BigEndian.Uint16(b.body[pos:pos+2]) & 0x7fff)
} else {
index = int(b.body[pos] & 0x7f)
}
pos += indexSize
out = append(out, boxAssociation{item: item, index: index})
}
}
return out
}
// box is one ISO-BMFF box: its type and the body after the header.
type box struct {
typ string
body []byte
}
// readBoxes splits a run of sibling boxes. A size of zero means "to the
// end of the input" and a size of one promotes to a 64-bit size; both
// are honoured, and a truncated or empty box stops the walk.
func readBoxes(data []byte) []box {
var out []box
pos := 0
for pos+8 <= len(data) {
size := uint64(binary.BigEndian.Uint32(data[pos : pos+4]))
typ := string(data[pos+4 : pos+8])
header := 8
if size == 1 {
if pos+16 > len(data) {
break
}
size = binary.BigEndian.Uint64(data[pos+8 : pos+16])
header = 16
}
if size < uint64(header) {
break
}
end := min(uint64(pos)+size, uint64(len(data)))
out = append(out, box{typ: typ, body: data[pos+header : end]})
if end <= uint64(pos)+8 {
break
}
pos = int(end)
}
return out
}
// svgRootTag returns the start tag of the root <svg> element, or nil when
// the bytes are not an SVG document. The XML prolog, comments and any
// leading declaration are stepped over on the way to it; anything that
// opens the document with another root element is refused, so an XHTML
// page never takes the .svg extension it is served under.
func svgRootTag(data []byte) []byte {
s := bytes.TrimPrefix(data, []byte("\ufeff"))
for {
s = bytes.TrimLeft(s, " \t\r\n")
switch {
case bytes.HasPrefix(s, []byte("<?xml")):
end := bytes.Index(s, []byte("?>"))
if end < 0 {
return nil
}
s = s[end+2:]
case bytes.HasPrefix(s, []byte("<!--")):
end := bytes.Index(s, []byte("-->"))
if end < 0 {
return nil
}
s = s[end+3:]
case bytes.HasPrefix(s, []byte("<!")):
end := bytes.IndexByte(s, '>')
if end < 0 {
return nil
}
s = s[end+1:]
case bytes.HasPrefix(s, []byte("<svg")):
if len(s) > 4 {
switch s[4] {
case ' ', '\t', '\n', '\r', '>', '/':
default:
return nil // <svgrect and friends are not a root
}
}
end := bytes.IndexByte(s, '>')
if end < 0 {
return nil
}
return s[:end+1]
default:
return nil
}
}
}
// svgDimensions reads the size off the root element: the width and height
// attributes when both are bare lengths, or the viewBox box otherwise. A
// percentage length carries no size the media library could show.
func svgDimensions(data []byte) (int, int, bool) {
tag := svgRootTag(data)
if tag == nil {
return 0, 0, false
}
if width, ok := svgLength(tag, "width"); ok {
if height, ok := svgLength(tag, "height"); ok {
return width, height, width > 0 && height > 0
}
}
if box, ok := svgAttr(tag, "viewBox"); ok {
parts := strings.FieldsFunc(box, func(r rune) bool {
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
})
if len(parts) == 4 {
w, errW := strconv.ParseFloat(parts[2], 64)
h, errH := strconv.ParseFloat(parts[3], 64)
if errW == nil && errH == nil && w >= 1 && h >= 1 {
return int(w), int(h), true
}
}
}
return 0, 0, false
}
// svgLength reads one of the root element's size attributes as a pixel
// length: a plain number or one written in px.
func svgLength(tag []byte, name string) (int, bool) {
value, ok := svgAttr(tag, name)
if !ok {
return 0, false
}
value = strings.TrimSuffix(strings.TrimSuffix(value, "px"), "PX")
if strings.TrimLeftFunc(value, func(r rune) bool {
return (r >= '0' && r <= '9') || r == '.'
}) != "" {
return 0, false // a unit the media library does not convert
}
n, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0, false
}
return int(n), n >= 0
}
// svgAttr returns the quoted value of one attribute of a start tag. The
// scan is deliberately shallow: it reads the plain attributes the size
// of a figure is written with and gives up on anything else.
func svgAttr(tag []byte, name string) (string, bool) {
s := string(tag)
i := strings.IndexByte(s, ' ') // past "<svg"
if i < 0 {
return "", false
}
for i < len(s) {
for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r') {
i++
}
start := i
for i < len(s) && s[i] != '=' && !isSVGTagSpace(s[i]) && s[i] != '>' && s[i] != '/' {
i++
}
key := s[start:i]
if i >= len(s) || s[i] != '=' {
return "", false
}
i++
if i >= len(s) || (s[i] != '"' && s[i] != '\'') {
return "", false
}
quote := s[i]
i++
valueStart := i
for i < len(s) && s[i] != quote {
i++
}
if i >= len(s) {
return "", false
}
value := s[valueStart:i]
i++
if key == name {
return value, true
}
}
return "", false
}
func isSVGTagSpace(b byte) bool {
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
}
+238
View File
@@ -0,0 +1,238 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package imagefile
import "testing"
// 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 ")...)
}
// avifBytes is a minimal ISO BMFF header carrying the avif brand.
func avifBytes() []byte {
return []byte{0, 0, 0, 24, 'f', 't', 'y', 'p', 'a', 'v', 'i', 'f', 0, 0, 0, 0}
}
func TestSignatureMatches(t *testing.T) {
if !SignatureMatches(webpBytes(), ExtWebP) {
t.Fatal("webp signature rejected")
}
if !SignatureMatches(avifBytes(), ExtAVIF) {
t.Fatal("avif signature rejected")
}
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><rect/></svg>`)
if !SignatureMatches(svg, ExtSVG) {
t.Fatal("svg signature rejected")
}
prologed := []byte("\ufeff<?xml version=\"1.0\"?>\n<!-- figure --><svg></svg>")
if !SignatureMatches(prologed, ExtSVG) {
t.Fatal("svg with prolog and comment rejected")
}
if SignatureMatches([]byte("<html><body>hi</body></html>"), ExtSVG) {
t.Fatal("an html document accepted as svg")
}
if SignatureMatches([]byte("<svgrect/>"), ExtSVG) {
t.Fatal("a lookalike element name accepted as svg")
}
// The avis brand is a valid AVIF image sequence.
avis := append([]byte{}, avifBytes()...)
copy(avis[8:12], "avis")
if !SignatureMatches(avis, ExtAVIF) {
t.Fatal("avis brand rejected")
}
if SignatureMatches([]byte("short"), ExtWebP) {
t.Fatal("short data accepted as webp")
}
if SignatureMatches([]byte("plain text here!!!"), ExtAVIF) {
t.Fatal("garbage accepted as avif")
}
if SignatureMatches(webpBytes(), ".png") {
t.Fatal("unknown extension accepted")
}
if SignatureMatches(avifBytes(), ExtWebP) {
t.Fatal("avif bytes accepted as webp")
}
}
func TestDetect(t *testing.T) {
if got := Detect(webpBytes()); got != ExtWebP {
t.Fatalf("Detect(webp) = %q", got)
}
if got := Detect(avifBytes()); got != ExtAVIF {
t.Fatalf("Detect(avif) = %q", got)
}
if got := Detect([]byte("<?xml version=\"1.0\"?><svg xmlns=\"http://www.w3.org/2000/svg\"></svg>")); got != ExtSVG {
t.Fatalf("Detect(svg) = %q", got)
}
for _, data := range [][]byte{
nil, []byte("RIFF"), []byte("GIF89a and then padding"),
[]byte("not an image at all, but long enough"),
[]byte("<!DOCTYPE html>\n<html></html>"),
} {
if got := Detect(data); got != "" {
t.Fatalf("Detect(%q) = %q, want empty", data, got)
}
}
}
func TestContentType(t *testing.T) {
cases := map[string]string{
"photo.webp": MIMEWebP,
"photo.AVIF": MIMEAVIF,
"figure.svg": MIMESVG,
"nested/dir/photo.webp": MIMEWebP,
"photo.png": "",
"photo": "",
"": "",
"photo.webp.html": "",
}
for name, want := range cases {
if got := ContentType(name); got != want {
t.Errorf("ContentType(%q) = %q, want %q", name, got, want)
}
if Allowed(name) != (want != "") {
t.Errorf("Allowed(%q) = %v, want %v", name, Allowed(name), want != "")
}
}
}
// mkbox assembles one ISO-BMFF box: a big-endian size, the four-byte type
// and the body.
func mkbox(typ string, body []byte) []byte {
size := len(body) + 8
out := make([]byte, 8, size)
out[0] = byte(size >> 24)
out[1] = byte(size >> 16)
out[2] = byte(size >> 8)
out[3] = byte(size)
copy(out[4:], typ)
return append(out, body...)
}
// avifWithDimensions assembles a minimal but structurally honest AVIF:
// ftyp, then meta naming item 1 as primary and associating its ispe.
func avifWithDimensions(width, height int) []byte {
ispe := []byte{0, 0, 0, 0} // full box: version and flags
ispe = append(ispe,
byte(width>>24), byte(width>>16), byte(width>>8), byte(width),
byte(height>>24), byte(height>>16), byte(height>>8), byte(height))
ipco := mkbox("ipco", mkbox("ispe", ispe))
// ipma v0, flags 0: one entry, item 1, one association naming
// property 1 (the ispe).
ipma := []byte{0, 0, 0, 0,
0, 0, 0, 1, // entry count
0, 1, // item id 1
1, // one association
1, // property index 1
}
pitm := []byte{0, 0, 0, 0, 0, 1} // v0, item id 1
iprp := append(ipco, mkbox("ipma", ipma)...)
metaBody := append([]byte{0, 0, 0, 0}, mkbox("pitm", pitm)...)
metaBody = append(metaBody, mkbox("iprp", iprp)...)
meta := mkbox("meta", metaBody)
ftyp := []byte{0, 0, 0, 16, 'f', 't', 'y', 'p', 'a', 'v', 'i', 'f', 0, 0, 0, 0}
return append(ftyp, meta...)
}
func TestDimensions(t *testing.T) {
// webpChunk builds a RIFF/WEBP file whose first chunk carries the
// payload: fourcc, little-endian size, then the bytes.
webpChunk := func(chunk string, payload ...byte) []byte {
out := append([]byte("RIFF"), 0, 0, 0, 0)
out = append(out, []byte("WEBP")...)
out = append(out, chunk...)
out = append(out, byte(len(payload)), 0, 0, 0)
return append(out, payload...)
}
// VP8L opens with 0x2f and packs width-1 then height-1 into 14-bit
// little-endian fields.
bits := uint32(1919) | uint32(1079)<<14
vp8l := webpChunk("VP8L", 0x2f, byte(bits), byte(bits>>8), byte(bits>>16), byte(bits>>24))
// VP8X carries two 24-bit minus-one canvas values after the flags
// and three reserved bytes: 999 and 599, little-endian.
vp8x := webpChunk("VP8X", 0, 0, 0, 0, 0xE7, 0x03, 0x00, 0x57, 0x02, 0x00)
// VP8 lossy: frame tag, the sync bytes, then two scaled 14-bit
// little-endian values.
vp8 := webpChunk("VP8 ", 0, 0, 0, 0x9d, 0x01, 0x2a, 0x80, 0x02, 0xe0, 0x01)
// The AVIF structure assembled above.
avif := avifWithDimensions(800, 600)
cases := []struct {
name string
data []byte
w, h int
ok bool
}{
{"webp lossless", vp8l, 1920, 1080, true},
{"webp extended", vp8x, 1000, 600, true},
{"webp lossy", vp8, 640, 480, true},
{"avif primary item", avif, 800, 600, true},
{"empty", nil, 0, 0, false},
{"truncated webp", vp8x[:18], 0, 0, false},
{"text", []byte("plainly not an image at all"), 0, 0, false},
{"svg attributes",
[]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="800px" height="600px"><g/></svg>`), 800, 600, true},
{"svg viewbox",
[]byte("<?xml version=\"1.0\"?>\n<svg viewBox=\"0 -10 1200 900\"></svg>"), 1200, 900, true},
{"svg percent length",
[]byte(`<svg width="100%" height="100%"></svg>`), 0, 0, false},
{"svg no size",
[]byte(`<svg xmlns="http://www.w3.org/2000/svg"><circle/></svg>`), 0, 0, false},
}
for _, tc := range cases {
w, h, ok := Dimensions(tc.data)
if ok != tc.ok || (ok && (w != tc.w || h != tc.h)) {
t.Errorf("%s: Dimensions = %d, %d, %v; want %d, %d, %v",
tc.name, w, h, ok, tc.w, tc.h, tc.ok)
}
}
}
// A box whose declared content is cut short must be refused, not panic:
// the walk may only read bytes the box really holds, and a truncated
// pitm or meta sits at the end of the buffer, where a read past the box
// runs past the whole input.
func TestDimensionsTruncatedBoxes(t *testing.T) {
ftyp := []byte{0, 0, 0, 16, 'f', 't', 'y', 'p', 'a', 'v', 'i', 'f', 0, 0, 0, 0}
fullBox := func(typ string, body []byte) []byte {
return append(ftyp, mkbox(typ, body)...)
}
// A v0 pitm carries a two-byte item id; only one byte of it survives.
shortPitm := []byte{0, 0, 0, 0, 0}
// A v1 pitm carries a four-byte item id; three bytes survive.
widePitm := []byte{1, 0, 0, 0, 1, 2, 3}
// A meta box whose full-box header itself is cut in half.
for _, tc := range []struct {
name string
data []byte
}{
{"truncated pitm", fullBox("meta", append([]byte{0, 0, 0, 0}, mkbox("pitm", shortPitm)...))},
{"truncated wide pitm", fullBox("meta", append([]byte{0, 0, 0, 0}, mkbox("pitm", widePitm)...))},
{"truncated meta header", fullBox("meta", []byte{0, 0})},
{"header-only pitm", fullBox("meta", append([]byte{0, 0, 0, 0}, mkbox("pitm", nil)...))},
} {
if _, _, ok := Dimensions(tc.data); ok {
t.Errorf("%s: Dimensions reported a size", tc.name)
}
}
}
// A prefix is enough: the media listing reads only the head of a file,
// so the sizes must come from there too.
func TestDimensionsFromPrefix(t *testing.T) {
full := avifWithDimensions(123, 45)
head := make([]byte, 64<<10)
copy(head, full)
if w, h, ok := Dimensions(head[:len(full)+1024]); !ok || w != 123 || h != 45 {
t.Fatalf("prefix Dimensions = %d, %d, %v", w, h, ok)
}
}
+34
View File
@@ -0,0 +1,34 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package markdown
import (
stdhtml "html"
"regexp"
"sourcedock.dev/petrbalvin/scriptorium"
)
// mermaidBlockRe matches the fenced mermaid blocks the renderer writes.
// The diagram source arrives HTML-escaped, as code content always does.
var mermaidBlockRe = regexp.MustCompile(`(?s)<pre><code class="language-mermaid">(.*?)</code></pre>`)
// renderDiagrams replaces every fenced mermaid block with the SVG
// scriptorium draws from it, wrapped so a style sheet can tell diagrams
// from prose. It runs on sanitised HTML: the SVG is machine-drawn output
// whose vocabulary the library itself bounds, and the sanitiser's
// HTML-only parser would corrupt its case-sensitive attributes. A
// diagram the library does not carry, or a line it cannot parse, keeps
// its code block: the author sees the source that was not understood,
// and the reader never a half-drawn figure.
func renderDiagrams(html string) string {
return mermaidBlockRe.ReplaceAllStringFunc(html, func(block string) string {
src := stdhtml.UnescapeString(mermaidBlockRe.FindStringSubmatch(block)[1])
svg, err := scriptorium.RenderDiagram([]byte(src))
if err != nil {
return block
}
return `<div class="diagram">` + "\n" + string(svg) + "\n</div>"
})
}
+56
View File
@@ -0,0 +1,56 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package markdown
import (
"flag"
"os"
"path/filepath"
"strings"
"testing"
)
var update = flag.Bool("update", false, "rewrite golden files")
// TestGoldenCorpus renders every corpus file and compares against the
// committed golden output. Run `go test ./internal/markdown -update` to
// regenerate after an intentional rendering change.
func TestGoldenCorpus(t *testing.T) {
corpusDir := filepath.Join("testdata", "corpus")
entries, err := os.ReadDir(corpusDir)
if err != nil {
t.Fatalf("read corpus: %v", err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
name := strings.TrimSuffix(entry.Name(), ".md")
t.Run(name, func(t *testing.T) {
src, err := os.ReadFile(filepath.Join(corpusDir, entry.Name()))
if err != nil {
t.Fatalf("read corpus file: %v", err)
}
htmlOut, toc, err := RenderWithTOC(string(src))
if err != nil {
t.Fatalf("RenderWithTOC: %v", err)
}
got := "=== HTML ===\n" + htmlOut + "=== TOC ===\n" + toc
goldenPath := filepath.Join("testdata", name+".html")
if *update {
if err := os.WriteFile(goldenPath, []byte(got), 0o644); err != nil {
t.Fatalf("write golden: %v", err)
}
return
}
want, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatalf("read golden (run with -update): %v", err)
}
if got != string(want) {
t.Fatalf("output differs from golden:\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
})
}
}
+341
View File
@@ -0,0 +1,341 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package markdown renders Markdown posts to sanitised HTML.
//
// scriptorium renders the body (CommonMark with the GFM extensions,
// footnotes and definition lists). Mathematics and Mermaid diagrams, which
// scriptorium renders only when a consumer asks, are recognised here: a
// pre-render scan lifts $$…$$ and $…$ runs out of the source into
// placeholders and splices the MathML back after rendering, and fenced
// mermaid blocks are replaced by their SVG. Images titled with a caption
// are wrapped in <figure>/<figcaption>, headings gain id attributes and a
// table of contents, and bluemonday strips everything outside a narrow
// tag/attribute allowlist.
package markdown
import (
"fmt"
stdhtml "html"
"regexp"
"strconv"
"strings"
"sync"
"github.com/microcosm-cc/bluemonday"
"sourcedock.dev/petrbalvin/scriptorium"
)
// MaxBodyLength caps the Markdown source size (1 MiB).
const MaxBodyLength = 1_048_576
// MaxQuoteDepth bounds how deeply one line may nest blockquote markers.
// Measured: rendering cost grows superlinearly with depth (100 000
// levels take seconds, and a 1 MiB body of nothing but markers could
// reach hundreds of thousands), while legitimate prose never approaches
// the bound. It turns the worst case from an unbounded CPU burn into a
// rejection.
const MaxQuoteDepth = 100
var (
policyOnce sync.Once
sanitizer *bluemonday.Policy
)
// tocHeading is one entry of the rendered table of contents.
type tocHeading struct {
level int
id string
text string
}
func getPolicy() *bluemonday.Policy {
policyOnce.Do(func() {
sanitizer = bluemonday.NewPolicy()
sanitizer.AllowElements(
"a", "abbr", "blockquote", "br", "caption", "code", "del",
"dd", "div", "dl", "dt", "em", "figcaption", "figure",
"h1", "h2", "h3", "h4", "h5", "h6", "hr", "img", "input",
"li", "ol", "p", "pre", "section", "span", "strong", "sub", "sup",
"table", "tbody", "td", "th", "thead", "tr", "ul",
// SVG, the output of the Mermaid diagram renderer.
"svg", "line", "path", "polygon", "rect", "text",
// MathML Core, the output of the mathematics renderer.
"math", "mi", "mn", "mo", "ms", "mtext", "mspace", "mrow",
"mfrac", "msqrt", "mroot", "msub", "msup", "msubsup",
"munder", "mover", "munderover", "merror", "mpadded",
"mphantom", "mstyle", "mtable", "mtr", "mtd",
)
// MathML leaves most elements bare: an <mi> carries no attribute
// at all, and without this the policy admits only elements that
// do.
sanitizer.AllowNoAttrs().OnElements(
"math", "mi", "mn", "mo", "ms", "mtext", "mspace", "mrow",
"mfrac", "msqrt", "mroot", "msub", "msup", "msubsup",
"munder", "mover", "munderover", "merror", "mpadded",
"mphantom", "mstyle", "mtable", "mtr", "mtd",
)
sanitizer.AllowAttrs("href", "title", "class", "id", "aria-label", "data-footnote-ref").OnElements("a")
sanitizer.AllowAttrs("src", "alt", "title", "width", "height", "loading").OnElements("img")
sanitizer.AllowAttrs("class").OnElements("div", "span", "code", "pre", "figure", "figcaption", "section", "li", "sup")
sanitizer.AllowAttrs("id").OnElements("h1", "h2", "h3", "h4", "h5", "h6", "section", "li")
sanitizer.AllowAttrs("align").OnElements("th", "td")
sanitizer.AllowAttrs("type", "checked", "disabled").OnElements("input")
sanitizer.AllowAttrs("display", "xmlns").OnElements("math")
sanitizer.AllowAttrs(
"mathvariant", "stretchy", "accent", "accentunder", "separator",
"form", "fence", "lspace", "rspace", "mathcolor",
).OnElements("mi", "mn", "mo", "ms", "mtext")
sanitizer.AllowAttrs("width").OnElements("mspace")
sanitizer.AllowAttrs("displaystyle", "scriptlevel", "mathcolor").OnElements("mstyle")
sanitizer.AllowAttrs("columnalign").OnElements("mtable")
sanitizer.AllowAttrs("data-footnotes").OnElements("section")
// The attributes of the diagram SVG: geometry and paint, the
// shapes the renderer draws and the styles a classDef or a
// linkStyle statement asks for.
sanitizer.AllowAttrs("xmlns", "viewBox", "width", "height", "font-family").OnElements("svg")
sanitizer.AllowAttrs("x", "y", "width", "height", "rx").OnElements("rect")
sanitizer.AllowAttrs("x1", "y1", "x2", "y2").OnElements("line")
sanitizer.AllowAttrs("d").OnElements("path")
sanitizer.AllowAttrs("x", "y", "text-anchor").OnElements("text")
sanitizer.AllowAttrs("points").OnElements("polygon")
sanitizer.AllowAttrs(
"fill", "stroke", "stroke-width", "stroke-dasharray",
"font-size", "opacity", "style",
).OnElements("svg", "line", "path", "polygon", "rect", "text")
sanitizer.AllowURLSchemes("http", "https", "mailto")
sanitizer.AllowRelativeURLs(true)
})
return sanitizer
}
// Render renders Markdown to sanitised HTML.
func Render(text string) (string, error) {
out, _, err := RenderWithTOC(text)
return out, err
}
// RenderWithTOC renders Markdown to sanitised HTML and returns
// (html, toc_html). toc_html is the sanitised table-of-contents markup,
// or an empty string when the body has no headings.
func RenderWithTOC(src string) (string, string, error) {
if src == "" {
return "", "", nil
}
if len(src) > MaxBodyLength {
return "", "", fmt.Errorf("body exceeds %d bytes", MaxBodyLength)
}
if quoteDepthTooDeep(src) {
return "", "", fmt.Errorf("body nests blockquotes deeper than %d levels", MaxQuoteDepth)
}
rewritten, spans := extractMath(src)
html := string(scriptorium.Render([]byte(rewritten)))
html = spliceMath(html, spans)
html, toc := addHeadingIDs(html)
raw := unwrapFigureParagraphs(wrapFigures(html))
out := sanitize(raw)
// The diagrams are drawn after sanitisation: the SVG is scriptorium's
// own output, not authored markup, and the HTML policy's parser
// rewrites the case-sensitive viewBox attribute into a form no browser
// reads. A diagram the library refuses keeps its code block, which the
// sanitiser above has already cleaned like every other one.
out = renderDiagrams(out)
return out, sanitize(toc), nil
}
// quoteDepthTooDeep reports whether any line nests blockquote markers
// beyond MaxQuoteDepth. The markers may be written with or without
// spaces between them, so both spellings are counted.
func quoteDepthTooDeep(src string) bool {
for line := range strings.SplitSeq(src, "\n") {
rest := strings.TrimLeft(line, " \t")
depth := 0
for strings.HasPrefix(rest, ">") {
depth++
if depth > MaxQuoteDepth {
return true
}
rest = strings.TrimLeft(rest[1:], " \t")
}
}
return false
}
var figureParaRe = regexp.MustCompile(`(?s)<p>(<figure>.*?</figure>)</p>`)
// unwrapFigureParagraphs lifts figures out of their wrapping paragraph
// (<p> cannot contain <figure>).
func unwrapFigureParagraphs(in string) string {
return figureParaRe.ReplaceAllString(in, "<p></p>$1<p></p>")
}
func sanitize(in string) string {
if in == "" {
return in
}
out := getPolicy().Sanitize(in)
return addLinkRel(out)
}
// linkTagRe matches anchor start tags in sanitised output.
var linkTagRe = regexp.MustCompile(`<a\s[^>]*>`)
// addLinkRel forces rel="noopener noreferrer" onto every link, matching
// the shape consumers expect.
func addLinkRel(in string) string {
return linkTagRe.ReplaceAllStringFunc(in, func(tag string) string {
if strings.Contains(tag, "rel=") {
return tag
}
return `<a rel="noopener noreferrer" ` + strings.TrimPrefix(tag, "<a ")
})
}
var (
imgTitleRe = regexp.MustCompile(`(?is)<img\s[^>]*\stitle=(?:"[^"]*"|'[^']*')[^>]*/?>`)
titleAttrRe = regexp.MustCompile(`(?is)\s+title=(?:"[^"]*"|'[^']*')`)
titleValRe = regexp.MustCompile(`(?is)^<img\s[^>]*\stitle=(?:"([^"]*)"|'([^']*)')`)
)
// wrapFigures wraps every <img title="…"> in a <figure> with a
// <figcaption>, on the raw pre-sanitisation output so the title
// attribute is still present.
func wrapFigures(in string) string {
return imgTitleRe.ReplaceAllStringFunc(in, func(imgTag string) string {
title := ""
if m := titleValRe.FindStringSubmatch(imgTag); m != nil {
title = m[1]
if title == "" {
title = m[2]
}
}
// The captured attribute value is HTML-escaped (the renderer
// escapes what it writes), so it is unescaped first: escaping it
// again would show "&amp;amp;" for a title containing "&".
// Unescaping leaves a raw-HTML title the author wrote verbatim,
// and the re-escape puts both forms back in canonical shape.
caption := stdhtml.EscapeString(stdhtml.UnescapeString(title))
cleanImg := titleAttrRe.ReplaceAllString(imgTag, "")
return "<figure>" +
cleanImg +
`<div class="fig-info" aria-hidden="true">i</div>` +
"<figcaption>" + caption + "</figcaption>" +
"</figure>"
})
}
// headingTagRe matches the headings the renderer writes: scriptorium
// emits them without attributes, so the pass below is the only source of
// their id attributes. RE2 has no backreference, so the func below
// checks that the opening and closing levels agree.
var headingTagRe = regexp.MustCompile(`(?s)<h([1-6])>(.*?)</h([1-6])>`)
// innerTagRe strips the inline markup of a heading, leaving its text.
var innerTagRe = regexp.MustCompile(`(?s)<[^>]*>`)
// addHeadingIDs gives every heading an id attribute derived from its
// text and returns the table of contents built from the same walk.
func addHeadingIDs(in string) (string, string) {
used := make(map[string]int)
var headings []tocHeading
out := headingTagRe.ReplaceAllStringFunc(in, func(m string) string {
sub := headingTagRe.FindStringSubmatch(m)
level, inner, closeLevel := sub[1], sub[2], sub[3]
if level != closeLevel {
return m
}
label := strings.TrimSpace(stdhtml.UnescapeString(innerTagRe.ReplaceAllString(inner, "")))
if label == "" {
return m
}
id := headingSlug(label, used)
levelNum, _ := strconv.Atoi(level)
headings = append(headings, tocHeading{level: levelNum, id: id, text: label})
return "<h" + level + ` id="` + id + `">` + inner + "</h" + level + ">"
})
return out, renderTOC(headings)
}
// headingSlug turns a heading label into a unique id by the rule the
// previous renderer established, so the anchors the published pages
// already carry keep resolving: ASCII letters and digits, lowercased;
// spaces, dashes and underscores as dashes; every other byte, the
// diacritics of Czech prose included, dropped. Collisions are told
// apart by a numeric suffix.
func headingSlug(label string, used map[string]int) string {
var b strings.Builder
for i := 0; i < len(label); i++ {
c := label[i]
switch {
case c >= 0x80:
// A multi-byte rune is dropped whole.
continue
case 'A' <= c && c <= 'Z':
b.WriteByte(c + 'a' - 'A')
case 'a' <= c && c <= 'z' || '0' <= c && c <= '9':
b.WriteByte(c)
case c == ' ' || c == '\t' || c == '-' || c == '_':
b.WriteByte('-')
}
}
id := b.String()
if id == "" {
id = "heading"
}
if n, ok := used[id]; ok {
used[id] = n + 1
id = id + "-" + strconv.Itoa(n)
}
used[id] = 1
return id
}
// renderTOC renders the headings as a table of contents in the shape the
// API documents:
// <div class="toc"><ul><li><a href="#id">Title</a></li></ul></div>.
// The wrapper is emitted even with an empty list, so a consumer can rely
// on its presence.
func renderTOC(headings []tocHeading) string {
var b strings.Builder
b.WriteString(`<div class="toc">` + "\n")
if len(headings) == 0 {
b.WriteString("<ul></ul>\n")
} else {
b.WriteString(renderTOCList(headings))
}
b.WriteString("</div>\n")
return b.String()
}
// renderTOCList renders the headings as nested lists. A run of deeper
// headings becomes a sub-list of the heading above it, and a heading
// that returns to a shallower level closes the lists it left behind and
// continues as a sibling, so a body that opens with a second-level
// heading and later uses a first-level one keeps both in the list.
func renderTOCList(headings []tocHeading) string {
var b strings.Builder
b.WriteString("<ul>\n")
for i := 0; i < len(headings); i++ {
h := headings[i]
b.WriteString(`<li><a href="#` + stdhtml.EscapeString(h.id) + `">` +
stdhtml.EscapeString(h.text) + "</a>")
if j := deeperRun(headings, i+1, h.level); j > i+1 {
b.WriteString("\n")
b.WriteString(renderTOCList(headings[i+1 : j]))
i = j - 1
}
b.WriteString("</li>\n")
}
b.WriteString("</ul>\n")
return b.String()
}
// deeperRun returns the end index of the contiguous run of headings
// deeper than level, starting at start.
func deeperRun(headings []tocHeading, start, level int) int {
end := start
for end < len(headings) && headings[end].level > level {
end++
}
return end
}
+583
View File
@@ -0,0 +1,583 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package markdown
import (
"regexp"
"strings"
"testing"
)
func TestRenderEmpty(t *testing.T) {
out, err := Render("")
if err != nil || out != "" {
t.Fatalf("Render(\"\") = %q, %v", out, err)
}
}
func TestRenderRejectsOversizedBody(t *testing.T) {
huge := strings.Repeat("a", MaxBodyLength+1)
if _, err := Render(huge); err == nil {
t.Fatal("want error for oversized body")
}
}
func TestRenderBasicParagraph(t *testing.T) {
out, err := Render("Hello *world*")
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(out, "<em>world</em>") {
t.Fatalf("out = %q", out)
}
}
func TestRenderStripsScript(t *testing.T) {
out, err := Render(`<script>alert("x")</script>`)
if err != nil {
t.Fatalf("Render: %v", err)
}
if strings.Contains(out, "<script") || strings.Contains(out, "alert") {
t.Fatalf("script survived: %q", out)
}
}
func TestRenderStripsEventHandlers(t *testing.T) {
out, err := Render(`<img src="/media/x.webp" onerror="alert(1)">`)
if err != nil {
t.Fatalf("Render: %v", err)
}
if strings.Contains(out, "onerror") {
t.Fatalf("onerror survived: %q", out)
}
}
func TestRenderBlocksJavascriptURL(t *testing.T) {
out, err := Render(`[click](javascript:alert(1))`)
if err != nil {
t.Fatalf("Render: %v", err)
}
if strings.Contains(out, "javascript:") {
t.Fatalf("javascript: URL survived: %q", out)
}
}
func TestRenderAddsLinkRel(t *testing.T) {
out, err := Render(`[link](https://example.com)`)
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(out, `rel="noopener noreferrer"`) {
t.Fatalf("rel missing: %q", out)
}
}
func TestRenderCodeBlockLanguageClass(t *testing.T) {
out, err := Render("```go\nfmt.Println(1)\n```\n")
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(out, `class="language-go"`) {
t.Fatalf("language class missing: %q", out)
}
}
func TestRenderTaskList(t *testing.T) {
out, err := Render("- [x] done\n- [ ] todo\n")
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(out, `type="checkbox"`) {
t.Fatalf("checkbox missing: %q", out)
}
if strings.Count(out, "checked") < 1 {
t.Fatalf("checked state missing: %q", out)
}
}
func TestRenderStrikethrough(t *testing.T) {
out, err := Render("~~gone~~")
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(out, "<del>gone</del>") {
t.Fatalf("out = %q", out)
}
}
func TestRenderTable(t *testing.T) {
out, err := Render("| a | b |\n|---|---|\n| 1 | 2 |\n")
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(out, "<table>") || !strings.Contains(out, "<td") {
t.Fatalf("table missing: %q", out)
}
}
func TestWrapFigures(t *testing.T) {
out, err := Render(`![alt](/media/pic.webp "Popisek")`)
if err != nil {
t.Fatalf("Render: %v", err)
}
for _, want := range []string{"<figure>", "<figcaption>Popisek</figcaption>", `class="fig-info"`} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in %q", want, out)
}
}
if strings.Contains(out, "title=") {
t.Fatalf("title attribute survived on figure image: %q", out)
}
}
func TestWrapFiguresEscapesCaption(t *testing.T) {
out, err := Render(`![alt](/media/pic.webp "<b>x</b>")`)
if err != nil {
t.Fatalf("Render: %v", err)
}
if strings.Contains(out, "<figcaption><b>") {
t.Fatalf("caption not escaped: %q", out)
}
}
func TestRenderWithTOC(t *testing.T) {
src := "# First\n\n## Second\n\n### Third\n\n## Another\n"
out, toc, err := RenderWithTOC(src)
if err != nil {
t.Fatalf("RenderWithTOC: %v", err)
}
if !strings.Contains(out, `id="first"`) {
t.Fatalf("heading id missing: %q", out)
}
for _, want := range []string{`<div class="toc">`, `href="#first"`, `href="#second"`, `href="#third"`, `href="#another"`} {
if !strings.Contains(toc, want) {
t.Fatalf("toc missing %q: %q", want, toc)
}
}
// "Third" nests under "Second".
secondAt := strings.Index(toc, `href="#second"`)
thirdAt := strings.Index(toc, `href="#third"`)
anotherAt := strings.Index(toc, `href="#another"`)
if !(secondAt < thirdAt && thirdAt < anotherAt) {
t.Fatalf("toc order wrong: %q", toc)
}
if strings.Count(toc, "<ul>") < 2 {
t.Fatalf("nested list missing: %q", toc)
}
}
func TestRenderWithTOCNoHeadings(t *testing.T) {
_, toc, err := RenderWithTOC("just text")
if err != nil {
t.Fatalf("RenderWithTOC: %v", err)
}
// The wrapper is present even with an empty list.
want := `<div class="toc">` + "\n<ul></ul>\n</div>\n"
if toc != want {
t.Fatalf("toc = %q, want %q", toc, want)
}
}
func TestRenderAllowsRelativeImage(t *testing.T) {
out, err := Render(`![](/media/pic.webp)`)
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(out, `src="/media/pic.webp"`) {
t.Fatalf("relative image stripped: %q", out)
}
}
// BenchmarkRender measures the rendering pipeline for a medium body
// (2.4 KB) and a large one (43 KB), which bracket the posts the engine is
// built for.
func BenchmarkRender(b *testing.B) {
medium := strings.Repeat("Some **markdown** text with a [link](https://example.com).\n\n", 40)
large := strings.Repeat(medium, 18)
for name, src := range map[string]string{"medium": medium, "large": large} {
b.Run(name, func(b *testing.B) {
b.SetBytes(int64(len(src)))
for b.Loop() {
if _, _, err := RenderWithTOC(src); err != nil {
b.Fatal(err)
}
}
})
}
}
// A body that opens with a second-level heading and later uses a
// first-level one must keep both in the table of contents: the shallower
// heading closes the list it was nested in rather than ending the walk.
func TestTOCKeepsShallowerHeadings(t *testing.T) {
_, toc, err := RenderWithTOC("## Intro\n\n### Detail\n\n# Later\n\ntext\n")
if err != nil {
t.Fatalf("RenderWithTOC: %v", err)
}
for _, want := range []string{"Intro", "Detail", "Later"} {
if !strings.Contains(toc, want) {
t.Fatalf("toc is missing %q:\n%s", want, toc)
}
}
if got := strings.Count(toc, "<li>"); got != 3 {
t.Fatalf("toc lists %d headings, want 3:\n%s", got, toc)
}
// Detail is nested one list deeper than Intro, and Later sits beside
// Intro rather than inside it.
nested := strings.Index(toc, `href="#detail"`)
intro := strings.Index(toc, `href="#intro"`)
later := strings.Index(toc, `href="#later"`)
if !(intro < nested && nested < later) {
t.Fatalf("headings are out of order in the toc:\n%s", toc)
}
if strings.Count(toc, "<ul>") != 2 {
t.Fatalf("want one nested list, got %d lists:\n%s", strings.Count(toc, "<ul>"), toc)
}
}
// A caption containing "&" is escaped once: goldmark writes the title
// attribute HTML-escaped, so escaping the captured value again would
// publish "&amp;amp;".
func TestFigureCaptionEscapesOnce(t *testing.T) {
out, _, err := RenderWithTOC(`![alt](/media/p.webp "Tom & Jerry")`)
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(out, "<figcaption>Tom &amp; Jerry</figcaption>") {
t.Fatalf("caption = %s", out)
}
if strings.Contains(out, "&amp;amp;") {
t.Fatalf("caption double-escaped: %s", out)
}
// The raw-HTML form (author-written, unescaped by goldmark) produces
// the same caption.
raw, _, err := RenderWithTOC(`<img src="/media/p.webp" title="Tom & Jerry">`)
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(raw, "<figcaption>Tom &amp; Jerry</figcaption>") {
t.Fatalf("raw caption = %s", raw)
}
}
// A heading written with an entity reference and the TOC entry for it
// agree: the TOC shows the decoded text, as the rendered heading does.
func TestTOCResolvesEntityReferences(t *testing.T) {
_, toc, err := RenderWithTOC("## Caf&eacute;\n\ntext")
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(toc, ">Café</a>") {
t.Fatalf("toc = %s", toc)
}
if strings.Contains(toc, "&amp;eacute;") {
t.Fatalf("toc double-escaped: %s", toc)
}
}
// A body of nothing but blockquote markers is bounded: rendering cost
// grows superlinearly with depth, so an absurd nest is rejected instead
// of rendered.
func TestQuoteDepthIsBounded(t *testing.T) {
tooDeep := strings.Repeat(">", MaxQuoteDepth+1) + " text"
if _, _, err := RenderWithTOC(tooDeep); err == nil {
t.Fatal("an absurdly nested body was rendered")
}
// Spelled with spaces it is the same nest.
spaced := strings.Repeat("> ", MaxQuoteDepth+1) + "text"
if _, _, err := RenderWithTOC(spaced); err == nil {
t.Fatal("the spaced form slipped through")
}
// Legitimate nesting still renders, and a quoted block that merely
// mentions the marker in prose is not counted.
deep := strings.Repeat("> ", 50) + "text"
if _, _, err := RenderWithTOC(deep); err != nil {
t.Fatalf("legitimate nesting rejected: %v", err)
}
if _, _, err := RenderWithTOC("The `>` in `>>> /dev/null` is code."); err != nil {
t.Fatalf("prose with markers rejected: %v", err)
}
}
// An inline equation the author broke across lines does not swallow the
// prose after it into mathematics: the run that spans the break contains
// a dollar inside, and such a run is refused, so the broken fragments
// stay the text they look like and the next whole equation still
// renders.
func TestBrokenInlineRunKeepsProseOut(t *testing.T) {
src := "5. Čtení nese **rovnici** $\\nabla^2 h =\n (8\\pi/\\kappa a)u$: vazba je pružná síla buňky $\\kappa a = c^4/G$,\n zdroj je energie.\n"
out, err := Render(src)
if err != nil {
t.Fatalf("render: %v", err)
}
if strings.Contains(out, "<merror>") {
t.Fatalf("the broken run degraded inside math: %q", out)
}
// The prose never becomes mathematics: ž occurs only in prose.
if strings.Contains(out, "<mi>ž</mi>") {
t.Fatalf("prose was swallowed into math: %q", out)
}
// The whole equation after the prose still renders.
if !strings.Contains(out, "<mi>κ</mi>") {
t.Fatalf("the clean equation did not render: %q", out)
}
// The broken fragments stay visible as their source.
if !strings.Contains(out, `$\nabla^2 h =`) {
t.Fatalf("the opening fragment did not stay text: %q", out)
}
}
// A display equation may span lines: the $$ opens on the line that
// starts the mathematics and closes on a later one, the shape the
// papers in the corpus are written in.
func TestMultiLineDisplayMath(t *testing.T) {
src := "Text above.\n\n$$r_h = \\frac{\\sigma}{\\sqrt{2\\pi G \\rho_{\\text{amb}}}},\n\\qquad M_h = \\frac{2\\sigma^2 r_h}{G}. \\quad (7)$$\n\ntext below\n"
out, err := Render(src)
if err != nil {
t.Fatalf("render: %v", err)
}
if strings.Contains(out, "$$") {
t.Fatalf("the markers survived: %q", out)
}
for _, want := range []string{
"<p>Text above.</p>",
`<div class="math math-display">`, `display="block"`,
"<mi>σ</mi>", "<mi>M</mi>", "<mn>7</mn>",
"<p>text below</p>",
} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in %q", want, out)
}
}
}
// A block that opens on a line of its own collects until a closing $$.
func TestMultiLineDisplayMathBareCloser(t *testing.T) {
src := "$$\nE = mc^2\n$$\n"
out, err := Render(src)
if err != nil {
t.Fatalf("render: %v", err)
}
if strings.Contains(out, "$$") || !strings.Contains(out, `display="block"`) {
t.Fatalf("out = %q", out)
}
}
// An unclosed $$ stays text: the search for the closer stops at a blank
// line or the line bound, so a stray marker cannot swallow the body.
func TestUnclosedDisplayMathStaysText(t *testing.T) {
src := "$$x = 1,\nstill prose\n\nmore prose\n"
out, err := Render(src)
if err != nil {
t.Fatalf("render: %v", err)
}
if strings.Contains(out, "<math") {
t.Fatalf("an unclosed block became mathematics: %q", out)
}
if !strings.Contains(out, "$$x = 1,") {
t.Fatalf("the stray marker did not survive as text: %q", out)
}
}
// A display equation set right below the sentence that introduces it,
// without a blank line, becomes its own block: the paragraph above ends,
// the equation stands alone, and the sentence after it opens a new
// paragraph.
func TestDisplayMathInterruptsParagraph(t *testing.T) {
src := "The metric reads\n$$g_{tt} = 1$$\nand continues.\n"
out, err := Render(src)
if err != nil {
t.Fatalf("render: %v", err)
}
for _, want := range []string{
"<p>The metric reads</p>",
`<div class="math math-display">`,
`display="block"`,
"<p>and continues.</p>",
} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in %q", want, out)
}
}
}
// Mathematics is a prose construct: a dollar inside a fenced block, an
// indented one or a code span stays the literal byte it is, and a
// backslash-escaped dollar never opens a run. The escape itself the
// renderer consumes, so the escaped dollar reaches the reader as a bare
// one that still opens no mathematics.
func TestMathSkipsCode(t *testing.T) {
src := "```tex\n$x^2$\n```\n\n $x^2$\n\nInline `$x$` code, and \\$x\\$ escaped.\n"
out, err := Render(src)
if err != nil {
t.Fatalf("render: %v", err)
}
if strings.Contains(out, "<math") {
t.Fatalf("code was turned into mathematics: %q", out)
}
for _, want := range []string{"$x^2$", "$x$"} {
if !strings.Contains(out, want) {
t.Fatalf("literal %q missing in %q", want, out)
}
}
}
// Two dollar amounts in a sentence are not a phantom equation. A $$…$$
// run that shares its line with text keeps the historical shape: the
// first dollar stays text, the inner $…$ is inline mathematics and the
// closing dollar follows it, exactly as the engine this pass replaces
// rendered it.
func TestCurrencyGuards(t *testing.T) {
out, err := Render("Costs $5 and $10 per group.\n\nSplit $$x$$ mid line.\n")
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(out, "<p>Costs $5 and $10 per group.</p>") {
t.Fatalf("amounts became mathematics: %q", out)
}
if !strings.Contains(out, "Split $<math") || !strings.Contains(out, "</math>$ mid line.") {
t.Fatalf("the mid-line run changed shape: %q", out)
}
}
// A construct outside the mappable surface is not refused: it degrades
// in place, its source visible in an merror element.
func TestMathDegradesInPlace(t *testing.T) {
out, err := Render("$$\\raisebox{1em}{E}$$\n")
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(out, "<merror>") || !strings.Contains(out, `\raisebox`) {
t.Fatalf("no honest degradation: %q", out)
}
}
// A body that already carries the private-use sentinel runes is left
// alone rather than spliced into the wrong place.
func TestSentinelRunesDisableMath(t *testing.T) {
src := "Text \uE000" + "0" + "\uE001 with $x$ inside.\n"
out, err := Render(src)
if err != nil {
t.Fatalf("render: %v", err)
}
if strings.Contains(out, "<math") {
t.Fatalf("a sentinel-bearing body was spliced: %q", out)
}
}
// A flowchart or a sequence diagram renders to an inline SVG in a
// wrapper a style sheet can address.
func TestMermaidRendersDiagram(t *testing.T) {
out, err := Render("```mermaid\nflowchart LR\n A --> B\n```\n")
if err != nil {
t.Fatalf("render: %v", err)
}
for _, want := range []string{
`<div class="diagram">`, "<svg", `viewBox=`, "</svg>",
} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in %q", want, out)
}
}
if strings.Contains(out, "<pre") {
t.Fatalf("the code block survived the rendered diagram: %q", out)
}
}
// A diagram type outside the two families the library carries keeps its
// code block, so the author sees the source that was refused.
func TestMermaidRefusalKeepsCode(t *testing.T) {
src := "```mermaid\nstateDiagram-v2\n [*] --> calm\n```\n"
out, err := Render(src)
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(out, `class="language-mermaid"`) || strings.Contains(out, "<svg") {
t.Fatalf("the refused diagram did not stay source: %q", out)
}
}
// The diagram SVG is spliced in after the sanitiser, so the diagram
// source must not be able to smuggle markup the policy would have
// refused: a hostile label or interaction line may at worst break the
// drawing, never open an element, name an event handler inside a tag or
// plant a javascript: URL. Label text itself is escaped by the renderer
// and stays inert, so the assertions look at markup positions, not at
// the mere presence of a word.
func TestMermaidSourceCannotInjectMarkup(t *testing.T) {
// handlerInTag matches an event handler attribute inside a tag, and
// scriptURL an attribute whose URL is a javascript: one.
handlerInTag := regexp.MustCompile(`(?is)<[a-z][^>]*\bon[a-z]+\s*=`)
scriptURL := regexp.MustCompile(`(?is)<[a-z][^>]*(?:href|src)\s*=\s*["']\s*javascript:`)
sources := []string{
"flowchart LR\n A[\"<img src=x onerror=alert(1)>\"] --> B\n",
"flowchart LR\n A[\"<script>alert(1)</script>\"] --> B\n",
"flowchart LR\n A[\"x\" onmouseover=\"alert(1)\"] --> B\n",
"flowchart LR\n A --> B\n click B \"javascript:alert(1)\"\n",
}
for _, src := range sources {
out, err := Render("```mermaid\n" + src + "```\n")
if err != nil {
t.Fatalf("render %q: %v", src, err)
}
lower := strings.ToLower(out)
for _, banned := range []string{"<img", "<script"} {
if strings.Contains(lower, banned) {
t.Fatalf("%q reached the page through the diagram: %q", banned, out)
}
}
if loc := handlerInTag.FindString(lower); loc != "" {
t.Fatalf("an event handler reached a tag through the diagram (%q): %q", loc, out)
}
if loc := scriptURL.FindString(lower); loc != "" {
t.Fatalf("a javascript URL reached a tag through the diagram (%q): %q", loc, out)
}
}
}
// Definition lists survive sanitisation as themselves: the terms stay
// dt, the definitions dd.
func TestDefinitionListSurvives(t *testing.T) {
out, err := Render("Term\n: definition\n")
if err != nil {
t.Fatalf("render: %v", err)
}
for _, want := range []string{"<dl>", "<dt>Term</dt>", "<dd>definition</dd>", "</dl>"} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in %q", want, out)
}
}
}
// The footnote round trip keeps its ids and markers: the reference
// carries the id the back reference points back to.
func TestFootnoteMarkersSurvive(t *testing.T) {
out, err := Render("Text[^1].\n\n[^1]: The note.\n")
if err != nil {
t.Fatalf("render: %v", err)
}
for _, want := range []string{
`href="#fn-1"`, `id="fnref-1"`, `data-footnote-ref`, `id="fn-1"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in %q", want, out)
}
}
}
// Two headings of the same text are told apart by a numeric suffix. A
// heading of Czech prose keeps the id the previous renderer gave it:
// the diacritics are dropped, exactly as the anchors already published
// spell them.
func TestHeadingSlugs(t *testing.T) {
out, _, err := RenderWithTOC("## Same\n\ntext\n\n## Same\n\n## Čeština pro vědce\n")
if err != nil {
t.Fatalf("render: %v", err)
}
for _, want := range []string{`id="same"`, `id="same-1"`, `id="etina-pro-vdce"`} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in %q", want, out)
}
}
}
+538
View File
@@ -0,0 +1,538 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package markdown
import (
"regexp"
"strconv"
"strings"
"sourcedock.dev/petrbalvin/scriptorium"
)
// The mathematics pass turns TeX runs into MathML: $$…$$ occupying a
// whole line becomes a display block, $…$ within one line becomes an
// inline element. scriptorium's Markdown grammar knows nothing about
// dollars, so the runs are lifted out of the source before rendering,
// each replaced by a placeholder of two private-use runes around its
// index, and the MathML is spliced back into the rendered HTML in the
// placeholder's place. A run outside the mappable surface is not
// refused: scriptorium degrades it in place, its verbatim source inside
// an merror element, so nothing is silently mistranslated.
// mathSpan is one equation lifted out of the source.
type mathSpan struct {
display bool
src string
}
// The placeholder runes come from Unicode's private-use area, so no
// authored body contains them; a body that somehow does is left without
// mathematics rather than spliced into the wrong place.
const (
sentinelOpen = '\uE000'
sentinelClose = '\uE001'
)
func mathToken(i int) string {
return string(sentinelOpen) + strconv.Itoa(i) + string(sentinelClose)
}
// lineKind tells how the previous emitted line ended, which is what the
// display rules need: an equation that follows text needs a separating
// blank line, or the renderer keeps it inside the paragraph above it.
type lineKind int
const (
prevBlank lineKind = iota
prevText
prevDisplay
)
// extractMath rewrites the source with placeholders and returns the
// equations in the order their placeholders appear.
func extractMath(src string) (string, []mathSpan) {
if strings.ContainsRune(src, sentinelOpen) || strings.ContainsRune(src, sentinelClose) {
return src, nil
}
var spans []mathSpan
next := func(display bool, src string) string {
spans = append(spans, mathSpan{display: display, src: src})
return mathToken(len(spans) - 1)
}
var out strings.Builder
emit := func(line string) {
out.WriteString(line)
out.WriteByte('\n')
}
fenceChar := byte(0)
fenceLen := 0
inCode := false
inHTML := false
pendingTick := 0
prev := prevBlank
// The lines are indexed rather than streamed: the multi-line display
// collector looks ahead from the line it stands on.
lines := strings.Split(src, "\n")
for i := 0; i < len(lines); i++ {
line := lines[i]
indent := len(line) - len(strings.TrimLeft(line, " \t"))
blank := strings.TrimSpace(line) == ""
// A fenced code block passes every line through verbatim.
if fenceChar != 0 {
emit(line)
if isClosingFence(line, fenceChar, fenceLen) {
fenceChar = 0
prev = prevBlank
}
continue
}
// The code-span cover of the line, carrying any run an earlier
// line left open. A span that has not closed keeps the whole
// line out of every other rule.
covered, open := codeSpans(line, pendingTick)
if open > 0 {
emit(line)
pendingTick = open
prev = prevText
continue
}
pendingTick = 0
startsCovered := len(covered) > 0 && covered[0]
if !startsCovered {
if c, n, ok := openingFence(line); ok {
fenceChar, fenceLen = c, n
emit(line)
continue
}
}
// An indented code block: entered from a blank line, left by the
// first line that is blank or carries less indentation.
if inCode {
if blank || indent >= 4 {
emit(line)
continue
}
inCode = false
} else if indent >= 4 && prev == prevBlank && !blank {
inCode = true
emit(line)
continue
}
// A raw HTML block runs to the next blank line, and its dollars
// are markup, not mathematics.
if inHTML {
emit(line)
if blank {
inHTML = false
prev = prevBlank
}
continue
}
if !startsCovered && startsHTMLBlock(line) {
inHTML = true
emit(line)
continue
}
if blank {
emit(line)
prev = prevBlank
continue
}
quotePrefix, rest := stripQuoteMarkers(line)
listPrefix, item, inList := stripListMarker(rest)
if inner, ok := displayMath(item); ok {
if !inList && prev != prevBlank {
emit(strings.TrimRight(quotePrefix, " \t"))
}
emit(quotePrefix + listPrefix + next(true, inner))
prev = prevDisplay
continue
}
if parts, end, ok := collectDisplayBlock(lines, i, item); ok {
if !inList && prev != prevBlank {
emit("")
}
emit(quotePrefix + listPrefix + next(true, strings.Join(parts, "\n")))
prev = prevDisplay
i = end
continue
}
if prev == prevDisplay && !inList {
emit(strings.TrimRight(quotePrefix, " \t"))
}
off := len(quotePrefix) + len(listPrefix)
emit(quotePrefix + listPrefix + renderInlineMath(item, covered, off, next))
prev = prevText
}
return out.String(), spans
}
// maxMathBlockLines bounds how far a multi-line display block may reach
// for its closing $$. A block the author never closed then falls back to
// literal text instead of swallowing the rest of the body.
const maxMathBlockLines = 64
// collectDisplayBlock looks ahead from lines[i], whose content opens a
// $$ block it does not close on the same line, for the line that closes
// it. The content lines between become the parts of one display
// equation. The collection stays inside one paragraph: a blank line, a
// code fence, a blockquote marker or the line bound ends the search and
// the block is refused, so a stray $$ stays the text it looks like.
func collectDisplayBlock(lines []string, i int, item string) (parts []string, end int, ok bool) {
if !strings.HasPrefix(item, "$$") {
return nil, 0, false
}
first := item[2:]
if strings.Contains(first, "$$") {
return nil, 0, false
}
if strings.TrimSpace(first) != "" {
parts = append(parts, first)
}
for j := i + 1; j < len(lines) && j-i <= maxMathBlockLines; j++ {
t := strings.TrimSpace(lines[j])
if t == "" || strings.HasPrefix(t, ">") {
return nil, 0, false
}
if _, _, fenced := openingFence(lines[j]); fenced {
return nil, 0, false
}
if strings.HasSuffix(t, "$$") && backslashRun(t, len(t)-2)%2 == 0 {
tail := t[:len(t)-2]
if strings.Contains(tail, "$$") {
return nil, 0, false
}
if strings.TrimSpace(tail) != "" {
parts = append(parts, tail)
}
if len(parts) == 0 || strings.TrimSpace(strings.Join(parts, "")) == "" {
return nil, 0, false
}
return parts, j, true
}
parts = append(parts, t)
}
return nil, 0, false
}
// renderInlineMath replaces the $…$ runs of one line with placeholders,
// leaving the bytes inside backtick code spans and the dollars written
// \$ alone. The cover was computed for the whole source line, so off
// tells where the line's remaining content begins in it.
func renderInlineMath(line string, covered []bool, off int, next func(bool, string) string) string {
var b strings.Builder
i := 0
for i < len(line) {
if i+off < len(covered) && covered[i+off] {
b.WriteByte(line[i])
i++
continue
}
if line[i] == '$' && backslashRun(line, i)%2 == 0 {
if value, consumed, ok := matchInlineMath(line[i:]); ok {
b.WriteString(next(false, value))
i += consumed
continue
}
}
b.WriteByte(line[i])
i++
}
return b.String()
}
// displayMath reports whether the whole of a line's content is one
// $$…$$ run, and returns the mathematics between the fences. A run that
// is empty, that hides another $$ or that shares its line with anything
// else is not a display equation.
func displayMath(t string) (string, bool) {
if len(t) < 5 || !strings.HasPrefix(t, "$$") || !strings.HasSuffix(t, "$$") {
return "", false
}
inner := t[2 : len(t)-2]
if strings.Contains(inner, "$$") || strings.TrimSpace(inner) == "" {
return "", false
}
return inner, true
}
// matchInlineMath matches one $…$ run at the head of line. The guards
// mirror the shape of real prose: the run must not be empty, may not
// start or end with a space, may not close before a digit, and may not
// contain a dollar inside, so a sentence with two dollar amounts does
// not become a phantom equation, and a run whose opening dollar is a
// closer of an equation broken across lines never swallows the prose
// around it into mathematics.
func matchInlineMath(line string) (value string, consumed int, ok bool) {
if len(line) < 3 || line[0] != '$' || line[1] == '$' || line[1] == ' ' {
return "", 0, false
}
for i := 1; i < len(line); i++ {
if line[i] == '\\' {
i++ // an escaped character is never the closer
continue
}
if line[i] != '$' || i == 1 {
continue
}
if line[i-1] == ' ' {
continue
}
if i+1 < len(line) && isDigit(line[i+1]) {
continue // currency: the next run of digits belongs outside
}
value := line[1:i]
if strings.ContainsRune(value, '$') {
return "", 0, false
}
return value, i + 1, true
}
return "", 0, false
}
func isDigit(b byte) bool { return '0' <= b && b <= '9' }
// backslashRun counts the backslashes immediately before line[i]; an
// odd count means the byte is escaped.
func backslashRun(line string, i int) int {
n := 0
for j := i - 1; j >= 0 && line[j] == '\\'; j-- {
n++
}
return n
}
// codeSpans marks the bytes of line that sit inside a backtick code
// span and reports the length of a run the line leaves open. A span
// opens with a run of n backticks and closes with the next run of
// exactly n; a run left open is carried to the next line by the
// pending state, and while it is open no dollar on the line starts
// mathematics.
func codeSpans(line string, pending int) (covered []bool, open int) {
covered = make([]bool, len(line))
open = pending
i := 0
if pending > 0 {
end := findTickRun(line, pending)
if end < 0 {
for j := range covered {
covered[j] = true
}
return covered, pending
}
for j := 0; j < end+pending; j++ {
covered[j] = true
}
i = end + pending
open = 0
}
for i < len(line) {
if line[i] != '`' {
i++
continue
}
n := 0
for i+n < len(line) && line[i+n] == '`' {
n++
}
end := findTickRun(line[i+n:], n)
if end < 0 {
if open == 0 {
open = n
}
i += n
continue
}
closeAt := i + n + end
for j := i; j < closeAt+n; j++ {
covered[j] = true
}
i = closeAt + n
}
return covered, open
}
// findTickRun returns the index in line where a run of exactly n
// backticks begins, or -1 when there is none.
func findTickRun(line string, n int) int {
for i := 0; i < len(line); {
if line[i] != '`' {
i++
continue
}
run := 0
for i+run < len(line) && line[i+run] == '`' {
run++
}
if run == n {
return i
}
i += run
}
return -1
}
// openingFence reports whether the line opens a fenced code block, and
// with which character and length.
func openingFence(line string) (byte, int, bool) {
t := strings.TrimLeft(line, " \t")
if len(line)-len(t) > 3 {
return 0, 0, false
}
if n := fenceRun(t, '`'); n > 0 {
return '`', n, true
}
if n := fenceRun(t, '~'); n > 0 {
return '~', n, true
}
return 0, 0, false
}
// fenceRun returns the length of a fence of c at the head of t, which
// the rest of the line may follow only with spaces, or 0 when this is
// not a fence.
func fenceRun(t string, c byte) int {
n := 0
for n < len(t) && t[n] == c {
n++
}
if n < 3 {
return 0
}
for _, r := range t[n:] {
if r != ' ' && r != '\t' {
return 0
}
}
return n
}
// isClosingFence reports whether the line closes an open fence.
func isClosingFence(line string, c byte, n int) bool {
t := strings.TrimLeft(line, " \t")
if len(line)-len(t) > 3 {
return false
}
run := 0
for run < len(t) && t[run] == c {
run++
}
if run < n {
return false
}
for _, r := range t[run:] {
if r != ' ' && r != '\t' {
return false
}
}
return true
}
// startsHTMLBlock approximates the CommonMark HTML block: a line that
// opens with a tag, a closing tag, a comment or a declaration starts
// one, and the block then runs to the next blank line.
func startsHTMLBlock(line string) bool {
t := strings.TrimLeft(line, " \t")
if len(t) < 2 || t[0] != '<' {
return false
}
c := t[1]
return c == '/' || c == '!' || c == '?' ||
('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z')
}
// stripQuoteMarkers removes the blockquote markers from the head of the
// line and returns everything consumed with what remains.
func stripQuoteMarkers(line string) (prefix, rest string) {
rest = line
for {
j := 0
for j < len(rest) && (rest[j] == ' ' || rest[j] == '\t') {
j++
}
if j >= len(rest) || rest[j] != '>' {
break
}
rest = rest[j+1:]
}
return line[:len(line)-len(rest)], rest
}
// stripListMarker removes one list marker from the head of the line, so
// an equation that is a list item's whole content is still recognised
// as display mathematics inside the item.
func stripListMarker(line string) (prefix, rest string, ok bool) {
j := 0
for j < len(line) && (line[j] == ' ' || line[j] == '\t') {
j++
}
rest = line[j:]
if strings.HasPrefix(rest, "- ") || strings.HasPrefix(rest, "* ") || strings.HasPrefix(rest, "+ ") {
rest = rest[2:]
} else {
digits := 0
for digits < len(rest) && digits < 9 && isDigit(rest[digits]) {
digits++
}
if digits == 0 || digits+1 >= len(rest) {
return "", line, false
}
if (rest[digits] == '.' || rest[digits] == ')') && rest[digits+1] == ' ' {
rest = rest[digits+2:]
} else {
return "", line, false
}
}
rest = strings.TrimLeft(rest, " \t")
if rest == "" {
return "", line, false
}
return line[:len(line)-len(rest)], rest, true
}
// spliceMath puts the rendered MathML into the HTML in each
// placeholder's place. A placeholder alone in its paragraph becomes the
// display wrapper; any other position takes the math element as it
// stands, which keeps the HTML valid where a display equation shares
// its paragraph with text or sits in a tight list item.
func spliceMath(html string, spans []mathSpan) string {
for i, span := range spans {
math := renderMathSpan(span)
alone := regexp.MustCompile(`(?s)<p>\s*` + mathToken(i) + `\s*</p>`)
if alone.MatchString(html) {
html = alone.ReplaceAllString(html, regexpEscapeRepl(math))
continue
}
html = strings.Replace(html, mathToken(i), math, 1)
}
return html
}
// regexpEscapeRepl guards the replacement text of ReplaceAllString,
// where a dollar sign would otherwise read as a capture group.
func regexpEscapeRepl(s string) string {
return strings.ReplaceAll(s, "$", "$$")
}
// renderMathSpan renders one equation through scriptorium. Rendering
// never fails: a construct outside the mappable surface degrades to its
// verbatim source inside an merror element, in place.
func renderMathSpan(span mathSpan) string {
if span.display {
return `<div class="math math-display">` + "\n" +
string(scriptorium.RenderMathDisplay([]byte(span.src))) + "\n</div>"
}
return string(scriptorium.RenderMath([]byte(span.src)))
}
+6
View File
@@ -0,0 +1,6 @@
=== HTML ===
<p>Hello <em>world</em> and <strong>bold</strong></p>
=== TOC ===
<div class="toc">
<ul></ul>
</div>
+11
View File
@@ -0,0 +1,11 @@
=== HTML ===
<p>Prose before.</p>
<pre><code class="language-go">fmt.Println(&#34;hi&#34;)
</code></pre>
<pre><code>indented code
</code></pre>
<p>Inline <code>code</code> too.</p>
=== TOC ===
<div class="toc">
<ul></ul>
</div>

Some files were not shown because too many files have changed in this diff Show More