From f8ed33df83836e4ec16e78f31758c1f23520103d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Fri, 18 Sep 2026 12:03:35 +0200 Subject: [PATCH] Initial commit Assisted-by: GLM 5.3 --- .gitea/workflows/race.yml | 39 + .gitea/workflows/release.yml | 424 ++ .gitea/workflows/test.yml | 92 + .gitignore | 21 + CHANGELOG.md | 250 ++ CONTRIBUTING.md | 149 + LICENSE | 133 + NOTICE.md | 289 ++ README.md | 125 + SECURITY.md | 35 + cmd/volumen/backup.go | 119 + cmd/volumen/content.go | 453 ++ cmd/volumen/default.pgo | Bin 0 -> 48432 bytes cmd/volumen/main.go | 101 + cmd/volumen/main_test.go | 461 ++ cmd/volumen/serve.go | 198 + config.toml.example | 91 + docs/API.md | 670 +++ docs/ARCHITECTURE.md | 198 + docs/BENCHMARKING.md | 92 + docs/CLI.md | 273 ++ docs/CONFIGURATION.md | 272 ++ docs/DEPLOYMENT.md | 748 ++++ docs/DEVELOPMENT.md | 161 + go.mod | 17 + go.sum | 16 + internal/admin/admin.go | 480 +++ internal/admin/admin_test.go | 364 ++ internal/admin/auth.go | 109 + internal/admin/media_routes.go | 63 + internal/admin/posts_history.go | 160 + internal/admin/posts_import.go | 92 + internal/admin/posts_routes.go | 642 +++ internal/admin/posts_routes_test.go | 929 ++++ internal/admin/posts_upload.go | 88 + internal/admin/render.go | 244 ++ internal/admin/settings_account.go | 221 + internal/admin/settings_api.go | 170 + internal/admin/settings_backup.go | 82 + internal/admin/settings_routes.go | 179 + internal/admin/settings_routes_test.go | 820 ++++ internal/admin/settings_totp.go | 189 + internal/admin/settings_update.go | 56 + internal/admin/settings_users.go | 129 + internal/admin/settings_views.go | 182 + internal/admin/settings_webhooks.go | 139 + internal/admin/setup.go | 240 ++ internal/admin/setup_test.go | 236 ++ internal/admin/totp_flow_test.go | 237 ++ internal/admin/views.go | 426 ++ internal/app/app.go | 484 +++ internal/app/app_test.go | 617 +++ internal/app/bench_test.go | 98 + internal/audit/audit.go | 124 + internal/audit/audit_test.go | 149 + internal/backup/backup.go | 322 ++ internal/backup/backup_test.go | 289 ++ internal/biblio/biblio.go | 497 +++ internal/biblio/biblio_test.go | 207 + internal/config/config.go | 523 +++ internal/config/config_test.go | 388 ++ internal/config/template.go | 107 + internal/diff/diff.go | 192 + internal/diff/diff_test.go | 129 + internal/fediverse/fediverse.go | 17 + internal/fediverse/fediverse_test.go | 21 + internal/feeds/feeds.go | 300 ++ internal/feeds/feeds_test.go | 228 + internal/frontmatter/frontmatter.go | 316 ++ internal/frontmatter/frontmatter_test.go | 426 ++ internal/httpapi/api.go | 937 ++++ internal/httpapi/api_test.go | 951 +++++ internal/httpapi/contract_test.go | 145 + .../httpapi/testdata/contract/feed_json.json | 1 + .../testdata/contract/post_detail.json | 1 + .../httpapi/testdata/contract/post_draft.json | 1 + .../testdata/contract/post_not_found.json | 1 + internal/httpapi/testdata/contract/posts.json | 1 + .../testdata/contract/posts_batch.json | 1 + .../testdata/contract/posts_cursor.json | 1 + .../testdata/contract/posts_filtered.json | 1 + .../testdata/contract/posts_page2.json | 1 + .../httpapi/testdata/contract/series.json | 1 + .../testdata/contract/series_detail.json | 1 + internal/httpapi/testdata/contract/site.json | 1 + .../httpapi/testdata/contract/tag_posts.json | 1 + internal/httpapi/testdata/contract/tags.json | 1 + internal/i18n/i18n.go | 831 ++++ internal/i18n/i18n_test.go | 144 + internal/identifiers/identifiers.go | 94 + internal/identifiers/identifiers_test.go | 84 + internal/imagefile/imagefile.go | 463 ++ internal/imagefile/imagefile_test.go | 238 ++ internal/markdown/diagram.go | 34 + internal/markdown/golden_test.go | 56 + internal/markdown/markdown.go | 341 ++ internal/markdown/markdown_test.go | 583 +++ internal/markdown/math.go | 538 +++ internal/markdown/testdata/basic.html | 6 + internal/markdown/testdata/code.html | 11 + internal/markdown/testdata/corpus/basic.md | 1 + internal/markdown/testdata/corpus/code.md | 9 + internal/markdown/testdata/corpus/figure.md | 3 + internal/markdown/testdata/corpus/headings.md | 9 + internal/markdown/testdata/corpus/hostile.md | 7 + internal/markdown/testdata/corpus/math.md | 28 + internal/markdown/testdata/corpus/mermaid.md | 28 + internal/markdown/testdata/corpus/misc.md | 10 + internal/markdown/testdata/corpus/table.md | 3 + internal/markdown/testdata/corpus/tasklist.md | 5 + internal/markdown/testdata/figure.html | 7 + internal/markdown/testdata/headings.html | 20 + internal/markdown/testdata/hostile.html | 9 + internal/markdown/testdata/math.html | 34 + internal/markdown/testdata/mermaid.html | 20 + internal/markdown/testdata/misc.html | 21 + internal/markdown/testdata/table.html | 19 + internal/markdown/testdata/tasklist.html | 13 + internal/password/password.go | 167 + internal/password/password_test.go | 84 + internal/payloads/dto.go | 335 ++ internal/payloads/dto_test.go | 172 + internal/payloads/identifiers_test.go | 116 + internal/payloads/payloads.go | 798 ++++ internal/payloads/payloads_test.go | 508 +++ internal/payloads/refs_test.go | 231 + internal/post/post.go | 606 +++ internal/post/post_test.go | 386 ++ internal/post/refs_test.go | 149 + internal/preview/preview.go | 55 + internal/preview/preview_test.go | 55 + internal/qrcode/galois.go | 161 + internal/qrcode/qrcode.go | 464 ++ internal/qrcode/qrcode_test.go | 144 + internal/ratelimit/ratelimit.go | 230 + internal/ratelimit/ratelimit_test.go | 167 + internal/scheduler/scheduler.go | 99 + internal/scheduler/scheduler_test.go | 151 + internal/session/session.go | 268 ++ internal/session/session_test.go | 181 + internal/store/file.go | 92 + internal/store/media.go | 141 + internal/store/revision.go | 493 +++ internal/store/store.go | 510 +++ internal/store/store_test.go | 846 ++++ internal/templates/templates.go | 265 ++ internal/templates/templates_test.go | 150 + internal/tokens/tokens.go | 347 ++ internal/tokens/tokens_test.go | 166 + internal/tomlfile/tomlfile.go | 141 + internal/tomlfile/tomlfile_test.go | 77 + internal/totp/totp.go | 118 + internal/totp/totp_test.go | 99 + internal/updater/exec.go | 19 + internal/updater/updater.go | 231 + internal/updater/updater_test.go | 217 + internal/users/totp.go | 144 + internal/users/totp_test.go | 147 + internal/users/users.go | 588 +++ internal/users/users_test.go | 408 ++ internal/version/version.go | 20 + internal/version/version_test.go | 19 + internal/web/assets.go | 90 + internal/web/assets_test.go | 142 + internal/web/crossorigin.go | 34 + internal/web/logger.go | 83 + internal/web/static.go | 16 + internal/web/static/admin.css | 3749 +++++++++++++++++ internal/web/static/fonts.css | 331 ++ .../fonts/ubuntu-italic-400-latin-ext.woff2 | Bin 0 -> 49776 bytes .../fonts/ubuntu-italic-400-latin.woff2 | Bin 0 -> 36468 bytes .../ubuntu-mono-normal-400-latin-ext.woff2 | Bin 0 -> 33276 bytes .../fonts/ubuntu-mono-normal-400-latin.woff2 | Bin 0 -> 27300 bytes .../ubuntu-mono-normal-700-latin-ext.woff2 | Bin 0 -> 31916 bytes .../fonts/ubuntu-mono-normal-700-latin.woff2 | Bin 0 -> 25748 bytes .../fonts/ubuntu-normal-400-latin-ext.woff2 | Bin 0 -> 46980 bytes .../fonts/ubuntu-normal-400-latin.woff2 | Bin 0 -> 34924 bytes .../fonts/ubuntu-normal-500-latin-ext.woff2 | Bin 0 -> 42480 bytes .../fonts/ubuntu-normal-500-latin.woff2 | Bin 0 -> 30508 bytes .../fonts/ubuntu-normal-700-latin-ext.woff2 | Bin 0 -> 38020 bytes .../fonts/ubuntu-normal-700-latin.woff2 | Bin 0 -> 29844 bytes internal/web/static/graphis.svg | 104 + internal/web/static/volumen-icon.svg | 46 + internal/web/templates.go | 12 + internal/web/templates/diff.html | 38 + internal/web/templates/form.html | 1181 ++++++ internal/web/templates/history.html | 50 + internal/web/templates/import.html | 189 + internal/web/templates/layout.html | 699 +++ internal/web/templates/list.html | 462 ++ internal/web/templates/login.html | 76 + internal/web/templates/media.html | 196 + internal/web/templates/notfound.html | 9 + internal/web/templates/settings.html | 753 ++++ internal/web/templates/setup.html | 145 + internal/web/templates/twofactor.html | 35 + internal/web/templates/update.html | 34 + internal/web/themes.go | 23 + internal/web/web.go | 231 + internal/web/web_test.go | 255 ++ internal/webhooks/file.go | 86 + internal/webhooks/webhooks.go | 268 ++ internal/webhooks/webhooks_test.go | 274 ++ justfile | 103 + man/volumen.1 | 408 ++ scripts/notices.pl | 224 + 206 files changed, 44165 insertions(+) create mode 100644 .gitea/workflows/race.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitea/workflows/test.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 NOTICE.md create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 cmd/volumen/backup.go create mode 100644 cmd/volumen/content.go create mode 100644 cmd/volumen/default.pgo create mode 100644 cmd/volumen/main.go create mode 100644 cmd/volumen/main_test.go create mode 100644 cmd/volumen/serve.go create mode 100644 config.toml.example create mode 100644 docs/API.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/BENCHMARKING.md create mode 100644 docs/CLI.md create mode 100644 docs/CONFIGURATION.md create mode 100644 docs/DEPLOYMENT.md create mode 100644 docs/DEVELOPMENT.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/admin/admin.go create mode 100644 internal/admin/admin_test.go create mode 100644 internal/admin/auth.go create mode 100644 internal/admin/media_routes.go create mode 100644 internal/admin/posts_history.go create mode 100644 internal/admin/posts_import.go create mode 100644 internal/admin/posts_routes.go create mode 100644 internal/admin/posts_routes_test.go create mode 100644 internal/admin/posts_upload.go create mode 100644 internal/admin/render.go create mode 100644 internal/admin/settings_account.go create mode 100644 internal/admin/settings_api.go create mode 100644 internal/admin/settings_backup.go create mode 100644 internal/admin/settings_routes.go create mode 100644 internal/admin/settings_routes_test.go create mode 100644 internal/admin/settings_totp.go create mode 100644 internal/admin/settings_update.go create mode 100644 internal/admin/settings_users.go create mode 100644 internal/admin/settings_views.go create mode 100644 internal/admin/settings_webhooks.go create mode 100644 internal/admin/setup.go create mode 100644 internal/admin/setup_test.go create mode 100644 internal/admin/totp_flow_test.go create mode 100644 internal/admin/views.go create mode 100644 internal/app/app.go create mode 100644 internal/app/app_test.go create mode 100644 internal/app/bench_test.go create mode 100644 internal/audit/audit.go create mode 100644 internal/audit/audit_test.go create mode 100644 internal/backup/backup.go create mode 100644 internal/backup/backup_test.go create mode 100644 internal/biblio/biblio.go create mode 100644 internal/biblio/biblio_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/template.go create mode 100644 internal/diff/diff.go create mode 100644 internal/diff/diff_test.go create mode 100644 internal/fediverse/fediverse.go create mode 100644 internal/fediverse/fediverse_test.go create mode 100644 internal/feeds/feeds.go create mode 100644 internal/feeds/feeds_test.go create mode 100644 internal/frontmatter/frontmatter.go create mode 100644 internal/frontmatter/frontmatter_test.go create mode 100644 internal/httpapi/api.go create mode 100644 internal/httpapi/api_test.go create mode 100644 internal/httpapi/contract_test.go create mode 100644 internal/httpapi/testdata/contract/feed_json.json create mode 100644 internal/httpapi/testdata/contract/post_detail.json create mode 100644 internal/httpapi/testdata/contract/post_draft.json create mode 100644 internal/httpapi/testdata/contract/post_not_found.json create mode 100644 internal/httpapi/testdata/contract/posts.json create mode 100644 internal/httpapi/testdata/contract/posts_batch.json create mode 100644 internal/httpapi/testdata/contract/posts_cursor.json create mode 100644 internal/httpapi/testdata/contract/posts_filtered.json create mode 100644 internal/httpapi/testdata/contract/posts_page2.json create mode 100644 internal/httpapi/testdata/contract/series.json create mode 100644 internal/httpapi/testdata/contract/series_detail.json create mode 100644 internal/httpapi/testdata/contract/site.json create mode 100644 internal/httpapi/testdata/contract/tag_posts.json create mode 100644 internal/httpapi/testdata/contract/tags.json create mode 100644 internal/i18n/i18n.go create mode 100644 internal/i18n/i18n_test.go create mode 100644 internal/identifiers/identifiers.go create mode 100644 internal/identifiers/identifiers_test.go create mode 100644 internal/imagefile/imagefile.go create mode 100644 internal/imagefile/imagefile_test.go create mode 100644 internal/markdown/diagram.go create mode 100644 internal/markdown/golden_test.go create mode 100644 internal/markdown/markdown.go create mode 100644 internal/markdown/markdown_test.go create mode 100644 internal/markdown/math.go create mode 100644 internal/markdown/testdata/basic.html create mode 100644 internal/markdown/testdata/code.html create mode 100644 internal/markdown/testdata/corpus/basic.md create mode 100644 internal/markdown/testdata/corpus/code.md create mode 100644 internal/markdown/testdata/corpus/figure.md create mode 100644 internal/markdown/testdata/corpus/headings.md create mode 100644 internal/markdown/testdata/corpus/hostile.md create mode 100644 internal/markdown/testdata/corpus/math.md create mode 100644 internal/markdown/testdata/corpus/mermaid.md create mode 100644 internal/markdown/testdata/corpus/misc.md create mode 100644 internal/markdown/testdata/corpus/table.md create mode 100644 internal/markdown/testdata/corpus/tasklist.md create mode 100644 internal/markdown/testdata/figure.html create mode 100644 internal/markdown/testdata/headings.html create mode 100644 internal/markdown/testdata/hostile.html create mode 100644 internal/markdown/testdata/math.html create mode 100644 internal/markdown/testdata/mermaid.html create mode 100644 internal/markdown/testdata/misc.html create mode 100644 internal/markdown/testdata/table.html create mode 100644 internal/markdown/testdata/tasklist.html create mode 100644 internal/password/password.go create mode 100644 internal/password/password_test.go create mode 100644 internal/payloads/dto.go create mode 100644 internal/payloads/dto_test.go create mode 100644 internal/payloads/identifiers_test.go create mode 100644 internal/payloads/payloads.go create mode 100644 internal/payloads/payloads_test.go create mode 100644 internal/payloads/refs_test.go create mode 100644 internal/post/post.go create mode 100644 internal/post/post_test.go create mode 100644 internal/post/refs_test.go create mode 100644 internal/preview/preview.go create mode 100644 internal/preview/preview_test.go create mode 100644 internal/qrcode/galois.go create mode 100644 internal/qrcode/qrcode.go create mode 100644 internal/qrcode/qrcode_test.go create mode 100644 internal/ratelimit/ratelimit.go create mode 100644 internal/ratelimit/ratelimit_test.go create mode 100644 internal/scheduler/scheduler.go create mode 100644 internal/scheduler/scheduler_test.go create mode 100644 internal/session/session.go create mode 100644 internal/session/session_test.go create mode 100644 internal/store/file.go create mode 100644 internal/store/media.go create mode 100644 internal/store/revision.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 internal/templates/templates.go create mode 100644 internal/templates/templates_test.go create mode 100644 internal/tokens/tokens.go create mode 100644 internal/tokens/tokens_test.go create mode 100644 internal/tomlfile/tomlfile.go create mode 100644 internal/tomlfile/tomlfile_test.go create mode 100644 internal/totp/totp.go create mode 100644 internal/totp/totp_test.go create mode 100644 internal/updater/exec.go create mode 100644 internal/updater/updater.go create mode 100644 internal/updater/updater_test.go create mode 100644 internal/users/totp.go create mode 100644 internal/users/totp_test.go create mode 100644 internal/users/users.go create mode 100644 internal/users/users_test.go create mode 100644 internal/version/version.go create mode 100644 internal/version/version_test.go create mode 100644 internal/web/assets.go create mode 100644 internal/web/assets_test.go create mode 100644 internal/web/crossorigin.go create mode 100644 internal/web/logger.go create mode 100644 internal/web/static.go create mode 100644 internal/web/static/admin.css create mode 100644 internal/web/static/fonts.css create mode 100644 internal/web/static/fonts/ubuntu-italic-400-latin-ext.woff2 create mode 100644 internal/web/static/fonts/ubuntu-italic-400-latin.woff2 create mode 100644 internal/web/static/fonts/ubuntu-mono-normal-400-latin-ext.woff2 create mode 100644 internal/web/static/fonts/ubuntu-mono-normal-400-latin.woff2 create mode 100644 internal/web/static/fonts/ubuntu-mono-normal-700-latin-ext.woff2 create mode 100644 internal/web/static/fonts/ubuntu-mono-normal-700-latin.woff2 create mode 100644 internal/web/static/fonts/ubuntu-normal-400-latin-ext.woff2 create mode 100644 internal/web/static/fonts/ubuntu-normal-400-latin.woff2 create mode 100644 internal/web/static/fonts/ubuntu-normal-500-latin-ext.woff2 create mode 100644 internal/web/static/fonts/ubuntu-normal-500-latin.woff2 create mode 100644 internal/web/static/fonts/ubuntu-normal-700-latin-ext.woff2 create mode 100644 internal/web/static/fonts/ubuntu-normal-700-latin.woff2 create mode 100644 internal/web/static/graphis.svg create mode 100644 internal/web/static/volumen-icon.svg create mode 100644 internal/web/templates.go create mode 100644 internal/web/templates/diff.html create mode 100644 internal/web/templates/form.html create mode 100644 internal/web/templates/history.html create mode 100644 internal/web/templates/import.html create mode 100644 internal/web/templates/layout.html create mode 100644 internal/web/templates/list.html create mode 100644 internal/web/templates/login.html create mode 100644 internal/web/templates/media.html create mode 100644 internal/web/templates/notfound.html create mode 100644 internal/web/templates/settings.html create mode 100644 internal/web/templates/setup.html create mode 100644 internal/web/templates/twofactor.html create mode 100644 internal/web/templates/update.html create mode 100644 internal/web/themes.go create mode 100644 internal/web/web.go create mode 100644 internal/web/web_test.go create mode 100644 internal/webhooks/file.go create mode 100644 internal/webhooks/webhooks.go create mode 100644 internal/webhooks/webhooks_test.go create mode 100644 justfile create mode 100644 man/volumen.1 create mode 100644 scripts/notices.pl 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