commit f8ed33df83836e4ec16e78f31758c1f23520103d Author: Petr Balvín Date: Fri Sep 18 12:03:35 2026 +0200 Initial commit Assisted-by: GLM 5.3 diff --git a/.gitea/workflows/race.yml b/.gitea/workflows/race.yml new file mode 100644 index 0000000..503bbfe --- /dev/null +++ b/.gitea/workflows/race.yml @@ -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 ./... diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..bb54e8a --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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---. 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); + ' diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..d063b31 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -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); + ' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f4d5aa --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f978f28 --- /dev/null +++ b/CHANGELOG.md @@ -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//`, 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; `" 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 carefully`, + }} + out := string(templatesJSON(list)) + if strings.Contains(out, "") { + t.Fatalf("literal script close survived: %s", out) + } + if !strings.Contains(out, `\u003c/script>`) { + t.Fatalf("expected unicode escapes: %s", out) + } +} diff --git a/internal/admin/auth.go b/internal/admin/auth.go new file mode 100644 index 0000000..b56895d --- /dev/null +++ b/internal/admin/auth.go @@ -0,0 +1,109 @@ +// Copyright (c) 2026 Petr Balvín (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 +} diff --git a/internal/admin/media_routes.go b/internal/admin/media_routes.go new file mode 100644 index 0000000..af9126a --- /dev/null +++ b/internal/admin/media_routes.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Petr Balvín (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) +} diff --git a/internal/admin/posts_history.go b/internal/admin/posts_history.go new file mode 100644 index 0000000..0f2fe7e --- /dev/null +++ b/internal/admin/posts_history.go @@ -0,0 +1,160 @@ +// Copyright (c) 2026 Petr Balvín (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 ------------------------------------------------ diff --git a/internal/admin/posts_import.go b/internal/admin/posts_import.go new file mode 100644 index 0000000..6b0e1e3 --- /dev/null +++ b/internal/admin/posts_import.go @@ -0,0 +1,92 @@ +// Copyright (c) 2026 Petr Balvín (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 +} diff --git a/internal/admin/posts_routes.go b/internal/admin/posts_routes.go new file mode 100644 index 0000000..061dc39 --- /dev/null +++ b/internal/admin/posts_routes.go @@ -0,0 +1,642 @@ +// Copyright (c) 2026 Petr Balvín (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, + }) +} diff --git a/internal/admin/posts_routes_test.go b/internal/admin/posts_routes_test.go new file mode 100644 index 0000000..c2cb631 --- /dev/null +++ b/internal/admin/posts_routes_test.go @@ -0,0 +1,929 @@ +// Copyright (c) 2026 Petr Balvín (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, `
bold") { + 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(), "`, + `[1]`, + `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()) + } + } +} diff --git a/internal/admin/posts_upload.go b/internal/admin/posts_upload.go new file mode 100644 index 0000000..630c764 --- /dev/null +++ b/internal/admin/posts_upload.go @@ -0,0 +1,88 @@ +// Copyright (c) 2026 Petr Balvín (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) +} diff --git a/internal/admin/render.go b/internal/admin/render.go new file mode 100644 index 0000000..ddc0dee --- /dev/null +++ b/internal/admin/render.go @@ -0,0 +1,244 @@ +// Copyright (c) 2026 Petr Balvín (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`)) + }, + } +} diff --git a/internal/admin/settings_account.go b/internal/admin/settings_account.go new file mode 100644 index 0000000..982f331 --- /dev/null +++ b/internal/admin/settings_account.go @@ -0,0 +1,221 @@ +// Copyright (c) 2026 Petr Balvín (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 ------------------------------------------------------------ diff --git a/internal/admin/settings_api.go b/internal/admin/settings_api.go new file mode 100644 index 0000000..7e05cc0 --- /dev/null +++ b/internal/admin/settings_api.go @@ -0,0 +1,170 @@ +// Copyright (c) 2026 Petr Balvín (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) +} diff --git a/internal/admin/settings_backup.go b/internal/admin/settings_backup.go new file mode 100644 index 0000000..6efb2c7 --- /dev/null +++ b/internal/admin/settings_backup.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Petr Balvín (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 ---------------------------------------------------------------- diff --git a/internal/admin/settings_routes.go b/internal/admin/settings_routes.go new file mode 100644 index 0000000..27063e9 --- /dev/null +++ b/internal/admin/settings_routes.go @@ -0,0 +1,179 @@ +// Copyright (c) 2026 Petr Balvín (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) +} diff --git a/internal/admin/settings_routes_test.go b/internal/admin/settings_routes_test.go new file mode 100644 index 0000000..141890a --- /dev/null +++ b/internal/admin/settings_routes_test.go @@ -0,0 +1,820 @@ +// Copyright (c) 2026 Petr Balvín (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") + } +} diff --git a/internal/admin/settings_totp.go b/internal/admin/settings_totp.go new file mode 100644 index 0000000..882dd31 --- /dev/null +++ b/internal/admin/settings_totp.go @@ -0,0 +1,189 @@ +// Copyright (c) 2026 Petr Balvín (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) +} diff --git a/internal/admin/settings_update.go b/internal/admin/settings_update.go new file mode 100644 index 0000000..1f363ca --- /dev/null +++ b/internal/admin/settings_update.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Petr Balvín (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 ---------------------------------------------------- diff --git a/internal/admin/settings_users.go b/internal/admin/settings_users.go new file mode 100644 index 0000000..6122de9 --- /dev/null +++ b/internal/admin/settings_users.go @@ -0,0 +1,129 @@ +// Copyright (c) 2026 Petr Balvín (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) +} diff --git a/internal/admin/settings_views.go b/internal/admin/settings_views.go new file mode 100644 index 0000000..d70b7ef --- /dev/null +++ b/internal/admin/settings_views.go @@ -0,0 +1,182 @@ +// Copyright (c) 2026 Petr Balvín (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