Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,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 ./...
|
||||||
@@ -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);
|
||||||
|
'
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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{} }
|
||||||
@@ -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
@@ -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"}` |
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 ------------------------------------------------
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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`))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ------------------------------------------------------------
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 ----------------------------------------------------------------
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 ----------------------------------------------------
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}, ""
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>"
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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" onmouseover="alert(1)\"") {
|
||||||
|
t.Fatalf("the internal link lost its target: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, "/")
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
`
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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í & <pos></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í & <pos></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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]}]}
|
||||||
@@ -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"}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"error":"draft"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"error":"not_found"}
|
||||||
@@ -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}
|
||||||
@@ -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"}}}]}
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"series":[{"name":"Series","count":1}]}
|
||||||
@@ -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"}]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"title":"Volumen","description":"Powered by Volumen.","base_url":"https://site.example","language":"en","author":"Anonymous","fediverse_creator":""}
|
||||||
@@ -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}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"tags":[{"name":"go","count":2},{"name":"research","count":1}]}
|
||||||
@@ -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 <token></code> on <code>POST/PUT/DELETE /api/volumen/posts</code>.": "Token posílejte jako <code>Authorization: Bearer <token></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
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>"
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;" 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
|
||||||
|
}
|
||||||
@@ -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(``)
|
||||||
|
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(``)
|
||||||
|
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(``)
|
||||||
|
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;".
|
||||||
|
func TestFigureCaptionEscapesOnce(t *testing.T) {
|
||||||
|
out, _, err := RenderWithTOC(``)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "<figcaption>Tom & Jerry</figcaption>") {
|
||||||
|
t.Fatalf("caption = %s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "&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 & 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é\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, "&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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -0,0 +1,6 @@
|
|||||||
|
=== HTML ===
|
||||||
|
<p>Hello <em>world</em> and <strong>bold</strong></p>
|
||||||
|
=== TOC ===
|
||||||
|
<div class="toc">
|
||||||
|
<ul></ul>
|
||||||
|
</div>
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
=== HTML ===
|
||||||
|
<p>Prose before.</p>
|
||||||
|
<pre><code class="language-go">fmt.Println("hi")
|
||||||
|
</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
Reference in New Issue
Block a user