refactor: rewrite from Ruby/Sinatra to Python/FastAPI
This commit is contained in:
+8
-7
@@ -1,10 +1,11 @@
|
|||||||
# Bundler
|
# Python
|
||||||
/.bundle/
|
__pycache__/
|
||||||
/vendor/bundle/
|
*.pyc
|
||||||
|
*.egg-info/
|
||||||
# Built gems
|
.venv/
|
||||||
/pkg/
|
.pytest_cache/
|
||||||
*.gem
|
.ruff_cache/
|
||||||
|
dist/
|
||||||
|
|
||||||
# Test / coverage
|
# Test / coverage
|
||||||
/coverage/
|
/coverage/
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
AllCops:
|
|
||||||
TargetRubyVersion: 3.3
|
|
||||||
NewCops: enable
|
|
||||||
SuggestExtensions: false
|
|
||||||
|
|
||||||
Style/StringLiterals:
|
|
||||||
EnforcedStyle: double_quotes
|
|
||||||
|
|
||||||
Style/StringLiteralsInInterpolation:
|
|
||||||
EnforcedStyle: double_quotes
|
|
||||||
|
|
||||||
Style/Documentation:
|
|
||||||
Exclude:
|
|
||||||
- "test/**/*"
|
|
||||||
- "exe/**/*"
|
|
||||||
|
|
||||||
Layout/LineLength:
|
|
||||||
Max: 100
|
|
||||||
|
|
||||||
Metrics/AbcSize:
|
|
||||||
Max: 30
|
|
||||||
Exclude:
|
|
||||||
- "lib/volumen/admin_routes.rb"
|
|
||||||
- "lib/volumen/api_routes.rb"
|
|
||||||
|
|
||||||
Metrics/MethodLength:
|
|
||||||
Max: 25
|
|
||||||
Exclude:
|
|
||||||
- "lib/volumen/admin_routes.rb"
|
|
||||||
- "lib/volumen/api_routes.rb"
|
|
||||||
|
|
||||||
Metrics/ClassLength:
|
|
||||||
Max: 440
|
|
||||||
|
|
||||||
Metrics/ModuleLength:
|
|
||||||
Exclude:
|
|
||||||
- "lib/volumen/admin_routes.rb"
|
|
||||||
|
|
||||||
Metrics/BlockLength:
|
|
||||||
Exclude:
|
|
||||||
- "test/**/*"
|
|
||||||
- "volumen.gemspec"
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
source "https://rubygems.org"
|
|
||||||
|
|
||||||
# Runtime dependencies are declared in the gemspec.
|
|
||||||
gemspec
|
|
||||||
|
|
||||||
group :development, :test do
|
|
||||||
gem "minitest", "~> 6.0"
|
|
||||||
gem "rack-test", "~> 2.2"
|
|
||||||
gem "rake", "~> 13.4"
|
|
||||||
gem "rubocop", "~> 1.88", require: false
|
|
||||||
end
|
|
||||||
-136
@@ -1,136 +0,0 @@
|
|||||||
PATH
|
|
||||||
remote: .
|
|
||||||
specs:
|
|
||||||
volumen (0.3.0)
|
|
||||||
kramdown (~> 2.5)
|
|
||||||
kramdown-parser-gfm (~> 1.1)
|
|
||||||
puma (~> 8.0)
|
|
||||||
rackup (~> 2.3)
|
|
||||||
sinatra (~> 4.2)
|
|
||||||
toml-rb (~> 4.2)
|
|
||||||
|
|
||||||
GEM
|
|
||||||
remote: https://rubygems.org/
|
|
||||||
specs:
|
|
||||||
ast (2.4.3)
|
|
||||||
base64 (0.3.0)
|
|
||||||
citrus (3.0.2)
|
|
||||||
drb (2.2.3)
|
|
||||||
json (2.19.9)
|
|
||||||
kramdown (2.5.2)
|
|
||||||
rexml (>= 3.4.4)
|
|
||||||
kramdown-parser-gfm (1.1.0)
|
|
||||||
kramdown (~> 2.0)
|
|
||||||
language_server-protocol (3.17.0.5)
|
|
||||||
lint_roller (1.1.0)
|
|
||||||
logger (1.7.0)
|
|
||||||
minitest (6.0.6)
|
|
||||||
drb (~> 2.0)
|
|
||||||
prism (~> 1.5)
|
|
||||||
mustermann (3.1.1)
|
|
||||||
nio4r (2.7.5)
|
|
||||||
parallel (2.1.0)
|
|
||||||
parser (3.3.11.1)
|
|
||||||
ast (~> 2.4.1)
|
|
||||||
racc
|
|
||||||
prism (1.9.0)
|
|
||||||
puma (8.0.2)
|
|
||||||
nio4r (~> 2.0)
|
|
||||||
racc (1.8.1)
|
|
||||||
rack (3.2.6)
|
|
||||||
rack-protection (4.2.1)
|
|
||||||
base64 (>= 0.1.0)
|
|
||||||
logger (>= 1.6.0)
|
|
||||||
rack (>= 3.0.0, < 4)
|
|
||||||
rack-session (2.1.2)
|
|
||||||
base64 (>= 0.1.0)
|
|
||||||
rack (>= 3.0.0)
|
|
||||||
rack-test (2.2.0)
|
|
||||||
rack (>= 1.3)
|
|
||||||
rackup (2.3.1)
|
|
||||||
rack (>= 3)
|
|
||||||
rainbow (3.1.1)
|
|
||||||
rake (13.4.2)
|
|
||||||
regexp_parser (2.12.0)
|
|
||||||
rexml (3.4.4)
|
|
||||||
rubocop (1.88.0)
|
|
||||||
json (~> 2.3)
|
|
||||||
language_server-protocol (~> 3.17.0.2)
|
|
||||||
lint_roller (~> 1.1.0)
|
|
||||||
parallel (>= 1.10)
|
|
||||||
parser (>= 3.3.0.2)
|
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
|
||||||
regexp_parser (>= 2.9.3, < 3.0)
|
|
||||||
rubocop-ast (>= 1.49.0, < 2.0)
|
|
||||||
ruby-progressbar (~> 1.7)
|
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
|
||||||
rubocop-ast (1.49.1)
|
|
||||||
parser (>= 3.3.7.2)
|
|
||||||
prism (~> 1.7)
|
|
||||||
ruby-progressbar (1.13.0)
|
|
||||||
sinatra (4.2.1)
|
|
||||||
logger (>= 1.6.0)
|
|
||||||
mustermann (~> 3.0)
|
|
||||||
rack (>= 3.0.0, < 4)
|
|
||||||
rack-protection (= 4.2.1)
|
|
||||||
rack-session (>= 2.0.0, < 3)
|
|
||||||
tilt (~> 2.0)
|
|
||||||
tilt (2.7.0)
|
|
||||||
toml-rb (4.2.0)
|
|
||||||
citrus (~> 3.0, > 3.0)
|
|
||||||
racc (~> 1.7)
|
|
||||||
unicode-display_width (3.2.0)
|
|
||||||
unicode-emoji (~> 4.1)
|
|
||||||
unicode-emoji (4.2.0)
|
|
||||||
|
|
||||||
PLATFORMS
|
|
||||||
ruby
|
|
||||||
x86_64-linux
|
|
||||||
|
|
||||||
DEPENDENCIES
|
|
||||||
minitest (~> 6.0)
|
|
||||||
rack-test (~> 2.2)
|
|
||||||
rake (~> 13.4)
|
|
||||||
rubocop (~> 1.88)
|
|
||||||
volumen!
|
|
||||||
|
|
||||||
CHECKSUMS
|
|
||||||
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
|
|
||||||
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
|
||||||
citrus (3.0.2) sha256=4ec2412fc389ad186735f4baee1460f7900a8e130ffe3f216b30d4f9c684f650
|
|
||||||
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
|
|
||||||
json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a
|
|
||||||
kramdown (2.5.2) sha256=1ba542204c66b6f9111ff00dcc26075b95b220b07f2905d8261740c82f7f02fa
|
|
||||||
kramdown-parser-gfm (1.1.0) sha256=fb39745516427d2988543bf01fc4cf0ab1149476382393e0e9c48592f6581729
|
|
||||||
language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
|
|
||||||
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
|
|
||||||
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
|
||||||
minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
|
|
||||||
mustermann (3.1.1) sha256=4c6170c7234d5499c345562ba7c7dfe73e1754286dcc1abb053064d66a127198
|
|
||||||
nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
|
|
||||||
parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356
|
|
||||||
parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54
|
|
||||||
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
|
|
||||||
puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb
|
|
||||||
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
|
|
||||||
rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2
|
|
||||||
rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac
|
|
||||||
rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8
|
|
||||||
rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
|
|
||||||
rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
|
|
||||||
rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
|
|
||||||
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
|
|
||||||
regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
|
|
||||||
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
|
|
||||||
rubocop (1.88.0) sha256=e420ddf1662d0ef34bc8a2910ac4b396a7ddda0b51a708264405241734b08e0b
|
|
||||||
rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035
|
|
||||||
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
|
|
||||||
sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080
|
|
||||||
tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3
|
|
||||||
toml-rb (4.2.0) sha256=10a48c91613e20cf63483a7a776767dfb3cd7d70e9327c0237443da601e13776
|
|
||||||
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
|
|
||||||
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
|
|
||||||
volumen (0.3.0)
|
|
||||||
|
|
||||||
BUNDLED WITH
|
|
||||||
4.0.10
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "rake/testtask"
|
|
||||||
|
|
||||||
Rake::TestTask.new(:test) do |t|
|
|
||||||
t.libs << "test"
|
|
||||||
t.libs << "lib"
|
|
||||||
t.test_files = FileList["test/**/*_test.rb"]
|
|
||||||
t.warning = false
|
|
||||||
end
|
|
||||||
|
|
||||||
task default: :test
|
|
||||||
+3
-7
@@ -1,7 +1,3 @@
|
|||||||
#!/usr/bin/env ruby
|
#!/usr/bin/env python3
|
||||||
# frozen_string_literal: true
|
from volumen.cli import main
|
||||||
|
main()
|
||||||
require "volumen"
|
|
||||||
require "volumen/cli"
|
|
||||||
|
|
||||||
Volumen::CLI.run(ARGV)
|
|
||||||
|
|||||||
@@ -1,48 +1,25 @@
|
|||||||
set dotenv-load := false
|
|
||||||
set positional-arguments := false
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@just --list
|
@just --list
|
||||||
|
|
||||||
# Install Ruby dependencies
|
|
||||||
install:
|
install:
|
||||||
@echo "→ Installing Ruby dependencies…"
|
uv sync
|
||||||
bundle install
|
|
||||||
|
|
||||||
# Run the development server
|
|
||||||
run:
|
run:
|
||||||
@echo "→ Starting volumen…"
|
uv run volumen serve
|
||||||
bundle exec exe/volumen serve
|
|
||||||
|
|
||||||
# Run the development server against ./posts and ./config.toml
|
|
||||||
dev:
|
dev:
|
||||||
@echo "→ Starting volumen (dev) on http://localhost:9090 …"
|
uv run volumen serve --config ./config.toml --content ./posts
|
||||||
bundle exec exe/volumen serve --config ./config.toml --content ./posts
|
|
||||||
|
|
||||||
# Lint the codebase (RuboCop, zero offenses)
|
|
||||||
lint:
|
|
||||||
@echo "→ Linting…"
|
|
||||||
bundle exec rubocop
|
|
||||||
|
|
||||||
# Auto-fix lint issues
|
|
||||||
fmt:
|
|
||||||
@echo "→ Auto-fixing…"
|
|
||||||
bundle exec rubocop -A
|
|
||||||
|
|
||||||
# Lint and package the gem (must pass with zero warnings)
|
|
||||||
build:
|
build:
|
||||||
@echo "→ Linting…"
|
uv run ruff check src/ tests/
|
||||||
bundle exec rubocop
|
uv run ruff format --check src/ tests/
|
||||||
@echo "→ Building gem…"
|
|
||||||
@mkdir -p pkg
|
|
||||||
gem build volumen.gemspec --output pkg/volumen.gem
|
|
||||||
|
|
||||||
# Run the test suite
|
|
||||||
test:
|
test:
|
||||||
@echo "→ Running tests…"
|
uv run pytest
|
||||||
bundle exec rake test
|
|
||||||
|
fmt:
|
||||||
|
uv run ruff format src/ tests/
|
||||||
|
uv run ruff check --fix src/ tests/
|
||||||
|
|
||||||
# Remove build artifacts
|
|
||||||
uninstall:
|
uninstall:
|
||||||
@echo "→ Removing build artifacts…"
|
rm -rf dist/ __pycache__ .pytest_cache .ruff_cache
|
||||||
rm -rf pkg *.gem
|
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require_relative "volumen/version"
|
|
||||||
require_relative "volumen/frontmatter"
|
|
||||||
require_relative "volumen/markdown"
|
|
||||||
require_relative "volumen/post"
|
|
||||||
require_relative "volumen/store"
|
|
||||||
require_relative "volumen/config"
|
|
||||||
require_relative "volumen/password"
|
|
||||||
require_relative "volumen/users"
|
|
||||||
|
|
||||||
# volumen is a small, file-based Markdown blog engine.
|
|
||||||
#
|
|
||||||
# Posts are plain `.md` files with a TOML frontmatter block delimited by
|
|
||||||
# `+++`. The engine exposes them as a JSON API and ships a server-rendered
|
|
||||||
# admin with a Markdown source editor and a visual editor.
|
|
||||||
#
|
|
||||||
# The Sinatra app (`Volumen::Server`) and the CLI (`Volumen::CLI`) are loaded
|
|
||||||
# on demand to keep the core library light.
|
|
||||||
module Volumen
|
|
||||||
CLEANUP_INTERVAL = 300 # seconds between full sweeps of login_attempts
|
|
||||||
|
|
||||||
def self.login_attempts
|
|
||||||
@login_attempts ||= {}
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.reset_login_attempts!
|
|
||||||
@login_attempts = {}
|
|
||||||
@last_cleanup = nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.sweep_login_attempts!
|
|
||||||
now = Time.now.to_f
|
|
||||||
return if @last_cleanup && (now - @last_cleanup) < CLEANUP_INTERVAL
|
|
||||||
|
|
||||||
cutoff = now - Volumen::Server::LOGIN_WINDOW
|
|
||||||
login_attempts.each_value { |timestamps| timestamps.select! { |t| t > cutoff } }
|
|
||||||
login_attempts.delete_if { |_ip, timestamps| timestamps.empty? }
|
|
||||||
@last_cleanup = now
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module Volumen
|
|
||||||
# Server-rendered admin routes, registered into Volumen::Server.
|
|
||||||
module AdminRoutes
|
|
||||||
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
||||||
def self.registered(app)
|
|
||||||
app.get "/admin" do
|
|
||||||
redirect to("/admin/")
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/admin/" do
|
|
||||||
require_login!
|
|
||||||
@posts = sorted_posts
|
|
||||||
@crumbs = [["Posts", nil]]
|
|
||||||
erb :list
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/admin/login" do
|
|
||||||
redirect to("/admin/") if authenticated?
|
|
||||||
|
|
||||||
erb :login
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/login" do
|
|
||||||
check_csrf!
|
|
||||||
check_login_rate_limit!
|
|
||||||
user = users.authenticate(params["username"], params["password"])
|
|
||||||
if user
|
|
||||||
session[:user] = user.username
|
|
||||||
redirect to("/admin/")
|
|
||||||
else
|
|
||||||
@error = "Invalid username or password."
|
|
||||||
erb :login
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/logout" do
|
|
||||||
check_csrf!
|
|
||||||
session.clear
|
|
||||||
redirect to("/admin/login")
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/admin/posts/new" do
|
|
||||||
require_login!
|
|
||||||
@mode = :new
|
|
||||||
@post = Post.new(metadata: { "lang" => config.language })
|
|
||||||
@crumbs = [["Posts", to("/admin/")], ["New post", nil]]
|
|
||||||
erb :form
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/posts" do
|
|
||||||
require_login!
|
|
||||||
check_csrf!
|
|
||||||
create_post
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/admin/posts/:slug/edit" do
|
|
||||||
require_login!
|
|
||||||
@mode = :edit
|
|
||||||
@post = store.find(params["slug"])
|
|
||||||
halt(404, "Not found") if @post.nil?
|
|
||||||
title = @post.title.to_s.empty? ? params["slug"] : @post.title
|
|
||||||
@crumbs = [["Posts", to("/admin/")], [title, nil]]
|
|
||||||
|
|
||||||
erb :form
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/posts/:slug" do
|
|
||||||
require_login!
|
|
||||||
check_csrf!
|
|
||||||
update_post
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/posts/:slug/delete" do
|
|
||||||
require_login!
|
|
||||||
check_csrf!
|
|
||||||
store.delete(params["slug"])
|
|
||||||
clear_posts_cache!
|
|
||||||
redirect to("/admin/")
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/preview" do
|
|
||||||
require_login!
|
|
||||||
check_csrf!
|
|
||||||
content_type :html
|
|
||||||
Markdown.render(params["body"].to_s)
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/uploads" do
|
|
||||||
require_login!
|
|
||||||
check_csrf!
|
|
||||||
content_type :json
|
|
||||||
upload = params["file"]
|
|
||||||
halt(400, json("error" => "no_file")) unless upload.is_a?(Hash) && upload[:tempfile]
|
|
||||||
|
|
||||||
validate_upload!(upload)
|
|
||||||
json("url" => store.store_upload(upload[:filename], upload[:tempfile]))
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/media/*" do
|
|
||||||
path = store.media_file(params["splat"].first)
|
|
||||||
halt(404) if path.nil?
|
|
||||||
|
|
||||||
send_file(path)
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/admin/logo.svg" do
|
|
||||||
send_file(File.expand_path("web/public/volumen-logo.svg", __dir__))
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/admin/icon.svg" do
|
|
||||||
send_file(File.expand_path("web/public/volumen-icon.svg", __dir__))
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/admin/settings" do
|
|
||||||
require_login!
|
|
||||||
@crumbs = [["Settings", nil]]
|
|
||||||
render_settings
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/settings/password" do
|
|
||||||
require_login!
|
|
||||||
check_csrf!
|
|
||||||
change_password
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/settings/username" do
|
|
||||||
require_login!
|
|
||||||
check_csrf!
|
|
||||||
change_username
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/settings/name" do
|
|
||||||
require_login!
|
|
||||||
check_csrf!
|
|
||||||
change_name
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/settings/fediverse" do
|
|
||||||
require_login!
|
|
||||||
check_csrf!
|
|
||||||
change_fediverse_creator
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/settings/photo" do
|
|
||||||
require_login!
|
|
||||||
check_csrf!
|
|
||||||
change_photo
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/settings/photo/remove" do
|
|
||||||
require_login!
|
|
||||||
check_csrf!
|
|
||||||
remove_photo
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/settings/users" do
|
|
||||||
require_login!
|
|
||||||
require_admin!
|
|
||||||
check_csrf!
|
|
||||||
create_user
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/settings/users/:username/role" do
|
|
||||||
require_login!
|
|
||||||
require_admin!
|
|
||||||
check_csrf!
|
|
||||||
change_role(params["username"])
|
|
||||||
end
|
|
||||||
|
|
||||||
app.post "/admin/settings/users/:username/delete" do
|
|
||||||
require_login!
|
|
||||||
require_admin!
|
|
||||||
check_csrf!
|
|
||||||
remove_user(params["username"])
|
|
||||||
end
|
|
||||||
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module Volumen
|
|
||||||
# Public JSON API routes, registered into Volumen::Server.
|
|
||||||
module ApiRoutes
|
|
||||||
def self.registered(app)
|
|
||||||
app.before "/api/volumen/*" do
|
|
||||||
headers "Access-Control-Allow-Origin" => "*",
|
|
||||||
"Cache-Control" => "public, max-age=60"
|
|
||||||
content_type :json
|
|
||||||
end
|
|
||||||
|
|
||||||
app.options "/api/volumen/*" do
|
|
||||||
headers "Access-Control-Allow-Origin" => "*"
|
|
||||||
headers "Access-Control-Allow-Methods" => "GET, OPTIONS"
|
|
||||||
headers "Access-Control-Allow-Headers" => "Content-Type"
|
|
||||||
content_type :json
|
|
||||||
""
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/api/volumen/site" do
|
|
||||||
json(site_payload)
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/api/volumen/posts" do
|
|
||||||
json(posts_payload)
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/api/volumen/posts/:slug" do
|
|
||||||
post = store.find(params["slug"], lang: params["lang"])
|
|
||||||
halt(404, json("error" => "not_found")) if post.nil?
|
|
||||||
halt(404, json("error" => "draft")) if post.draft?
|
|
||||||
|
|
||||||
json(post.detail)
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/api/volumen/tags" do
|
|
||||||
json("tags" => tag_counts)
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/api/volumen/feed.xml" do
|
|
||||||
content_type "application/rss+xml"
|
|
||||||
feed_xml
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/api/volumen/feed.json" do
|
|
||||||
json(feed_json)
|
|
||||||
end
|
|
||||||
|
|
||||||
app.get "/api/volumen/sitemap.xml" do
|
|
||||||
content_type "application/xml"
|
|
||||||
sitemap_xml
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "optparse"
|
|
||||||
require_relative "version"
|
|
||||||
require_relative "config"
|
|
||||||
require_relative "store"
|
|
||||||
require_relative "password"
|
|
||||||
|
|
||||||
module Volumen
|
|
||||||
# Command-line interface: serve, hash-password, version, help.
|
|
||||||
class CLI
|
|
||||||
USAGE = <<~TEXT.freeze
|
|
||||||
volumen #{Volumen::VERSION} — a small, file-based Markdown blog engine.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
volumen serve [--config PATH] [--content DIR] [--host HOST] [--port PORT]
|
|
||||||
volumen hash-password
|
|
||||||
volumen version
|
|
||||||
volumen help
|
|
||||||
TEXT
|
|
||||||
|
|
||||||
def self.run(argv)
|
|
||||||
new(argv).run
|
|
||||||
end
|
|
||||||
|
|
||||||
def initialize(argv)
|
|
||||||
@argv = argv.dup
|
|
||||||
end
|
|
||||||
|
|
||||||
def run
|
|
||||||
command = @argv.shift
|
|
||||||
case command
|
|
||||||
when "version", "--version", "-v" then puts Volumen::VERSION
|
|
||||||
when "help", "--help", "-h", nil then puts USAGE
|
|
||||||
when "serve" then serve
|
|
||||||
when "hash-password" then hash_password
|
|
||||||
else
|
|
||||||
warn "volumen: unknown command #{command.inspect}\n\n#{USAGE}"
|
|
||||||
exit 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def serve
|
|
||||||
options = parse_serve_options
|
|
||||||
prepare_config(options[:config])
|
|
||||||
config = Config.load(path: options[:config], overrides: options)
|
|
||||||
|
|
||||||
require_relative "server"
|
|
||||||
store = Store.new(config.content_dir, default_lang: config.language)
|
|
||||||
app = Server.configured(config: config, store: store)
|
|
||||||
app.run!(bind: config.host, port: config.port)
|
|
||||||
end
|
|
||||||
|
|
||||||
def parse_serve_options
|
|
||||||
options = { config: Config::DEFAULT_PATH }
|
|
||||||
OptionParser.new do |parser|
|
|
||||||
parser.on("--config PATH") { |value| options[:config] = value }
|
|
||||||
parser.on("--content DIR") { |value| options[:content] = value }
|
|
||||||
parser.on("--host HOST") { |value| options[:host] = value }
|
|
||||||
parser.on("--port PORT", Integer) { |value| options[:port] = value }
|
|
||||||
end.parse!(@argv)
|
|
||||||
options
|
|
||||||
end
|
|
||||||
|
|
||||||
def prepare_config(path)
|
|
||||||
if Config.generate_default(path)
|
|
||||||
warn "volumen: wrote a default config to #{path}; review it and restart."
|
|
||||||
elsif !File.exist?(path)
|
|
||||||
warn "volumen: config #{path} not found; using built-in defaults."
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def hash_password
|
|
||||||
require "io/console"
|
|
||||||
print "Password: "
|
|
||||||
password = ($stdin.noecho(&:gets) || "").chomp
|
|
||||||
puts
|
|
||||||
if password.empty?
|
|
||||||
warn "volumen: empty password"
|
|
||||||
exit 1
|
|
||||||
end
|
|
||||||
puts Password.hash(password)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "toml-rb"
|
|
||||||
require "fileutils"
|
|
||||||
|
|
||||||
module Volumen
|
|
||||||
# Loads and merges volumen configuration from a TOML file, then applies
|
|
||||||
# command-line overrides on top of the file values and built-in defaults.
|
|
||||||
class Config
|
|
||||||
DEFAULT_PATH = "/etc/volumen/config.toml"
|
|
||||||
|
|
||||||
DEFAULTS = {
|
|
||||||
"server" => { "host" => "0.0.0.0", "port" => 9090 }.freeze,
|
|
||||||
"content_dir" => "/var/lib/volumen/posts",
|
|
||||||
"users_file" => "/var/lib/volumen/users.toml",
|
|
||||||
"site" => {
|
|
||||||
"title" => "My Blog",
|
|
||||||
"description" => "A blog powered by volumen.",
|
|
||||||
"base_url" => "https://example.com",
|
|
||||||
"language" => "en",
|
|
||||||
"author" => "Anonymous",
|
|
||||||
"fediverse_creator" => nil
|
|
||||||
}.freeze,
|
|
||||||
"admin" => {
|
|
||||||
"password_hash" => "",
|
|
||||||
"session_key" => "",
|
|
||||||
"session_ttl" => 86_400
|
|
||||||
}.freeze
|
|
||||||
}.freeze
|
|
||||||
|
|
||||||
TEMPLATE = <<~TOML
|
|
||||||
# volumen configuration.
|
|
||||||
|
|
||||||
[server]
|
|
||||||
host = "0.0.0.0"
|
|
||||||
port = 9090
|
|
||||||
|
|
||||||
# Directory of your Markdown posts (.md with TOML frontmatter).
|
|
||||||
content_dir = "/var/lib/volumen/posts"
|
|
||||||
|
|
||||||
# File storing admin users (managed from the admin Settings page).
|
|
||||||
users_file = "/var/lib/volumen/users.toml"
|
|
||||||
|
|
||||||
[site]
|
|
||||||
title = "My Blog"
|
|
||||||
description = "A blog powered by volumen."
|
|
||||||
base_url = "https://example.com"
|
|
||||||
language = "en"
|
|
||||||
author = "Anonymous"
|
|
||||||
# Default fediverse handle surfaced in <meta name="fediverse:creator">
|
|
||||||
# by frontends consuming the API. Leave empty to disable.
|
|
||||||
fediverse_creator = ""
|
|
||||||
|
|
||||||
[admin]
|
|
||||||
# Bootstrap admin login: paste a hash from `volumen hash-password` to sign
|
|
||||||
# in as user "admin". Once you manage users in Settings, users_file wins.
|
|
||||||
password_hash = ""
|
|
||||||
# Secret signing session cookies (>= 64 bytes). Empty = random per start.
|
|
||||||
session_key = ""
|
|
||||||
# Session lifetime in seconds (24h default).
|
|
||||||
session_ttl = 86400
|
|
||||||
TOML
|
|
||||||
|
|
||||||
def self.load(path: DEFAULT_PATH, overrides: {})
|
|
||||||
data = File.exist?(path) ? TomlRB.load_file(path) : {}
|
|
||||||
merged = deep_merge(DEFAULTS, data)
|
|
||||||
new(apply_overrides(merged, overrides))
|
|
||||||
end
|
|
||||||
|
|
||||||
# Writes the commented template to `path` if it does not exist yet.
|
|
||||||
# Returns the path on success, or nil if it already existed or could not
|
|
||||||
# be created.
|
|
||||||
def self.generate_default(path)
|
|
||||||
return nil if File.exist?(path)
|
|
||||||
|
|
||||||
FileUtils.mkdir_p(File.dirname(path))
|
|
||||||
File.write(path, TEMPLATE)
|
|
||||||
path
|
|
||||||
rescue SystemCallError
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.deep_merge(base, override)
|
|
||||||
result = {}
|
|
||||||
base.each do |key, base_val|
|
|
||||||
result[key] = if override.key?(key)
|
|
||||||
merge_leaf(base_val, override[key])
|
|
||||||
elsif base_val.is_a?(Hash)
|
|
||||||
{}.merge(base_val)
|
|
||||||
else
|
|
||||||
base_val
|
|
||||||
end
|
|
||||||
end
|
|
||||||
override.each do |key, val|
|
|
||||||
result[key] = val unless result.key?(key)
|
|
||||||
end
|
|
||||||
result
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.merge_leaf(base_val, override_val)
|
|
||||||
if base_val.is_a?(Hash) && override_val.is_a?(Hash)
|
|
||||||
deep_merge(base_val, override_val)
|
|
||||||
else
|
|
||||||
override_val
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.apply_overrides(data, overrides)
|
|
||||||
server = data["server"].dup
|
|
||||||
server["host"] = overrides[:host] if overrides[:host]
|
|
||||||
server["port"] = overrides[:port] if overrides[:port]
|
|
||||||
|
|
||||||
result = data.dup
|
|
||||||
result["server"] = server
|
|
||||||
result["content_dir"] = overrides[:content] if overrides[:content]
|
|
||||||
result
|
|
||||||
end
|
|
||||||
|
|
||||||
private_class_method :deep_merge, :apply_overrides
|
|
||||||
|
|
||||||
attr_reader :data
|
|
||||||
|
|
||||||
def initialize(data)
|
|
||||||
@data = data
|
|
||||||
end
|
|
||||||
|
|
||||||
def host
|
|
||||||
@data["server"]["host"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def port
|
|
||||||
@data["server"]["port"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def content_dir
|
|
||||||
@data["content_dir"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def users_file
|
|
||||||
@data["users_file"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def site
|
|
||||||
@data["site"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def language
|
|
||||||
@data["site"]["language"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def admin
|
|
||||||
@data["admin"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module Volumen
|
|
||||||
# RSS, JSON Feed, and sitemap helpers extracted from Server to keep the
|
|
||||||
# main class focused on configuration and routing.
|
|
||||||
module FeedHelpers
|
|
||||||
private
|
|
||||||
|
|
||||||
def feed_xml
|
|
||||||
site = config.site
|
|
||||||
items = published_posts.first(20).map { |post| feed_item(post) }.join("\n")
|
|
||||||
<<~XML
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
|
||||||
<channel>
|
|
||||||
<title>#{escape(site["title"])}</title>
|
|
||||||
<link>#{escape(base_url)}</link>
|
|
||||||
<description>#{escape(site["description"])}</description>
|
|
||||||
<language>#{escape(site["language"])}</language>
|
|
||||||
#{items}
|
|
||||||
</channel>
|
|
||||||
</rss>
|
|
||||||
XML
|
|
||||||
end
|
|
||||||
|
|
||||||
def feed_item(post)
|
|
||||||
link = "#{base_url}/#{post.slug}"
|
|
||||||
pub = if post.metadata["date"]
|
|
||||||
"\n <pubDate>#{escape(rfc822_date(post))}</pubDate>"
|
|
||||||
else
|
|
||||||
""
|
|
||||||
end
|
|
||||||
creator = post.fediverse_creator
|
|
||||||
creator_tag = creator ? "\n <dc:creator>#{escape(creator)}</dc:creator>" : ""
|
|
||||||
<<~XML.chomp
|
|
||||||
<item>
|
|
||||||
<title>#{escape(post.title)}</title>
|
|
||||||
<link>#{escape(link)}</link>
|
|
||||||
<guid>#{escape(link)}</guid>
|
|
||||||
<description>#{escape(post.excerpt)}</description>#{pub}#{creator_tag}
|
|
||||||
</item>
|
|
||||||
XML
|
|
||||||
end
|
|
||||||
|
|
||||||
def rfc822_date(post)
|
|
||||||
value = post.metadata["date"]
|
|
||||||
case value
|
|
||||||
when Date then value.to_time.rfc822
|
|
||||||
when Time then value.rfc822
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def feed_json
|
|
||||||
site = config.site
|
|
||||||
feed_url = "#{base_url}/api/volumen/feed.json"
|
|
||||||
{
|
|
||||||
"version" => "https://jsonfeed.org/version/1.1",
|
|
||||||
"title" => site["title"],
|
|
||||||
"home_page_url" => base_url,
|
|
||||||
"feed_url" => feed_url,
|
|
||||||
"description" => site["description"],
|
|
||||||
"language" => site["language"],
|
|
||||||
"items" => published_posts.first(20).map { |post| json_feed_item(post) }
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def json_feed_item(post)
|
|
||||||
link = "#{base_url}/#{post.slug}"
|
|
||||||
item = {
|
|
||||||
"id" => link,
|
|
||||||
"url" => link,
|
|
||||||
"title" => post.title,
|
|
||||||
"content_html" => post.html,
|
|
||||||
"summary" => post.excerpt,
|
|
||||||
"date_published" => iso_date(post.metadata["date"]),
|
|
||||||
"tags" => post.tags
|
|
||||||
}
|
|
||||||
author = post.fediverse_creator || site_fediverse_creator
|
|
||||||
item["authors"] = [{ "name" => author }] if author
|
|
||||||
item.reject { |_k, v| v.nil? || v == "" || v == [] }
|
|
||||||
end
|
|
||||||
|
|
||||||
def iso_date(value)
|
|
||||||
case value
|
|
||||||
when Date then value.iso8601
|
|
||||||
when Time then value.to_date.iso8601
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def sitemap_xml
|
|
||||||
urls = published_posts.map do |post|
|
|
||||||
url = " <url><loc>#{escape("#{base_url}/#{post.slug}")}</loc>"
|
|
||||||
url << "<lastmod>#{escape(post.date_string)}</lastmod>" if post.date_string
|
|
||||||
url << "</url>"
|
|
||||||
end.join("\n")
|
|
||||||
<<~XML
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
||||||
#{urls}
|
|
||||||
</urlset>
|
|
||||||
XML
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "toml-rb"
|
|
||||||
|
|
||||||
module Volumen
|
|
||||||
# Parses and serialises the TOML frontmatter block of a Markdown file.
|
|
||||||
#
|
|
||||||
# A post file starts with a `+++` line, contains a TOML document, is closed
|
|
||||||
# by another `+++` line, and is followed by the Markdown body:
|
|
||||||
#
|
|
||||||
# +++
|
|
||||||
# title = "Hello"
|
|
||||||
# +++
|
|
||||||
#
|
|
||||||
# Body in **Markdown**.
|
|
||||||
module Frontmatter
|
|
||||||
DELIMITER = "+++"
|
|
||||||
|
|
||||||
# Returns `[metadata_hash, body_string]`. If the content has no
|
|
||||||
# frontmatter, the metadata hash is empty and the body is the full input.
|
|
||||||
def self.parse(content)
|
|
||||||
text = normalize(content)
|
|
||||||
lines = text.lines
|
|
||||||
return [{}, text] unless delimiter?(lines.first)
|
|
||||||
|
|
||||||
closing = closing_index(lines)
|
|
||||||
return [{}, text] if closing.nil?
|
|
||||||
|
|
||||||
toml = lines[1...closing].join
|
|
||||||
body = lines[(closing + 1)..]&.join.to_s
|
|
||||||
metadata = toml.strip.empty? ? {} : TomlRB.parse(toml)
|
|
||||||
[metadata, body.sub(/\A\r?\n/, "")]
|
|
||||||
end
|
|
||||||
|
|
||||||
# Serialises metadata + body back into a single post file string.
|
|
||||||
def self.dump(metadata, body)
|
|
||||||
toml = serialize(metadata)
|
|
||||||
buffer = "#{DELIMITER}\n"
|
|
||||||
buffer << toml
|
|
||||||
buffer << "\n" unless toml.empty? || toml.end_with?("\n")
|
|
||||||
buffer << "#{DELIMITER}\n\n"
|
|
||||||
buffer << body.to_s.sub(/\A\s+/, "")
|
|
||||||
buffer.end_with?("\n") ? buffer : "#{buffer}\n"
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.normalize(content)
|
|
||||||
content.to_s.gsub("\r\n", "\n")
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.closing_index(lines)
|
|
||||||
index = lines[1..].index { |line| delimiter?(line) }
|
|
||||||
index.nil? ? nil : index + 1
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.delimiter?(line)
|
|
||||||
line&.chomp&.strip == DELIMITER
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.serialize(metadata)
|
|
||||||
data = metadata || {}
|
|
||||||
return "" if data.empty?
|
|
||||||
|
|
||||||
TomlRB.dump(data)
|
|
||||||
end
|
|
||||||
|
|
||||||
private_class_method :normalize, :closing_index, :delimiter?, :serialize
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "kramdown"
|
|
||||||
require "rexml/document"
|
|
||||||
|
|
||||||
module Volumen
|
|
||||||
# Renders Markdown to HTML using kramdown's GFM parser (tables, fenced code,
|
|
||||||
# task lists, strikethrough).
|
|
||||||
#
|
|
||||||
# Posts are authored by the single trusted admin, so kramdown's default
|
|
||||||
# behaviour of passing raw HTML through is intentional.
|
|
||||||
#
|
|
||||||
# Images with a title (``) are wrapped in a `<figure>`
|
|
||||||
# element with a `<figcaption>` so the frontend can style captions and
|
|
||||||
# overlay an info icon.
|
|
||||||
module Markdown
|
|
||||||
OPTIONS = {
|
|
||||||
input: "GFM",
|
|
||||||
auto_ids: true,
|
|
||||||
hard_wrap: false,
|
|
||||||
syntax_highlighter: nil
|
|
||||||
}.freeze
|
|
||||||
|
|
||||||
def self.render(text)
|
|
||||||
html = ::Kramdown::Document.new(text.to_s, OPTIONS).to_html
|
|
||||||
wrap_figures(html)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Wrap every `<img title="...">` inside a `<figure>` with `<figcaption>`.
|
|
||||||
# Images without a title are left untouched.
|
|
||||||
def self.wrap_figures(html)
|
|
||||||
doc = REXML::Document.new("<root>#{html}</root>")
|
|
||||||
|
|
||||||
REXML::XPath.each(doc, "//img[@title]") do |img|
|
|
||||||
caption = img.attributes["title"]
|
|
||||||
img.attributes.delete("title")
|
|
||||||
|
|
||||||
figure = REXML::Element.new("figure")
|
|
||||||
figure.add_element(img.deep_clone)
|
|
||||||
|
|
||||||
info = REXML::Element.new("div")
|
|
||||||
info.add_attribute("class", "fig-info")
|
|
||||||
info.add_attribute("aria-hidden", "true")
|
|
||||||
info.text = "i"
|
|
||||||
figure.add_element(info)
|
|
||||||
|
|
||||||
figcaption = REXML::Element.new("figcaption")
|
|
||||||
figcaption.text = caption
|
|
||||||
figure.add_element(figcaption)
|
|
||||||
|
|
||||||
img.parent.replace_child(img, figure)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Strip the wrapping <root> we added.
|
|
||||||
result = +""
|
|
||||||
doc.root.children.each { |child| result << child.to_s }
|
|
||||||
result
|
|
||||||
rescue REXML::ParseException
|
|
||||||
html
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "openssl"
|
|
||||||
require "securerandom"
|
|
||||||
|
|
||||||
module Volumen
|
|
||||||
# Hashes and verifies admin passwords with scrypt (via OpenSSL::KDF).
|
|
||||||
#
|
|
||||||
# Encoded form: "scrypt$N$r$p$salt_base64$hash_base64".
|
|
||||||
module Password
|
|
||||||
COST_N = 16_384
|
|
||||||
BLOCK_R = 8
|
|
||||||
PARALLEL_P = 1
|
|
||||||
KEY_LENGTH = 32
|
|
||||||
SALT_BYTES = 16
|
|
||||||
PREFIX = "scrypt"
|
|
||||||
|
|
||||||
def self.hash(password)
|
|
||||||
salt = SecureRandom.bytes(SALT_BYTES)
|
|
||||||
derived = OpenSSL::KDF.scrypt(
|
|
||||||
password.to_s, salt: salt, N: COST_N, r: BLOCK_R, p: PARALLEL_P, length: KEY_LENGTH
|
|
||||||
)
|
|
||||||
[PREFIX, COST_N, BLOCK_R, PARALLEL_P, encode(salt), encode(derived)].join("$")
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.verify(password, encoded)
|
|
||||||
parts = encoded.to_s.split("$")
|
|
||||||
return false unless parts.length == 6 && parts.first == PREFIX
|
|
||||||
|
|
||||||
_, cost, block, parallel, salt_b64, hash_b64 = parts
|
|
||||||
salt = decode(salt_b64)
|
|
||||||
expected = decode(hash_b64)
|
|
||||||
derived = OpenSSL::KDF.scrypt(
|
|
||||||
password.to_s, salt: salt,
|
|
||||||
N: cost.to_i, r: block.to_i, p: parallel.to_i, length: expected.bytesize
|
|
||||||
)
|
|
||||||
OpenSSL.fixed_length_secure_compare(derived, expected)
|
|
||||||
rescue ArgumentError, OpenSSL::KDF::KDFError
|
|
||||||
false
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.encode(bytes)
|
|
||||||
[bytes].pack("m0")
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.decode(string)
|
|
||||||
string.to_s.unpack1("m0")
|
|
||||||
end
|
|
||||||
|
|
||||||
private_class_method :encode, :decode
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "date"
|
|
||||||
require_relative "frontmatter"
|
|
||||||
require_relative "markdown"
|
|
||||||
|
|
||||||
module Volumen
|
|
||||||
# A single blog post: TOML frontmatter metadata plus a Markdown body.
|
|
||||||
class Post
|
|
||||||
FEDIVERSE_CREATOR_REGEX = /\A@[^@\s]+@[^@\s]+\z/
|
|
||||||
|
|
||||||
attr_reader :metadata, :body
|
|
||||||
attr_accessor :path
|
|
||||||
|
|
||||||
def initialize(metadata: {}, body: "")
|
|
||||||
@metadata = (metadata || {}).transform_keys(&:to_s)
|
|
||||||
@body = body.to_s
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.parse(content)
|
|
||||||
metadata, body = Frontmatter.parse(content)
|
|
||||||
new(metadata: metadata, body: body)
|
|
||||||
end
|
|
||||||
|
|
||||||
def slug
|
|
||||||
metadata["slug"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def title
|
|
||||||
metadata["title"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def lang
|
|
||||||
metadata["lang"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def author
|
|
||||||
metadata["author"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def tags
|
|
||||||
Array(metadata["tags"]).map(&:to_s)
|
|
||||||
end
|
|
||||||
|
|
||||||
def draft?
|
|
||||||
metadata["draft"] == true
|
|
||||||
end
|
|
||||||
|
|
||||||
def all_langs?
|
|
||||||
metadata["all_langs"] == true
|
|
||||||
end
|
|
||||||
|
|
||||||
def translations
|
|
||||||
metadata["translations"] || {}
|
|
||||||
end
|
|
||||||
|
|
||||||
def cover
|
|
||||||
metadata["cover"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def cover_alt
|
|
||||||
metadata["cover_alt"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def cover_caption
|
|
||||||
metadata["cover_caption"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def fediverse_creator
|
|
||||||
value = metadata["fediverse_creator"]
|
|
||||||
return nil if value.nil?
|
|
||||||
|
|
||||||
text = value.to_s.strip
|
|
||||||
text.empty? ? nil : text
|
|
||||||
end
|
|
||||||
|
|
||||||
def valid_fediverse_creator?
|
|
||||||
fediverse_creator&.match?(FEDIVERSE_CREATOR_REGEX)
|
|
||||||
end
|
|
||||||
|
|
||||||
def publish_at
|
|
||||||
metadata["publish_at"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def scheduled?
|
|
||||||
value = publish_at
|
|
||||||
return false if value.nil?
|
|
||||||
|
|
||||||
date = case value
|
|
||||||
when Date then value
|
|
||||||
when Time then value.to_date
|
|
||||||
else Date.iso8601(value.to_s)
|
|
||||||
end
|
|
||||||
date > Date.today
|
|
||||||
rescue ArgumentError
|
|
||||||
false
|
|
||||||
end
|
|
||||||
|
|
||||||
def reading_time
|
|
||||||
words = body.split(/\s+/).count { |word| !word.empty? }
|
|
||||||
[(words / 200.0).ceil, 1].max
|
|
||||||
end
|
|
||||||
|
|
||||||
def date_string
|
|
||||||
value = metadata["date"]
|
|
||||||
case value
|
|
||||||
when nil then nil
|
|
||||||
when Date, Time then value.strftime("%Y-%m-%d")
|
|
||||||
else value.to_s
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def excerpt
|
|
||||||
text = metadata["excerpt"]
|
|
||||||
return text if text && !text.to_s.empty?
|
|
||||||
|
|
||||||
derived_excerpt
|
|
||||||
end
|
|
||||||
|
|
||||||
def html
|
|
||||||
Markdown.render(body)
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_file
|
|
||||||
Frontmatter.dump(metadata, body)
|
|
||||||
end
|
|
||||||
|
|
||||||
def summary
|
|
||||||
{
|
|
||||||
"slug" => slug,
|
|
||||||
"title" => title,
|
|
||||||
"excerpt" => excerpt,
|
|
||||||
"date" => date_string,
|
|
||||||
"lang" => lang,
|
|
||||||
"tags" => tags,
|
|
||||||
"author" => author,
|
|
||||||
"fediverse_creator" => fediverse_creator,
|
|
||||||
"cover" => cover,
|
|
||||||
"cover_alt" => cover_alt,
|
|
||||||
"cover_caption" => cover_caption,
|
|
||||||
"reading_time" => reading_time,
|
|
||||||
"translations" => translations,
|
|
||||||
"url" => "/api/volumen/posts/#{slug}"
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def detail
|
|
||||||
summary.merge("body" => body, "html" => html)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def derived_excerpt(limit = 200)
|
|
||||||
paragraph = body.split(/\n\s*\n/).map(&:strip)
|
|
||||||
.find { |para| !para.empty? && !para.start_with?("#") }
|
|
||||||
return "" if paragraph.nil?
|
|
||||||
|
|
||||||
stripped = paragraph.gsub(/<[^>]+>/, "").gsub(/[*_`>#]/, "").strip
|
|
||||||
stripped.length > limit ? "#{stripped[0, limit].rstrip}…" : stripped
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,488 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "sinatra/base"
|
|
||||||
require "json"
|
|
||||||
require "date"
|
|
||||||
require "securerandom"
|
|
||||||
require_relative "config"
|
|
||||||
require_relative "store"
|
|
||||||
require_relative "markdown"
|
|
||||||
require_relative "password"
|
|
||||||
require_relative "api_routes"
|
|
||||||
require_relative "admin_routes"
|
|
||||||
require_relative "feed_helpers"
|
|
||||||
|
|
||||||
module Volumen
|
|
||||||
# Sinatra application: the public JSON API at /api/volumen and the
|
|
||||||
# server-rendered admin at /admin.
|
|
||||||
class Server < Sinatra::Base
|
|
||||||
MAX_LOGIN_ATTEMPTS = 10
|
|
||||||
LOGIN_WINDOW = 60 # seconds
|
|
||||||
MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
||||||
ALLOWED_UPLOAD_TYPES = %w[image/webp image/avif].freeze
|
|
||||||
SLUG_REGEX = /\A[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\z/
|
|
||||||
|
|
||||||
register ApiRoutes
|
|
||||||
register AdminRoutes
|
|
||||||
helpers FeedHelpers
|
|
||||||
|
|
||||||
configure do
|
|
||||||
set :show_exceptions, false
|
|
||||||
set :raise_errors, false
|
|
||||||
set :host_authorization, { permitted_hosts: [] } # empty = allow all hosts
|
|
||||||
set :views, File.expand_path("web/views", __dir__)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Returns a configured subclass bound to a specific config + store.
|
|
||||||
def self.configured(config:, store:)
|
|
||||||
secret = session_secret(config)
|
|
||||||
ttl = config.admin["session_ttl"].to_i
|
|
||||||
ttl = nil if ttl <= 0
|
|
||||||
Class.new(self) do
|
|
||||||
set :volumen_config, config
|
|
||||||
set :volumen_store, store
|
|
||||||
set :sessions, httponly: true, same_site: :strict, expire_after: ttl
|
|
||||||
set :session_secret, secret
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.session_secret(config)
|
|
||||||
key = config.admin["session_key"].to_s
|
|
||||||
return SecureRandom.hex(64) if key.empty?
|
|
||||||
|
|
||||||
if key.bytesize < 64
|
|
||||||
warn "volumen: session_key is shorter than 64 bytes, falling back to random secret"
|
|
||||||
return SecureRandom.hex(64)
|
|
||||||
end
|
|
||||||
|
|
||||||
key
|
|
||||||
end
|
|
||||||
|
|
||||||
# Mark the session cookie Secure only when the request is HTTPS (directly or
|
|
||||||
# via a trusted X-Forwarded-Proto from nginx). Over plain HTTP (local dev)
|
|
||||||
# the cookie stays usable so the admin login round-trips.
|
|
||||||
before do
|
|
||||||
options = request.env["rack.session.options"]
|
|
||||||
options[:secure] = request.ssl? if options
|
|
||||||
|
|
||||||
if request.path_info.start_with?("/admin")
|
|
||||||
headers "Content-Security-Policy" => [
|
|
||||||
"default-src 'self'",
|
|
||||||
"script-src 'self' 'unsafe-inline'",
|
|
||||||
"style-src 'self' 'unsafe-inline'",
|
|
||||||
"img-src 'self' data:",
|
|
||||||
"font-src 'self'",
|
|
||||||
"connect-src 'self'",
|
|
||||||
"form-action 'self'",
|
|
||||||
"frame-ancestors 'none'"
|
|
||||||
].join("; ")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def config
|
|
||||||
settings.volumen_config
|
|
||||||
end
|
|
||||||
|
|
||||||
def store
|
|
||||||
settings.volumen_store
|
|
||||||
end
|
|
||||||
|
|
||||||
# --- API helpers -----------------------------------------------------
|
|
||||||
|
|
||||||
def json(data)
|
|
||||||
JSON.generate(data)
|
|
||||||
end
|
|
||||||
|
|
||||||
def published_posts
|
|
||||||
@published_posts ||= store.all
|
|
||||||
.reject { |post| post.draft? || post.scheduled? }
|
|
||||||
.sort_by { |post| post.date_string || "" }.reverse
|
|
||||||
end
|
|
||||||
|
|
||||||
def clear_posts_cache!
|
|
||||||
@published_posts = nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def site_payload
|
|
||||||
config.site.slice("title", "description", "base_url", "language", "author",
|
|
||||||
"fediverse_creator")
|
|
||||||
end
|
|
||||||
|
|
||||||
def site_fediverse_creator
|
|
||||||
config.site["fediverse_creator"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def posts_payload
|
|
||||||
posts = filtered_posts
|
|
||||||
page = positive_int(params["page"], 1)
|
|
||||||
limit = positive_int(params["limit"], 20).clamp(1, 100)
|
|
||||||
offset = (page - 1) * limit
|
|
||||||
total = posts.length
|
|
||||||
{
|
|
||||||
"page" => page,
|
|
||||||
"page_size" => limit,
|
|
||||||
"total" => total,
|
|
||||||
"has_next" => offset + limit < total,
|
|
||||||
"has_prev" => page > 1,
|
|
||||||
"posts" => posts[offset, limit].to_a.map(&:summary)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def filtered_posts
|
|
||||||
posts = published_posts
|
|
||||||
posts = filter_by_lang(posts)
|
|
||||||
posts = posts.select { |post| post.tags.include?(params["tag"]) } if params["tag"]
|
|
||||||
posts = search_posts(posts) if params["q"]
|
|
||||||
posts
|
|
||||||
end
|
|
||||||
|
|
||||||
def filter_by_lang(posts)
|
|
||||||
return posts unless params["lang"]
|
|
||||||
|
|
||||||
lang = params["lang"]
|
|
||||||
posts.select { |post| post.lang == lang || post.all_langs? }
|
|
||||||
end
|
|
||||||
|
|
||||||
def search_posts(posts)
|
|
||||||
query = params["q"].to_s.strip.downcase
|
|
||||||
return posts if query.empty?
|
|
||||||
|
|
||||||
posts.select do |post|
|
|
||||||
post.title.to_s.downcase.include?(query) ||
|
|
||||||
post.body.downcase.include?(query)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def tag_counts
|
|
||||||
counts = Hash.new(0)
|
|
||||||
published_posts.each { |post| post.tags.each { |tag| counts[tag] += 1 } }
|
|
||||||
counts.sort_by { |name, count| [-count, name] }
|
|
||||||
.map { |name, count| { "name" => name, "count" => count } }
|
|
||||||
end
|
|
||||||
|
|
||||||
def positive_int(value, default)
|
|
||||||
number = value.to_i
|
|
||||||
number.positive? ? number : default
|
|
||||||
end
|
|
||||||
|
|
||||||
def base_url
|
|
||||||
config.site["base_url"].to_s.chomp("/")
|
|
||||||
end
|
|
||||||
|
|
||||||
def escape(text)
|
|
||||||
Rack::Utils.escape_html(text.to_s)
|
|
||||||
end
|
|
||||||
|
|
||||||
alias h escape
|
|
||||||
|
|
||||||
# --- Admin helpers ---------------------------------------------------
|
|
||||||
|
|
||||||
def users
|
|
||||||
Users.new(path: config.users_file, bootstrap_hash: config.admin["password_hash"])
|
|
||||||
end
|
|
||||||
|
|
||||||
def authenticated?
|
|
||||||
!session[:user].to_s.empty?
|
|
||||||
end
|
|
||||||
|
|
||||||
def current_user
|
|
||||||
session[:user]
|
|
||||||
end
|
|
||||||
|
|
||||||
def current_user_record
|
|
||||||
users.find(current_user)
|
|
||||||
end
|
|
||||||
|
|
||||||
def current_role
|
|
||||||
current_user_record&.role
|
|
||||||
end
|
|
||||||
|
|
||||||
def current_user_name
|
|
||||||
current_user_record&.name
|
|
||||||
end
|
|
||||||
|
|
||||||
def current_user_fediverse
|
|
||||||
current_user_record&.fediverse_creator
|
|
||||||
end
|
|
||||||
|
|
||||||
def current_user_photo
|
|
||||||
current_user_record&.photo
|
|
||||||
end
|
|
||||||
|
|
||||||
def admin?
|
|
||||||
current_role == "admin"
|
|
||||||
end
|
|
||||||
|
|
||||||
def require_login!
|
|
||||||
unless authenticated?
|
|
||||||
redirect to("/admin/login")
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
return unless current_user_record.nil?
|
|
||||||
|
|
||||||
session.clear
|
|
||||||
redirect to("/admin/login")
|
|
||||||
end
|
|
||||||
|
|
||||||
def require_admin!
|
|
||||||
halt(403, "Forbidden") unless admin?
|
|
||||||
end
|
|
||||||
|
|
||||||
def csrf_token
|
|
||||||
session[:csrf] ||= SecureRandom.hex(32)
|
|
||||||
end
|
|
||||||
|
|
||||||
def check_csrf!
|
|
||||||
token = params["_csrf"]
|
|
||||||
return if token && session["csrf"] && Rack::Utils.secure_compare(session["csrf"], token)
|
|
||||||
|
|
||||||
halt(403, "Invalid CSRF token")
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_upload!(upload)
|
|
||||||
if upload[:tempfile].size > MAX_UPLOAD_BYTES
|
|
||||||
halt(413, json("error" => "file_too_large", "max_bytes" => MAX_UPLOAD_BYTES))
|
|
||||||
end
|
|
||||||
|
|
||||||
content_type = upload[:type].to_s.split(";").first.to_s.strip.downcase
|
|
||||||
return if ALLOWED_UPLOAD_TYPES.include?(content_type)
|
|
||||||
|
|
||||||
halt(415, json("error" => "unsupported_format",
|
|
||||||
"message" => "Only WebP (.webp) and AVIF (.avif) images are supported. " \
|
|
||||||
"Please convert your image before uploading — for example with " \
|
|
||||||
"`cwebp` (JPEG/PNG → WebP) or `avifenc` (PNG/JPEG → AVIF).",
|
|
||||||
"allowed" => ALLOWED_UPLOAD_TYPES))
|
|
||||||
end
|
|
||||||
|
|
||||||
def check_login_rate_limit!
|
|
||||||
Volumen.sweep_login_attempts!
|
|
||||||
ip = request.ip.to_s
|
|
||||||
now = Time.now.to_f
|
|
||||||
cutoff = now - LOGIN_WINDOW
|
|
||||||
attempts = (Volumen.login_attempts[ip] ||= []).select { |t| t > cutoff }
|
|
||||||
if attempts.length >= MAX_LOGIN_ATTEMPTS
|
|
||||||
halt(429, "Too many login attempts. Please wait and try again.")
|
|
||||||
end
|
|
||||||
|
|
||||||
attempts << now
|
|
||||||
Volumen.login_attempts[ip] = attempts
|
|
||||||
end
|
|
||||||
|
|
||||||
def render_settings(error: nil, notice: nil)
|
|
||||||
@error = error
|
|
||||||
@notice = notice
|
|
||||||
@users = users.all
|
|
||||||
erb :settings
|
|
||||||
end
|
|
||||||
|
|
||||||
def change_password
|
|
||||||
unless users.authenticate(current_user, params["current_password"])
|
|
||||||
return render_settings(error: "Current password is incorrect.")
|
|
||||||
end
|
|
||||||
|
|
||||||
fresh = presence(params["new_password"])
|
|
||||||
return render_settings(error: "New password cannot be empty.") if fresh.nil?
|
|
||||||
|
|
||||||
users.update_password(current_user, fresh)
|
|
||||||
render_settings(notice: "Password updated.")
|
|
||||||
end
|
|
||||||
|
|
||||||
def change_username
|
|
||||||
new_name = presence(params["username"])
|
|
||||||
return render_settings(error: "Username cannot be empty.") if new_name.nil?
|
|
||||||
unless new_name.match?(/\A[a-zA-Z0-9._-]+\z/)
|
|
||||||
return render_settings(error: "Username may use letters, numbers, dot, dash, underscore.")
|
|
||||||
end
|
|
||||||
|
|
||||||
return render_settings(notice: "Username unchanged.") if new_name == current_user
|
|
||||||
|
|
||||||
if users.rename(current_user, new_name)
|
|
||||||
session[:user] = new_name
|
|
||||||
render_settings(notice: "Username updated.")
|
|
||||||
else
|
|
||||||
render_settings(error: "That username is already taken.")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def change_name
|
|
||||||
name = params["name"].to_s.strip
|
|
||||||
name = nil if name.empty?
|
|
||||||
users.update_name(current_user, name)
|
|
||||||
render_settings(notice: name ? "Display name updated." : "Display name cleared.")
|
|
||||||
end
|
|
||||||
|
|
||||||
def change_fediverse_creator
|
|
||||||
value = params["fediverse_creator"].to_s.strip
|
|
||||||
if value.empty?
|
|
||||||
users.update_fediverse_creator(current_user, nil)
|
|
||||||
return render_settings(notice: "Fediverse handle cleared.")
|
|
||||||
end
|
|
||||||
|
|
||||||
unless value.match?(Post::FEDIVERSE_CREATOR_REGEX)
|
|
||||||
return render_settings(error: "Fediverse handle must look like @user@host.")
|
|
||||||
end
|
|
||||||
|
|
||||||
users.update_fediverse_creator(current_user, value)
|
|
||||||
render_settings(notice: "Fediverse handle updated.")
|
|
||||||
end
|
|
||||||
|
|
||||||
def change_photo
|
|
||||||
upload = params["photo"]
|
|
||||||
unless upload.is_a?(Hash) && upload[:tempfile]
|
|
||||||
return render_settings(error: "No file selected.")
|
|
||||||
end
|
|
||||||
|
|
||||||
validate_upload!(upload)
|
|
||||||
url = store.store_user_photo(current_user, upload[:filename], upload[:tempfile])
|
|
||||||
users.update_photo(current_user, url)
|
|
||||||
render_settings(notice: "Profile photo updated.")
|
|
||||||
end
|
|
||||||
|
|
||||||
def remove_photo
|
|
||||||
users.update_photo(current_user, nil)
|
|
||||||
render_settings(notice: "Profile photo removed.")
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_user
|
|
||||||
name = presence(params["username"])
|
|
||||||
password = presence(params["password"])
|
|
||||||
if name.nil? || password.nil?
|
|
||||||
return render_settings(error: "Username and password are required.")
|
|
||||||
end
|
|
||||||
unless name.match?(/\A[a-zA-Z0-9._-]+\z/)
|
|
||||||
return render_settings(error: "Username may use letters, numbers, dot, dash, underscore.")
|
|
||||||
end
|
|
||||||
|
|
||||||
if users.add(name, password, params["role"].to_s)
|
|
||||||
render_settings(notice: "User added.")
|
|
||||||
else
|
|
||||||
render_settings(error: "That username already exists.")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def remove_user(username)
|
|
||||||
if username == current_user
|
|
||||||
return render_settings(error: "You cannot delete your own account.")
|
|
||||||
end
|
|
||||||
|
|
||||||
if users.delete(username)
|
|
||||||
render_settings(notice: "User removed.")
|
|
||||||
else
|
|
||||||
render_settings(error: "Cannot remove the last user or the last admin.")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def change_role(username)
|
|
||||||
return render_settings(error: "You cannot change your own role.") if username == current_user
|
|
||||||
|
|
||||||
if users.set_role(username, params["role"].to_s)
|
|
||||||
render_settings(notice: "Role updated.")
|
|
||||||
else
|
|
||||||
render_settings(error: "Could not change role (last admin?).")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def sorted_posts
|
|
||||||
store.all.sort_by { |post| post.date_string || "" }.reverse
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_post
|
|
||||||
post = post_from_params(params)
|
|
||||||
error = creation_error(post)
|
|
||||||
if error
|
|
||||||
@mode = :new
|
|
||||||
@post = post
|
|
||||||
@error = error
|
|
||||||
return erb(:form)
|
|
||||||
end
|
|
||||||
store.save(post)
|
|
||||||
clear_posts_cache!
|
|
||||||
redirect to("/admin/")
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_post
|
|
||||||
existing = store.find(params["slug"])
|
|
||||||
halt(404, "Not found") if existing.nil?
|
|
||||||
|
|
||||||
post = post_from_params(params, existing: existing)
|
|
||||||
error = fediverse_creator_error(post)
|
|
||||||
if error
|
|
||||||
@mode = :edit
|
|
||||||
@post = post
|
|
||||||
@error = error
|
|
||||||
return erb(:form)
|
|
||||||
end
|
|
||||||
store.save(post)
|
|
||||||
clear_posts_cache!
|
|
||||||
redirect to("/admin/")
|
|
||||||
end
|
|
||||||
|
|
||||||
def creation_error(post)
|
|
||||||
return "Slug is required." if presence(post.slug).nil?
|
|
||||||
return "Invalid slug." unless post.slug.match?(SLUG_REGEX)
|
|
||||||
return "A post with that slug already exists." if store.find(post.slug)
|
|
||||||
|
|
||||||
fediverse_creator_error(post)
|
|
||||||
end
|
|
||||||
|
|
||||||
def post_from_params(http_params, existing: nil)
|
|
||||||
metadata = base_metadata(http_params, existing: existing)
|
|
||||||
post = Post.new(metadata: clean_metadata(metadata), body: http_params["body"].to_s)
|
|
||||||
post.path = existing.path if existing
|
|
||||||
post
|
|
||||||
end
|
|
||||||
|
|
||||||
def base_metadata(http_params, existing:)
|
|
||||||
{
|
|
||||||
"title" => presence(http_params["title"]),
|
|
||||||
"slug" => presence(http_params["slug"]) || existing&.slug,
|
|
||||||
"lang" => presence(http_params["lang"]),
|
|
||||||
"author" => presence(http_params["author"]),
|
|
||||||
"fediverse_creator" => presence(http_params["fediverse_creator"]),
|
|
||||||
"date" => parse_date(http_params["date"]),
|
|
||||||
"publish_at" => parse_date(http_params["publish_at"]),
|
|
||||||
"tags" => parse_tags(http_params["tags"]),
|
|
||||||
"excerpt" => presence(http_params["excerpt"]),
|
|
||||||
"cover" => presence(http_params["cover"]),
|
|
||||||
"cover_alt" => presence(http_params["cover_alt"]),
|
|
||||||
"cover_caption" => presence(http_params["cover_caption"]),
|
|
||||||
"draft" => http_params["draft"] == "on",
|
|
||||||
"all_langs" => http_params["all_langs"] == "on"
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def fediverse_creator_error(post)
|
|
||||||
value = post.metadata["fediverse_creator"]
|
|
||||||
return nil if value.nil?
|
|
||||||
|
|
||||||
if Post::FEDIVERSE_CREATOR_REGEX.match?(value.to_s)
|
|
||||||
nil
|
|
||||||
else
|
|
||||||
"Fediverse creator must look like @user@host."
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def clean_metadata(metadata)
|
|
||||||
metadata.reject { |_key, value| value.nil? || value == "" || value == [] || value == false }
|
|
||||||
end
|
|
||||||
|
|
||||||
def presence(value)
|
|
||||||
text = value.to_s.strip
|
|
||||||
text.empty? ? nil : text
|
|
||||||
end
|
|
||||||
|
|
||||||
def parse_date(value)
|
|
||||||
text = presence(value)
|
|
||||||
text && Date.iso8601(text)
|
|
||||||
rescue ArgumentError
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def parse_tags(value)
|
|
||||||
presence(value).to_s.split(",").map(&:strip).reject(&:empty?)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "fileutils"
|
|
||||||
require "securerandom"
|
|
||||||
require_relative "post"
|
|
||||||
|
|
||||||
module Volumen
|
|
||||||
# Reads and writes Markdown posts in a content directory.
|
|
||||||
#
|
|
||||||
# Posts live either at `content_dir/<slug>.md` (default language) or at
|
|
||||||
# `content_dir/<lang>/<slug>.md` (per-language subdirectory). Missing
|
|
||||||
# `slug`/`lang` frontmatter is derived from the file path.
|
|
||||||
class Store
|
|
||||||
attr_reader :content_dir
|
|
||||||
|
|
||||||
def initialize(content_dir, default_lang: nil)
|
|
||||||
@content_dir = File.expand_path(content_dir.to_s)
|
|
||||||
@default_lang = default_lang
|
|
||||||
@all = nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def all
|
|
||||||
current_dir_mtime = Dir.exist?(@content_dir) ? File.mtime(@content_dir) : nil
|
|
||||||
current_files_mtime = newest_file_mtime
|
|
||||||
@all = nil if current_dir_mtime != @all_dir_mtime || current_files_mtime != @all_files_mtime
|
|
||||||
@all ||= begin
|
|
||||||
@all_dir_mtime = current_dir_mtime
|
|
||||||
@all_files_mtime = current_files_mtime
|
|
||||||
markdown_files.filter_map { |path| load_post(path) }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def find(slug, lang: nil)
|
|
||||||
all.find do |post|
|
|
||||||
post.slug == slug && (lang.nil? || post.lang == lang || post.all_langs?)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def save(post)
|
|
||||||
target = post.path || default_path_for(post)
|
|
||||||
FileUtils.mkdir_p(File.dirname(target))
|
|
||||||
tmp = "#{target}.tmp"
|
|
||||||
File.write(tmp, post.to_file)
|
|
||||||
File.rename(tmp, target)
|
|
||||||
post.path = target
|
|
||||||
@all = nil
|
|
||||||
post
|
|
||||||
end
|
|
||||||
|
|
||||||
def delete(slug, lang: nil)
|
|
||||||
post = find(slug, lang: lang)
|
|
||||||
if post&.path
|
|
||||||
File.delete(post.path)
|
|
||||||
@all = nil
|
|
||||||
end
|
|
||||||
post
|
|
||||||
end
|
|
||||||
|
|
||||||
def media_dir
|
|
||||||
File.join(@content_dir, "media")
|
|
||||||
end
|
|
||||||
|
|
||||||
def store_upload(original_name, source)
|
|
||||||
name = safe_media_name(original_name)
|
|
||||||
FileUtils.mkdir_p(media_dir)
|
|
||||||
File.open(File.join(media_dir, name), "wb") { |file| IO.copy_stream(source, file) }
|
|
||||||
"/media/#{name}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def store_user_photo(username, original_name, source)
|
|
||||||
ext = File.extname(original_name.to_s).gsub(/[^a-zA-Z0-9.]/, "").downcase
|
|
||||||
ext = ".webp" if ext.empty?
|
|
||||||
name = "#{username}-#{SecureRandom.hex(4)}#{ext}"
|
|
||||||
FileUtils.mkdir_p(media_dir)
|
|
||||||
File.open(File.join(media_dir, name), "wb") { |file| IO.copy_stream(source, file) }
|
|
||||||
"/media/#{name}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def media_file(name)
|
|
||||||
path = File.join(media_dir, File.basename(name.to_s))
|
|
||||||
return nil unless File.file?(path)
|
|
||||||
|
|
||||||
real = File.realpath(path)
|
|
||||||
within?(media_dir, real) ? path : nil
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def markdown_files
|
|
||||||
Dir.glob(File.join(@content_dir, "**", "*.md"))
|
|
||||||
end
|
|
||||||
|
|
||||||
def newest_file_mtime
|
|
||||||
files = markdown_files
|
|
||||||
return nil if files.empty?
|
|
||||||
|
|
||||||
files.map { |path| File.mtime(path) }.max
|
|
||||||
rescue SystemCallError
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_post(path)
|
|
||||||
post = Post.parse(File.read(path))
|
|
||||||
post.metadata["slug"] ||= File.basename(path, ".md")
|
|
||||||
post.metadata["lang"] ||= lang_from_path(path) || @default_lang
|
|
||||||
post.path = path
|
|
||||||
post
|
|
||||||
rescue StandardError => e
|
|
||||||
warn "volumen: skipping #{path}: #{e.message}"
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def lang_from_path(path)
|
|
||||||
parent = File.dirname(File.expand_path(path))
|
|
||||||
return nil if parent == @content_dir
|
|
||||||
|
|
||||||
File.basename(parent)
|
|
||||||
end
|
|
||||||
|
|
||||||
def default_path_for(post)
|
|
||||||
dir = @content_dir
|
|
||||||
dir = File.join(dir, post.lang) if post.lang && post.lang != @default_lang
|
|
||||||
target = File.join(dir, "#{post.slug}.md")
|
|
||||||
unless File.expand_path(target).start_with?(File.expand_path(@content_dir) + File::SEPARATOR)
|
|
||||||
raise ArgumentError, "slug escapes content directory"
|
|
||||||
end
|
|
||||||
|
|
||||||
target
|
|
||||||
end
|
|
||||||
|
|
||||||
def safe_media_name(original)
|
|
||||||
base = File.basename(original.to_s)
|
|
||||||
ext = File.extname(base).gsub(/[^a-zA-Z0-9.]/, "").downcase
|
|
||||||
stem = File.basename(base, File.extname(base))
|
|
||||||
.gsub(/[^a-zA-Z0-9_-]+/, "-").gsub(/-+/, "-").downcase
|
|
||||||
stem = "file" if stem.empty?
|
|
||||||
"#{SecureRandom.hex(4)}-#{stem}#{ext}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def within?(dir, path)
|
|
||||||
File.expand_path(path).start_with?(File.expand_path(dir) + File::SEPARATOR)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "fileutils"
|
|
||||||
require "toml-rb"
|
|
||||||
require_relative "password"
|
|
||||||
|
|
||||||
module Volumen
|
|
||||||
# File-backed set of admin users, stored as a TOML `[[users]]` array.
|
|
||||||
#
|
|
||||||
# Each user has a role: "admin" (full access, including user management) or
|
|
||||||
# "author" (posts and own account only). If the file does not exist yet, a
|
|
||||||
# single bootstrap "admin" user is synthesised from the legacy
|
|
||||||
# `admin.password_hash` config value.
|
|
||||||
class Users
|
|
||||||
ROLES = %w[admin author].freeze
|
|
||||||
DEFAULT_ROLE = "author"
|
|
||||||
|
|
||||||
User = Struct.new(:username, :password_hash, :role, :name, :fediverse_creator, :photo)
|
|
||||||
|
|
||||||
def initialize(path:, bootstrap_hash: nil)
|
|
||||||
@path = path
|
|
||||||
@bootstrap_hash = bootstrap_hash.to_s
|
|
||||||
@all = nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def all
|
|
||||||
mtime = File.exist?(@path) ? File.mtime(@path) : nil
|
|
||||||
@all = nil if mtime != @all_mtime
|
|
||||||
@all ||= begin
|
|
||||||
@all_mtime = mtime
|
|
||||||
if File.exist?(@path)
|
|
||||||
read_file
|
|
||||||
elsif @bootstrap_hash.empty?
|
|
||||||
[]
|
|
||||||
else
|
|
||||||
[User.new("admin", @bootstrap_hash, "admin", nil, nil, nil)]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def any?
|
|
||||||
all.any?
|
|
||||||
end
|
|
||||||
|
|
||||||
def find(username)
|
|
||||||
all.find { |user| user.username == username }
|
|
||||||
end
|
|
||||||
|
|
||||||
def authenticate(username, password)
|
|
||||||
user = find(username)
|
|
||||||
user if user && Password.verify(password, user.password_hash)
|
|
||||||
end
|
|
||||||
|
|
||||||
def add(username, password, role = DEFAULT_ROLE)
|
|
||||||
return nil if username.to_s.empty? || find(username)
|
|
||||||
|
|
||||||
user = User.new(username, Password.hash(password), normalize_role(role), nil, nil, nil)
|
|
||||||
persist(all + [user])
|
|
||||||
user
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_name(username, name)
|
|
||||||
mutate(username) { |user| user.name = name.to_s.empty? ? nil : name.to_s }
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_fediverse_creator(username, value)
|
|
||||||
mutate(username) do |user|
|
|
||||||
user.fediverse_creator = value.to_s.empty? ? nil : value.to_s
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_photo(username, path)
|
|
||||||
mutate(username) { |user| user.photo = path }
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_password(username, password)
|
|
||||||
mutate(username) { |user| user.password_hash = Password.hash(password) }
|
|
||||||
end
|
|
||||||
|
|
||||||
def rename(current_name, new_name)
|
|
||||||
return nil if new_name.to_s.empty? || find(new_name)
|
|
||||||
|
|
||||||
mutate(current_name) { |user| user.username = new_name }
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_role(username, role)
|
|
||||||
return nil unless ROLES.include?(role)
|
|
||||||
|
|
||||||
users = all
|
|
||||||
user = users.find { |entry| entry.username == username }
|
|
||||||
return nil if user.nil? || demoting_last_admin?(users, user, role)
|
|
||||||
|
|
||||||
user.role = role
|
|
||||||
persist(users)
|
|
||||||
user
|
|
||||||
end
|
|
||||||
|
|
||||||
def delete(username)
|
|
||||||
users = all
|
|
||||||
target = users.find { |entry| entry.username == username }
|
|
||||||
return nil if target.nil? || users.length <= 1 || last_admin?(users, target)
|
|
||||||
|
|
||||||
persist(users.reject { |entry| entry.username == username })
|
|
||||||
username
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def mutate(username)
|
|
||||||
users = all
|
|
||||||
user = users.find { |entry| entry.username == username }
|
|
||||||
return nil if user.nil?
|
|
||||||
|
|
||||||
yield user
|
|
||||||
persist(users)
|
|
||||||
user
|
|
||||||
end
|
|
||||||
|
|
||||||
def normalize_role(role)
|
|
||||||
ROLES.include?(role) ? role : DEFAULT_ROLE
|
|
||||||
end
|
|
||||||
|
|
||||||
def admins(users)
|
|
||||||
users.select { |user| user.role == "admin" }
|
|
||||||
end
|
|
||||||
|
|
||||||
def last_admin?(users, user)
|
|
||||||
user.role == "admin" && admins(users).length <= 1
|
|
||||||
end
|
|
||||||
|
|
||||||
def demoting_last_admin?(users, user, role)
|
|
||||||
user.role == "admin" && role != "admin" && admins(users).length <= 1
|
|
||||||
end
|
|
||||||
|
|
||||||
def read_file
|
|
||||||
data = TomlRB.load_file(@path)
|
|
||||||
Array(data["users"]).map do |entry|
|
|
||||||
User.new(entry["username"], entry["password_hash"], entry["role"] || "admin",
|
|
||||||
entry["name"], entry["fediverse_creator"], entry["photo"])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def persist(users)
|
|
||||||
FileUtils.mkdir_p(File.dirname(@path))
|
|
||||||
tmp = "#{@path}.tmp"
|
|
||||||
File.write(tmp, TomlRB.dump("users" => users.map { |user| user_hash(user) }))
|
|
||||||
File.rename(tmp, @path)
|
|
||||||
end
|
|
||||||
|
|
||||||
def user_hash(user)
|
|
||||||
hash = {
|
|
||||||
"username" => user.username,
|
|
||||||
"password_hash" => user.password_hash,
|
|
||||||
"role" => user.role
|
|
||||||
}
|
|
||||||
hash["name"] = user.name if user.name
|
|
||||||
hash["fediverse_creator"] = user.fediverse_creator if user.fediverse_creator
|
|
||||||
hash["photo"] = user.photo if user.photo
|
|
||||||
hash
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module Volumen
|
|
||||||
VERSION = "0.3.0"
|
|
||||||
end
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=75", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "volumen"
|
||||||
|
version = "0.4.0"
|
||||||
|
description = "A small, file-based Markdown blog engine with a built-in admin."
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
license = {text = "MIT"}
|
||||||
|
authors = [{name = "Petr Balvín", email = "petrbalvin@yandex.com"}]
|
||||||
|
dependencies = [
|
||||||
|
"fastapi[standard]>=0.115",
|
||||||
|
"itsdangerous>=2.2",
|
||||||
|
"jinja2>=3.1",
|
||||||
|
"markdown>=3.7",
|
||||||
|
"pymdown-extensions>=10.14",
|
||||||
|
"tomli-w>=1.2",
|
||||||
|
"python-multipart>=0.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
volumen = "volumen.cli:main"
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = ["pytest>=8", "httpx>=0.28", "ruff>=0.9"]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
indent-width = 4
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
quote-style = "double"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "C4"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"src/volumen/admin_routes.py" = ["B008"]
|
||||||
|
"src/volumen/api_routes.py" = ["B008"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
VERSION = "0.4.0"
|
||||||
|
|
||||||
|
CLEANUP_INTERVAL: int = 300
|
||||||
|
|
||||||
|
_login_attempts: dict[str, list[float]] = {}
|
||||||
|
_last_cleanup: float | None = None
|
||||||
|
LOGIN_WINDOW: float = 60.0
|
||||||
|
MAX_LOGIN_ATTEMPTS: int = 10
|
||||||
|
|
||||||
|
|
||||||
|
def login_attempts() -> dict[str, list[float]]:
|
||||||
|
"""Return the global login-attempts tracking dict."""
|
||||||
|
return _login_attempts
|
||||||
|
|
||||||
|
|
||||||
|
def reset_login_attempts() -> None:
|
||||||
|
"""Clear all tracked login attempts (useful in tests)."""
|
||||||
|
global _login_attempts, _last_cleanup
|
||||||
|
_login_attempts = {}
|
||||||
|
_last_cleanup = None
|
||||||
|
|
||||||
|
|
||||||
|
def sweep_login_attempts(window: float = LOGIN_WINDOW) -> None:
|
||||||
|
"""Remove login attempts older than `window` seconds.
|
||||||
|
|
||||||
|
Full sweeps only run every CLEANUP_INTERVAL seconds to avoid
|
||||||
|
needless work on every request.
|
||||||
|
"""
|
||||||
|
global _last_cleanup
|
||||||
|
now = time.monotonic()
|
||||||
|
if _last_cleanup is not None and (now - _last_cleanup) < CLEANUP_INTERVAL:
|
||||||
|
return
|
||||||
|
cutoff = now - window
|
||||||
|
for ip in list(_login_attempts):
|
||||||
|
_login_attempts[ip] = [t for t in _login_attempts[ip] if t > cutoff]
|
||||||
|
if not _login_attempts[ip]:
|
||||||
|
del _login_attempts[ip]
|
||||||
|
_last_cleanup = now
|
||||||
@@ -0,0 +1,719 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
|
||||||
|
from fastapi.responses import (
|
||||||
|
FileResponse,
|
||||||
|
HTMLResponse,
|
||||||
|
JSONResponse,
|
||||||
|
RedirectResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .post import Post
|
||||||
|
from .server import (
|
||||||
|
admin_context,
|
||||||
|
change_fediverse_creator,
|
||||||
|
change_name,
|
||||||
|
change_password,
|
||||||
|
change_photo,
|
||||||
|
change_role,
|
||||||
|
change_username,
|
||||||
|
check_login_rate_limit,
|
||||||
|
create_user,
|
||||||
|
creation_error,
|
||||||
|
fediverse_creator_error,
|
||||||
|
get_config,
|
||||||
|
get_store,
|
||||||
|
get_users,
|
||||||
|
post_from_params,
|
||||||
|
remove_photo,
|
||||||
|
remove_user,
|
||||||
|
require_admin,
|
||||||
|
require_login,
|
||||||
|
templates,
|
||||||
|
validate_csrf_form,
|
||||||
|
validate_upload,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin")
|
||||||
|
media_router = APIRouter(prefix="/media")
|
||||||
|
|
||||||
|
# Static logo / icon served from the web/public directory.
|
||||||
|
_PKG_DIR = Path(__file__).resolve().parent
|
||||||
|
_PUBLIC_DIR = _PKG_DIR.parent / "lib" / "volumen" / "web" / "public"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Redirect /admin → /admin/
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", include_in_schema=False)
|
||||||
|
async def admin_root() -> RedirectResponse:
|
||||||
|
"""Redirect bare ``/admin`` to ``/admin/``."""
|
||||||
|
return RedirectResponse(url="/admin/", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Post list (dashboard)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def admin_index(
|
||||||
|
request: Request,
|
||||||
|
config: Any = Depends(get_config),
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
users_obj: Any = Depends(get_users),
|
||||||
|
_username: str = Depends(require_login),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Show the admin post list (requires login)."""
|
||||||
|
all_posts = sorted(store.all(), key=lambda p: p.date_string or "", reverse=True)
|
||||||
|
ctx = admin_context(
|
||||||
|
request,
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
users_obj,
|
||||||
|
posts=all_posts,
|
||||||
|
crumbs=[("Posts", None)],
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(request, "list.html.jinja", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Login
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login")
|
||||||
|
async def admin_login_form(
|
||||||
|
request: Request,
|
||||||
|
config: Any = Depends(get_config),
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
users_obj: Any = Depends(get_users),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Show the login form, or redirect to ``/admin/`` if already logged in."""
|
||||||
|
if request.session.get("user", ""):
|
||||||
|
return RedirectResponse(url="/admin/", status_code=303)
|
||||||
|
ctx = admin_context(request, config, store, users_obj)
|
||||||
|
return templates.TemplateResponse(request, "login.html.jinja", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def admin_login(
|
||||||
|
request: Request,
|
||||||
|
config: Any = Depends(get_config),
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
users_obj: Any = Depends(get_users),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Authenticate the user and redirect to the admin dashboard."""
|
||||||
|
check_login_rate_limit(request)
|
||||||
|
await validate_csrf_form(request)
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
username = str(form.get("username", ""))
|
||||||
|
password = str(form.get("password", ""))
|
||||||
|
|
||||||
|
authed_user = users_obj.authenticate(username, password)
|
||||||
|
if authed_user:
|
||||||
|
request.session["user"] = authed_user.username
|
||||||
|
return RedirectResponse(url="/admin/", status_code=303)
|
||||||
|
|
||||||
|
ctx = admin_context(
|
||||||
|
request,
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
users_obj,
|
||||||
|
error="Invalid username or password.",
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(request, "login.html.jinja", ctx, status_code=401)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Logout
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def admin_logout(request: Request) -> RedirectResponse:
|
||||||
|
"""Clear the session and redirect to the login page."""
|
||||||
|
await validate_csrf_form(request)
|
||||||
|
request.session.clear()
|
||||||
|
return RedirectResponse(url="/admin/login", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# New post form
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/posts/new")
|
||||||
|
async def admin_post_new(
|
||||||
|
request: Request,
|
||||||
|
config: Any = Depends(get_config),
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
users_obj: Any = Depends(get_users),
|
||||||
|
_username: str = Depends(require_login),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Show the new-post form."""
|
||||||
|
post = Post(metadata={"lang": config.language})
|
||||||
|
ctx = admin_context(
|
||||||
|
request,
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
users_obj,
|
||||||
|
mode="new",
|
||||||
|
post=post,
|
||||||
|
crumbs=[("Posts", "/admin/"), ("New post", None)],
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(request, "form.html.jinja", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Create post
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/posts")
|
||||||
|
async def admin_create_post(
|
||||||
|
request: Request,
|
||||||
|
config: Any = Depends(get_config),
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
users_obj: Any = Depends(get_users),
|
||||||
|
_username: str = Depends(require_login),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Create a new post from form data."""
|
||||||
|
await validate_csrf_form(request)
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
form_dict: dict[str, str] = {k: str(v) for k, v in form.items()}
|
||||||
|
post = post_from_params(form_dict)
|
||||||
|
|
||||||
|
error = creation_error(post, store)
|
||||||
|
if error:
|
||||||
|
ctx = admin_context(
|
||||||
|
request,
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
users_obj,
|
||||||
|
mode="new",
|
||||||
|
post=post,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(request, "form.html.jinja", ctx, status_code=422)
|
||||||
|
|
||||||
|
store.save(post)
|
||||||
|
return RedirectResponse(url="/admin/", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Edit post form
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/posts/{slug}/edit")
|
||||||
|
async def admin_post_edit(
|
||||||
|
slug: str,
|
||||||
|
request: Request,
|
||||||
|
config: Any = Depends(get_config),
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
users_obj: Any = Depends(get_users),
|
||||||
|
_username: str = Depends(require_login),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Show the edit-post form for the given slug."""
|
||||||
|
post = store.find(slug)
|
||||||
|
if post is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
title = str(post.title or slug)
|
||||||
|
ctx = admin_context(
|
||||||
|
request,
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
users_obj,
|
||||||
|
mode="edit",
|
||||||
|
post=post,
|
||||||
|
crumbs=[("Posts", "/admin/"), (title, None)],
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(request, "form.html.jinja", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Update post
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/posts/{slug}")
|
||||||
|
async def admin_update_post(
|
||||||
|
slug: str,
|
||||||
|
request: Request,
|
||||||
|
config: Any = Depends(get_config),
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
users_obj: Any = Depends(get_users),
|
||||||
|
_username: str = Depends(require_login),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Update an existing post."""
|
||||||
|
await validate_csrf_form(request)
|
||||||
|
|
||||||
|
existing = store.find(slug)
|
||||||
|
if existing is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
form_dict: dict[str, str] = {k: str(v) for k, v in form.items()}
|
||||||
|
post = post_from_params(form_dict, existing=existing)
|
||||||
|
|
||||||
|
error = fediverse_creator_error(post)
|
||||||
|
if error:
|
||||||
|
ctx = admin_context(
|
||||||
|
request,
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
users_obj,
|
||||||
|
mode="edit",
|
||||||
|
post=post,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(request, "form.html.jinja", ctx, status_code=422)
|
||||||
|
|
||||||
|
store.save(post)
|
||||||
|
return RedirectResponse(url="/admin/", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Delete post
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/posts/{slug}/delete")
|
||||||
|
async def admin_delete_post(
|
||||||
|
slug: str,
|
||||||
|
request: Request,
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
_username: str = Depends(require_login),
|
||||||
|
) -> RedirectResponse:
|
||||||
|
"""Delete a post by slug."""
|
||||||
|
await validate_csrf_form(request)
|
||||||
|
store.delete(slug)
|
||||||
|
return RedirectResponse(url="/admin/", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Markdown preview
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/preview")
|
||||||
|
async def admin_preview(
|
||||||
|
request: Request,
|
||||||
|
_username: str = Depends(require_login),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Render Markdown body to HTML and return it."""
|
||||||
|
await validate_csrf_form(request)
|
||||||
|
|
||||||
|
from .markdown import Markdown
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
body = str(form.get("body", ""))
|
||||||
|
html = Markdown.render(body)
|
||||||
|
return HTMLResponse(content=html)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# File upload (returns JSON URL)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/uploads")
|
||||||
|
async def admin_uploads(
|
||||||
|
request: Request,
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
_username: str = Depends(require_login),
|
||||||
|
file: UploadFile | None = None,
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Upload a media file and return its URL as JSON."""
|
||||||
|
await validate_csrf_form(request)
|
||||||
|
|
||||||
|
if file is None or file.filename is None:
|
||||||
|
raise HTTPException(status_code=400, detail=json.dumps({"error": "no_file"}))
|
||||||
|
|
||||||
|
validate_upload(file)
|
||||||
|
url = store.store_upload(file.filename, file.file)
|
||||||
|
return JSONResponse(content=json.dumps({"url": url}))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Logo / icon static files
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logo.svg", include_in_schema=False)
|
||||||
|
async def admin_logo() -> FileResponse:
|
||||||
|
"""Serve the volumen logo SVG."""
|
||||||
|
path = _PUBLIC_DIR / "volumen-logo.svg"
|
||||||
|
return FileResponse(path)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/icon.svg", include_in_schema=False)
|
||||||
|
async def admin_icon() -> FileResponse:
|
||||||
|
"""Serve the volumen icon SVG."""
|
||||||
|
path = _PUBLIC_DIR / "volumen-icon.svg"
|
||||||
|
return FileResponse(path)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Settings page
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings")
|
||||||
|
async def admin_settings(
|
||||||
|
request: Request,
|
||||||
|
config: Any = Depends(get_config),
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
users_obj: Any = Depends(get_users),
|
||||||
|
_username: str = Depends(require_login),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Show the admin settings page."""
|
||||||
|
ctx = admin_context(
|
||||||
|
request,
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
users_obj,
|
||||||
|
users_list=users_obj.all,
|
||||||
|
crumbs=[("Settings", None)],
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Settings — password
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/password")
|
||||||
|
async def admin_settings_password(
|
||||||
|
request: Request,
|
||||||
|
config: Any = Depends(get_config),
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
users_obj: Any = Depends(get_users),
|
||||||
|
current_user: str = Depends(require_login),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Handle password change."""
|
||||||
|
await validate_csrf_form(request)
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
error, notice = change_password(
|
||||||
|
users_obj,
|
||||||
|
current_user,
|
||||||
|
str(form.get("current_password", "")),
|
||||||
|
str(form.get("new_password", "")),
|
||||||
|
)
|
||||||
|
ctx = admin_context(
|
||||||
|
request,
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
users_obj,
|
||||||
|
users_list=users_obj.all,
|
||||||
|
error=error,
|
||||||
|
notice=notice,
|
||||||
|
crumbs=[("Settings", None)],
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Settings — username
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/username")
|
||||||
|
async def admin_settings_username(
|
||||||
|
request: Request,
|
||||||
|
config: Any = Depends(get_config),
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
users_obj: Any = Depends(get_users),
|
||||||
|
current_user: str = Depends(require_login),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Handle username change."""
|
||||||
|
await validate_csrf_form(request)
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
error, notice = change_username(
|
||||||
|
users_obj,
|
||||||
|
current_user,
|
||||||
|
str(form.get("username", "")),
|
||||||
|
request.session,
|
||||||
|
)
|
||||||
|
ctx = admin_context(
|
||||||
|
request,
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
users_obj,
|
||||||
|
users_list=users_obj.all,
|
||||||
|
error=error,
|
||||||
|
notice=notice,
|
||||||
|
crumbs=[("Settings", None)],
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Settings — display name
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/name")
|
||||||
|
async def admin_settings_name(
|
||||||
|
request: Request,
|
||||||
|
config: Any = Depends(get_config),
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
users_obj: Any = Depends(get_users),
|
||||||
|
current_user: str = Depends(require_login),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Handle display name change."""
|
||||||
|
await validate_csrf_form(request)
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
error, notice = change_name(users_obj, current_user, str(form.get("name", "")))
|
||||||
|
ctx = admin_context(
|
||||||
|
request,
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
users_obj,
|
||||||
|
users_list=users_obj.all,
|
||||||
|
error=error,
|
||||||
|
notice=notice,
|
||||||
|
crumbs=[("Settings", None)],
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Settings — fediverse handle
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/fediverse")
|
||||||
|
async def admin_settings_fediverse(
|
||||||
|
request: Request,
|
||||||
|
config: Any = Depends(get_config),
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
users_obj: Any = Depends(get_users),
|
||||||
|
current_user: str = Depends(require_login),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Handle fediverse creator handle change."""
|
||||||
|
await validate_csrf_form(request)
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
error, notice = change_fediverse_creator(
|
||||||
|
users_obj,
|
||||||
|
current_user,
|
||||||
|
str(form.get("fediverse_creator", "")),
|
||||||
|
)
|
||||||
|
ctx = admin_context(
|
||||||
|
request,
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
users_obj,
|
||||||
|
users_list=users_obj.all,
|
||||||
|
error=error,
|
||||||
|
notice=notice,
|
||||||
|
crumbs=[("Settings", None)],
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Settings — profile photo
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/photo")
|
||||||
|
async def admin_settings_photo(
|
||||||
|
request: Request,
|
||||||
|
config: Any = Depends(get_config),
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
users_obj: Any = Depends(get_users),
|
||||||
|
current_user: str = Depends(require_login),
|
||||||
|
photo: UploadFile | None = None,
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Upload a profile photo."""
|
||||||
|
await validate_csrf_form(request)
|
||||||
|
|
||||||
|
error: str | None = None
|
||||||
|
notice: str | None = None
|
||||||
|
|
||||||
|
if photo is None or photo.filename is None:
|
||||||
|
error = "No file selected."
|
||||||
|
else:
|
||||||
|
error, notice = change_photo(users_obj, store, current_user, photo)
|
||||||
|
|
||||||
|
ctx = admin_context(
|
||||||
|
request,
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
users_obj,
|
||||||
|
users_list=users_obj.all,
|
||||||
|
error=error,
|
||||||
|
notice=notice,
|
||||||
|
crumbs=[("Settings", None)],
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Settings — remove photo
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/photo/remove")
|
||||||
|
async def admin_settings_photo_remove(
|
||||||
|
request: Request,
|
||||||
|
config: Any = Depends(get_config),
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
users_obj: Any = Depends(get_users),
|
||||||
|
current_user: str = Depends(require_login),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Remove the current user's profile photo."""
|
||||||
|
await validate_csrf_form(request)
|
||||||
|
|
||||||
|
error, notice = remove_photo(users_obj, current_user)
|
||||||
|
ctx = admin_context(
|
||||||
|
request,
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
users_obj,
|
||||||
|
users_list=users_obj.all,
|
||||||
|
error=error,
|
||||||
|
notice=notice,
|
||||||
|
crumbs=[("Settings", None)],
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Settings — create user (admin only)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/users")
|
||||||
|
async def admin_settings_users_create(
|
||||||
|
request: Request,
|
||||||
|
config: Any = Depends(get_config),
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
users_obj: Any = Depends(get_users),
|
||||||
|
_admin: str = Depends(require_admin),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Create a new user (admin only)."""
|
||||||
|
await validate_csrf_form(request)
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
error, notice = create_user(
|
||||||
|
users_obj,
|
||||||
|
str(form.get("username", "")),
|
||||||
|
str(form.get("password", "")),
|
||||||
|
str(form.get("role", "author")),
|
||||||
|
)
|
||||||
|
ctx = admin_context(
|
||||||
|
request,
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
users_obj,
|
||||||
|
users_list=users_obj.all,
|
||||||
|
error=error,
|
||||||
|
notice=notice,
|
||||||
|
crumbs=[("Settings", None)],
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Settings — change role (admin only)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/users/{username}/role")
|
||||||
|
async def admin_settings_users_role(
|
||||||
|
username: str,
|
||||||
|
request: Request,
|
||||||
|
config: Any = Depends(get_config),
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
users_obj: Any = Depends(get_users),
|
||||||
|
current_user: str = Depends(require_admin),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Change a user's role (admin only)."""
|
||||||
|
await validate_csrf_form(request)
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
error, notice = change_role(
|
||||||
|
users_obj,
|
||||||
|
current_user,
|
||||||
|
username,
|
||||||
|
str(form.get("role", "author")),
|
||||||
|
)
|
||||||
|
ctx = admin_context(
|
||||||
|
request,
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
users_obj,
|
||||||
|
users_list=users_obj.all,
|
||||||
|
error=error,
|
||||||
|
notice=notice,
|
||||||
|
crumbs=[("Settings", None)],
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Settings — delete user (admin only)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/users/{username}/delete")
|
||||||
|
async def admin_settings_users_delete(
|
||||||
|
username: str,
|
||||||
|
request: Request,
|
||||||
|
config: Any = Depends(get_config),
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
users_obj: Any = Depends(get_users),
|
||||||
|
current_user: str = Depends(require_admin),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Delete a user (admin only)."""
|
||||||
|
await validate_csrf_form(request)
|
||||||
|
|
||||||
|
error, notice = remove_user(users_obj, current_user, username)
|
||||||
|
ctx = admin_context(
|
||||||
|
request,
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
users_obj,
|
||||||
|
users_list=users_obj.all,
|
||||||
|
error=error,
|
||||||
|
notice=notice,
|
||||||
|
crumbs=[("Settings", None)],
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(request, "settings.html.jinja", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Media file serving (separate router, mounted at /media)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@media_router.get("/{path:path}")
|
||||||
|
async def serve_media(
|
||||||
|
path: str,
|
||||||
|
request: Request,
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
) -> FileResponse:
|
||||||
|
"""Serve an uploaded media file through the Store's media resolution."""
|
||||||
|
file_path = store.media_file(path)
|
||||||
|
if file_path is None:
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
return FileResponse(file_path)
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
from fastapi.responses import JSONResponse, PlainTextResponse, Response
|
||||||
|
|
||||||
|
from .feed_helpers import render_json_feed, render_rss_feed, render_sitemap
|
||||||
|
from .server import (
|
||||||
|
get_config,
|
||||||
|
get_store,
|
||||||
|
posts_payload,
|
||||||
|
published_posts,
|
||||||
|
site_payload,
|
||||||
|
tag_counts,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/volumen")
|
||||||
|
|
||||||
|
CORS_HEADERS: dict[str, str] = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
|
}
|
||||||
|
|
||||||
|
CACHE_HEADER: dict[str, str] = {"Cache-Control": "public, max-age=60"}
|
||||||
|
|
||||||
|
|
||||||
|
def _cors_response(content: Any, status_code: int = 200) -> JSONResponse:
|
||||||
|
"""Return a JSON response with CORS and Cache-Control headers."""
|
||||||
|
return JSONResponse(
|
||||||
|
content=content,
|
||||||
|
status_code=status_code,
|
||||||
|
headers={**CORS_HEADERS, **CACHE_HEADER},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_cors_headers(response: Response) -> Response:
|
||||||
|
"""Attach CORS and Cache-Control headers to an existing response."""
|
||||||
|
for key, value in {**CORS_HEADERS, **CACHE_HEADER}.items():
|
||||||
|
response.headers.setdefault(key, value)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# OPTIONS preflight
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.options("/{rest:path}")
|
||||||
|
async def options_preflight() -> Response:
|
||||||
|
"""Handle CORS preflight requests for all API endpoints."""
|
||||||
|
return _add_cors_headers(PlainTextResponse(""))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Site metadata
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/site")
|
||||||
|
async def api_site(config: Any = Depends(get_config)) -> JSONResponse:
|
||||||
|
"""Return site metadata (title, description, base_url, etc.)."""
|
||||||
|
return _cors_response(site_payload(config))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Posts list (paginated, filtered)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/posts")
|
||||||
|
async def api_posts(
|
||||||
|
request: Request,
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
lang: str = Query(default=""),
|
||||||
|
tag: str = Query(default=""),
|
||||||
|
q: str = Query(default=""),
|
||||||
|
page: str = Query(default=""),
|
||||||
|
limit: str = Query(default=""),
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Return a paginated, filtered list of published posts."""
|
||||||
|
params: dict[str, str] = {}
|
||||||
|
if lang:
|
||||||
|
params["lang"] = lang
|
||||||
|
if tag:
|
||||||
|
params["tag"] = tag
|
||||||
|
if q:
|
||||||
|
params["q"] = q
|
||||||
|
if page:
|
||||||
|
params["page"] = page
|
||||||
|
if limit:
|
||||||
|
params["limit"] = limit
|
||||||
|
return _cors_response(posts_payload(store, params))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Single post
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/posts/{slug}")
|
||||||
|
async def api_post(
|
||||||
|
slug: str,
|
||||||
|
request: Request,
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
lang: str = Query(default=""),
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Return a single published post by slug, optionally filtered by language."""
|
||||||
|
post = store.find(slug, lang=lang if lang else None)
|
||||||
|
if post is None:
|
||||||
|
raise HTTPException(status_code=404, detail={"error": "not_found"})
|
||||||
|
if post.draft:
|
||||||
|
raise HTTPException(status_code=404, detail={"error": "draft"})
|
||||||
|
return _cors_response(post.detail())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tag cloud
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tags")
|
||||||
|
async def api_tags(store: Any = Depends(get_store)) -> JSONResponse:
|
||||||
|
"""Return a tag cloud with post counts."""
|
||||||
|
return _cors_response({"tags": tag_counts(store)})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RSS feed
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feed.xml")
|
||||||
|
async def api_rss_feed(
|
||||||
|
config: Any = Depends(get_config),
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
) -> Response:
|
||||||
|
"""Return an RSS 2.0 XML feed of the 20 most recent posts."""
|
||||||
|
posts = published_posts(store)
|
||||||
|
base = config.site.get("base_url", "").rstrip("/")
|
||||||
|
xml = render_rss_feed(posts, config.site, base)
|
||||||
|
return _add_cors_headers(Response(content=xml, media_type="application/rss+xml"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# JSON Feed
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feed.json")
|
||||||
|
async def api_json_feed(
|
||||||
|
config: Any = Depends(get_config),
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Return a JSON Feed 1.1 document for the 20 most recent posts."""
|
||||||
|
posts = published_posts(store)
|
||||||
|
base = config.site.get("base_url", "").rstrip("/")
|
||||||
|
data = render_json_feed(posts, config.site, base)
|
||||||
|
return _cors_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sitemap
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sitemap.xml")
|
||||||
|
async def api_sitemap(
|
||||||
|
config: Any = Depends(get_config),
|
||||||
|
store: Any = Depends(get_store),
|
||||||
|
) -> Response:
|
||||||
|
"""Return an XML sitemap for all published posts."""
|
||||||
|
posts = published_posts(store)
|
||||||
|
base = config.site.get("base_url", "").rstrip("/")
|
||||||
|
xml = render_sitemap(posts, base)
|
||||||
|
return _add_cors_headers(Response(content=xml, media_type="application/xml"))
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""CLI entry point for volumen."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from . import VERSION
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="volumen", description="A small, file-based Markdown blog engine."
|
||||||
|
)
|
||||||
|
parser.add_argument("--version", action="version", version=f"volumen {VERSION}")
|
||||||
|
sub = parser.add_subparsers(dest="command")
|
||||||
|
|
||||||
|
serve_parser = sub.add_parser("serve", help="Start the blog server")
|
||||||
|
serve_parser.add_argument("--config", default="/etc/volumen/config.toml")
|
||||||
|
serve_parser.add_argument("--host", default=None)
|
||||||
|
serve_parser.add_argument("--port", type=int, default=None)
|
||||||
|
serve_parser.add_argument("--content", default=None)
|
||||||
|
|
||||||
|
hash_parser = sub.add_parser("hash-password", help="Hash a password")
|
||||||
|
hash_parser.add_argument("password", nargs="?", default=None)
|
||||||
|
|
||||||
|
sub.add_parser("version", help="Show version")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command == "version":
|
||||||
|
print(VERSION)
|
||||||
|
elif args.command == "hash-password":
|
||||||
|
from .password import hash as hash_pw
|
||||||
|
|
||||||
|
pw = args.password or input("Password: ")
|
||||||
|
print(hash_pw(pw))
|
||||||
|
elif args.command == "serve":
|
||||||
|
_serve(args)
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _serve(args: argparse.Namespace) -> None:
|
||||||
|
from .config import Config
|
||||||
|
from .server import create_app
|
||||||
|
from .store import Store
|
||||||
|
|
||||||
|
overrides: dict[str, object] = {}
|
||||||
|
if args.host:
|
||||||
|
overrides["host"] = args.host
|
||||||
|
if args.port:
|
||||||
|
overrides["port"] = args.port
|
||||||
|
if args.content:
|
||||||
|
overrides["content"] = args.content
|
||||||
|
|
||||||
|
config = Config.load(path=args.config, overrides=overrides)
|
||||||
|
store = Store(config.content_dir, default_lang=config.language)
|
||||||
|
app = create_app(config, store)
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(app, host=config.host, port=config.port, log_level="info")
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Config layer: loads TOML config, applies overrides, deep-merges defaults."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
except ImportError:
|
||||||
|
import tomli as tomllib # type: ignore[import-not-found,unused-ignore]
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_PATH = "/etc/volumen/config.toml"
|
||||||
|
|
||||||
|
DEFAULTS: dict[str, Any] = {
|
||||||
|
"server": {"host": "0.0.0.0", "port": 9090},
|
||||||
|
"content_dir": "/var/lib/volumen/posts",
|
||||||
|
"users_file": "/var/lib/volumen/users.toml",
|
||||||
|
"site": {
|
||||||
|
"title": "My Blog",
|
||||||
|
"description": "A blog powered by volumen.",
|
||||||
|
"base_url": "https://example.com",
|
||||||
|
"language": "en",
|
||||||
|
"author": "Anonymous",
|
||||||
|
"fediverse_creator": None,
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"password_hash": "",
|
||||||
|
"session_key": "",
|
||||||
|
"session_ttl": 86400,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
TEMPLATE = """\
|
||||||
|
# volumen configuration.
|
||||||
|
|
||||||
|
[server]
|
||||||
|
host = "0.0.0.0"
|
||||||
|
port = 9090
|
||||||
|
|
||||||
|
# Directory of your Markdown posts (.md with TOML frontmatter).
|
||||||
|
content_dir = "/var/lib/volumen/posts"
|
||||||
|
|
||||||
|
# File storing admin users (managed from the admin Settings page).
|
||||||
|
users_file = "/var/lib/volumen/users.toml"
|
||||||
|
|
||||||
|
[site]
|
||||||
|
title = "My Blog"
|
||||||
|
description = "A blog powered by volumen."
|
||||||
|
base_url = "https://example.com"
|
||||||
|
language = "en"
|
||||||
|
author = "Anonymous"
|
||||||
|
# Default fediverse handle surfaced in <meta name="fediverse:creator">
|
||||||
|
# by frontends consuming the API. Leave empty to disable.
|
||||||
|
fediverse_creator = ""
|
||||||
|
|
||||||
|
[admin]
|
||||||
|
# Bootstrap admin login: paste a hash from `volumen hash-password` to sign
|
||||||
|
# in as user "admin". Once you manage users in Settings, users_file wins.
|
||||||
|
password_hash = ""
|
||||||
|
# Secret signing session cookies (>= 64 bytes). Empty = random per start.
|
||||||
|
session_key = ""
|
||||||
|
# Session lifetime in seconds (24h default).
|
||||||
|
session_ttl = 86400
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
for key, base_val in base.items():
|
||||||
|
if key in override:
|
||||||
|
result[key] = _merge_leaf(base_val, override[key])
|
||||||
|
elif isinstance(base_val, dict):
|
||||||
|
result[key] = dict(base_val)
|
||||||
|
else:
|
||||||
|
result[key] = base_val
|
||||||
|
for key, val in override.items():
|
||||||
|
if key not in result:
|
||||||
|
result[key] = val
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_leaf(base_val: Any, override_val: Any) -> Any:
|
||||||
|
if isinstance(base_val, dict) and isinstance(override_val, dict):
|
||||||
|
return _deep_merge(base_val, override_val)
|
||||||
|
return override_val
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_overrides(data: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
server = dict(data["server"])
|
||||||
|
if overrides.get("host"):
|
||||||
|
server["host"] = overrides["host"]
|
||||||
|
if overrides.get("port"):
|
||||||
|
server["port"] = overrides["port"]
|
||||||
|
|
||||||
|
result = dict(data)
|
||||||
|
result["server"] = server
|
||||||
|
if overrides.get("content"):
|
||||||
|
result["content_dir"] = overrides["content"]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Loaded and merged volumen configuration."""
|
||||||
|
|
||||||
|
def __init__(self, data: dict[str, Any]) -> None:
|
||||||
|
self._data = data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path: str = DEFAULT_PATH, overrides: dict[str, Any] | None = None) -> Config:
|
||||||
|
overrides = overrides or {}
|
||||||
|
if os.path.isfile(path):
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
file_data = tomllib.load(fh)
|
||||||
|
else:
|
||||||
|
file_data = {}
|
||||||
|
merged = _deep_merge(copy.deepcopy(DEFAULTS), file_data)
|
||||||
|
return cls(_apply_overrides(merged, overrides))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_default(path: str) -> str | None:
|
||||||
|
if os.path.exists(path):
|
||||||
|
return None
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
with open(path, "w") as fh:
|
||||||
|
fh.write(TEMPLATE)
|
||||||
|
return path
|
||||||
|
|
||||||
|
@property
|
||||||
|
def host(self) -> str:
|
||||||
|
return self._data["server"]["host"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def port(self) -> int:
|
||||||
|
return self._data["server"]["port"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def content_dir(self) -> str:
|
||||||
|
return self._data["content_dir"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def users_file(self) -> str:
|
||||||
|
return self._data["users_file"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def site(self) -> dict[str, Any]:
|
||||||
|
return self._data["site"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def language(self) -> str:
|
||||||
|
return self._data["site"]["language"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def admin(self) -> dict[str, Any]:
|
||||||
|
return self._data["admin"]
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
from email.utils import format_datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .post import Post
|
||||||
|
|
||||||
|
|
||||||
|
def render_rss_feed(posts: list[Post], site: dict, base_url: str) -> str:
|
||||||
|
"""Render an RSS 2.0 XML feed for the given posts.
|
||||||
|
|
||||||
|
The feed includes the 20 most recent published posts.
|
||||||
|
"""
|
||||||
|
items = "\n".join(_rss_item(post, base_url) for post in posts[:20])
|
||||||
|
title = html.escape(site.get("title", ""))
|
||||||
|
link = html.escape(base_url)
|
||||||
|
description = html.escape(site.get("description", ""))
|
||||||
|
language = html.escape(site.get("language", "en"))
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
|
'<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/">\n'
|
||||||
|
" <channel>\n"
|
||||||
|
f" <title>{title}</title>\n"
|
||||||
|
f" <link>{link}</link>\n"
|
||||||
|
f" <description>{description}</description>\n"
|
||||||
|
f" <language>{language}</language>\n"
|
||||||
|
f"{items}\n"
|
||||||
|
" </channel>\n"
|
||||||
|
"</rss>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rss_item(post: Post, base_url: str) -> str:
|
||||||
|
"""Render a single RSS <item> element."""
|
||||||
|
permalink = f"{base_url}/{post.slug}"
|
||||||
|
pub_date = ""
|
||||||
|
post_date = post.date_string
|
||||||
|
if post_date:
|
||||||
|
pub_date = f"\n <pubDate>{html.escape(_rfc822_date(post_date))}</pubDate>"
|
||||||
|
creator = post.fediverse_creator
|
||||||
|
creator_tag = ""
|
||||||
|
if creator:
|
||||||
|
creator_tag = f"\n <dc:creator>{html.escape(creator)}</dc:creator>"
|
||||||
|
return (
|
||||||
|
f" <item>\n"
|
||||||
|
f" <title>{html.escape(post.title or '')}</title>\n"
|
||||||
|
f" <link>{html.escape(permalink)}</link>\n"
|
||||||
|
f" <guid>{html.escape(permalink)}</guid>\n"
|
||||||
|
f" <description>{html.escape(post.excerpt or '')}</description>"
|
||||||
|
f"{pub_date}{creator_tag}\n"
|
||||||
|
f" </item>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rfc822_date(value: date | str) -> str:
|
||||||
|
"""Format a date or date-string as an RFC 822 / RFC 2822 timestamp."""
|
||||||
|
if isinstance(value, date):
|
||||||
|
dt = datetime.combine(value, datetime.min.time(), tzinfo=UTC)
|
||||||
|
return format_datetime(dt, usegmt=True)
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(str(value), "%Y-%m-%d").replace(tzinfo=UTC)
|
||||||
|
return format_datetime(dt, usegmt=True)
|
||||||
|
except ValueError:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_date(value: date | str | None) -> str | None:
|
||||||
|
"""Format a date or date-string as ISO 8601 (YYYY-MM-DD)."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value.isoformat()
|
||||||
|
try:
|
||||||
|
datetime.strptime(str(value), "%Y-%m-%d")
|
||||||
|
return str(value)
|
||||||
|
except ValueError:
|
||||||
|
return str(value) if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def render_json_feed(posts: list[Post], site: dict, base_url: str) -> dict:
|
||||||
|
"""Build a JSON Feed 1.1 document for the given posts.
|
||||||
|
|
||||||
|
https://jsonfeed.org/version/1.1
|
||||||
|
"""
|
||||||
|
feed_url = f"{base_url}/api/volumen/feed.json"
|
||||||
|
result: dict = {
|
||||||
|
"version": "https://jsonfeed.org/version/1.1",
|
||||||
|
"title": site.get("title", ""),
|
||||||
|
"home_page_url": base_url,
|
||||||
|
"feed_url": feed_url,
|
||||||
|
"description": site.get("description", ""),
|
||||||
|
"language": site.get("language", "en"),
|
||||||
|
"items": [_json_feed_item(post, base_url, site) for post in posts[:20]],
|
||||||
|
}
|
||||||
|
return {k: v for k, v in result.items() if v not in (None, "", [])}
|
||||||
|
|
||||||
|
|
||||||
|
def _json_feed_item(post: Post, base_url: str, site: dict) -> dict:
|
||||||
|
"""Build a single JSON Feed item dict."""
|
||||||
|
permalink = f"{base_url}/{post.slug}"
|
||||||
|
item: dict = {
|
||||||
|
"id": permalink,
|
||||||
|
"url": permalink,
|
||||||
|
"title": post.title,
|
||||||
|
"content_html": post.html,
|
||||||
|
"summary": post.excerpt,
|
||||||
|
"date_published": _iso_date(post.date_string),
|
||||||
|
"tags": post.tags,
|
||||||
|
}
|
||||||
|
author = post.fediverse_creator or site.get("fediverse_creator")
|
||||||
|
if author:
|
||||||
|
item["authors"] = [{"name": author}]
|
||||||
|
return {k: v for k, v in item.items() if v not in (None, "", [])}
|
||||||
|
|
||||||
|
|
||||||
|
def render_sitemap(posts: list[Post], base_url: str) -> str:
|
||||||
|
"""Render an XML sitemap for the given posts."""
|
||||||
|
urls = []
|
||||||
|
for post in posts:
|
||||||
|
loc = html.escape(f"{base_url}/{post.slug}")
|
||||||
|
entry = f" <url><loc>{loc}</loc>"
|
||||||
|
if post.date_string:
|
||||||
|
entry += f"<lastmod>{html.escape(post.date_string)}</lastmod>"
|
||||||
|
entry += "</url>"
|
||||||
|
urls.append(entry)
|
||||||
|
url_block = "\n".join(urls)
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||||
|
f"{url_block}\n"
|
||||||
|
"</urlset>"
|
||||||
|
)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""TOML frontmatter parser for Markdown post files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
except ImportError:
|
||||||
|
import tomli as tomllib # type: ignore[import-not-found,unused-ignore]
|
||||||
|
|
||||||
|
import tomli_w
|
||||||
|
|
||||||
|
DELIMITER = "+++"
|
||||||
|
|
||||||
|
|
||||||
|
def parse(content: str) -> tuple[dict[str, object], str]:
|
||||||
|
"""Parse TOML frontmatter and body from a post string.
|
||||||
|
|
||||||
|
Returns (metadata_dict, body_string). If there is no frontmatter,
|
||||||
|
returns an empty dict and the full content as body.
|
||||||
|
"""
|
||||||
|
text = content.replace("\r\n", "\n")
|
||||||
|
lines = text.split("\n")
|
||||||
|
if not lines or not _is_delimiter(lines[0]):
|
||||||
|
return {}, text
|
||||||
|
|
||||||
|
closing = _closing_index(lines)
|
||||||
|
if closing is None:
|
||||||
|
return {}, text
|
||||||
|
|
||||||
|
toml_text = "\n".join(lines[1:closing])
|
||||||
|
body = "\n".join(lines[closing + 1 :])
|
||||||
|
metadata: dict[str, object] = {}
|
||||||
|
if toml_text.strip():
|
||||||
|
metadata = tomllib.loads(toml_text)
|
||||||
|
|
||||||
|
if body.startswith("\n"):
|
||||||
|
body = body[1:]
|
||||||
|
return metadata, body
|
||||||
|
|
||||||
|
|
||||||
|
def dump(metadata: dict[str, object], body: str) -> str:
|
||||||
|
"""Serialise metadata + body back into a post file string."""
|
||||||
|
toml_text = tomli_w.dumps(metadata) if metadata else ""
|
||||||
|
parts = [DELIMITER, "\n"]
|
||||||
|
if toml_text:
|
||||||
|
parts.append(toml_text)
|
||||||
|
if not toml_text.endswith("\n"):
|
||||||
|
parts.append("\n")
|
||||||
|
parts.append(DELIMITER)
|
||||||
|
parts.append("\n\n")
|
||||||
|
parts.append(body.lstrip())
|
||||||
|
if not parts[-1].endswith("\n"):
|
||||||
|
parts.append("\n")
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_delimiter(line: str) -> bool:
|
||||||
|
return line.strip() == DELIMITER
|
||||||
|
|
||||||
|
|
||||||
|
def _closing_index(lines: list[str]) -> int | None:
|
||||||
|
for i in range(1, len(lines)):
|
||||||
|
if _is_delimiter(lines[i]):
|
||||||
|
return i
|
||||||
|
return None
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Markdown rendering using Python-Markdown with pymdown-extensions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
import markdown
|
||||||
|
|
||||||
|
|
||||||
|
def render(text: str) -> str:
|
||||||
|
"""Render Markdown to HTML with GFM extensions and figure wrapping."""
|
||||||
|
md = markdown.Markdown(
|
||||||
|
extensions=[
|
||||||
|
"markdown.extensions.extra",
|
||||||
|
"markdown.extensions.codehilite",
|
||||||
|
"markdown.extensions.toc",
|
||||||
|
"markdown.extensions.sane_lists",
|
||||||
|
"pymdownx.tasklist",
|
||||||
|
"pymdownx.tilde",
|
||||||
|
],
|
||||||
|
extension_configs={
|
||||||
|
"pymdownx.tasklist": {"custom_checkbox": True},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
html = md.convert(text)
|
||||||
|
md.reset()
|
||||||
|
return _wrap_figures(html)
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_figures(html: str) -> str:
|
||||||
|
"""Wrap every <img title='...'> inside a <figure> with <figcaption>."""
|
||||||
|
if 'title="' not in html and "title='" not in html:
|
||||||
|
return html
|
||||||
|
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(f"<root>{html}</root>")
|
||||||
|
except ET.ParseError:
|
||||||
|
return html
|
||||||
|
|
||||||
|
for img in root.iter("img"):
|
||||||
|
title = img.get("title")
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
del img.attrib["title"]
|
||||||
|
|
||||||
|
parent = root
|
||||||
|
for child in root:
|
||||||
|
if img in list(child.iter()):
|
||||||
|
parent = child
|
||||||
|
break
|
||||||
|
|
||||||
|
figure = ET.Element("figure")
|
||||||
|
img_clone = _clone_element(img)
|
||||||
|
figure.append(img_clone)
|
||||||
|
|
||||||
|
info = ET.Element("div")
|
||||||
|
info.set("class", "fig-info")
|
||||||
|
info.set("aria-hidden", "true")
|
||||||
|
info.text = "i"
|
||||||
|
figure.append(info)
|
||||||
|
|
||||||
|
figcaption = ET.Element("figcaption")
|
||||||
|
figcaption.text = title
|
||||||
|
figure.append(figcaption)
|
||||||
|
|
||||||
|
for child in list(parent):
|
||||||
|
if child is img or img in list(child.iter()):
|
||||||
|
idx = list(parent).index(child)
|
||||||
|
parent.remove(child)
|
||||||
|
parent.insert(idx, figure)
|
||||||
|
break
|
||||||
|
|
||||||
|
parts: list[str] = [
|
||||||
|
ET.tostring(child, encoding="unicode")
|
||||||
|
if hasattr(ET, "tostring")
|
||||||
|
else _element_to_string(child)
|
||||||
|
for child in root
|
||||||
|
]
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _clone_element(elem: ET.Element) -> ET.Element:
|
||||||
|
"""Deep-clone an ElementTree element."""
|
||||||
|
clone = ET.Element(elem.tag, elem.attrib)
|
||||||
|
clone.text = elem.text
|
||||||
|
clone.tail = elem.tail
|
||||||
|
for child in elem:
|
||||||
|
clone.append(_clone_element(child))
|
||||||
|
return clone
|
||||||
|
|
||||||
|
|
||||||
|
def _element_to_string(elem: ET.Element) -> str:
|
||||||
|
"""Convert an ElementTree element to its string representation."""
|
||||||
|
attribs = " ".join(f'{k}="{v}"' for k, v in elem.attrib.items())
|
||||||
|
tag = f"{elem.tag} {attribs}".strip()
|
||||||
|
if elem.text and not len(elem):
|
||||||
|
return f"<{tag}>{elem.text}</{elem.tag}>"
|
||||||
|
children = "".join(_element_to_string(c) for c in elem)
|
||||||
|
return f"<{tag}>{elem.text or ''}{children}</{elem.tag}>"
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Password hashing and verification using scrypt."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
|
||||||
|
COST_N = 16384
|
||||||
|
BLOCK_R = 8
|
||||||
|
PARALLEL_P = 1
|
||||||
|
KEY_LENGTH = 32
|
||||||
|
SALT_BYTES = 16
|
||||||
|
PREFIX = "scrypt"
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""Hash a password and return the encoded scrypt string."""
|
||||||
|
salt = os.urandom(SALT_BYTES)
|
||||||
|
derived = hashlib.scrypt(
|
||||||
|
password.encode("utf-8"), salt=salt, n=COST_N, r=BLOCK_R, p=PARALLEL_P, dklen=KEY_LENGTH
|
||||||
|
)
|
||||||
|
return f"{PREFIX}${COST_N}${BLOCK_R}${PARALLEL_P}${_b64(salt)}${_b64(derived)}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, encoded: str) -> bool:
|
||||||
|
"""Verify a password against an encoded scrypt hash."""
|
||||||
|
parts = encoded.split("$")
|
||||||
|
if len(parts) != 6 or parts[0] != PREFIX:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, n_str, r_str, p_str, salt_b64, hash_b64 = parts
|
||||||
|
salt = base64.b64decode(salt_b64)
|
||||||
|
expected = base64.b64decode(hash_b64)
|
||||||
|
derived = hashlib.scrypt(
|
||||||
|
password.encode("utf-8"),
|
||||||
|
salt=salt,
|
||||||
|
n=int(n_str),
|
||||||
|
r=int(r_str),
|
||||||
|
p=int(p_str),
|
||||||
|
dklen=len(expected),
|
||||||
|
)
|
||||||
|
return derived == expected
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _b64(data: bytes) -> str:
|
||||||
|
return base64.b64encode(data).decode("ascii")
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""A single blog post: TOML frontmatter metadata plus a Markdown body."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .frontmatter import dump as fm_dump
|
||||||
|
from .frontmatter import parse as fm_parse
|
||||||
|
from .markdown import render as md_render
|
||||||
|
|
||||||
|
FEDIVERSE_CREATOR_REGEX = re.compile(r"\A@[^@\s]+@[^@\s]+\Z")
|
||||||
|
|
||||||
|
|
||||||
|
class Post:
|
||||||
|
"""A single blog post with metadata and Markdown body."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, metadata: dict[str, Any] | None = None, body: str = "", path: str | None = None
|
||||||
|
) -> None:
|
||||||
|
self.metadata: dict[str, Any] = {str(k): v for k, v in (metadata or {}).items()}
|
||||||
|
self.body = body
|
||||||
|
self.path: str | None = path
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse(cls, content: str) -> Post:
|
||||||
|
metadata, body = fm_parse(content)
|
||||||
|
return cls(metadata=metadata, body=body)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def slug(self) -> str | None:
|
||||||
|
return self.metadata.get("slug")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def title(self) -> str | None:
|
||||||
|
return self.metadata.get("title")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lang(self) -> str | None:
|
||||||
|
return self.metadata.get("lang")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def author(self) -> str | None:
|
||||||
|
return self.metadata.get("author")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tags(self) -> list[str]:
|
||||||
|
raw = self.metadata.get("tags", [])
|
||||||
|
if isinstance(raw, list):
|
||||||
|
return [str(t) for t in raw]
|
||||||
|
return []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def draft(self) -> bool:
|
||||||
|
return self.metadata.get("draft") is True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def all_langs(self) -> bool:
|
||||||
|
return self.metadata.get("all_langs") is True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def translations(self) -> dict[str, str]:
|
||||||
|
val = self.metadata.get("translations", {})
|
||||||
|
if isinstance(val, dict):
|
||||||
|
return {str(k): str(v) for k, v in val.items()}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cover(self) -> str | None:
|
||||||
|
return self.metadata.get("cover")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cover_alt(self) -> str | None:
|
||||||
|
return self.metadata.get("cover_alt")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cover_caption(self) -> str | None:
|
||||||
|
return self.metadata.get("cover_caption")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fediverse_creator(self) -> str | None:
|
||||||
|
value = self.metadata.get("fediverse_creator")
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text if text else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def valid_fediverse_creator(self) -> bool:
|
||||||
|
fc = self.fediverse_creator
|
||||||
|
return fc is not None and bool(FEDIVERSE_CREATOR_REGEX.match(fc))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def publish_at(self) -> date | None:
|
||||||
|
val = self.metadata.get("publish_at")
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
if isinstance(val, (date, datetime)):
|
||||||
|
return val if isinstance(val, date) else val.date()
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(str(val))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scheduled(self) -> bool:
|
||||||
|
pub = self.publish_at
|
||||||
|
return pub is not None and pub > date.today()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reading_time(self) -> int:
|
||||||
|
words = len([w for w in self.body.split() if w.strip()])
|
||||||
|
return max(1, (words + 199) // 200)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def date_string(self) -> str | None:
|
||||||
|
value = self.metadata.get("date")
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, (date, datetime)):
|
||||||
|
return value.strftime("%Y-%m-%d")
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def excerpt(self) -> str:
|
||||||
|
text = self.metadata.get("excerpt")
|
||||||
|
if text and str(text).strip():
|
||||||
|
return str(text)
|
||||||
|
return self._derived_excerpt()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def html(self) -> str:
|
||||||
|
return md_render(self.body)
|
||||||
|
|
||||||
|
def to_file(self) -> str:
|
||||||
|
return fm_dump(self.metadata, self.body)
|
||||||
|
|
||||||
|
def summary(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"slug": self.slug,
|
||||||
|
"title": self.title,
|
||||||
|
"excerpt": self.excerpt,
|
||||||
|
"date": self.date_string,
|
||||||
|
"lang": self.lang,
|
||||||
|
"tags": self.tags,
|
||||||
|
"author": self.author,
|
||||||
|
"fediverse_creator": self.fediverse_creator,
|
||||||
|
"cover": self.cover,
|
||||||
|
"cover_alt": self.cover_alt,
|
||||||
|
"cover_caption": self.cover_caption,
|
||||||
|
"reading_time": self.reading_time,
|
||||||
|
"translations": self.translations,
|
||||||
|
"url": f"/api/volumen/posts/{self.slug}",
|
||||||
|
}
|
||||||
|
|
||||||
|
def detail(self) -> dict[str, Any]:
|
||||||
|
base = self.summary()
|
||||||
|
base["body"] = self.body
|
||||||
|
base["html"] = self.html
|
||||||
|
return base
|
||||||
|
|
||||||
|
def _derived_excerpt(self, limit: int = 200) -> str:
|
||||||
|
paragraphs = self.body.split("\n\n")
|
||||||
|
for para in paragraphs:
|
||||||
|
stripped = para.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
cleaned = re.sub(r"<[^>]+>", "", stripped)
|
||||||
|
cleaned = re.sub(r"[*_`>#]", "", cleaned).strip()
|
||||||
|
if len(cleaned) > limit:
|
||||||
|
return f"{cleaned[:limit].rstrip()}\u2026"
|
||||||
|
return cleaned
|
||||||
|
return ""
|
||||||
@@ -0,0 +1,635 @@
|
|||||||
|
"""FastAPI application: public JSON API and server-rendered admin.
|
||||||
|
|
||||||
|
This module provides the ``create_app`` factory plus all dependency-injection
|
||||||
|
callables and helper functions consumed by ``api_routes`` and ``admin_routes``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
from datetime import date, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
|
from . import LOGIN_WINDOW, MAX_LOGIN_ATTEMPTS, login_attempts, sweep_login_attempts
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants (mirror Ruby Server)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
MAX_UPLOAD_BYTES: int = 10 * 1024 * 1024 # 10 MB
|
||||||
|
ALLOWED_UPLOAD_TYPES: tuple[str, ...] = ("image/webp", "image/avif")
|
||||||
|
SLUG_REGEX: re.Pattern[str] = re.compile(r"\A[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\Z")
|
||||||
|
|
||||||
|
CSP_HEADER: str = "; ".join(
|
||||||
|
[
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self' 'unsafe-inline'",
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"img-src 'self' data:",
|
||||||
|
"font-src 'self'",
|
||||||
|
"connect-src 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Template & static file discovery
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_PKG_DIR: Path = Path(__file__).resolve().parent
|
||||||
|
_TEMPLATE_DIR: Path = _PKG_DIR / "web" / "templates"
|
||||||
|
_STATIC_DIR: Path = _PKG_DIR / "web" / "static"
|
||||||
|
|
||||||
|
templates: Jinja2Templates = Jinja2Templates(directory=str(_TEMPLATE_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# App factory
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config: Any, store: Any) -> FastAPI:
|
||||||
|
"""Build a fully-configured FastAPI app bound to *config* and *store*."""
|
||||||
|
|
||||||
|
app = FastAPI(title="volumen")
|
||||||
|
|
||||||
|
# ── Session middleware ──────────────────────────────────────────
|
||||||
|
ttl = int(config.admin.get("session_ttl", 0))
|
||||||
|
if ttl <= 0:
|
||||||
|
ttl = None
|
||||||
|
app.add_middleware(
|
||||||
|
SessionMiddleware,
|
||||||
|
secret_key=session_secret(config),
|
||||||
|
https_only=False,
|
||||||
|
same_site="strict",
|
||||||
|
session_cookie="volumen_session",
|
||||||
|
max_age=ttl,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Custom middleware ───────────────────────────────────────────
|
||||||
|
app.add_middleware(SecureCookieMiddleware)
|
||||||
|
app.add_middleware(CSPMiddleware)
|
||||||
|
|
||||||
|
# ── Attach objects to app.state for dependency injection ────────
|
||||||
|
app.state.config = config
|
||||||
|
app.state.store = store
|
||||||
|
|
||||||
|
# ── Static files (logo / icon SVGs) ────────────────────────────
|
||||||
|
if _STATIC_DIR.is_dir():
|
||||||
|
app.mount("/admin/static", StaticFiles(directory=str(_STATIC_DIR)), name="admin_static")
|
||||||
|
|
||||||
|
# ── Routers ─────────────────────────────────────────────────────
|
||||||
|
from .admin_routes import media_router
|
||||||
|
from .admin_routes import router as admin_router
|
||||||
|
from .api_routes import router as api_router
|
||||||
|
|
||||||
|
app.include_router(api_router)
|
||||||
|
app.include_router(admin_router)
|
||||||
|
app.include_router(media_router) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def session_secret(config: Any) -> str:
|
||||||
|
"""Derive session-signing secret from config (mirrors Ruby logic)."""
|
||||||
|
key = str(config.admin.get("session_key", ""))
|
||||||
|
if not key:
|
||||||
|
return secrets.token_hex(64)
|
||||||
|
if len(key.encode()) < 64:
|
||||||
|
logger.warning(
|
||||||
|
"volumen: session_key is shorter than 64 bytes, falling back to random secret"
|
||||||
|
)
|
||||||
|
return secrets.token_hex(64)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Middleware
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class SecureCookieMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Mark session cookie ``Secure`` when ``X-Forwarded-Proto`` is ``https``."""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next): # type: ignore[override]
|
||||||
|
proto = (request.headers.get("X-Forwarded-Proto", "")).lower()
|
||||||
|
request.state.session_secure = proto == "https"
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
class CSPMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Inject Content-Security-Policy on ``/admin`` routes."""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next): # type: ignore[override]
|
||||||
|
response = await call_next(request)
|
||||||
|
if request.url.path.startswith("/admin"):
|
||||||
|
response.headers["Content-Security-Policy"] = CSP_HEADER
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# FastAPI Dependencies
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def get_config(request: Request) -> Any:
|
||||||
|
"""Return the :class:`Config` from app state."""
|
||||||
|
return request.app.state.config
|
||||||
|
|
||||||
|
|
||||||
|
def get_store(request: Request) -> Any:
|
||||||
|
"""Return the :class:`Store` from app state."""
|
||||||
|
return request.app.state.store
|
||||||
|
|
||||||
|
|
||||||
|
def get_users(request: Request) -> Any:
|
||||||
|
"""Build a :class:`Users` instance from the current config."""
|
||||||
|
from .users import Users
|
||||||
|
|
||||||
|
config = request.app.state.config
|
||||||
|
return Users(
|
||||||
|
path=config.users_file,
|
||||||
|
bootstrap_hash=config.admin.get("password_hash", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(request: Request) -> str | None:
|
||||||
|
"""Return the logged-in username from the session or ``None``."""
|
||||||
|
user = request.session.get("user", "")
|
||||||
|
return user if user else None
|
||||||
|
|
||||||
|
|
||||||
|
def require_login(request: Request) -> str:
|
||||||
|
"""Enforce login — redirects to ``/admin/login`` otherwise."""
|
||||||
|
username = request.session.get("user", "")
|
||||||
|
if not username:
|
||||||
|
raise _login_redirect()
|
||||||
|
config = request.app.state.config
|
||||||
|
from .users import Users
|
||||||
|
|
||||||
|
users_obj = Users(
|
||||||
|
path=config.users_file,
|
||||||
|
bootstrap_hash=config.admin.get("password_hash", ""),
|
||||||
|
)
|
||||||
|
if users_obj.find(username) is None:
|
||||||
|
request.session.clear()
|
||||||
|
raise _login_redirect()
|
||||||
|
return username
|
||||||
|
|
||||||
|
|
||||||
|
def _login_redirect() -> HTTPException:
|
||||||
|
exc = HTTPException(status_code=303)
|
||||||
|
exc.headers = {"Location": "/admin/login"} # type: ignore[assignment]
|
||||||
|
return exc
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(request: Request) -> str:
|
||||||
|
"""Enforce admin role — raises 403 otherwise."""
|
||||||
|
username = require_login(request)
|
||||||
|
config = request.app.state.config
|
||||||
|
from .users import Users
|
||||||
|
|
||||||
|
users_obj = Users(
|
||||||
|
path=config.users_file,
|
||||||
|
bootstrap_hash=config.admin.get("password_hash", ""),
|
||||||
|
)
|
||||||
|
record = users_obj.find(username)
|
||||||
|
if record is None or record.role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
return username
|
||||||
|
|
||||||
|
|
||||||
|
def csrf_token(request: Request) -> str:
|
||||||
|
"""Return (and lazily create) the CSRF token stored in the session."""
|
||||||
|
token = request.session.get("csrf", "")
|
||||||
|
if not token:
|
||||||
|
token = secrets.token_hex(32)
|
||||||
|
request.session["csrf"] = token
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_csrf_form(request: Request) -> None:
|
||||||
|
"""Constant-time CSRF check against POST form data.
|
||||||
|
|
||||||
|
Must be called inside a route handler after awaiting form data is safe
|
||||||
|
(it reads request body via ``await request.form()``).
|
||||||
|
"""
|
||||||
|
form = await request.form()
|
||||||
|
token = form.get("_csrf", "")
|
||||||
|
session_token = request.session.get("csrf", "")
|
||||||
|
if not token or not session_token:
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid CSRF token")
|
||||||
|
if not secrets.compare_digest(str(session_token), str(token)):
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid CSRF token")
|
||||||
|
|
||||||
|
|
||||||
|
def check_login_rate_limit(request: Request) -> None:
|
||||||
|
"""Rate-limit login attempts per IP — raises 429 on excess."""
|
||||||
|
sweep_login_attempts()
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
now = datetime.now().timestamp()
|
||||||
|
cutoff = now - LOGIN_WINDOW
|
||||||
|
attempts = login_attempts().setdefault(ip, [])
|
||||||
|
attempts = [t for t in attempts if t > cutoff]
|
||||||
|
if len(attempts) >= MAX_LOGIN_ATTEMPTS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Too many login attempts. Please wait and try again.",
|
||||||
|
)
|
||||||
|
attempts.append(now)
|
||||||
|
login_attempts()[ip] = attempts
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Admin template context builder
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def admin_context(
|
||||||
|
request: Request,
|
||||||
|
config: Any,
|
||||||
|
store: Any,
|
||||||
|
users_obj: Any,
|
||||||
|
**extra: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build the standard Jinja2 template context for admin routes."""
|
||||||
|
username: str = request.session.get("user", "")
|
||||||
|
record = users_obj.find(username) if username else None
|
||||||
|
# Ensure CSRF token exists before building context
|
||||||
|
csrf_val = csrf_token(request)
|
||||||
|
ctx: dict[str, Any] = {
|
||||||
|
"request": request,
|
||||||
|
"config": config,
|
||||||
|
"store": store,
|
||||||
|
"users": users_obj,
|
||||||
|
"csrf_token": csrf_val,
|
||||||
|
"current_user": username or None,
|
||||||
|
"current_role": record.role if record else None,
|
||||||
|
"current_user_record": record,
|
||||||
|
}
|
||||||
|
ctx.update(extra)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# API / site helpers
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def site_payload(config: Any) -> dict[str, Any]:
|
||||||
|
"""Build the ``/api/volumen/site`` payload."""
|
||||||
|
keys = ("title", "description", "base_url", "language", "author", "fediverse_creator")
|
||||||
|
return {k: config.site.get(k) for k in keys}
|
||||||
|
|
||||||
|
|
||||||
|
def published_posts(store: Any) -> list[Any]:
|
||||||
|
"""All published (non-draft, non-scheduled) posts, newest first."""
|
||||||
|
all_posts = store.all()
|
||||||
|
visible = [p for p in all_posts if not p.draft and not p.scheduled]
|
||||||
|
visible.sort(key=lambda p: p.date_string or "", reverse=True)
|
||||||
|
return visible
|
||||||
|
|
||||||
|
|
||||||
|
def filtered_posts(store: Any, params: dict[str, str]) -> list[Any]:
|
||||||
|
"""Filter published posts by ``lang``, ``tag``, and ``q``."""
|
||||||
|
posts = published_posts(store)
|
||||||
|
lang = params.get("lang", "")
|
||||||
|
if lang:
|
||||||
|
posts = [p for p in posts if p.lang == lang or p.all_langs]
|
||||||
|
tag = params.get("tag", "")
|
||||||
|
if tag:
|
||||||
|
posts = [p for p in posts if tag in p.tags]
|
||||||
|
q = params.get("q", "").strip().lower()
|
||||||
|
if q:
|
||||||
|
posts = [p for p in posts if q in (p.title or "").lower() or q in (p.body or "").lower()]
|
||||||
|
return posts
|
||||||
|
|
||||||
|
|
||||||
|
def posts_payload(store: Any, params: dict[str, str]) -> dict[str, Any]:
|
||||||
|
"""Paginated posts payload for ``/api/volumen/posts``."""
|
||||||
|
posts = filtered_posts(store, params)
|
||||||
|
page = positive_int(params.get("page"), 1)
|
||||||
|
limit = min(max(positive_int(params.get("limit"), 20), 1), 100)
|
||||||
|
offset = (page - 1) * limit
|
||||||
|
total = len(posts)
|
||||||
|
return {
|
||||||
|
"page": page,
|
||||||
|
"page_size": limit,
|
||||||
|
"total": total,
|
||||||
|
"has_next": offset + limit < total,
|
||||||
|
"has_prev": page > 1,
|
||||||
|
"posts": [p.summary() for p in posts[offset : offset + limit]],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def tag_counts(store: Any) -> list[dict[str, Any]]:
|
||||||
|
"""Tag-cloud payload (sorted by frequency desc, then name)."""
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for post in published_posts(store):
|
||||||
|
for tag in post.tags:
|
||||||
|
counts[tag] = counts.get(tag, 0) + 1
|
||||||
|
sorted_counts = sorted(counts.items(), key=lambda item: (-item[1], item[0]))
|
||||||
|
return [{"name": name, "count": cnt} for name, cnt in sorted_counts]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Small value helpers
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def positive_int(value: Any, default: int) -> int:
|
||||||
|
"""Parse *value* as a positive integer, falling back to *default*."""
|
||||||
|
try:
|
||||||
|
n = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
return n if n > 0 else default
|
||||||
|
|
||||||
|
|
||||||
|
def presence(value: Any) -> str | None:
|
||||||
|
"""Return stripped string or ``None`` if empty."""
|
||||||
|
text = str(value).strip() if value is not None else ""
|
||||||
|
return text if text else None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date(value: Any) -> date | None:
|
||||||
|
"""Parse an ISO 8601 date string, returning ``None`` on failure."""
|
||||||
|
text = presence(value)
|
||||||
|
if text is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tags(value: Any) -> list[str]:
|
||||||
|
"""Parse a comma-separated tag string."""
|
||||||
|
raw = presence(value)
|
||||||
|
if raw is None:
|
||||||
|
return []
|
||||||
|
return [t.strip() for t in raw.split(",") if t.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Upload validation
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def validate_upload(upload: Any) -> None:
|
||||||
|
"""Validate uploaded file size and MIME type.
|
||||||
|
|
||||||
|
Raises ``HTTPException`` (413 or 415) on failure.
|
||||||
|
"""
|
||||||
|
# Size check
|
||||||
|
if hasattr(upload, "file"):
|
||||||
|
upload.file.seek(0, 2)
|
||||||
|
size = upload.file.tell()
|
||||||
|
upload.file.seek(0)
|
||||||
|
else:
|
||||||
|
size = 0
|
||||||
|
if size > MAX_UPLOAD_BYTES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413,
|
||||||
|
detail=json.dumps({"error": "file_too_large", "max_bytes": MAX_UPLOAD_BYTES}),
|
||||||
|
)
|
||||||
|
|
||||||
|
# MIME type check
|
||||||
|
content_type = (getattr(upload, "content_type", None) or "").split(";")[0].strip().lower()
|
||||||
|
if content_type not in ALLOWED_UPLOAD_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=415,
|
||||||
|
detail=json.dumps(
|
||||||
|
{
|
||||||
|
"error": "unsupported_format",
|
||||||
|
"message": (
|
||||||
|
"Only WebP (.webp) and AVIF (.avif) images are supported. "
|
||||||
|
"Please convert your image before uploading — for example with "
|
||||||
|
"`cwebp` (JPEG/PNG → WebP) or `avifenc` (PNG/JPEG → AVIF)."
|
||||||
|
),
|
||||||
|
"allowed": list(ALLOWED_UPLOAD_TYPES),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Post metadata builder
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def post_from_params(form_data: dict[str, str], existing: Any = None) -> Any:
|
||||||
|
"""Build a :class:`Post` from HTTP form data, carrying *existing*.path."""
|
||||||
|
from .post import Post
|
||||||
|
|
||||||
|
metadata = _base_metadata(form_data, existing)
|
||||||
|
cleaned = _clean_metadata(metadata)
|
||||||
|
post = Post(metadata=cleaned, body=form_data.get("body", ""))
|
||||||
|
if existing is not None:
|
||||||
|
post.path = existing.path
|
||||||
|
return post
|
||||||
|
|
||||||
|
|
||||||
|
def _base_metadata(form_data: dict[str, str], existing: Any = None) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"title": presence(form_data.get("title")),
|
||||||
|
"slug": presence(form_data.get("slug")) or (existing.slug if existing else None),
|
||||||
|
"lang": presence(form_data.get("lang")),
|
||||||
|
"author": presence(form_data.get("author")),
|
||||||
|
"fediverse_creator": presence(form_data.get("fediverse_creator")),
|
||||||
|
"date": parse_date(form_data.get("date")),
|
||||||
|
"publish_at": parse_date(form_data.get("publish_at")),
|
||||||
|
"tags": parse_tags(form_data.get("tags")),
|
||||||
|
"excerpt": presence(form_data.get("excerpt")),
|
||||||
|
"cover": presence(form_data.get("cover")),
|
||||||
|
"cover_alt": presence(form_data.get("cover_alt")),
|
||||||
|
"cover_caption": presence(form_data.get("cover_caption")),
|
||||||
|
"draft": form_data.get("draft") == "on",
|
||||||
|
"all_langs": form_data.get("all_langs") == "on",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Drop nil/empty/false entries."""
|
||||||
|
return {
|
||||||
|
k: v
|
||||||
|
for k, v in metadata.items()
|
||||||
|
if v is not None and v != "" and v != [] and v is not False
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Post validation
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def creation_error(post: Any, store: Any) -> str | None:
|
||||||
|
"""Validate a new post before saving. Returns error string or ``None``."""
|
||||||
|
if presence(post.slug) is None:
|
||||||
|
return "Slug is required."
|
||||||
|
if not SLUG_REGEX.match(post.slug or ""):
|
||||||
|
return "Invalid slug."
|
||||||
|
if store.find(post.slug):
|
||||||
|
return "A post with that slug already exists."
|
||||||
|
return fediverse_creator_error(post)
|
||||||
|
|
||||||
|
|
||||||
|
def fediverse_creator_error(post: Any) -> str | None:
|
||||||
|
"""Validate the optional fediverse_creator field. Returns error or ``None``."""
|
||||||
|
from .post import FEDIVERSE_CREATOR_REGEX
|
||||||
|
|
||||||
|
value = post.metadata.get("fediverse_creator") if hasattr(post, "metadata") else None
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if re.match(FEDIVERSE_CREATOR_REGEX, str(value)):
|
||||||
|
return None
|
||||||
|
return "Fediverse creator must look like @user@host."
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Settings action helpers (return (error, notice) — exactly one is non-None)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def change_password(
|
||||||
|
users_obj: Any,
|
||||||
|
current_username: str,
|
||||||
|
current_password: str,
|
||||||
|
new_password: str,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Change the user's password."""
|
||||||
|
if not users_obj.authenticate(current_username, current_password):
|
||||||
|
return ("Current password is incorrect.", None)
|
||||||
|
fresh = presence(new_password)
|
||||||
|
if fresh is None:
|
||||||
|
return ("New password cannot be empty.", None)
|
||||||
|
users_obj.update_password(current_username, fresh)
|
||||||
|
return (None, "Password updated.")
|
||||||
|
|
||||||
|
|
||||||
|
def change_username(
|
||||||
|
users_obj: Any,
|
||||||
|
current_username: str,
|
||||||
|
new_username: str,
|
||||||
|
session: dict[str, Any],
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Rename the current user and update the session."""
|
||||||
|
name = presence(new_username)
|
||||||
|
if name is None:
|
||||||
|
return ("Username cannot be empty.", None)
|
||||||
|
if not re.match(r"\A[a-zA-Z0-9._-]+\Z", name):
|
||||||
|
return ("Username may use letters, numbers, dot, dash, underscore.", None)
|
||||||
|
if name == current_username:
|
||||||
|
return (None, "Username unchanged.")
|
||||||
|
if users_obj.rename(current_username, name):
|
||||||
|
session["user"] = name
|
||||||
|
return (None, "Username updated.")
|
||||||
|
return ("That username is already taken.", None)
|
||||||
|
|
||||||
|
|
||||||
|
def change_name(
|
||||||
|
users_obj: Any,
|
||||||
|
current_username: str,
|
||||||
|
display_name: str,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Update or clear the display name."""
|
||||||
|
name = display_name.strip()
|
||||||
|
name = name if name else None
|
||||||
|
users_obj.update_name(current_username, name)
|
||||||
|
return (None, "Display name updated." if name else "Display name cleared.")
|
||||||
|
|
||||||
|
|
||||||
|
def change_fediverse_creator(
|
||||||
|
users_obj: Any,
|
||||||
|
current_username: str,
|
||||||
|
value: str,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Update or clear the fediverse handle."""
|
||||||
|
from .post import FEDIVERSE_CREATOR_REGEX
|
||||||
|
|
||||||
|
text = value.strip()
|
||||||
|
if not text:
|
||||||
|
users_obj.update_fediverse_creator(current_username, None)
|
||||||
|
return (None, "Fediverse handle cleared.")
|
||||||
|
if not re.match(FEDIVERSE_CREATOR_REGEX, text):
|
||||||
|
return ("Fediverse handle must look like @user@host.", None)
|
||||||
|
users_obj.update_fediverse_creator(current_username, text)
|
||||||
|
return (None, "Fediverse handle updated.")
|
||||||
|
|
||||||
|
|
||||||
|
def change_photo(
|
||||||
|
users_obj: Any,
|
||||||
|
store: Any,
|
||||||
|
current_username: str,
|
||||||
|
upload: Any,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Store a profile photo."""
|
||||||
|
validate_upload(upload)
|
||||||
|
url = store.store_user_photo(current_username, upload.filename, upload.file)
|
||||||
|
users_obj.update_photo(current_username, url)
|
||||||
|
return (None, "Profile photo updated.")
|
||||||
|
|
||||||
|
|
||||||
|
def remove_photo(
|
||||||
|
users_obj: Any,
|
||||||
|
current_username: str,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Remove the profile photo."""
|
||||||
|
users_obj.update_photo(current_username, None)
|
||||||
|
return (None, "Profile photo removed.")
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(
|
||||||
|
users_obj: Any,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
role: str,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Create a new user (admin only)."""
|
||||||
|
name = presence(username)
|
||||||
|
pwd = presence(password)
|
||||||
|
if name is None or pwd is None:
|
||||||
|
return ("Username and password are required.", None)
|
||||||
|
if not re.match(r"\A[a-zA-Z0-9._-]+\Z", name):
|
||||||
|
return ("Username may use letters, numbers, dot, dash, underscore.", None)
|
||||||
|
if users_obj.add(name, pwd, role):
|
||||||
|
return (None, "User added.")
|
||||||
|
return ("That username already exists.", None)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_user(
|
||||||
|
users_obj: Any,
|
||||||
|
current_username: str,
|
||||||
|
target_username: str,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Delete a user (admin only)."""
|
||||||
|
if target_username == current_username:
|
||||||
|
return ("You cannot delete your own account.", None)
|
||||||
|
if users_obj.delete(target_username):
|
||||||
|
return (None, "User removed.")
|
||||||
|
return ("Cannot remove the last user or the last admin.", None)
|
||||||
|
|
||||||
|
|
||||||
|
def change_role(
|
||||||
|
users_obj: Any,
|
||||||
|
current_username: str,
|
||||||
|
target_username: str,
|
||||||
|
new_role: str,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Change a user's role (admin only)."""
|
||||||
|
if target_username == current_username:
|
||||||
|
return ("You cannot change your own role.", None)
|
||||||
|
if users_obj.set_role(target_username, new_role):
|
||||||
|
return (None, "Role updated.")
|
||||||
|
return ("Could not change role (last admin?).", None)
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""Reads and writes Markdown posts in a content directory."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from .post import Post
|
||||||
|
|
||||||
|
SAFE_SLUG_RE = re.compile(r"[^a-zA-Z0-9._-]")
|
||||||
|
|
||||||
|
|
||||||
|
class Store:
|
||||||
|
"""Manages posts on disk in a flat or language-subdivided directory."""
|
||||||
|
|
||||||
|
def __init__(self, content_dir: str, default_lang: str | None = None) -> None:
|
||||||
|
self.content_dir = os.path.abspath(content_dir)
|
||||||
|
self._default_lang = default_lang
|
||||||
|
self._cached: list[Post] | None = None
|
||||||
|
self._dir_mtime: float | None = None
|
||||||
|
self._files_mtime: float | None = None
|
||||||
|
|
||||||
|
def all(self) -> list[Post]:
|
||||||
|
dir_mtime = os.path.getmtime(self.content_dir) if os.path.isdir(self.content_dir) else None
|
||||||
|
files_mtime = self._newest_file_mtime()
|
||||||
|
if (
|
||||||
|
self._cached is not None
|
||||||
|
and dir_mtime == self._dir_mtime
|
||||||
|
and files_mtime == self._files_mtime
|
||||||
|
):
|
||||||
|
return list(self._cached)
|
||||||
|
self._dir_mtime = dir_mtime
|
||||||
|
self._files_mtime = files_mtime
|
||||||
|
self._cached = [
|
||||||
|
p for p in (self._load_post(path) for path in self._md_files()) if p is not None
|
||||||
|
]
|
||||||
|
return list(self._cached)
|
||||||
|
|
||||||
|
def find(self, slug: str, lang: str | None = None) -> Post | None:
|
||||||
|
for post in self.all():
|
||||||
|
if post.slug == slug and (lang is None or post.lang == lang or post.all_langs):
|
||||||
|
return post
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save(self, post: Post) -> Post:
|
||||||
|
target = post.path or self._default_path_for(post)
|
||||||
|
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||||
|
tmp = f"{target}.tmp"
|
||||||
|
with open(tmp, "w") as fh:
|
||||||
|
fh.write(post.to_file())
|
||||||
|
os.replace(tmp, target)
|
||||||
|
post.path = target
|
||||||
|
self._cached = None
|
||||||
|
return post
|
||||||
|
|
||||||
|
def delete(self, slug: str, lang: str | None = None) -> Post | None:
|
||||||
|
post = self.find(slug, lang=lang)
|
||||||
|
if post and post.path:
|
||||||
|
os.remove(post.path)
|
||||||
|
self._cached = None
|
||||||
|
return post
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_dir(self) -> str:
|
||||||
|
return os.path.join(self.content_dir, "media")
|
||||||
|
|
||||||
|
def store_upload(self, original_name: str, source: object) -> str:
|
||||||
|
name = self._safe_media_name(original_name)
|
||||||
|
os.makedirs(self.media_dir, exist_ok=True)
|
||||||
|
dest = os.path.join(self.media_dir, name)
|
||||||
|
if hasattr(source, "read"):
|
||||||
|
with open(dest, "wb") as fh:
|
||||||
|
while True:
|
||||||
|
chunk = source.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
fh.write(chunk)
|
||||||
|
else:
|
||||||
|
with open(source, "rb") as src, open(dest, "wb") as dst: # type: ignore[arg-type]
|
||||||
|
shutil.copyfileobj(src, dst)
|
||||||
|
return f"/media/{name}"
|
||||||
|
|
||||||
|
def store_user_photo(self, username: str, original_name: str, source: object) -> str:
|
||||||
|
ext = os.path.splitext(original_name)[1]
|
||||||
|
ext = re.sub(r"[^a-zA-Z0-9.]", "", ext).lower() or ".webp"
|
||||||
|
name = f"{username}-{secrets.token_hex(4)}{ext}"
|
||||||
|
os.makedirs(self.media_dir, exist_ok=True)
|
||||||
|
dest = os.path.join(self.media_dir, name)
|
||||||
|
if hasattr(source, "read"):
|
||||||
|
with open(dest, "wb") as fh:
|
||||||
|
while True:
|
||||||
|
chunk = source.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
fh.write(chunk)
|
||||||
|
else:
|
||||||
|
with open(source, "rb") as src, open(dest, "wb") as dst: # type: ignore[arg-type]
|
||||||
|
shutil.copyfileobj(src, dst)
|
||||||
|
return f"/media/{name}"
|
||||||
|
|
||||||
|
def media_file(self, name: str) -> str | None:
|
||||||
|
safe = os.path.basename(name)
|
||||||
|
path = os.path.join(self.media_dir, safe)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return None
|
||||||
|
real = os.path.realpath(path)
|
||||||
|
if self._within(self.media_dir, real):
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _md_files(self) -> list[str]:
|
||||||
|
pattern = os.path.join(self.content_dir, "**", "*.md")
|
||||||
|
return glob.glob(pattern, recursive=True)
|
||||||
|
|
||||||
|
def _newest_file_mtime(self) -> float | None:
|
||||||
|
files = self._md_files()
|
||||||
|
if not files:
|
||||||
|
return None
|
||||||
|
return max(os.path.getmtime(path) for path in files)
|
||||||
|
|
||||||
|
def _load_post(self, path: str) -> Post | None:
|
||||||
|
try:
|
||||||
|
with open(path) as fh:
|
||||||
|
post = Post.parse(fh.read())
|
||||||
|
except Exception as e:
|
||||||
|
import sys
|
||||||
|
|
||||||
|
print(f"volumen: skipping {path}: {e}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
if not post.metadata.get("slug"):
|
||||||
|
post.metadata["slug"] = os.path.splitext(os.path.basename(path))[0]
|
||||||
|
if not post.metadata.get("lang"):
|
||||||
|
post.metadata["lang"] = self._lang_from_path(path) or self._default_lang
|
||||||
|
post.path = path
|
||||||
|
return post
|
||||||
|
|
||||||
|
def _lang_from_path(self, path: str) -> str | None:
|
||||||
|
parent = os.path.dirname(os.path.abspath(path))
|
||||||
|
if parent == self.content_dir:
|
||||||
|
return None
|
||||||
|
return os.path.basename(parent)
|
||||||
|
|
||||||
|
def _default_path_for(self, post: Post) -> str:
|
||||||
|
directory = self.content_dir
|
||||||
|
if post.lang and post.lang != self._default_lang:
|
||||||
|
directory = os.path.join(directory, post.lang)
|
||||||
|
target = os.path.join(directory, f"{post.slug}.md")
|
||||||
|
target_abs = os.path.abspath(target)
|
||||||
|
content_abs = os.path.abspath(self.content_dir)
|
||||||
|
if not target_abs.startswith(content_abs + os.sep) and target_abs != content_abs:
|
||||||
|
raise ValueError("slug escapes content directory")
|
||||||
|
return target
|
||||||
|
|
||||||
|
def _safe_media_name(self, original: str) -> str:
|
||||||
|
base = os.path.basename(original)
|
||||||
|
ext = re.sub(r"[^a-zA-Z0-9.]", "", os.path.splitext(base)[1]).lower()
|
||||||
|
stem = os.path.splitext(base)[0]
|
||||||
|
stem = re.sub(r"[^a-zA-Z0-9_-]+", "-", stem).strip("-").lower() or "file"
|
||||||
|
return f"{secrets.token_hex(4)}-{stem}{ext}"
|
||||||
|
|
||||||
|
def _within(self, directory: str, path: str) -> bool:
|
||||||
|
dir_abs = os.path.abspath(directory)
|
||||||
|
path_abs = os.path.abspath(path)
|
||||||
|
return path_abs.startswith(dir_abs + os.sep) or path_abs == dir_abs
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""File-backed set of admin users, stored as a TOML [[users]] array."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
except ImportError:
|
||||||
|
import tomli as tomllib # type: ignore[import-not-found,unused-ignore]
|
||||||
|
|
||||||
|
import tomli_w
|
||||||
|
|
||||||
|
from .password import hash_password as _hash_pw
|
||||||
|
from .password import verify_password as _verify_pw
|
||||||
|
|
||||||
|
ROLES = ("admin", "author")
|
||||||
|
DEFAULT_ROLE = "author"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class User:
|
||||||
|
username: str
|
||||||
|
password_hash: str
|
||||||
|
role: str = "author"
|
||||||
|
name: str | None = None
|
||||||
|
fediverse_creator: str | None = None
|
||||||
|
photo: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Users:
|
||||||
|
"""File-backed collection of admin/authors."""
|
||||||
|
|
||||||
|
def __init__(self, path: str, bootstrap_hash: str | None = None) -> None:
|
||||||
|
self._path = path
|
||||||
|
self._bootstrap_hash = bootstrap_hash or ""
|
||||||
|
self._cached: list[User] | None = None
|
||||||
|
self._mtime: float | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def all(self) -> list[User]:
|
||||||
|
mtime = os.path.getmtime(self._path) if os.path.isfile(self._path) else None
|
||||||
|
if self._cached is not None and mtime == self._mtime:
|
||||||
|
return list(self._cached)
|
||||||
|
self._mtime = mtime
|
||||||
|
if os.path.isfile(self._path):
|
||||||
|
self._cached = self._read_file()
|
||||||
|
elif self._bootstrap_hash:
|
||||||
|
self._cached = [User("admin", self._bootstrap_hash, "admin")]
|
||||||
|
else:
|
||||||
|
self._cached = []
|
||||||
|
return list(self._cached)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def any(self) -> bool:
|
||||||
|
return len(self.all) > 0
|
||||||
|
|
||||||
|
def find(self, username: str) -> User | None:
|
||||||
|
for u in self.all:
|
||||||
|
if u.username == username:
|
||||||
|
return u
|
||||||
|
return None
|
||||||
|
|
||||||
|
def authenticate(self, username: str, password: str) -> User | None:
|
||||||
|
user = self.find(username)
|
||||||
|
if user and _verify_pw(password, user.password_hash):
|
||||||
|
return user
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add(self, username: str, password: str, role: str = DEFAULT_ROLE) -> User | None:
|
||||||
|
if not username or self.find(username):
|
||||||
|
return None
|
||||||
|
role = role if role in ROLES else DEFAULT_ROLE
|
||||||
|
user = User(username, _hash_pw(password), role)
|
||||||
|
self._persist([*self.all, user])
|
||||||
|
return user
|
||||||
|
|
||||||
|
def update_name(self, username: str, name: str | None) -> User | None:
|
||||||
|
return self._mutate(username, lambda u: setattr(u, "name", name if name else None))
|
||||||
|
|
||||||
|
def update_fediverse_creator(self, username: str, value: str | None) -> User | None:
|
||||||
|
return self._mutate(
|
||||||
|
username, lambda u: setattr(u, "fediverse_creator", value if value else None)
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_photo(self, username: str, path: str | None) -> User | None:
|
||||||
|
return self._mutate(username, lambda u: setattr(u, "photo", path))
|
||||||
|
|
||||||
|
def update_password(self, username: str, password: str) -> User | None:
|
||||||
|
return self._mutate(username, lambda u: setattr(u, "password_hash", _hash_pw(password)))
|
||||||
|
|
||||||
|
def rename(self, current_name: str, new_name: str) -> User | None:
|
||||||
|
if not new_name or self.find(new_name):
|
||||||
|
return None
|
||||||
|
return self._mutate(current_name, lambda u: setattr(u, "username", new_name))
|
||||||
|
|
||||||
|
def set_role(self, username: str, role: str) -> User | None:
|
||||||
|
if role not in ROLES:
|
||||||
|
return None
|
||||||
|
users = self.all
|
||||||
|
user = next((u for u in users if u.username == username), None)
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
if user.role == "admin" and role != "admin" and self._admin_count(users) <= 1:
|
||||||
|
return None
|
||||||
|
user.role = role
|
||||||
|
self._persist(users)
|
||||||
|
return user
|
||||||
|
|
||||||
|
def delete(self, username: str) -> str | None:
|
||||||
|
users = self.all
|
||||||
|
target = next((u for u in users if u.username == username), None)
|
||||||
|
if target is None or len(users) <= 1:
|
||||||
|
return None
|
||||||
|
if target.role == "admin" and self._admin_count(users) <= 1:
|
||||||
|
return None
|
||||||
|
self._persist([u for u in users if u.username != username])
|
||||||
|
return username
|
||||||
|
|
||||||
|
def _mutate(self, username: str, func: Any) -> User | None:
|
||||||
|
users = self.all
|
||||||
|
user = next((u for u in users if u.username == username), None)
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
func(user)
|
||||||
|
self._persist(users)
|
||||||
|
return user
|
||||||
|
|
||||||
|
def _admin_count(self, users: list[User]) -> int:
|
||||||
|
return sum(1 for u in users if u.role == "admin")
|
||||||
|
|
||||||
|
def _read_file(self) -> list[User]:
|
||||||
|
with open(self._path, "rb") as fh:
|
||||||
|
data = tomllib.load(fh)
|
||||||
|
result: list[User] = [
|
||||||
|
User(
|
||||||
|
username=entry["username"],
|
||||||
|
password_hash=entry["password_hash"],
|
||||||
|
role=entry.get("role", "admin"),
|
||||||
|
name=entry.get("name"),
|
||||||
|
fediverse_creator=entry.get("fediverse_creator"),
|
||||||
|
photo=entry.get("photo"),
|
||||||
|
)
|
||||||
|
for entry in data.get("users", [])
|
||||||
|
]
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _persist(self, users: list[User]) -> None:
|
||||||
|
os.makedirs(os.path.dirname(self._path) or ".", exist_ok=True)
|
||||||
|
tmp = f"{self._path}.tmp"
|
||||||
|
user_list = []
|
||||||
|
for u in users:
|
||||||
|
entry: dict[str, Any] = {
|
||||||
|
"username": u.username,
|
||||||
|
"password_hash": u.password_hash,
|
||||||
|
"role": u.role,
|
||||||
|
}
|
||||||
|
if u.name:
|
||||||
|
entry["name"] = u.name
|
||||||
|
if u.fediverse_creator:
|
||||||
|
entry["fediverse_creator"] = u.fediverse_creator
|
||||||
|
if u.photo:
|
||||||
|
entry["photo"] = u.photo
|
||||||
|
user_list.append(entry)
|
||||||
|
with open(tmp, "wb") as fh:
|
||||||
|
fh.write(tomli_w.dumps({"users": user_list}).encode("utf-8"))
|
||||||
|
os.replace(tmp, self._path)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""volumen version."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
VERSION = "0.4.0"
|
||||||
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.8 KiB |
@@ -1,20 +1,25 @@
|
|||||||
<%
|
{% extends "layout.html.jinja" %}
|
||||||
crumbs = @mode == :new ? [["Posts", to("/admin/")], ["New post", nil]] : [["Posts", to("/admin/")], [@post.title.to_s.empty? ? "Untitled" : @post.title, nil]]
|
|
||||||
%>
|
{% block content %}
|
||||||
|
{% if mode == "new" %}
|
||||||
|
{% set crumbs = [("Posts", "/admin/"), ("New post", None)] %}
|
||||||
|
{% else %}
|
||||||
|
{% set crumbs = [("Posts", "/admin/"), (post.title | string | trim if post.title | string | trim | length > 0 else "Untitled", None)] %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h1><%= @mode == :new ? "New post" : "Edit post" %></h1>
|
<h1>{{ "New post" if mode == "new" else "Edit post" }}</h1>
|
||||||
<p class="lede"><%= @mode == :new ? "Draft a new article in Markdown." : "Update and publish this post." %></p>
|
<p class="lede">{{ "Draft a new article in Markdown." if mode == "new" else "Update and publish this post." }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
<% if @mode == :edit %>
|
{% if mode == "edit" %}
|
||||||
<a class="btn btn-ghost" href="<%= to("/api/volumen/posts/#{@post.slug}") %>" target="_blank">
|
<a class="btn btn-ghost" href="/api/volumen/posts/{{ post.slug }}" target="_blank">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||||
Preview
|
Preview
|
||||||
</a>
|
</a>
|
||||||
<% end %>
|
{% endif %}
|
||||||
<a class="btn btn-ghost" href="<%= to("/admin/") %>">Cancel</a>
|
<a class="btn btn-ghost" href="/admin/">Cancel</a>
|
||||||
<button type="submit" form="post-form" class="btn btn-primary">
|
<button type="submit" form="post-form" class="btn btn-primary">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||||||
Save
|
Save
|
||||||
@@ -22,16 +27,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if @error %>
|
{% if error %}
|
||||||
<div class="alert alert-error">
|
<div class="alert alert-error">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||||
<div><%= h(@error) %></div>
|
<div>{{ error }}</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
{% endif %}
|
||||||
|
|
||||||
<form id="post-form" method="post"
|
<form id="post-form" method="post"
|
||||||
action="<%= @mode == :new ? to("/admin/posts") : h(to("/admin/posts/#{@post.slug}")) %>">
|
action="{{ '/admin/posts' if mode == 'new' else '/admin/posts/' + post.slug }}">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
|
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="surface full">
|
<div class="surface full">
|
||||||
@@ -44,62 +49,64 @@
|
|||||||
|
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
<label for="title">Title</label>
|
<label for="title">Title</label>
|
||||||
<input id="title" class="input" type="text" name="title" value="<%= h(@post.title) %>" placeholder="A clear, descriptive title" required>
|
<input id="title" class="input" type="text" name="title" value="{{ post.title if post.title else '' }}" placeholder="A clear, descriptive title" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="slug">Slug</label>
|
<label for="slug">Slug</label>
|
||||||
<input id="slug" class="input" type="text" name="slug" value="<%= h(@post.slug) %>"
|
<input id="slug" class="input" type="text" name="slug" value="{{ post.slug if post.slug else '' }}"
|
||||||
<%= @mode == :edit ? "readonly" : "required" %> placeholder="my-post-slug">
|
{{ 'readonly' if mode == 'edit' else 'required' }} placeholder="my-post-slug">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="lang">Language</label>
|
<label for="lang">Language</label>
|
||||||
<input id="lang" class="input" type="text" name="lang" value="<%= h(@post.lang) %>" placeholder="en">
|
<input id="lang" class="input" type="text" name="lang" value="{{ post.lang if post.lang else '' }}" placeholder="en">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
|
{% set author_value = post.author if post.author and post.author | string | trim | length > 0 else (current_user_record.name if current_user_record else current_user) %}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="author">Author</label>
|
<label for="author">Author</label>
|
||||||
<input id="author" class="input" type="text" name="author" value="<%= h(@post.author.to_s.empty? ? current_user_name : @post.author) %>" placeholder="<%= h(current_user_name || 'Petr Balvín') %>">
|
<input id="author" class="input" type="text" name="author" value="{{ author_value if author_value else '' }}" placeholder="{{ current_user_record.name if current_user_record else 'Petr Balvín' }}">
|
||||||
</div>
|
</div>
|
||||||
|
{% set fediverse_default = current_user_record.fediverse_creator if current_user_record and current_user_record.fediverse_creator else config.site.fediverse_creator %}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="fediverse_creator">Fediverse creator</label>
|
<label for="fediverse_creator">Fediverse creator</label>
|
||||||
<input id="fediverse_creator" class="input" type="text" name="fediverse_creator"
|
<input id="fediverse_creator" class="input" type="text" name="fediverse_creator"
|
||||||
value="<%= h(@post.fediverse_creator.to_s.empty? ? (current_user_fediverse || config.site['fediverse_creator']) : @post.fediverse_creator) %>" placeholder="@user@instance.tld">
|
value="{{ post.fediverse_creator if post.fediverse_creator and post.fediverse_creator | string | trim | length > 0 else fediverse_default }}" placeholder="@user@instance.tld">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field-row-3">
|
<div class="field-row-3">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="date">Publish date</label>
|
<label for="date">Publish date</label>
|
||||||
<input id="date" class="input" type="date" name="date" value="<%= h(@post.date_string || Date.today.iso8601) %>">
|
<input id="date" class="input" type="date" name="date" value="{{ post.date_string if post.date_string else today_date }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="publish_at">Schedule for</label>
|
<label for="publish_at">Schedule for</label>
|
||||||
<input id="publish_at" class="input" type="date" name="publish_at"
|
<input id="publish_at" class="input" type="date" name="publish_at"
|
||||||
value="<%= @post.publish_at.is_a?(Date) ? @post.publish_at.strftime("%Y-%m-%d") : "" %>">
|
value="{{ post.publish_at.strftime('%Y-%m-%d') if post.publish_at else '' }}">
|
||||||
<p class="help">Leave empty to publish immediately</p>
|
<p class="help">Leave empty to publish immediately</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="tags">Tags</label>
|
<label for="tags">Tags</label>
|
||||||
<input id="tags" class="input" type="text" name="tags" value="<%= h(@post.tags.join(", ")) %>" placeholder="comma, separated">
|
<input id="tags" class="input" type="text" name="tags" value="{{ post.tags | join(', ') }}" placeholder="comma, separated">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
<label for="excerpt">Excerpt</label>
|
<label for="excerpt">Excerpt</label>
|
||||||
<input id="excerpt" class="input" type="text" name="excerpt" value="<%= h(@post.excerpt) %>" placeholder="Short summary for listings and previews">
|
<input id="excerpt" class="input" type="text" name="excerpt" value="{{ post.excerpt if post.excerpt else '' }}" placeholder="Short summary for listings and previews">
|
||||||
<p class="help">Auto-generated from the first paragraph if left empty</p>
|
<p class="help">Auto-generated from the first paragraph if left empty</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display: flex; gap: 1.5rem; flex-wrap: wrap; margin-top: 0.5rem;">
|
<div style="display: flex; gap: 1.5rem; flex-wrap: wrap; margin-top: 0.5rem;">
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
<input type="checkbox" name="draft" <%= @post.draft? ? "checked" : "" %>> Draft
|
<input type="checkbox" name="draft" {{ 'checked' if post.draft else '' }}> Draft
|
||||||
</label>
|
</label>
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
<input type="checkbox" name="all_langs" <%= @post.all_langs? ? "checked" : "" %>> Available in all languages
|
<input type="checkbox" name="all_langs" {{ 'checked' if post.all_langs else '' }}> Available in all languages
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,14 +120,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="cover-field">
|
<div class="cover-field">
|
||||||
<div class="cover-input-row">
|
<div class="cover-input-row">
|
||||||
<input id="cover-input" class="input" type="text" name="cover" value="<%= h(@post.cover) %>"
|
<input id="cover-input" class="input" type="text" name="cover" value="{{ post.cover if post.cover else '' }}"
|
||||||
placeholder="/media/… or https://…">
|
placeholder="/media/… or https://…">
|
||||||
<button type="button" id="cover-upload" class="btn">Upload</button>
|
<button type="button" id="cover-upload" class="btn">Upload</button>
|
||||||
<button type="button" id="cover-clear" class="btn btn-ghost">Clear</button>
|
<button type="button" id="cover-clear" class="btn btn-ghost">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
<input id="cover-alt-input" class="input" type="text" name="cover_alt" value="<%= h(@post.cover_alt) %>"
|
<input id="cover-alt-input" class="input" type="text" name="cover_alt" value="{{ post.cover_alt if post.cover_alt else '' }}"
|
||||||
placeholder="ALT text (for accessibility)">
|
placeholder="ALT text (for accessibility)">
|
||||||
<input id="cover-caption-input" class="input" type="text" name="cover_caption" value="<%= h(@post.cover_caption) %>"
|
<input id="cover-caption-input" class="input" type="text" name="cover_caption" value="{{ post.cover_caption if post.cover_caption else '' }}"
|
||||||
placeholder="Caption (e.g. 'Generated by AI')">
|
placeholder="Caption (e.g. 'Generated by AI')">
|
||||||
<img id="cover-preview" class="cover-preview" alt="" hidden>
|
<img id="cover-preview" class="cover-preview" alt="" hidden>
|
||||||
<input type="file" id="cover-file" accept="image/*" hidden>
|
<input type="file" id="cover-file" accept="image/*" hidden>
|
||||||
@@ -171,7 +178,7 @@
|
|||||||
<button type="button" class="editor-toolbar-btn" data-md="image" title="Image">IMG</button>
|
<button type="button" class="editor-toolbar-btn" data-md="image" title="Image">IMG</button>
|
||||||
<button type="button" id="toggle-preview" class="editor-toolbar-btn" title="Toggle preview">PREVIEW</button>
|
<button type="button" id="toggle-preview" class="editor-toolbar-btn" title="Toggle preview">PREVIEW</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="body" name="body" class="editor-textarea" rows="22" placeholder="Write your post in Markdown…"><%= h(@post.body) %></textarea>
|
<textarea id="body" name="body" class="editor-textarea" rows="22" placeholder="Write your post in Markdown…">{{ post.body if post.body else '' }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-pane" id="preview-pane">
|
<div class="editor-pane" id="preview-pane">
|
||||||
<div class="editor-head">
|
<div class="editor-head">
|
||||||
@@ -199,9 +206,9 @@
|
|||||||
var coverInput = document.getElementById("cover-input");
|
var coverInput = document.getElementById("cover-input");
|
||||||
var coverFile = document.getElementById("cover-file");
|
var coverFile = document.getElementById("cover-file");
|
||||||
var coverPreview = document.getElementById("cover-preview");
|
var coverPreview = document.getElementById("cover-preview");
|
||||||
var previewUrl = "<%= to("/admin/preview") %>";
|
var previewUrl = "/admin/preview";
|
||||||
var uploadUrl = "<%= to("/admin/uploads") %>";
|
var uploadUrl = "/admin/uploads";
|
||||||
var csrf = "<%= csrf_token %>";
|
var csrf = "{{ csrf_token }}";
|
||||||
var timer = null;
|
var timer = null;
|
||||||
|
|
||||||
try { document.execCommand("defaultParagraphSeparator", false, "p"); } catch (e) {}
|
try { document.execCommand("defaultParagraphSeparator", false, "p"); } catch (e) {}
|
||||||
@@ -581,3 +588,4 @@
|
|||||||
applyPreviewPreference();
|
applyPreviewPreference();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="<%= h(config.language) %>">
|
<html lang="{{ config.language }}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
@@ -556,11 +556,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* --- Native form control polish (date / time / file) --- */
|
/* --- Native form control polish (date / time / file) --- */
|
||||||
/* HTML5 date and time inputs have a native popup that is drawn by
|
|
||||||
the OS and cannot be themed. We polish the input itself so it
|
|
||||||
matches the rest of the design and replace the OS picker
|
|
||||||
indicator with a themed one. The JS below replaces the popup
|
|
||||||
with a fully custom one when JavaScript runs. */
|
|
||||||
input[type="date"],
|
input[type="date"],
|
||||||
input[type="time"],
|
input[type="time"],
|
||||||
input[type="datetime-local"] {
|
input[type="datetime-local"] {
|
||||||
@@ -604,7 +599,6 @@
|
|||||||
filter: invert(0.85);
|
filter: invert(0.85);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* Firefox draws a small inner "X" clear button — tone it down. */
|
|
||||||
input[type="date"]::-moz-clear,
|
input[type="date"]::-moz-clear,
|
||||||
input[type="time"]::-moz-clear { opacity: 0.5; }
|
input[type="time"]::-moz-clear { opacity: 0.5; }
|
||||||
|
|
||||||
@@ -644,7 +638,6 @@
|
|||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
}
|
}
|
||||||
input[type="file"]::file-selector-button:active { transform: translateY(1px); }
|
input[type="file"]::file-selector-button:active { transform: translateY(1px); }
|
||||||
/* Firefox variant of the file selector button pseudo-element. */
|
|
||||||
input[type="file"]::-webkit-file-upload-button {
|
input[type="file"]::-webkit-file-upload-button {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
@@ -822,11 +815,13 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<% if request.path_info == "/admin/login" || (defined?(login_page) && login_page) %>
|
{% set is_login = request.url.path == "/admin/login" or login_page %}
|
||||||
|
{% set is_authenticated = current_user and current_user|length > 0 %}
|
||||||
|
{% if is_login %}
|
||||||
<div class="login-page">
|
<div class="login-page">
|
||||||
<aside class="login-side">
|
<aside class="login-side">
|
||||||
<div class="brand-mark">
|
<div class="brand-mark">
|
||||||
<img class="logo" src="<%= to('/admin/icon.svg') %>" alt="volumen">
|
<img class="logo" src="/admin/icon.svg" alt="volumen">
|
||||||
<span>volumen</span>
|
<span>volumen</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pitch">
|
<div class="pitch">
|
||||||
@@ -850,26 +845,26 @@
|
|||||||
</aside>
|
</aside>
|
||||||
<main class="login-form-side">
|
<main class="login-form-side">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<%= yield %>
|
{% block login_content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
{% else %}
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-head">
|
<div class="sidebar-head">
|
||||||
<img class="logo" src="<%= to('/admin/icon.svg') %>" alt="volumen">
|
<img class="logo" src="/admin/icon.svg" alt="volumen">
|
||||||
<div class="name">volumen</div>
|
<div class="name">volumen</div>
|
||||||
</div>
|
</div>
|
||||||
<% if authenticated? %>
|
{% if is_authenticated %}
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<div class="nav-section">
|
<div class="nav-section">
|
||||||
<div class="nav-label">Content</div>
|
<div class="nav-label">Content</div>
|
||||||
<a href="<%= to("/admin/") %>" class="nav-item <%= request.path_info == "/admin/" || request.path_info == "/admin" ? "active" : "" %>">
|
<a href="/admin/" class="nav-item {{ 'active' if request.url.path in ('/admin/', '/admin') else '' }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="13" y2="17"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="13" y2="17"/></svg>
|
||||||
Posts
|
Posts
|
||||||
</a>
|
</a>
|
||||||
<a href="<%= to("/admin/posts/new") %>" class="nav-item">
|
<a href="/admin/posts/new" class="nav-item">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
|
||||||
New post
|
New post
|
||||||
</a>
|
</a>
|
||||||
@@ -877,54 +872,55 @@
|
|||||||
<div class="nav-divider"></div>
|
<div class="nav-divider"></div>
|
||||||
<div class="nav-section">
|
<div class="nav-section">
|
||||||
<div class="nav-label">Configure</div>
|
<div class="nav-label">Configure</div>
|
||||||
<a href="<%= to("/admin/settings") %>" class="nav-item <%= request.path_info.start_with?("/admin/settings") ? "active" : "" %>">
|
<a href="/admin/settings" class="nav-item {{ 'active' if request.url.path.startswith('/admin/settings') else '' }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
Settings
|
Settings
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
{% set display_name = current_user_record.name if current_user_record else current_user %}
|
||||||
|
{% set user_photo = current_user_record.photo if current_user_record else None %}
|
||||||
<div class="sidebar-foot">
|
<div class="sidebar-foot">
|
||||||
<% if current_user_photo %>
|
{% if user_photo %}
|
||||||
<img class="avatar avatar-img" src="<%= h(current_user_photo) %>" alt="">
|
<img class="avatar avatar-img" src="{{ user_photo }}" alt="">
|
||||||
<% else %>
|
{% else %}
|
||||||
<div class="avatar"><%= h((current_user_name || current_user).to_s[0].upcase) %></div>
|
<div class="avatar">{{ display_name[0] | upper if display_name else '?' }}</div>
|
||||||
<% end %>
|
{% endif %}
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<div class="name"><%= h(current_user_name || current_user) %></div>
|
<div class="name">{{ display_name }}</div>
|
||||||
<div class="role"><%= h(current_role) %></div>
|
<div class="role">{{ current_role }}</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="<%= to("/admin/logout") %>" style="margin: 0;">
|
<form method="post" action="/admin/logout" style="margin: 0;">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
<button type="submit" class="icon-btn" title="Log out" aria-label="Log out">
|
<button type="submit" class="icon-btn" title="Log out" aria-label="Log out">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-version">v<%= h(Volumen::VERSION) %></div>
|
<div class="sidebar-version">v{{ version }}</div>
|
||||||
<% end %>
|
{% endif %}
|
||||||
</aside>
|
</aside>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<% if defined?(@crumbs) && @crumbs %>
|
{% if crumbs %}
|
||||||
<nav class="crumbs">
|
<nav class="crumbs">
|
||||||
<% @crumbs.each_with_index do |(label, href), i| %>
|
{% for label, href in crumbs %}
|
||||||
<% if i > 0 %><span class="sep">/</span><% end %>
|
{% if not loop.first %}<span class="sep">/</span>{% endif %}
|
||||||
<% if href && i < @crumbs.length - 1 %>
|
{% if href and not loop.last %}
|
||||||
<a href="<%= href %>"><%= label %></a>
|
<a href="{{ href }}">{{ label }}</a>
|
||||||
<% else %>
|
{% else %}
|
||||||
<span class="current"><%= label %></span>
|
<span class="current">{{ label }}</span>
|
||||||
<% end %>
|
{% endif %}
|
||||||
<% end %>
|
{% endfor %}
|
||||||
</nav>
|
</nav>
|
||||||
<% end %>
|
{% endif %}
|
||||||
|
|
||||||
</header>
|
</header>
|
||||||
<main class="content">
|
<main class="content">
|
||||||
<%= yield %>
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
/* Custom date picker — replaces <input type="date" class="input"> with
|
/* Custom date picker — replaces <input type="date" class="input"> with
|
||||||
@@ -968,7 +964,7 @@
|
|||||||
if (!WEEK_CACHE[key]) {
|
if (!WEEK_CACHE[key]) {
|
||||||
try {
|
try {
|
||||||
var fmt = new Intl.DateTimeFormat(lang, { weekday: "short" });
|
var fmt = new Intl.DateTimeFormat(lang, { weekday: "short" });
|
||||||
var base = [1, 2, 3, 4, 5, 6, 7]; // 2001-01-01 was Monday, so 1..7 = Mon..Sun
|
var base = [1, 2, 3, 4, 5, 6, 7];
|
||||||
var all = base.map(function (d) { return fmt.format(new Date(2001, 0, d)); });
|
var all = base.map(function (d) { return fmt.format(new Date(2001, 0, d)); });
|
||||||
var offset = firstDay - 1;
|
var offset = firstDay - 1;
|
||||||
WEEK_CACHE[key] = all.slice(offset).concat(all.slice(0, offset));
|
WEEK_CACHE[key] = all.slice(offset).concat(all.slice(0, offset));
|
||||||
@@ -1126,7 +1122,7 @@
|
|||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
var first = new Date(viewYear, viewMonth, 1);
|
var first = new Date(viewYear, viewMonth, 1);
|
||||||
var firstWd = ((first.getDay() - weekdayStart + 7) % 7) + 1; // 1..7
|
var firstWd = ((first.getDay() - weekdayStart + 7) % 7) + 1;
|
||||||
var start = new Date(viewYear, viewMonth, 1 - (firstWd - 1));
|
var start = new Date(viewYear, viewMonth, 1 - (firstWd - 1));
|
||||||
var today = todayMidnight();
|
var today = todayMidnight();
|
||||||
var cells = [];
|
var cells = [];
|
||||||
@@ -1237,7 +1233,6 @@
|
|||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
enhanceAll();
|
enhanceAll();
|
||||||
// Re-scan on Turbolinks-style page swaps, should the admin adopt them.
|
|
||||||
if (typeof MutationObserver !== "undefined") {
|
if (typeof MutationObserver !== "undefined") {
|
||||||
var obs = new MutationObserver(function () {
|
var obs = new MutationObserver(function () {
|
||||||
enhanceAll(document);
|
enhanceAll(document);
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
|
{% extends "layout.html.jinja" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h1>Posts</h1>
|
<h1>Posts</h1>
|
||||||
<p class="lede"><%= @posts.length %> post<%= @posts.length == 1 ? "" : "s" %> in total</p>
|
<p class="lede">{{ posts|length }} post{{ '' if posts|length == 1 else 's' }} in total</p>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-primary" href="<%= to("/admin/posts/new") %>">
|
<a class="btn btn-primary" href="/admin/posts/new">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
New post
|
New post
|
||||||
</a>
|
</a>
|
||||||
@@ -22,64 +25,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if @posts.empty? %>
|
{% if not posts %}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||||
<h3>No posts yet</h3>
|
<h3>No posts yet</h3>
|
||||||
<p>Create your first post to get started.</p>
|
<p>Create your first post to get started.</p>
|
||||||
<a class="btn btn-primary" href="<%= to("/admin/posts/new") %>" style="margin-top: 0.75rem;">Create post</a>
|
<a class="btn btn-primary" href="/admin/posts/new" style="margin-top: 0.75rem;">Create post</a>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
{% else %}
|
||||||
<div class="post-grid" id="post-grid">
|
<div class="post-grid" id="post-grid">
|
||||||
<% @posts.each do |post| %>
|
{% for post in posts %}
|
||||||
<%
|
{% if post.draft %}
|
||||||
status = if post.draft?
|
{% set status = "draft" %}
|
||||||
"draft"
|
{% elif post.scheduled %}
|
||||||
elsif post.scheduled?
|
{% set status = "scheduled" %}
|
||||||
"scheduled"
|
{% else %}
|
||||||
else
|
{% set status = "published" %}
|
||||||
"published"
|
{% endif %}
|
||||||
end
|
<a href="/admin/posts/{{ post.slug }}/edit"
|
||||||
%>
|
|
||||||
<a href="<%= h(to("/admin/posts/#{post.slug}/edit")) %>"
|
|
||||||
class="post-card"
|
class="post-card"
|
||||||
data-title="<%= h(post.title.to_s.downcase) %>"
|
data-title="{{ post.title | default('') | lower }}"
|
||||||
data-slug="<%= h(post.slug) %>"
|
data-slug="{{ post.slug }}"
|
||||||
data-status="<%= status %>">
|
data-status="{{ status }}">
|
||||||
<% if post.cover %>
|
{% if post.cover %}
|
||||||
<div class="post-cover" style="background-image: url('<%= h(post.cover) %>');"></div>
|
<div class="post-cover" style="background-image: url('{{ post.cover }}');"></div>
|
||||||
<% else %>
|
{% else %}
|
||||||
<div class="post-cover placeholder">
|
<div class="post-cover placeholder">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
{% endif %}
|
||||||
<div class="post-body">
|
<div class="post-body">
|
||||||
<h3 class="post-title"><%= h(post.title) %></h3>
|
<h3 class="post-title">{{ post.title }}</h3>
|
||||||
<% if post.excerpt && !post.excerpt.to_s.empty? %>
|
{% if post.excerpt and post.excerpt | string | trim | length > 0 %}
|
||||||
<p class="post-excerpt"><%= h(post.excerpt) %></p>
|
<p class="post-excerpt">{{ post.excerpt }}</p>
|
||||||
<% end %>
|
{% endif %}
|
||||||
<div class="post-meta">
|
<div class="post-meta">
|
||||||
<span class="badge badge-lang"><%= h(post.lang) %></span>
|
<span class="badge badge-lang">{{ post.lang }}</span>
|
||||||
<% if post.draft? %><span class="badge badge-warn">Draft</span><% end %>
|
{% if post.draft %}<span class="badge badge-warn">Draft</span>{% endif %}
|
||||||
<% if post.scheduled? %><span class="badge badge-accent">Scheduled</span><% end %>
|
{% if post.scheduled %}<span class="badge badge-accent">Scheduled</span>{% endif %}
|
||||||
<% if post.date_string %><span><%= h(post.date_string) %></span><% end %>
|
{% if post.date_string %}<span>{{ post.date_string }}</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="post-actions">
|
<div class="post-actions">
|
||||||
<span class="btn btn-sm btn-ghost" style="cursor: default;">Edit</span>
|
<span class="btn btn-sm btn-ghost" style="cursor: default;">Edit</span>
|
||||||
<span class="spacer" style="flex: 1;"></span>
|
<span class="spacer" style="flex: 1;"></span>
|
||||||
<form method="post" action="<%= h(to("/admin/posts/#{post.slug}/delete")) %>"
|
<form method="post" action="/admin/posts/{{ post.slug }}/delete"
|
||||||
onsubmit="return confirm('Delete this post?');" style="margin: 0;">
|
onsubmit="return confirm('Delete this post?');" style="margin: 0;">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
<button type="submit" class="btn btn-sm btn-ghost" style="color: var(--danger);" onclick="event.preventDefault(); event.stopPropagation(); this.closest('form').submit();">
|
<button type="submit" class="btn btn-sm btn-ghost" style="color: var(--danger);" onclick="event.preventDefault(); event.stopPropagation(); this.closest('form').submit();">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<% end %>
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
@@ -111,3 +112,4 @@
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,22 +1,25 @@
|
|||||||
|
{% extends "layout.html.jinja" %}
|
||||||
|
|
||||||
|
{% block login_content %}
|
||||||
<h2>Sign in</h2>
|
<h2>Sign in</h2>
|
||||||
<p class="lede">Welcome back. Sign in to manage your posts.</p>
|
<p class="lede">Welcome back. Sign in to manage your posts.</p>
|
||||||
|
|
||||||
<% if @error %>
|
{% if error %}
|
||||||
<div class="alert alert-error">
|
<div class="alert alert-error">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||||
<div><%= h(@error) %></div>
|
<div>{{ error }}</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
{% endif %}
|
||||||
|
|
||||||
<% unless users.any? %>
|
{% if not users_exist %}
|
||||||
<div class="alert alert-warn">
|
<div class="alert alert-warn">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||||
<div>No users configured. Set <code>admin.password_hash</code> in your config or run <code>volumen hash-password</code>.</div>
|
<div>No users configured. Set <code>admin.password_hash</code> in your config or run <code>volumen hash-password</code>.</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" action="<%= to("/admin/login") %>">
|
<form method="post" action="/admin/login">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
<input id="username" class="input" type="text" name="username" autofocus autocomplete="username" required>
|
<input id="username" class="input" type="text" name="username" autofocus autocomplete="username" required>
|
||||||
@@ -27,3 +30,4 @@
|
|||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">Sign in</button>
|
<button type="submit" class="btn btn-primary">Sign in</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% endblock %}
|
||||||
+76
-70
@@ -1,3 +1,6 @@
|
|||||||
|
{% extends "layout.html.jinja" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h1>Settings</h1>
|
<h1>Settings</h1>
|
||||||
@@ -5,18 +8,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if @notice %>
|
{% if notice %}
|
||||||
<div class="alert alert-success">
|
<div class="alert alert-success">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
<div><%= h(@notice) %></div>
|
<div>{{ notice }}</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
{% endif %}
|
||||||
<% if @error %>
|
{% if error %}
|
||||||
<div class="alert alert-error">
|
<div class="alert alert-error">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||||
<div><%= h(@error) %></div>
|
<div>{{ error }}</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
{% endif %}
|
||||||
|
|
||||||
<div class="settings-layout">
|
<div class="settings-layout">
|
||||||
<nav class="settings-nav" aria-label="Settings sections">
|
<nav class="settings-nav" aria-label="Settings sections">
|
||||||
@@ -24,12 +27,12 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
Account
|
Account
|
||||||
</a>
|
</a>
|
||||||
<% if admin? %>
|
{% if current_role == "admin" %}
|
||||||
<a href="#users">
|
<a href="#users">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
Users
|
Users
|
||||||
</a>
|
</a>
|
||||||
<% end %>
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="settings-panels">
|
<div class="settings-panels">
|
||||||
@@ -37,7 +40,7 @@
|
|||||||
<div class="surface-head">
|
<div class="surface-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>Account</h2>
|
<h2>Account</h2>
|
||||||
<p class="lede">Signed in as <strong style="color: var(--fg);"><%= h(current_user) %></strong> · <%= h(current_role) %></p>
|
<p class="lede">Signed in as <strong style="color: var(--fg);">{{ current_user }}</strong> · {{ current_role }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -48,30 +51,31 @@
|
|||||||
<div class="settings-section-body">
|
<div class="settings-section-body">
|
||||||
<h3>Profile photo</h3>
|
<h3>Profile photo</h3>
|
||||||
<p class="settings-section-desc">WebP or AVIF, max 10 MB. Shown in the sidebar.</p>
|
<p class="settings-section-desc">WebP or AVIF, max 10 MB. Shown in the sidebar.</p>
|
||||||
<form method="post" action="<%= to("/admin/settings/photo") %>" enctype="multipart/form-data" class="settings-photo-form">
|
<form method="post" action="/admin/settings/photo" enctype="multipart/form-data" class="settings-photo-form">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
<div class="photo-input-row">
|
<div class="photo-input-row">
|
||||||
<% if current_user_record&.photo %>
|
{% if current_user_record and current_user_record.photo %}
|
||||||
<img src="<%= h(current_user_record.photo) %>" alt="" class="photo-preview">
|
<img src="{{ current_user_record.photo }}" alt="" class="photo-preview">
|
||||||
<% else %>
|
{% else %}
|
||||||
<div class="photo-preview photo-preview-placeholder"><%= h((current_user_record&.name || current_user).to_s[0].upcase) %></div>
|
{% set initial_char = (current_user_record.name if current_user_record else current_user)[0] | upper if (current_user_record and current_user_record.name) or current_user else '?' %}
|
||||||
<% end %>
|
<div class="photo-preview photo-preview-placeholder">{{ initial_char }}</div>
|
||||||
|
{% endif %}
|
||||||
<div class="photo-input-fields">
|
<div class="photo-input-fields">
|
||||||
<input type="file" name="photo" accept="image/webp,image/avif" required class="file-input">
|
<input type="file" name="photo" accept="image/webp,image/avif" required class="file-input">
|
||||||
<div class="photo-buttons">
|
<div class="photo-buttons">
|
||||||
<button type="submit" class="btn btn-primary">Upload</button>
|
<button type="submit" class="btn btn-primary">Upload</button>
|
||||||
<% if current_user_record&.photo %>
|
{% if current_user_record and current_user_record.photo %}
|
||||||
<button type="submit" form="remove-photo-form" class="btn btn-ghost btn-danger">Remove</button>
|
<button type="submit" form="remove-photo-form" class="btn btn-ghost btn-danger">Remove</button>
|
||||||
<% end %>
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<% if current_user_record&.photo %>
|
{% if current_user_record and current_user_record.photo %}
|
||||||
<form id="remove-photo-form" method="post" action="<%= to("/admin/settings/photo/remove") %>" style="display: none;">
|
<form id="remove-photo-form" method="post" action="/admin/settings/photo/remove" style="display: none;">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
</form>
|
</form>
|
||||||
<% end %>
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -82,19 +86,19 @@
|
|||||||
<div class="settings-section-body">
|
<div class="settings-section-body">
|
||||||
<h3>Identity</h3>
|
<h3>Identity</h3>
|
||||||
<p class="settings-section-desc">Display name pre-fills the author field. Fediverse handle pre-fills the creator.</p>
|
<p class="settings-section-desc">Display name pre-fills the author field. Fediverse handle pre-fills the creator.</p>
|
||||||
<form method="post" action="<%= to("/admin/settings/name") %>" class="settings-inline-form">
|
<form method="post" action="/admin/settings/name" class="settings-inline-form">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="name">Display name (real name)</label>
|
<label for="name">Display name (real name)</label>
|
||||||
<input id="name" class="input" type="text" name="name" value="<%= h(current_user_record&.name) %>" placeholder="Your real name">
|
<input id="name" class="input" type="text" name="name" value="{{ current_user_record.name if current_user_record else '' }}" placeholder="Your real name">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn">Save name</button>
|
<button type="submit" class="btn">Save name</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="<%= to("/admin/settings/fediverse") %>" class="settings-inline-form" style="margin-top: 1rem;">
|
<form method="post" action="/admin/settings/fediverse" class="settings-inline-form" style="margin-top: 1rem;">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="fediverse_creator">Fediverse handle</label>
|
<label for="fediverse_creator">Fediverse handle</label>
|
||||||
<input id="fediverse_creator" class="input" type="text" name="fediverse_creator" value="<%= h(current_user_record&.fediverse_creator) %>" placeholder="@user@instance.tld">
|
<input id="fediverse_creator" class="input" type="text" name="fediverse_creator" value="{{ current_user_record.fediverse_creator if current_user_record else '' }}" placeholder="@user@instance.tld">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn">Save handle</button>
|
<button type="submit" class="btn">Save handle</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -108,8 +112,8 @@
|
|||||||
<div class="settings-section-body">
|
<div class="settings-section-body">
|
||||||
<h3>Security</h3>
|
<h3>Security</h3>
|
||||||
<p class="settings-section-desc">Change your password and username. You can also lock yourself out.</p>
|
<p class="settings-section-desc">Change your password and username. You can also lock yourself out.</p>
|
||||||
<form method="post" action="<%= to("/admin/settings/password") %>" class="settings-inline-form">
|
<form method="post" action="/admin/settings/password" class="settings-inline-form">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="current_password">Current password</label>
|
<label for="current_password">Current password</label>
|
||||||
@@ -122,11 +126,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn">Update password</button>
|
<button type="submit" class="btn">Update password</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="<%= to("/admin/settings/username") %>" class="settings-inline-form" style="margin-top: 1rem;">
|
<form method="post" action="/admin/settings/username" class="settings-inline-form" style="margin-top: 1rem;">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
<input id="username" class="input" type="text" name="username" value="<%= h(current_user) %>">
|
<input id="username" class="input" type="text" name="username" value="{{ current_user }}">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn">Update username</button>
|
<button type="submit" class="btn">Update username</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -134,58 +138,59 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<% if admin? %>
|
{% if current_role == "admin" %}
|
||||||
<section id="users" class="surface">
|
<section id="users" class="surface">
|
||||||
<div class="surface-head">
|
<div class="surface-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>Users</h2>
|
<h2>Users</h2>
|
||||||
<p class="lede">Add or remove admin and editor accounts</p>
|
<p class="lede">Add or remove admin and editor accounts</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="users-list">
|
<div class="users-list">
|
||||||
<% @users.each do |user| %>
|
{% for user in users_list %}
|
||||||
<div class="user-row">
|
<div class="user-row">
|
||||||
<% if user.photo %>
|
{% if user.photo %}
|
||||||
<img src="<%= h(user.photo) %>" alt="" class="user-avatar">
|
<img src="{{ user.photo }}" alt="" class="user-avatar">
|
||||||
<% else %>
|
{% else %}
|
||||||
<div class="user-avatar user-avatar-placeholder"><%= h((user.name || user.username).to_s[0].upcase) %></div>
|
{% set user_initial = (user.name or user.username)[0] | upper %}
|
||||||
<% end %>
|
<div class="user-avatar user-avatar-placeholder">{{ user_initial }}</div>
|
||||||
|
{% endif %}
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<div class="user-name">
|
<div class="user-name">
|
||||||
<%= h(user.name || user.username) %>
|
{{ user.name or user.username }}
|
||||||
<% if user.username == current_user %><span class="badge badge-accent" style="margin-left: 0.4rem;">you</span><% end %>
|
{% if user.username == current_user %}<span class="badge badge-accent" style="margin-left: 0.4rem;">you</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="user-username">@<%= h(user.username) %></div>
|
<div class="user-username">@{{ user.username }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-role">
|
<div class="user-role">
|
||||||
<% if user.username == current_user %>
|
{% if user.username == current_user %}
|
||||||
<span class="badge badge-neutral"><%= h(user.role) %></span>
|
<span class="badge badge-neutral">{{ user.role }}</span>
|
||||||
<% else %>
|
{% else %}
|
||||||
<form method="post" action="<%= h(to("/admin/settings/users/#{user.username}/role")) %>" style="margin: 0;">
|
<form method="post" action="/admin/settings/users/{{ user.username }}/role" style="margin: 0;">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
<select class="select select-sm" name="role" onchange="this.form.submit()">
|
<select class="select select-sm" name="role" onchange="this.form.submit()">
|
||||||
<% Volumen::Users::ROLES.each do |role| %>
|
{% for role in roles %}
|
||||||
<option value="<%= role %>" <%= user.role == role ? "selected" : "" %>><%= role %></option>
|
<option value="{{ role }}" {{ 'selected' if user.role == role else '' }}>{{ role }}</option>
|
||||||
<% end %>
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</form>
|
</form>
|
||||||
<% end %>
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<% unless user.username == current_user %>
|
{% if user.username != current_user %}
|
||||||
<form method="post" action="<%= h(to("/admin/settings/users/#{user.username}/delete")) %>" onsubmit="return confirm('Remove this user?');" style="margin: 0;">
|
<form method="post" action="/admin/settings/users/{{ user.username }}/delete" onsubmit="return confirm('Remove this user?');" style="margin: 0;">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
<button type="submit" class="btn btn-sm btn-ghost btn-danger">Remove</button>
|
<button type="submit" class="btn btn-sm btn-ghost btn-danger">Remove</button>
|
||||||
</form>
|
</form>
|
||||||
<% end %>
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="add-user-form">
|
<div class="add-user-form">
|
||||||
<h3 style="margin: 0 0 0.75rem;">Add user</h3>
|
<h3 style="margin: 0 0 0.75rem;">Add user</h3>
|
||||||
<form method="post" action="<%= to("/admin/settings/users") %>">
|
<form method="post" action="/admin/settings/users">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
<div class="field-row-3">
|
<div class="field-row-3">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="new-username">Username</label>
|
<label for="new-username">Username</label>
|
||||||
@@ -198,9 +203,9 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="new-role">Role</label>
|
<label for="new-role">Role</label>
|
||||||
<select id="new-role" class="select" name="role">
|
<select id="new-role" class="select" name="role">
|
||||||
<% Volumen::Users::ROLES.each do |role| %>
|
{% for role in roles %}
|
||||||
<option value="<%= role %>" <%= role == Volumen::Users::DEFAULT_ROLE ? "selected" : "" %>><%= role %></option>
|
<option value="{{ role }}" {{ 'selected' if role == default_role else '' }}>{{ role }}</option>
|
||||||
<% end %>
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,7 +213,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<% end %>
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -242,3 +247,4 @@
|
|||||||
showSection(hasInitial ? initial : sections[0].id);
|
showSection(hasInitial ? initial : sections[0].id);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,339 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "test_helper"
|
|
||||||
require "volumen/server"
|
|
||||||
require "rack/test"
|
|
||||||
require "tmpdir"
|
|
||||||
require "fileutils"
|
|
||||||
|
|
||||||
class AdminTest < Minitest::Test
|
|
||||||
include Rack::Test::Methods
|
|
||||||
|
|
||||||
def app
|
|
||||||
Volumen::Server.configured(config: @config, store: @store)
|
|
||||||
end
|
|
||||||
|
|
||||||
def setup
|
|
||||||
@dir = Dir.mktmpdir
|
|
||||||
@posts_dir = File.join(@dir, "posts")
|
|
||||||
FileUtils.mkdir_p(@posts_dir)
|
|
||||||
@password = "secret"
|
|
||||||
@hash = Volumen::Password.hash(@password)
|
|
||||||
@users_path = File.join(@dir, "users.toml")
|
|
||||||
config_path = File.join(@dir, "config.toml")
|
|
||||||
File.write(config_path, <<~TOML)
|
|
||||||
content_dir = "#{@posts_dir}"
|
|
||||||
users_file = "#{@users_path}"
|
|
||||||
|
|
||||||
[admin]
|
|
||||||
password_hash = "#{@hash}"
|
|
||||||
TOML
|
|
||||||
@config = Volumen::Config.load(path: config_path)
|
|
||||||
@store = Volumen::Store.new(@posts_dir, default_lang: "en")
|
|
||||||
Volumen.reset_login_attempts!
|
|
||||||
end
|
|
||||||
|
|
||||||
def teardown
|
|
||||||
FileUtils.remove_entry(@dir)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_admin_requires_login
|
|
||||||
get "/admin/"
|
|
||||||
assert_equal 302, last_response.status
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_login_page_renders
|
|
||||||
get "/admin/login"
|
|
||||||
assert_predicate last_response, :ok?
|
|
||||||
assert_includes last_response.body, "Sign in"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_session_cookie_is_secure_over_https
|
|
||||||
get "https://example.org/admin/login"
|
|
||||||
post "https://example.org/admin/login",
|
|
||||||
"username" => "admin", "password" => @password, "_csrf" => csrf(last_response.body)
|
|
||||||
cookie = last_response["Set-Cookie"].to_s
|
|
||||||
assert_match(/;\s*secure/i, cookie)
|
|
||||||
assert_match(/;\s*httponly/i, cookie)
|
|
||||||
assert_match(/;\s*samesite=strict/i, cookie)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_session_cookie_plain_http_is_not_secure
|
|
||||||
login
|
|
||||||
refute_match(/;\s*secure/i, last_response["Set-Cookie"].to_s)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_wrong_password_is_rejected
|
|
||||||
get "/admin/login"
|
|
||||||
post "/admin/login",
|
|
||||||
"username" => "admin", "password" => "nope", "_csrf" => csrf(last_response.body)
|
|
||||||
assert_includes last_response.body, "Invalid username or password"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_login_then_list
|
|
||||||
login
|
|
||||||
assert_equal 302, last_response.status
|
|
||||||
follow_redirect!
|
|
||||||
assert_includes last_response.body, "Posts"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_create_post_writes_file
|
|
||||||
login
|
|
||||||
get "/admin/posts/new"
|
|
||||||
post "/admin/posts",
|
|
||||||
"_csrf" => csrf(last_response.body), "title" => "Test", "slug" => "test",
|
|
||||||
"lang" => "en", "tags" => "a, b", "body" => "Hello **world**."
|
|
||||||
assert_equal 302, last_response.status
|
|
||||||
path = File.join(@posts_dir, "test.md")
|
|
||||||
assert File.exist?(path)
|
|
||||||
saved = Volumen::Post.parse(File.read(path))
|
|
||||||
assert_equal "Test", saved.title
|
|
||||||
assert_equal %w[a b], saved.tags
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_create_post_with_all_langs
|
|
||||||
login
|
|
||||||
get "/admin/posts/new"
|
|
||||||
post "/admin/posts",
|
|
||||||
"_csrf" => csrf(last_response.body), "title" => "Global", "slug" => "global",
|
|
||||||
"lang" => "en", "all_langs" => "on", "body" => "Hi"
|
|
||||||
assert_equal 302, last_response.status
|
|
||||||
saved = Volumen::Post.parse(File.read(File.join(@posts_dir, "global.md")))
|
|
||||||
assert_predicate saved, :all_langs?
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_create_post_with_cover
|
|
||||||
login
|
|
||||||
get "/admin/posts/new"
|
|
||||||
post "/admin/posts",
|
|
||||||
"_csrf" => csrf(last_response.body), "title" => "Covered", "slug" => "covered",
|
|
||||||
"lang" => "en", "cover" => "/media/c.png", "body" => "Hi"
|
|
||||||
assert_equal 302, last_response.status
|
|
||||||
saved = Volumen::Post.parse(File.read(File.join(@posts_dir, "covered.md")))
|
|
||||||
assert_equal "/media/c.png", saved.cover
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_preview_requires_login
|
|
||||||
post "/admin/preview", "body" => "**hi**"
|
|
||||||
assert_equal 302, last_response.status
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_csrf_is_enforced
|
|
||||||
login
|
|
||||||
post "/admin/posts", "title" => "X", "slug" => "x", "body" => "y"
|
|
||||||
assert_equal 403, last_response.status
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_upload_rejects_unsupported_media_type
|
|
||||||
login
|
|
||||||
get "/admin/posts/new"
|
|
||||||
token = csrf(last_response.body)
|
|
||||||
txt_path = File.join(@dir, "readme.txt")
|
|
||||||
File.binwrite(txt_path, "hello")
|
|
||||||
post "/admin/uploads",
|
|
||||||
"_csrf" => token,
|
|
||||||
"file" => Rack::Test::UploadedFile.new(txt_path, "text/plain")
|
|
||||||
assert_equal 415, last_response.status
|
|
||||||
assert_equal "unsupported_format", JSON.parse(last_response.body)["error"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_upload_rejects_file_too_large
|
|
||||||
login
|
|
||||||
get "/admin/posts/new"
|
|
||||||
token = csrf(last_response.body)
|
|
||||||
big_path = File.join(@dir, "big.png")
|
|
||||||
File.binwrite(big_path, "X" * (Volumen::Server::MAX_UPLOAD_BYTES + 1))
|
|
||||||
post "/admin/uploads",
|
|
||||||
"_csrf" => token,
|
|
||||||
"file" => Rack::Test::UploadedFile.new(big_path, "image/png")
|
|
||||||
assert_equal 413, last_response.status
|
|
||||||
assert_equal "file_too_large", JSON.parse(last_response.body)["error"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_image_upload_and_serving
|
|
||||||
login
|
|
||||||
get "/admin/posts/new"
|
|
||||||
token = csrf(last_response.body)
|
|
||||||
image_path = File.join(@dir, "pic.webp")
|
|
||||||
File.binwrite(image_path, "WEBDATA")
|
|
||||||
post "/admin/uploads",
|
|
||||||
"_csrf" => token,
|
|
||||||
"file" => Rack::Test::UploadedFile.new(image_path, "image/webp")
|
|
||||||
assert_predicate last_response, :ok?
|
|
||||||
url = JSON.parse(last_response.body)["url"]
|
|
||||||
assert_match(%r{\A/media/}, url)
|
|
||||||
|
|
||||||
get url
|
|
||||||
assert_predicate last_response, :ok?
|
|
||||||
assert_equal "WEBDATA", last_response.body
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_settings_requires_login
|
|
||||||
get "/admin/settings"
|
|
||||||
assert_equal 302, last_response.status
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_change_password
|
|
||||||
login
|
|
||||||
get "/admin/settings"
|
|
||||||
post "/admin/settings/password",
|
|
||||||
"_csrf" => csrf(last_response.body),
|
|
||||||
"current_password" => @password, "new_password" => "brand-new"
|
|
||||||
assert_includes last_response.body, "Password updated"
|
|
||||||
fresh = Volumen::Users.new(path: @users_path)
|
|
||||||
refute fresh.authenticate("admin", @password)
|
|
||||||
assert fresh.authenticate("admin", "brand-new")
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_add_and_login_as_new_user
|
|
||||||
login
|
|
||||||
get "/admin/settings"
|
|
||||||
post "/admin/settings/users",
|
|
||||||
"_csrf" => csrf(last_response.body), "username" => "editor", "password" => "pw12345"
|
|
||||||
assert_includes last_response.body, "User added"
|
|
||||||
|
|
||||||
post "/admin/logout", "_csrf" => csrf(last_response.body)
|
|
||||||
get "/admin/login"
|
|
||||||
post "/admin/login",
|
|
||||||
"username" => "editor", "password" => "pw12345", "_csrf" => csrf(last_response.body)
|
|
||||||
assert_equal 302, last_response.status
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_cannot_delete_self
|
|
||||||
login
|
|
||||||
get "/admin/settings"
|
|
||||||
post "/admin/settings/users/admin/delete", "_csrf" => csrf(last_response.body)
|
|
||||||
assert_includes last_response.body, "cannot delete your own account"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_admin_can_assign_role
|
|
||||||
login
|
|
||||||
add_user("writer", "pw12345", "author")
|
|
||||||
assert_includes last_response.body, "User added"
|
|
||||||
assert_equal "author", Volumen::Users.new(path: @users_path).find("writer").role
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_author_cannot_manage_users
|
|
||||||
login
|
|
||||||
add_user("writer", "pw12345", "author")
|
|
||||||
logout
|
|
||||||
login_as("writer", "pw12345")
|
|
||||||
follow_redirect!
|
|
||||||
post "/admin/settings/users",
|
|
||||||
"_csrf" => csrf(last_response.body), "username" => "intruder", "password" => "pw12345"
|
|
||||||
assert_equal 403, last_response.status
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_author_settings_omits_user_management
|
|
||||||
login
|
|
||||||
add_user("writer", "pw12345", "author")
|
|
||||||
logout
|
|
||||||
login_as("writer", "pw12345")
|
|
||||||
follow_redirect!
|
|
||||||
get "/admin/settings"
|
|
||||||
refute_includes last_response.body, "Add user"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_author_can_create_post
|
|
||||||
login
|
|
||||||
add_user("writer", "pw12345", "author")
|
|
||||||
logout
|
|
||||||
login_as("writer", "pw12345")
|
|
||||||
get "/admin/posts/new"
|
|
||||||
post "/admin/posts",
|
|
||||||
"_csrf" => csrf(last_response.body), "title" => "By author", "slug" => "by-author",
|
|
||||||
"lang" => "en", "body" => "Hi"
|
|
||||||
assert_equal 302, last_response.status
|
|
||||||
assert File.exist?(File.join(@posts_dir, "by-author.md"))
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_login_rate_limit_sweep_removes_stale_entries
|
|
||||||
Volumen.login_attempts["1.2.3.4"] = [Time.now.to_f - 120]
|
|
||||||
Volumen.instance_variable_set(:@last_cleanup, Time.now.to_f - 600)
|
|
||||||
Volumen.sweep_login_attempts!
|
|
||||||
refute Volumen.login_attempts.key?("1.2.3.4"),
|
|
||||||
"sweep should remove IPs with only stale entries"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_create_post_rejects_invalid_slug
|
|
||||||
login
|
|
||||||
get "/admin/posts/new"
|
|
||||||
post "/admin/posts",
|
|
||||||
"_csrf" => csrf(last_response.body), "title" => "X", "slug" => "../../etc/passwd",
|
|
||||||
"lang" => "en", "body" => "y"
|
|
||||||
assert_includes last_response.body, "Invalid slug"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_create_post_rejects_xss_in_slug
|
|
||||||
login
|
|
||||||
get "/admin/posts/new"
|
|
||||||
post "/admin/posts",
|
|
||||||
"_csrf" => csrf(last_response.body), "title" => "X", "slug" => "foo\" onclick",
|
|
||||||
"lang" => "en", "body" => "y"
|
|
||||||
assert_includes last_response.body, "Invalid slug"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_change_username_rejects_special_chars
|
|
||||||
login
|
|
||||||
get "/admin/settings"
|
|
||||||
post "/admin/settings/username",
|
|
||||||
"_csrf" => csrf(last_response.body), "username" => "foo\" bar"
|
|
||||||
assert_includes last_response.body, "Username may use letters"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_change_username_accepts_same_name
|
|
||||||
login
|
|
||||||
get "/admin/settings"
|
|
||||||
post "/admin/settings/username",
|
|
||||||
"_csrf" => csrf(last_response.body), "username" => "admin"
|
|
||||||
assert_includes last_response.body, "Username unchanged"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_preview_requires_csrf
|
|
||||||
login
|
|
||||||
get "/admin/posts/new"
|
|
||||||
post "/admin/preview", "body" => "**hi**"
|
|
||||||
assert_equal 403, last_response.status
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_orphaned_session_is_rejected
|
|
||||||
login
|
|
||||||
session_for = -> { rack_mock_session.cookie_jar["rack.session"].to_s }
|
|
||||||
refute_empty session_for.call, "expected a session cookie after login"
|
|
||||||
|
|
||||||
# Simulate a deleted user by clearing users.toml while keeping the cookie.
|
|
||||||
File.write(@users_path, "")
|
|
||||||
get "/admin/"
|
|
||||||
assert_equal 302, last_response.status
|
|
||||||
assert_equal "http://example.org/admin/login", last_response.location
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def csrf(body)
|
|
||||||
body[/name="_csrf" value="([^"]+)"/, 1]
|
|
||||||
end
|
|
||||||
|
|
||||||
def login
|
|
||||||
get "/admin/login"
|
|
||||||
post "/admin/login",
|
|
||||||
"username" => "admin", "password" => @password, "_csrf" => csrf(last_response.body)
|
|
||||||
end
|
|
||||||
|
|
||||||
def login_as(username, password)
|
|
||||||
get "/admin/login"
|
|
||||||
post "/admin/login",
|
|
||||||
"username" => username, "password" => password, "_csrf" => csrf(last_response.body)
|
|
||||||
end
|
|
||||||
|
|
||||||
def logout
|
|
||||||
post "/admin/logout", "_csrf" => csrf(last_response.body)
|
|
||||||
end
|
|
||||||
|
|
||||||
def add_user(username, password, role)
|
|
||||||
get "/admin/settings"
|
|
||||||
post "/admin/settings/users",
|
|
||||||
"_csrf" => csrf(last_response.body),
|
|
||||||
"username" => username, "password" => password, "role" => role
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "test_helper"
|
|
||||||
require "tmpdir"
|
|
||||||
require "volumen/cli"
|
|
||||||
|
|
||||||
class CLITest < Minitest::Test
|
|
||||||
def test_version
|
|
||||||
assert_output("#{Volumen::VERSION}\n") do
|
|
||||||
Volumen::CLI.run(["version"])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_help
|
|
||||||
output, = capture_io { Volumen::CLI.run(["help"]) }
|
|
||||||
assert_includes output, "volumen #{Volumen::VERSION}"
|
|
||||||
assert_includes output, "Usage:"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_unknown_command
|
|
||||||
_output, err = capture_io do
|
|
||||||
assert_raises(SystemExit) { Volumen::CLI.run(["bogus"]) }
|
|
||||||
end
|
|
||||||
assert_includes err, "unknown command"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_parse_serve_defaults
|
|
||||||
cli = Volumen::CLI.new(["serve"])
|
|
||||||
options = cli.__send__(:parse_serve_options)
|
|
||||||
assert_equal Volumen::Config::DEFAULT_PATH, options[:config]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_parse_serve_overrides
|
|
||||||
cli = Volumen::CLI.new([
|
|
||||||
"serve", "--config", "/t.toml", "--host", "127.0.0.1", "--port", "9000"
|
|
||||||
])
|
|
||||||
options = cli.__send__(:parse_serve_options)
|
|
||||||
assert_equal "/t.toml", options[:config]
|
|
||||||
assert_equal "127.0.0.1", options[:host]
|
|
||||||
assert_equal 9000, options[:port]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_prepare_config_writes_template
|
|
||||||
Dir.mktmpdir do |dir|
|
|
||||||
path = File.join(dir, "config.toml")
|
|
||||||
cli = Volumen::CLI.new([])
|
|
||||||
_output, err = capture_io { cli.__send__(:prepare_config, path) }
|
|
||||||
assert_includes err, "wrote a default config"
|
|
||||||
assert_path_exists path
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_prepare_config_noops_when_present
|
|
||||||
Dir.mktmpdir do |dir|
|
|
||||||
path = File.join(dir, "config.toml")
|
|
||||||
File.write(path, "[server]\nport = 1\n")
|
|
||||||
cli = Volumen::CLI.new([])
|
|
||||||
out, err = capture_io { cli.__send__(:prepare_config, path) }
|
|
||||||
assert_empty err
|
|
||||||
assert_empty out
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "test_helper"
|
|
||||||
require "tmpdir"
|
|
||||||
|
|
||||||
class ConfigTest < Minitest::Test
|
|
||||||
MISSING = "/nonexistent/volumen.toml"
|
|
||||||
|
|
||||||
def test_defaults_when_file_missing
|
|
||||||
config = Volumen::Config.load(path: MISSING)
|
|
||||||
assert_equal 9090, config.port
|
|
||||||
assert_equal "en", config.language
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_overrides_apply
|
|
||||||
config = Volumen::Config.load(
|
|
||||||
path: MISSING,
|
|
||||||
overrides: { host: "127.0.0.1", port: 9000, content: "/srv/posts" }
|
|
||||||
)
|
|
||||||
assert_equal "127.0.0.1", config.host
|
|
||||||
assert_equal 9000, config.port
|
|
||||||
assert_equal "/srv/posts", config.content_dir
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_does_not_mutate_defaults
|
|
||||||
Volumen::Config.load(path: MISSING, overrides: { host: "10.0.0.1" })
|
|
||||||
assert_equal "0.0.0.0", Volumen::Config.load(path: MISSING).host
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_does_not_mutate_default_inner_hashes
|
|
||||||
config = Volumen::Config.load(path: MISSING)
|
|
||||||
config.site["title"] = "MUTATED"
|
|
||||||
fresh = Volumen::Config.load(path: MISSING)
|
|
||||||
assert_equal "My Blog", fresh.site["title"],
|
|
||||||
"mutating a loaded config must not poison DEFAULTS for later loads"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_loads_and_merges_file
|
|
||||||
Dir.mktmpdir do |dir|
|
|
||||||
path = File.join(dir, "config.toml")
|
|
||||||
File.write(path, "[site]\ntitle = \"Custom\"\n")
|
|
||||||
config = Volumen::Config.load(path: path)
|
|
||||||
assert_equal "Custom", config.site["title"]
|
|
||||||
assert_equal 9090, config.port
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_generate_default_writes_template
|
|
||||||
Dir.mktmpdir do |dir|
|
|
||||||
path = File.join(dir, "sub", "config.toml")
|
|
||||||
assert_equal path, Volumen::Config.generate_default(path)
|
|
||||||
assert_path_exists path
|
|
||||||
assert_equal 9090, Volumen::Config.load(path: path).port
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_generate_default_skips_existing
|
|
||||||
Dir.mktmpdir do |dir|
|
|
||||||
path = File.join(dir, "config.toml")
|
|
||||||
File.write(path, "[server]\nport = 9999\n")
|
|
||||||
assert_nil Volumen::Config.generate_default(path)
|
|
||||||
assert_equal 9999, Volumen::Config.load(path: path).port
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "test_helper"
|
|
||||||
|
|
||||||
class FrontmatterTest < Minitest::Test
|
|
||||||
def test_parse_extracts_metadata_and_body
|
|
||||||
content = <<~POST
|
|
||||||
+++
|
|
||||||
title = "Hello"
|
|
||||||
tags = ["a", "b"]
|
|
||||||
+++
|
|
||||||
|
|
||||||
Body text.
|
|
||||||
POST
|
|
||||||
metadata, body = Volumen::Frontmatter.parse(content)
|
|
||||||
assert_equal "Hello", metadata["title"]
|
|
||||||
assert_equal %w[a b], metadata["tags"]
|
|
||||||
assert_equal "Body text.\n", body
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_parse_without_frontmatter
|
|
||||||
metadata, body = Volumen::Frontmatter.parse("# Just markdown\n")
|
|
||||||
assert_empty metadata
|
|
||||||
assert_equal "# Just markdown\n", body
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_parse_without_closing_delimiter
|
|
||||||
content = "+++\ntitle = \"x\"\n\nbody"
|
|
||||||
metadata, body = Volumen::Frontmatter.parse(content)
|
|
||||||
assert_empty metadata
|
|
||||||
assert_equal content, body
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_round_trip
|
|
||||||
metadata = { "title" => "Hello", "tags" => ["intro"], "draft" => false }
|
|
||||||
dumped = Volumen::Frontmatter.dump(metadata, "Hello **world**.")
|
|
||||||
parsed_meta, parsed_body = Volumen::Frontmatter.parse(dumped)
|
|
||||||
assert_equal metadata, parsed_meta
|
|
||||||
assert_equal "Hello **world**.", parsed_body.strip
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_parse_normalises_crlf
|
|
||||||
metadata, body = Volumen::Frontmatter.parse("+++\r\ntitle = \"x\"\r\n+++\r\n\r\nBody\r\n")
|
|
||||||
assert_equal "x", metadata["title"]
|
|
||||||
assert_equal "Body\n", body
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "test_helper"
|
|
||||||
|
|
||||||
class MarkdownTest < Minitest::Test
|
|
||||||
def test_renders_bold
|
|
||||||
assert_includes Volumen::Markdown.render("**bold**"), "<strong>bold</strong>"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_renders_heading
|
|
||||||
assert_includes Volumen::Markdown.render("# Title"), "<h1"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_renders_gfm_table
|
|
||||||
table = <<~MD
|
|
||||||
| A | B |
|
|
||||||
|---|---|
|
|
||||||
| 1 | 2 |
|
|
||||||
MD
|
|
||||||
assert_includes Volumen::Markdown.render(table), "<table"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_renders_fenced_code_block
|
|
||||||
code = <<~MD
|
|
||||||
```ruby
|
|
||||||
puts 1
|
|
||||||
```
|
|
||||||
MD
|
|
||||||
html = Volumen::Markdown.render(code)
|
|
||||||
assert_includes html, "<pre"
|
|
||||||
assert_includes html, "<code"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "test_helper"
|
|
||||||
|
|
||||||
class PasswordTest < Minitest::Test
|
|
||||||
def test_hash_and_verify_round_trip
|
|
||||||
encoded = Volumen::Password.hash("s3cret")
|
|
||||||
assert Volumen::Password.verify("s3cret", encoded)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_verify_rejects_wrong_password
|
|
||||||
encoded = Volumen::Password.hash("s3cret")
|
|
||||||
refute Volumen::Password.verify("nope", encoded)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_verify_rejects_malformed_input
|
|
||||||
refute Volumen::Password.verify("x", "not-a-hash")
|
|
||||||
refute Volumen::Password.verify("x", "")
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_encoded_format
|
|
||||||
assert Volumen::Password.hash("pw").start_with?("scrypt$16384$8$1$")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "test_helper"
|
|
||||||
|
|
||||||
class PostTest < Minitest::Test
|
|
||||||
def test_parse_builds_post
|
|
||||||
content = <<~POST
|
|
||||||
+++
|
|
||||||
title = "Hello"
|
|
||||||
slug = "hello"
|
|
||||||
lang = "en"
|
|
||||||
tags = ["intro", "ruby"]
|
|
||||||
date = 2026-01-15
|
|
||||||
draft = true
|
|
||||||
+++
|
|
||||||
|
|
||||||
First paragraph.
|
|
||||||
POST
|
|
||||||
post = Volumen::Post.parse(content)
|
|
||||||
assert_equal "hello", post.slug
|
|
||||||
assert_equal "Hello", post.title
|
|
||||||
assert_equal "en", post.lang
|
|
||||||
assert_equal %w[intro ruby], post.tags
|
|
||||||
assert_predicate post, :draft?
|
|
||||||
assert_equal "2026-01-15", post.date_string
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_excerpt_derived_from_body
|
|
||||||
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "# Heading\n\nThe body paragraph.")
|
|
||||||
assert_equal "The body paragraph.", post.excerpt
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_derived_excerpt_strips_html_tags
|
|
||||||
body = "<strong>Bold</strong> and <em>italic</em>."
|
|
||||||
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: body)
|
|
||||||
assert_equal "Bold and italic.", post.excerpt
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_summary_shape
|
|
||||||
post = Volumen::Post.new(metadata: { "slug" => "x", "title" => "X" }, body: "Body")
|
|
||||||
summary = post.summary
|
|
||||||
assert_equal "/api/volumen/posts/x", summary["url"]
|
|
||||||
assert_equal "X", summary["title"]
|
|
||||||
refute_predicate post, :draft?
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_detail_includes_rendered_html
|
|
||||||
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "**hi**")
|
|
||||||
assert_includes post.detail["html"], "<strong>hi</strong>"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_summary_includes_cover_and_reading_time
|
|
||||||
post = Volumen::Post.new(
|
|
||||||
metadata: { "slug" => "x", "cover" => "/media/c.png" },
|
|
||||||
body: ("word " * 250)
|
|
||||||
)
|
|
||||||
assert_equal "/media/c.png", post.summary["cover"]
|
|
||||||
assert_equal 2, post.summary["reading_time"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_reading_time_is_at_least_one
|
|
||||||
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "short")
|
|
||||||
assert_equal 1, post.reading_time
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_scheduled_post_is_not_scheduled_when_publish_at_is_past
|
|
||||||
post = Volumen::Post.new(metadata: { "publish_at" => Date.today - 1 })
|
|
||||||
refute_predicate post, :scheduled?
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_scheduled_post_is_scheduled_when_publish_at_is_future
|
|
||||||
post = Volumen::Post.new(metadata: { "publish_at" => Date.today + 7 })
|
|
||||||
assert_predicate post, :scheduled?
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_post_without_publish_at_is_not_scheduled
|
|
||||||
post = Volumen::Post.new(metadata: {})
|
|
||||||
refute_predicate post, :scheduled?
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_fediverse_creator_returns_metadata_value
|
|
||||||
post = Volumen::Post.new(
|
|
||||||
metadata: { "slug" => "x", "fediverse_creator" => "@user@example.social" }
|
|
||||||
)
|
|
||||||
assert_equal "@user@example.social", post.fediverse_creator
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_fediverse_creator_is_nil_when_missing
|
|
||||||
post = Volumen::Post.new(metadata: { "slug" => "x" })
|
|
||||||
assert_nil post.fediverse_creator
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_fediverse_creator_is_nil_when_blank
|
|
||||||
post = Volumen::Post.new(metadata: { "slug" => "x", "fediverse_creator" => " " })
|
|
||||||
assert_nil post.fediverse_creator
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_fediverse_creator_validation_accepts_well_formed_handles
|
|
||||||
post = Volumen::Post.new(
|
|
||||||
metadata: { "slug" => "x", "fediverse_creator" => "@ada@lovelace.org" }
|
|
||||||
)
|
|
||||||
assert_predicate post, :valid_fediverse_creator?
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_fediverse_creator_validation_rejects_malformed_handles
|
|
||||||
[
|
|
||||||
"ada@example.com",
|
|
||||||
"@only-one-at",
|
|
||||||
"@user@example.com extra",
|
|
||||||
"@user@",
|
|
||||||
"@@example.com"
|
|
||||||
].each do |value|
|
|
||||||
post = Volumen::Post.new(metadata: { "slug" => "x", "fediverse_creator" => value })
|
|
||||||
refute_predicate post, :valid_fediverse_creator?, "expected #{value.inspect} to be invalid"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_fediverse_creator_validation_accepts_padded_value
|
|
||||||
post = Volumen::Post.new(
|
|
||||||
metadata: { "slug" => "x", "fediverse_creator" => " @user@example.com " }
|
|
||||||
)
|
|
||||||
assert_predicate post, :valid_fediverse_creator?
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_summary_includes_fediverse_creator
|
|
||||||
post = Volumen::Post.new(
|
|
||||||
metadata: { "slug" => "x", "fediverse_creator" => "@user@example.social" },
|
|
||||||
body: "body"
|
|
||||||
)
|
|
||||||
assert_equal "@user@example.social", post.summary["fediverse_creator"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_summary_omits_fediverse_creator_when_absent
|
|
||||||
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "body")
|
|
||||||
assert_nil post.summary["fediverse_creator"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "test_helper"
|
|
||||||
require "volumen/server"
|
|
||||||
require "rack/test"
|
|
||||||
require "tmpdir"
|
|
||||||
require "fileutils"
|
|
||||||
require "json"
|
|
||||||
|
|
||||||
class ServerTest < Minitest::Test
|
|
||||||
include Rack::Test::Methods
|
|
||||||
|
|
||||||
def app
|
|
||||||
Volumen::Server.configured(config: @config, store: @store)
|
|
||||||
end
|
|
||||||
|
|
||||||
def setup
|
|
||||||
@dir = Dir.mktmpdir
|
|
||||||
File.write(File.join(@dir, "hello.md"), <<~POST)
|
|
||||||
+++
|
|
||||||
title = "Hello"
|
|
||||||
tags = ["intro"]
|
|
||||||
+++
|
|
||||||
|
|
||||||
Hello **world**.
|
|
||||||
POST
|
|
||||||
File.write(File.join(@dir, "draft.md"), <<~POST)
|
|
||||||
+++
|
|
||||||
title = "Draft"
|
|
||||||
draft = true
|
|
||||||
+++
|
|
||||||
|
|
||||||
Secret.
|
|
||||||
POST
|
|
||||||
@config = Volumen::Config.load(path: "/nonexistent", overrides: { content: @dir })
|
|
||||||
@store = Volumen::Store.new(@dir, default_lang: "en")
|
|
||||||
end
|
|
||||||
|
|
||||||
def teardown
|
|
||||||
FileUtils.remove_entry(@dir)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_site_endpoint
|
|
||||||
get "/api/volumen/site"
|
|
||||||
assert_predicate last_response, :ok?
|
|
||||||
assert_equal "My Blog", JSON.parse(last_response.body)["title"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_posts_excludes_drafts
|
|
||||||
get "/api/volumen/posts"
|
|
||||||
data = JSON.parse(last_response.body)
|
|
||||||
assert_equal 1, data["total"]
|
|
||||||
assert_equal "hello", data["posts"].first["slug"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_post_detail_renders_html
|
|
||||||
get "/api/volumen/posts/hello"
|
|
||||||
assert_predicate last_response, :ok?
|
|
||||||
assert_includes JSON.parse(last_response.body)["html"], "<strong>world</strong>"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_post_not_found
|
|
||||||
get "/api/volumen/posts/missing"
|
|
||||||
assert_equal 404, last_response.status
|
|
||||||
assert_equal "not_found", JSON.parse(last_response.body)["error"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_tags_endpoint
|
|
||||||
get "/api/volumen/tags"
|
|
||||||
assert_equal [{ "name" => "intro", "count" => 1 }], JSON.parse(last_response.body)["tags"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_cors_header_present
|
|
||||||
get "/api/volumen/site"
|
|
||||||
header = last_response.headers["Access-Control-Allow-Origin"] ||
|
|
||||||
last_response.headers["access-control-allow-origin"]
|
|
||||||
assert_equal "*", header
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_all_langs_post_appears_in_other_languages
|
|
||||||
File.write(File.join(@dir, "global.md"), <<~POST)
|
|
||||||
+++
|
|
||||||
title = "Global"
|
|
||||||
lang = "en"
|
|
||||||
all_langs = true
|
|
||||||
+++
|
|
||||||
|
|
||||||
Everywhere.
|
|
||||||
POST
|
|
||||||
get "/api/volumen/posts?lang=cs"
|
|
||||||
slugs = JSON.parse(last_response.body)["posts"].map { |post| post["slug"] }
|
|
||||||
assert_includes slugs, "global"
|
|
||||||
refute_includes slugs, "hello"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_feed_endpoint
|
|
||||||
get "/api/volumen/feed.xml"
|
|
||||||
assert_predicate last_response, :ok?
|
|
||||||
assert_equal "application/rss+xml", last_response["Content-Type"]&.split(";")&.first
|
|
||||||
body = last_response.body
|
|
||||||
assert_includes body, "<rss version=\"2.0\""
|
|
||||||
assert_includes body, "<title>Hello</title>"
|
|
||||||
refute_includes body, "Draft"
|
|
||||||
assert_includes body, "<language>en</language>"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_draft_post_detail_returns_draft_error
|
|
||||||
get "/api/volumen/posts/draft"
|
|
||||||
assert_equal 404, last_response.status
|
|
||||||
assert_equal "draft", JSON.parse(last_response.body)["error"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_sitemap_endpoint
|
|
||||||
get "/api/volumen/sitemap.xml"
|
|
||||||
assert_predicate last_response, :ok?
|
|
||||||
assert_equal "application/xml", last_response["Content-Type"]&.split(";")&.first
|
|
||||||
body = last_response.body
|
|
||||||
assert_includes body, "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">"
|
|
||||||
assert_includes body, "<loc>https://example.com/hello</loc>"
|
|
||||||
refute_includes body, "draft"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_cors_preflight
|
|
||||||
options "/api/volumen/site"
|
|
||||||
assert_predicate last_response, :ok?
|
|
||||||
assert_equal "*", last_response.headers["Access-Control-Allow-Origin"]
|
|
||||||
assert_equal "GET, OPTIONS", last_response.headers["Access-Control-Allow-Methods"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_scheduled_post_is_hidden
|
|
||||||
future = (Date.today + 30).strftime("%Y-%m-%d")
|
|
||||||
File.write(File.join(@dir, "later.md"), <<~POST)
|
|
||||||
+++
|
|
||||||
title = "Later"
|
|
||||||
publish_at = #{future}
|
|
||||||
+++
|
|
||||||
|
|
||||||
Not yet.
|
|
||||||
POST
|
|
||||||
get "/api/volumen/posts"
|
|
||||||
slugs = JSON.parse(last_response.body)["posts"].map { |post| post["slug"] }
|
|
||||||
refute_includes slugs, "later"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_json_feed_endpoint
|
|
||||||
get "/api/volumen/feed.json"
|
|
||||||
assert_predicate last_response, :ok?
|
|
||||||
data = JSON.parse(last_response.body)
|
|
||||||
assert_equal "https://jsonfeed.org/version/1.1", data["version"]
|
|
||||||
assert_equal "My Blog", data["title"]
|
|
||||||
assert_equal 1, data["items"].length
|
|
||||||
assert_equal "Hello", data["items"].first["title"]
|
|
||||||
assert_includes data["items"].first["content_html"], "<strong>world</strong>"
|
|
||||||
refute_includes data["items"].map { |i| i["title"] }, "Draft"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_post_summary_exposes_fediverse_creator
|
|
||||||
File.write(File.join(@dir, "attributed.md"), <<~POST)
|
|
||||||
+++
|
|
||||||
title = "Attributed"
|
|
||||||
slug = "attributed"
|
|
||||||
fediverse_creator = "@ada@lovelace.org"
|
|
||||||
+++
|
|
||||||
|
|
||||||
Body.
|
|
||||||
POST
|
|
||||||
get "/api/volumen/posts/attributed"
|
|
||||||
assert_predicate last_response, :ok?
|
|
||||||
data = JSON.parse(last_response.body)
|
|
||||||
assert_equal "@ada@lovelace.org", data["fediverse_creator"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_post_summary_fediverse_creator_is_null_when_not_set
|
|
||||||
get "/api/volumen/posts/hello"
|
|
||||||
assert_predicate last_response, :ok?
|
|
||||||
assert_nil JSON.parse(last_response.body)["fediverse_creator"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_posts_list_summary_exposes_fediverse_creator
|
|
||||||
File.write(File.join(@dir, "fed.md"), <<~POST)
|
|
||||||
+++
|
|
||||||
title = "Fed"
|
|
||||||
slug = "fed"
|
|
||||||
fediverse_creator = "@user@example.social"
|
|
||||||
+++
|
|
||||||
|
|
||||||
Body.
|
|
||||||
POST
|
|
||||||
get "/api/volumen/posts"
|
|
||||||
data = JSON.parse(last_response.body)
|
|
||||||
fed = data["posts"].find { |post| post["slug"] == "fed" }
|
|
||||||
assert_equal "@user@example.social", fed["fediverse_creator"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_site_endpoint_exposes_fediverse_creator_default
|
|
||||||
get "/api/volumen/site"
|
|
||||||
assert_predicate last_response, :ok?
|
|
||||||
assert JSON.parse(last_response.body).key?("fediverse_creator")
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_rss_feed_includes_dc_creator_when_set
|
|
||||||
File.write(File.join(@dir, "rss.md"), <<~POST)
|
|
||||||
+++
|
|
||||||
title = "Rss"
|
|
||||||
slug = "rss"
|
|
||||||
fediverse_creator = "@rss@example.com"
|
|
||||||
+++
|
|
||||||
|
|
||||||
Body.
|
|
||||||
POST
|
|
||||||
get "/api/volumen/feed.xml"
|
|
||||||
assert_includes last_response.body, "<dc:creator>@rss@example.com</dc:creator>"
|
|
||||||
assert_includes last_response.body, "xmlns:dc=\"http://purl.org/dc/elements/1.1/\""
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_rss_feed_omits_dc_creator_when_not_set
|
|
||||||
get "/api/volumen/feed.xml"
|
|
||||||
refute_includes last_response.body, "<dc:creator>"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_json_feed_includes_authors_from_post
|
|
||||||
File.write(File.join(@dir, "jsonfed.md"), <<~POST)
|
|
||||||
+++
|
|
||||||
title = "Jsonfed"
|
|
||||||
slug = "jsonfed"
|
|
||||||
fediverse_creator = "@jf@example.io"
|
|
||||||
+++
|
|
||||||
|
|
||||||
Body.
|
|
||||||
POST
|
|
||||||
get "/api/volumen/feed.json"
|
|
||||||
item = JSON.parse(last_response.body)["items"].find { |i| i["id"] == "https://example.com/jsonfed" }
|
|
||||||
assert_equal [{ "name" => "@jf@example.io" }], item["authors"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_json_feed_falls_back_to_site_default_fediverse_creator
|
|
||||||
Dir.chdir(@dir) do
|
|
||||||
File.write("config.toml", <<~TOML)
|
|
||||||
[site]
|
|
||||||
fediverse_creator = "@owner@example.com"
|
|
||||||
TOML
|
|
||||||
end
|
|
||||||
@config = Volumen::Config.load(
|
|
||||||
path: File.join(@dir, "config.toml"),
|
|
||||||
overrides: { content: @dir }
|
|
||||||
)
|
|
||||||
get "/api/volumen/feed.json"
|
|
||||||
items = JSON.parse(last_response.body)["items"]
|
|
||||||
expected = [{ "name" => "@owner@example.com" }]
|
|
||||||
assert(items.all? { |i| i["authors"] == expected })
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_search_by_title
|
|
||||||
get "/api/volumen/posts?q=Hello"
|
|
||||||
data = JSON.parse(last_response.body)
|
|
||||||
assert_equal 1, data["total"]
|
|
||||||
assert_equal "hello", data["posts"].first["slug"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_search_by_body
|
|
||||||
get "/api/volumen/posts?q=world"
|
|
||||||
data = JSON.parse(last_response.body)
|
|
||||||
assert_equal 1, data["total"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_search_no_match
|
|
||||||
get "/api/volumen/posts?q=nonexistent"
|
|
||||||
data = JSON.parse(last_response.body)
|
|
||||||
assert_equal 0, data["total"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_search_is_case_insensitive
|
|
||||||
get "/api/volumen/posts?q=HELLO"
|
|
||||||
data = JSON.parse(last_response.body)
|
|
||||||
assert_equal 1, data["total"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_pagination_has_next_and_prev
|
|
||||||
# Create enough posts to paginate
|
|
||||||
5.times do |i|
|
|
||||||
File.write(File.join(@dir, "post-#{i}.md"), <<~POST)
|
|
||||||
+++
|
|
||||||
title = "Post #{i}"
|
|
||||||
+++
|
|
||||||
|
|
||||||
Body #{i}.
|
|
||||||
POST
|
|
||||||
end
|
|
||||||
get "/api/volumen/posts?page=1&limit=2"
|
|
||||||
data = JSON.parse(last_response.body)
|
|
||||||
assert_equal true, data["has_next"]
|
|
||||||
assert_equal false, data["has_prev"]
|
|
||||||
|
|
||||||
get "/api/volumen/posts?page=2&limit=2"
|
|
||||||
data = JSON.parse(last_response.body)
|
|
||||||
assert_equal true, data["has_next"]
|
|
||||||
assert_equal true, data["has_prev"]
|
|
||||||
|
|
||||||
get "/api/volumen/posts?page=3&limit=2"
|
|
||||||
data = JSON.parse(last_response.body)
|
|
||||||
assert_equal false, data["has_next"]
|
|
||||||
assert_equal true, data["has_prev"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "test_helper"
|
|
||||||
require "tmpdir"
|
|
||||||
require "fileutils"
|
|
||||||
|
|
||||||
class StoreTest < Minitest::Test
|
|
||||||
def setup
|
|
||||||
@dir = Dir.mktmpdir
|
|
||||||
File.write(File.join(@dir, "hello.md"), "+++\ntitle = \"Hello\"\n+++\n\nBody\n")
|
|
||||||
FileUtils.mkdir_p(File.join(@dir, "cs"))
|
|
||||||
File.write(File.join(@dir, "cs", "ahoj.md"), "+++\ntitle = \"Ahoj\"\n+++\n\nTelo\n")
|
|
||||||
end
|
|
||||||
|
|
||||||
def teardown
|
|
||||||
FileUtils.remove_entry(@dir)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_all_loads_every_post
|
|
||||||
assert_equal 2, Volumen::Store.new(@dir).all.length
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_slug_falls_back_to_filename
|
|
||||||
slugs = Volumen::Store.new(@dir).all.map(&:slug).sort
|
|
||||||
assert_equal %w[ahoj hello], slugs
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_lang_derived_from_subdirectory
|
|
||||||
store = Volumen::Store.new(@dir, default_lang: "en")
|
|
||||||
assert_equal "cs", store.find("ahoj").lang
|
|
||||||
assert_equal "en", store.find("hello").lang
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_find_returns_nil_when_missing
|
|
||||||
assert_nil Volumen::Store.new(@dir).find("nope")
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_all_langs_post_is_visible_in_any_language
|
|
||||||
File.write(File.join(@dir, "global.md"), <<~POST)
|
|
||||||
+++
|
|
||||||
title = "Global"
|
|
||||||
lang = "en"
|
|
||||||
all_langs = true
|
|
||||||
+++
|
|
||||||
|
|
||||||
Visible everywhere.
|
|
||||||
POST
|
|
||||||
store = Volumen::Store.new(@dir, default_lang: "en")
|
|
||||||
assert store.find("global", lang: "cs")
|
|
||||||
assert store.find("global", lang: "en")
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_save_rejects_path_traversal_slug
|
|
||||||
store = Volumen::Store.new(@dir)
|
|
||||||
post = Volumen::Post.new(metadata: { "slug" => "../../etc/passwd" }, body: "x")
|
|
||||||
assert_raises(ArgumentError, "slug escapes content directory") { store.save(post) }
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_save_uses_lang_subdirectory_for_non_default_language
|
|
||||||
store = Volumen::Store.new(@dir, default_lang: "en")
|
|
||||||
post = Volumen::Post.new(metadata: { "slug" => "bonjour", "lang" => "fr" }, body: "x")
|
|
||||||
store.save(post)
|
|
||||||
assert File.exist?(File.join(@dir, "fr", "bonjour.md"))
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_save_uses_root_for_default_language
|
|
||||||
store = Volumen::Store.new(@dir, default_lang: "en")
|
|
||||||
post = Volumen::Post.new(metadata: { "slug" => "hello-new", "lang" => "en" }, body: "x")
|
|
||||||
store.save(post)
|
|
||||||
assert File.exist?(File.join(@dir, "hello-new.md"))
|
|
||||||
refute File.exist?(File.join(@dir, "en", "hello-new.md"))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "minitest/autorun"
|
|
||||||
require "volumen"
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "test_helper"
|
|
||||||
require "tmpdir"
|
|
||||||
require "fileutils"
|
|
||||||
|
|
||||||
class UsersTest < Minitest::Test
|
|
||||||
def setup
|
|
||||||
@dir = Dir.mktmpdir
|
|
||||||
@path = File.join(@dir, "users.toml")
|
|
||||||
end
|
|
||||||
|
|
||||||
def teardown
|
|
||||||
FileUtils.remove_entry(@dir)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_bootstrap_user_from_hash
|
|
||||||
hash = Volumen::Password.hash("secret")
|
|
||||||
users = Volumen::Users.new(path: @path, bootstrap_hash: hash)
|
|
||||||
assert_equal 1, users.all.length
|
|
||||||
assert users.authenticate("admin", "secret")
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_no_users_without_file_or_hash
|
|
||||||
refute_predicate Volumen::Users.new(path: @path), :any?
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_add_and_authenticate
|
|
||||||
users = Volumen::Users.new(path: @path)
|
|
||||||
assert users.add("alice", "wonderland")
|
|
||||||
assert_path_exists @path
|
|
||||||
assert users.authenticate("alice", "wonderland")
|
|
||||||
refute users.authenticate("alice", "nope")
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_add_rejects_duplicate
|
|
||||||
users = Volumen::Users.new(path: @path)
|
|
||||||
users.add("alice", "x")
|
|
||||||
assert_nil users.add("alice", "y")
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_update_password
|
|
||||||
users = Volumen::Users.new(path: @path)
|
|
||||||
users.add("alice", "old")
|
|
||||||
users.update_password("alice", "new")
|
|
||||||
refute users.authenticate("alice", "old")
|
|
||||||
assert users.authenticate("alice", "new")
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_rename
|
|
||||||
users = Volumen::Users.new(path: @path)
|
|
||||||
users.add("alice", "x")
|
|
||||||
assert users.rename("alice", "alicia")
|
|
||||||
assert_nil users.find("alice")
|
|
||||||
assert users.find("alicia")
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_delete_keeps_last_user
|
|
||||||
users = Volumen::Users.new(path: @path)
|
|
||||||
users.add("alice", "x")
|
|
||||||
assert_nil users.delete("alice")
|
|
||||||
users.add("bob", "y")
|
|
||||||
assert users.delete("bob")
|
|
||||||
assert_nil users.find("bob")
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_default_role_is_author
|
|
||||||
users = Volumen::Users.new(path: @path)
|
|
||||||
users.add("alice", "x")
|
|
||||||
assert_equal "author", users.find("alice").role
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_add_with_explicit_role
|
|
||||||
users = Volumen::Users.new(path: @path)
|
|
||||||
users.add("boss", "x", "admin")
|
|
||||||
assert_equal "admin", users.find("boss").role
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_invalid_role_falls_back_to_default
|
|
||||||
users = Volumen::Users.new(path: @path)
|
|
||||||
users.add("alice", "x", "wizard")
|
|
||||||
assert_equal "author", users.find("alice").role
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_bootstrap_user_is_admin
|
|
||||||
users = Volumen::Users.new(path: @path, bootstrap_hash: Volumen::Password.hash("s"))
|
|
||||||
assert_equal "admin", users.find("admin").role
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_set_role
|
|
||||||
users = Volumen::Users.new(path: @path)
|
|
||||||
users.add("alice", "x", "admin")
|
|
||||||
users.add("bob", "y", "author")
|
|
||||||
assert users.set_role("bob", "admin")
|
|
||||||
assert_equal "admin", users.find("bob").role
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_cannot_demote_last_admin
|
|
||||||
users = Volumen::Users.new(path: @path)
|
|
||||||
users.add("alice", "x", "admin")
|
|
||||||
users.add("bob", "y", "author")
|
|
||||||
assert_nil users.set_role("alice", "author")
|
|
||||||
assert_equal "admin", users.find("alice").role
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_cannot_delete_last_admin
|
|
||||||
users = Volumen::Users.new(path: @path)
|
|
||||||
users.add("alice", "x", "admin")
|
|
||||||
users.add("bob", "y", "author")
|
|
||||||
assert_nil users.delete("alice")
|
|
||||||
users.add("carol", "z", "admin")
|
|
||||||
assert users.delete("alice")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require "test_helper"
|
|
||||||
|
|
||||||
class VolumenTest < Minitest::Test
|
|
||||||
def test_version_is_defined
|
|
||||||
refute_nil Volumen::VERSION
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_version_is_semver
|
|
||||||
assert_match(/\A\d+\.\d+\.\d+\z/, Volumen::VERSION)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Shared fixtures for the volumen test suite."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from collections.abc import Generator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from volumen import reset_login_attempts
|
||||||
|
from volumen.config import Config
|
||||||
|
from volumen.password import hash_password as hash_pw
|
||||||
|
from volumen.store import Store
|
||||||
|
from volumen.users import Users
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_login_attempts() -> Generator[None, None, None]:
|
||||||
|
"""Reset the global login-attempts tracker before every test."""
|
||||||
|
reset_login_attempts()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_dir() -> Generator[Path, None, None]:
|
||||||
|
"""Create a temporary directory that is cleaned up after the test."""
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
yield Path(d)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_content_dir(tmp_path: Path) -> Path:
|
||||||
|
"""Temporary content directory with sample posts."""
|
||||||
|
content = tmp_path / "posts"
|
||||||
|
content.mkdir()
|
||||||
|
(content / "hello.md").write_text(
|
||||||
|
'+++\ntitle = "Hello"\ntags = ["intro"]\n+++\n\nHello **world**.\n'
|
||||||
|
)
|
||||||
|
(content / "draft.md").write_text('+++\ntitle = "Draft"\ndraft = true\n+++\n\nSecret.\n')
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config(tmp_content_dir: Path) -> Config:
|
||||||
|
"""Test Config instance pointing at a temp content directory."""
|
||||||
|
return Config.load(
|
||||||
|
path="/nonexistent/volumen.toml",
|
||||||
|
overrides={"content": str(tmp_content_dir)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(tmp_content_dir: Path) -> Store:
|
||||||
|
"""Test Store instance backed by a temp content directory."""
|
||||||
|
return Store(str(tmp_content_dir), default_lang="en")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def users_path(tmp_path: Path) -> str:
|
||||||
|
"""Path for a temporary users.toml file."""
|
||||||
|
return str(tmp_path / "users.toml")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def bootstrap_hash() -> str:
|
||||||
|
"""A pre-hashed password for bootstrap tests."""
|
||||||
|
return hash_pw("secret")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def users(users_path: str, bootstrap_hash: str) -> Users:
|
||||||
|
"""Test Users instance with bootstrap admin user."""
|
||||||
|
return Users(path=users_path, bootstrap_hash=bootstrap_hash)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(config: Config, store: Store) -> TestClient:
|
||||||
|
"""FastAPI TestClient for public API tests."""
|
||||||
|
from volumen.server import create_app
|
||||||
|
|
||||||
|
app = create_app(config, store)
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_config_dir(tmp_path: Path) -> Path:
|
||||||
|
"""Create a directory with config and posts for admin tests."""
|
||||||
|
posts_dir = tmp_path / "posts"
|
||||||
|
posts_dir.mkdir()
|
||||||
|
users_path = tmp_path / "users.toml"
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
pw_hash = hash_pw("secret")
|
||||||
|
config_path.write_text(
|
||||||
|
f'content_dir = "{posts_dir}"\n'
|
||||||
|
f'users_file = "{users_path}"\n\n'
|
||||||
|
"[admin]\n"
|
||||||
|
f'password_hash = "{pw_hash}"\n'
|
||||||
|
)
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_client(admin_config_dir: Path) -> TestClient:
|
||||||
|
"""FastAPI TestClient for admin tests with bootstrap admin."""
|
||||||
|
from volumen.config import Config
|
||||||
|
from volumen.server import create_app
|
||||||
|
from volumen.store import Store
|
||||||
|
|
||||||
|
config = Config.load(path=str(admin_config_dir / "config.toml"))
|
||||||
|
store = Store(config.content_dir, default_lang="en")
|
||||||
|
app = create_app(config, store)
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_post_md() -> str:
|
||||||
|
"""Sample post in Markdown with TOML frontmatter."""
|
||||||
|
return (
|
||||||
|
'+++\ntitle = "Test Post"\nslug = "test-post"\nlang = "en"\n'
|
||||||
|
'tags = ["python", "blog"]\ndate = 2026-01-15\ndraft = false\n+++\n\n'
|
||||||
|
"This is the **body** of the post.\n\nSecond paragraph."
|
||||||
|
)
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
"""Tests for the admin routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from volumen.users import Users
|
||||||
|
|
||||||
|
|
||||||
|
def _csrf_from_body(body: str) -> str:
|
||||||
|
match = re.search(r'name="_csrf" value="([^"]+)"', body)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdminLogin:
|
||||||
|
def test_admin_redirect_to_login(self, admin_client: TestClient) -> None:
|
||||||
|
response = admin_client.get("/admin/", follow_redirects=False)
|
||||||
|
assert response.status_code in (302, 307, 301, 303)
|
||||||
|
|
||||||
|
def test_login_page_renders(self, admin_client: TestClient) -> None:
|
||||||
|
response = admin_client.get("/admin/login")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Sign in" in response.text
|
||||||
|
|
||||||
|
def test_login_with_valid_credentials(self, admin_client: TestClient) -> None:
|
||||||
|
resp = admin_client.get("/admin/login")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={"username": "admin", "password": "secret", "_csrf": token},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert resp.status_code in (302, 307, 303)
|
||||||
|
|
||||||
|
def test_login_with_invalid_credentials(self, admin_client: TestClient) -> None:
|
||||||
|
resp = admin_client.get("/admin/login")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={"username": "admin", "password": "nope", "_csrf": token},
|
||||||
|
)
|
||||||
|
assert "Invalid" in resp.text or "invalid" in resp.text.lower()
|
||||||
|
|
||||||
|
def test_wrong_password_is_rejected(self, admin_client: TestClient) -> None:
|
||||||
|
resp = admin_client.get("/admin/login")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={"username": "admin", "password": "nope", "_csrf": token},
|
||||||
|
)
|
||||||
|
assert "Invalid username or password" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client: TestClient) -> str:
|
||||||
|
"""Log in as admin and return the session cookie value."""
|
||||||
|
resp = client.get("/admin/login")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={"username": "admin", "password": "secret", "_csrf": token},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _login_as(client: TestClient, username: str, password: str) -> str:
|
||||||
|
resp = client.get("/admin/login")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={"username": username, "password": password, "_csrf": token},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdminPostList:
|
||||||
|
def test_post_list_requires_login(self, admin_client: TestClient) -> None:
|
||||||
|
resp = admin_client.get("/admin/", follow_redirects=False)
|
||||||
|
assert resp.status_code in (302, 307, 303)
|
||||||
|
|
||||||
|
def test_post_list_after_login(self, admin_client: TestClient) -> None:
|
||||||
|
_login(admin_client)
|
||||||
|
resp = admin_client.get("/admin/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Posts" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreatePost:
|
||||||
|
def test_create_post_writes_file(
|
||||||
|
self, admin_client: TestClient, admin_config_dir: Path
|
||||||
|
) -> None:
|
||||||
|
_login(admin_client)
|
||||||
|
resp = admin_client.get("/admin/posts/new")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/admin/posts",
|
||||||
|
data={
|
||||||
|
"_csrf": token,
|
||||||
|
"title": "Test",
|
||||||
|
"slug": "test",
|
||||||
|
"lang": "en",
|
||||||
|
"tags": "a, b",
|
||||||
|
"body": "Hello **world**.",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert resp.status_code in (302, 307, 303)
|
||||||
|
post_path = admin_config_dir / "posts" / "test.md"
|
||||||
|
assert post_path.exists()
|
||||||
|
content = post_path.read_text()
|
||||||
|
assert "Test" in content
|
||||||
|
assert "a, b" in content or "a" in content
|
||||||
|
|
||||||
|
def test_create_post_with_all_langs(
|
||||||
|
self, admin_client: TestClient, admin_config_dir: Path
|
||||||
|
) -> None:
|
||||||
|
_login(admin_client)
|
||||||
|
resp = admin_client.get("/admin/posts/new")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
admin_client.post(
|
||||||
|
"/admin/posts",
|
||||||
|
data={
|
||||||
|
"_csrf": token,
|
||||||
|
"title": "Global",
|
||||||
|
"slug": "global",
|
||||||
|
"lang": "en",
|
||||||
|
"all_langs": "on",
|
||||||
|
"body": "Hi",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
post_path = admin_config_dir / "posts" / "global.md"
|
||||||
|
assert post_path.exists()
|
||||||
|
content = post_path.read_text()
|
||||||
|
assert "all_langs" in content
|
||||||
|
|
||||||
|
def test_create_post_with_cover(self, admin_client: TestClient, admin_config_dir: Path) -> None:
|
||||||
|
_login(admin_client)
|
||||||
|
resp = admin_client.get("/admin/posts/new")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
admin_client.post(
|
||||||
|
"/admin/posts",
|
||||||
|
data={
|
||||||
|
"_csrf": token,
|
||||||
|
"title": "Covered",
|
||||||
|
"slug": "covered",
|
||||||
|
"lang": "en",
|
||||||
|
"cover": "/media/c.png",
|
||||||
|
"body": "Hi",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
post_path = admin_config_dir / "posts" / "covered.md"
|
||||||
|
content = post_path.read_text()
|
||||||
|
assert "/media/c.png" in content
|
||||||
|
|
||||||
|
def test_create_post_rejects_invalid_slug(self, admin_client: TestClient) -> None:
|
||||||
|
_login(admin_client)
|
||||||
|
resp = admin_client.get("/admin/posts/new")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/admin/posts",
|
||||||
|
data={
|
||||||
|
"_csrf": token,
|
||||||
|
"title": "X",
|
||||||
|
"slug": "../../etc/passwd",
|
||||||
|
"lang": "en",
|
||||||
|
"body": "y",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert "Invalid slug" in resp.text
|
||||||
|
|
||||||
|
def test_create_post_rejects_xss_in_slug(self, admin_client: TestClient) -> None:
|
||||||
|
_login(admin_client)
|
||||||
|
resp = admin_client.get("/admin/posts/new")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/admin/posts",
|
||||||
|
data={
|
||||||
|
"_csrf": token,
|
||||||
|
"title": "X",
|
||||||
|
"slug": 'foo" onclick',
|
||||||
|
"lang": "en",
|
||||||
|
"body": "y",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert "Invalid slug" in resp.text
|
||||||
|
|
||||||
|
def test_create_post_duplicate_slug(
|
||||||
|
self, admin_client: TestClient, admin_config_dir: Path
|
||||||
|
) -> None:
|
||||||
|
(admin_config_dir / "posts" / "dup.md").write_text(
|
||||||
|
'+++\ntitle = "Existing"\n+++\n\nBody.\n'
|
||||||
|
)
|
||||||
|
_login(admin_client)
|
||||||
|
resp = admin_client.get("/admin/posts/new")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/admin/posts",
|
||||||
|
data={
|
||||||
|
"_csrf": token,
|
||||||
|
"title": "Dup",
|
||||||
|
"slug": "dup",
|
||||||
|
"lang": "en",
|
||||||
|
"body": "x",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert "already exists" in resp.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCSRF:
|
||||||
|
def test_csrf_token_required(self, admin_client: TestClient) -> None:
|
||||||
|
_login(admin_client)
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/admin/posts",
|
||||||
|
data={"title": "X", "slug": "x", "body": "y"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_invalid_csrf_token_rejected(self, admin_client: TestClient) -> None:
|
||||||
|
_login(admin_client)
|
||||||
|
resp = admin_client.get("/admin/posts/new")
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/admin/posts",
|
||||||
|
data={
|
||||||
|
"_csrf": "invalid-token",
|
||||||
|
"title": "X",
|
||||||
|
"slug": "x",
|
||||||
|
"body": "y",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogout:
|
||||||
|
def test_logout_clears_session(self, admin_client: TestClient) -> None:
|
||||||
|
_login(admin_client)
|
||||||
|
resp = admin_client.get("/admin/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
resp = admin_client.get("/admin/settings")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
admin_client.post("/admin/logout", data={"_csrf": token})
|
||||||
|
resp = admin_client.get("/admin/", follow_redirects=False)
|
||||||
|
assert resp.status_code in (302, 307, 303)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettings:
|
||||||
|
def test_settings_requires_login(self, admin_client: TestClient) -> None:
|
||||||
|
resp = admin_client.get("/admin/settings", follow_redirects=False)
|
||||||
|
assert resp.status_code in (302, 307, 303)
|
||||||
|
|
||||||
|
def test_change_password(self, admin_client: TestClient, admin_config_dir: Path) -> None:
|
||||||
|
_login(admin_client)
|
||||||
|
resp = admin_client.get("/admin/settings")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/admin/settings/password",
|
||||||
|
data={
|
||||||
|
"_csrf": token,
|
||||||
|
"current_password": "secret",
|
||||||
|
"new_password": "brand-new",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert "Password updated" in resp.text
|
||||||
|
|
||||||
|
users_path = str(admin_config_dir / "users.toml")
|
||||||
|
fresh = Users(path=users_path)
|
||||||
|
assert fresh.authenticate("admin", "secret") is None
|
||||||
|
assert fresh.authenticate("admin", "brand-new") is not None
|
||||||
|
|
||||||
|
def test_change_password_wrong_current(self, admin_client: TestClient) -> None:
|
||||||
|
_login(admin_client)
|
||||||
|
resp = admin_client.get("/admin/settings")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/admin/settings/password",
|
||||||
|
data={
|
||||||
|
"_csrf": token,
|
||||||
|
"current_password": "wrong",
|
||||||
|
"new_password": "brand-new",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert "incorrect" in resp.text.lower()
|
||||||
|
|
||||||
|
def test_change_username(self, admin_client: TestClient) -> None:
|
||||||
|
_login(admin_client)
|
||||||
|
resp = admin_client.get("/admin/settings")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/admin/settings/username",
|
||||||
|
data={"_csrf": token, "username": "superadmin"},
|
||||||
|
)
|
||||||
|
assert "updated" in resp.text.lower()
|
||||||
|
|
||||||
|
def test_change_username_accepts_same_name(self, admin_client: TestClient) -> None:
|
||||||
|
_login(admin_client)
|
||||||
|
resp = admin_client.get("/admin/settings")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/admin/settings/username",
|
||||||
|
data={"_csrf": token, "username": "admin"},
|
||||||
|
)
|
||||||
|
assert "unchanged" in resp.text.lower()
|
||||||
|
|
||||||
|
def test_change_username_rejects_special_chars(self, admin_client: TestClient) -> None:
|
||||||
|
_login(admin_client)
|
||||||
|
resp = admin_client.get("/admin/settings")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/admin/settings/username",
|
||||||
|
data={"_csrf": token, "username": 'foo" bar'},
|
||||||
|
)
|
||||||
|
assert "letters" in resp.text.lower()
|
||||||
|
|
||||||
|
def test_change_display_name(self, admin_client: TestClient) -> None:
|
||||||
|
_login(admin_client)
|
||||||
|
resp = admin_client.get("/admin/settings")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/admin/settings/name",
|
||||||
|
data={"_csrf": token, "name": "Admin User"},
|
||||||
|
)
|
||||||
|
assert "Display name updated" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestFediverseSettings:
|
||||||
|
def test_change_fediverse_creator_valid(self, admin_client: TestClient) -> None:
|
||||||
|
_login(admin_client)
|
||||||
|
resp = admin_client.get("/admin/settings")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/admin/settings/fediverse",
|
||||||
|
data={"_csrf": token, "fediverse_creator": "@user@example.com"},
|
||||||
|
)
|
||||||
|
assert "updated" in resp.text.lower() or "updated" in resp.text.lower()
|
||||||
|
|
||||||
|
def test_change_fediverse_creator_invalid(self, admin_client: TestClient) -> None:
|
||||||
|
_login(admin_client)
|
||||||
|
resp = admin_client.get("/admin/settings")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/admin/settings/fediverse",
|
||||||
|
data={"_csrf": token, "fediverse_creator": "not-a-handle"},
|
||||||
|
)
|
||||||
|
assert "handle" in resp.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserManagement:
|
||||||
|
def test_create_user_admin_only(self, admin_client: TestClient, admin_config_dir: Path) -> None:
|
||||||
|
_login(admin_client)
|
||||||
|
resp = admin_client.get("/admin/settings")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/admin/settings/users",
|
||||||
|
data={"_csrf": token, "username": "editor", "password": "pw12345", "role": "author"},
|
||||||
|
)
|
||||||
|
assert "User added" in resp.text or "added" in resp.text.lower()
|
||||||
|
|
||||||
|
users_path = str(admin_config_dir / "users.toml")
|
||||||
|
users = Users(path=users_path)
|
||||||
|
assert users.find("editor") is not None
|
||||||
|
|
||||||
|
def test_create_user_then_login(self, admin_client: TestClient, admin_config_dir: Path) -> None:
|
||||||
|
_login(admin_client)
|
||||||
|
resp = admin_client.get("/admin/settings")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
admin_client.post(
|
||||||
|
"/admin/settings/users",
|
||||||
|
data={"_csrf": token, "username": "editor2", "password": "pw12345"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Logout
|
||||||
|
resp = admin_client.get("/admin/login")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
admin_client.post("/admin/logout", data={"_csrf": token})
|
||||||
|
|
||||||
|
# Login as new user
|
||||||
|
_login_as(admin_client, "editor2", "pw12345")
|
||||||
|
resp = admin_client.get("/admin/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_cannot_delete_own_account(self, admin_client: TestClient) -> None:
|
||||||
|
_login(admin_client)
|
||||||
|
resp = admin_client.get("/admin/settings")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/admin/settings/users/admin/delete",
|
||||||
|
data={"_csrf": token},
|
||||||
|
)
|
||||||
|
assert "cannot delete your own" in resp.text.lower()
|
||||||
|
|
||||||
|
def test_cannot_create_user_with_empty_fields(self, admin_client: TestClient) -> None:
|
||||||
|
_login(admin_client)
|
||||||
|
resp = admin_client.get("/admin/settings")
|
||||||
|
token = _csrf_from_body(resp.text)
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/admin/settings/users",
|
||||||
|
data={"_csrf": token, "username": "", "password": ""},
|
||||||
|
)
|
||||||
|
assert "required" in resp.text.lower() or "empty" in resp.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrphanedSession:
|
||||||
|
def test_orphaned_session_is_rejected(
|
||||||
|
self, admin_client: TestClient, admin_config_dir: Path
|
||||||
|
) -> None:
|
||||||
|
_login(admin_client)
|
||||||
|
resp = admin_client.get("/admin/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Clear users file to simulate deleted user
|
||||||
|
users_path = admin_config_dir / "users.toml"
|
||||||
|
users_path.write_text("")
|
||||||
|
|
||||||
|
resp = admin_client.get("/admin/", follow_redirects=False)
|
||||||
|
assert resp.status_code in (302, 307, 303)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Tests for CLI entry point."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from volumen import VERSION
|
||||||
|
from volumen.cli import main
|
||||||
|
|
||||||
|
|
||||||
|
class TestVersionCommand:
|
||||||
|
def test_version_output(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
with (
|
||||||
|
mock.patch.object(sys, "argv", ["volumen", "version"]),
|
||||||
|
contextlib.suppress(SystemExit),
|
||||||
|
):
|
||||||
|
main()
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert VERSION in captured.out
|
||||||
|
|
||||||
|
|
||||||
|
class TestHelpCommand:
|
||||||
|
def test_help_output(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
with mock.patch.object(sys, "argv", ["volumen", "--help"]), contextlib.suppress(SystemExit):
|
||||||
|
main()
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "volumen" in captured.out or "usage" in captured.out.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnknownCommand:
|
||||||
|
def test_unknown_command_exits(self) -> None:
|
||||||
|
with mock.patch.object(sys, "argv", ["volumen", "bogus"]), pytest.raises(SystemExit):
|
||||||
|
main()
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseServeOptions:
|
||||||
|
def test_parse_serve_defaults(self) -> None:
|
||||||
|
with mock.patch.object(sys, "argv", ["volumen", "serve"]):
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
sp = parser.add_subparsers(dest="command")
|
||||||
|
sp_serve = sp.add_parser("serve")
|
||||||
|
sp_serve.add_argument("--config", default="/etc/volumen/config.toml")
|
||||||
|
sp_serve.add_argument("--host", default=None)
|
||||||
|
sp_serve.add_argument("--port", type=int, default=None)
|
||||||
|
sp_serve.add_argument("--content", default=None)
|
||||||
|
args = parser.parse_args(["serve"])
|
||||||
|
assert args.config == "/etc/volumen/config.toml"
|
||||||
|
|
||||||
|
def test_parse_serve_overrides(self) -> None:
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
sp = parser.add_subparsers(dest="command")
|
||||||
|
sp_serve = sp.add_parser("serve")
|
||||||
|
sp_serve.add_argument("--config", default="/etc/volumen/config.toml")
|
||||||
|
sp_serve.add_argument("--host", default=None)
|
||||||
|
sp_serve.add_argument("--port", type=int, default=None)
|
||||||
|
sp_serve.add_argument("--content", default=None)
|
||||||
|
args = parser.parse_args(
|
||||||
|
["serve", "--config", "/t.toml", "--host", "127.0.0.1", "--port", "9000"]
|
||||||
|
)
|
||||||
|
assert args.config == "/t.toml"
|
||||||
|
assert args.host == "127.0.0.1"
|
||||||
|
assert args.port == 9000
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrepareConfig:
|
||||||
|
def test_prepare_config_writes_template(self, tmp_path: Path) -> None:
|
||||||
|
from volumen.config import Config
|
||||||
|
|
||||||
|
path = tmp_path / "config.toml"
|
||||||
|
result = Config.generate_default(str(path))
|
||||||
|
assert result == str(path)
|
||||||
|
assert path.exists()
|
||||||
|
|
||||||
|
def test_prepare_config_noops_when_present(self, tmp_path: Path) -> None:
|
||||||
|
from volumen.config import Config
|
||||||
|
|
||||||
|
path = tmp_path / "config.toml"
|
||||||
|
path.write_text("[server]\nport = 1\n")
|
||||||
|
result = Config.generate_default(str(path))
|
||||||
|
assert result is None
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Tests for Config — loading, merging, defaults, overrides."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from volumen.config import Config
|
||||||
|
|
||||||
|
MISSING = "/nonexistent/volumen.toml"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaults:
|
||||||
|
def test_defaults_when_file_missing(self) -> None:
|
||||||
|
config = Config.load(path=MISSING)
|
||||||
|
assert config.port == 9090
|
||||||
|
assert config.language == "en"
|
||||||
|
assert config.host == "0.0.0.0"
|
||||||
|
|
||||||
|
def test_default_content_dir(self) -> None:
|
||||||
|
config = Config.load(path=MISSING)
|
||||||
|
assert config.content_dir == "/var/lib/volumen/posts"
|
||||||
|
|
||||||
|
def test_default_site_title(self) -> None:
|
||||||
|
config = Config.load(path=MISSING)
|
||||||
|
assert config.site["title"] == "My Blog"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOverrides:
|
||||||
|
def test_overrides_apply(self) -> None:
|
||||||
|
config = Config.load(
|
||||||
|
path=MISSING,
|
||||||
|
overrides={"host": "127.0.0.1", "port": 9000, "content": "/srv/posts"},
|
||||||
|
)
|
||||||
|
assert config.host == "127.0.0.1"
|
||||||
|
assert config.port == 9000
|
||||||
|
assert config.content_dir == "/srv/posts"
|
||||||
|
|
||||||
|
def test_does_not_mutate_defaults(self) -> None:
|
||||||
|
Config.load(path=MISSING, overrides={"host": "10.0.0.1"})
|
||||||
|
fresh = Config.load(path=MISSING)
|
||||||
|
assert fresh.host == "0.0.0.0"
|
||||||
|
|
||||||
|
def test_does_not_mutate_default_inner_hashes(self) -> None:
|
||||||
|
config = Config.load(path=MISSING)
|
||||||
|
config.site["title"] = "MUTATED"
|
||||||
|
fresh = Config.load(path=MISSING)
|
||||||
|
assert fresh.site["title"] == "My Blog"
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadFromFile:
|
||||||
|
def test_loads_and_merges_file(self, tmp_path: Path) -> None:
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
config_path.write_text('[site]\ntitle = "Custom"\n')
|
||||||
|
config = Config.load(path=str(config_path))
|
||||||
|
assert config.site["title"] == "Custom"
|
||||||
|
assert config.port == 9090 # default preserved
|
||||||
|
|
||||||
|
def test_loads_server_section(self, tmp_path: Path) -> None:
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
config_path.write_text('[server]\nhost = "1.2.3.4"\nport = 8000\n')
|
||||||
|
config = Config.load(path=str(config_path))
|
||||||
|
assert config.host == "1.2.3.4"
|
||||||
|
assert config.port == 8000
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateDefault:
|
||||||
|
def test_generate_default_writes_template(self, tmp_path: Path) -> None:
|
||||||
|
dest = tmp_path / "sub" / "config.toml"
|
||||||
|
result = Config.generate_default(str(dest))
|
||||||
|
assert result == str(dest)
|
||||||
|
assert dest.exists()
|
||||||
|
loaded = Config.load(path=str(dest))
|
||||||
|
assert loaded.port == 9090
|
||||||
|
|
||||||
|
def test_generate_default_skips_existing(self, tmp_path: Path) -> None:
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
config_path.write_text("[server]\nport = 9999\n")
|
||||||
|
result = Config.generate_default(str(config_path))
|
||||||
|
assert result is None
|
||||||
|
loaded = Config.load(path=str(config_path))
|
||||||
|
assert loaded.port == 9999
|
||||||
|
|
||||||
|
|
||||||
|
class TestAccessors:
|
||||||
|
def test_host(self) -> None:
|
||||||
|
config = Config.load(path=MISSING, overrides={"host": "127.0.0.1"})
|
||||||
|
assert config.host == "127.0.0.1"
|
||||||
|
|
||||||
|
def test_port(self) -> None:
|
||||||
|
assert Config.load(path=MISSING).port == 9090
|
||||||
|
|
||||||
|
def test_content_dir(self) -> None:
|
||||||
|
config = Config.load(path=MISSING, overrides={"content": "/tmp/posts"})
|
||||||
|
assert config.content_dir == "/tmp/posts"
|
||||||
|
|
||||||
|
def test_users_file(self) -> None:
|
||||||
|
config = Config.load(path=MISSING)
|
||||||
|
assert config.users_file == "/var/lib/volumen/users.toml"
|
||||||
|
|
||||||
|
def test_site(self) -> None:
|
||||||
|
config = Config.load(path=MISSING)
|
||||||
|
assert isinstance(config.site, dict)
|
||||||
|
assert "title" in config.site
|
||||||
|
|
||||||
|
def test_admin(self) -> None:
|
||||||
|
config = Config.load(path=MISSING)
|
||||||
|
assert isinstance(config.admin, dict)
|
||||||
|
assert "password_hash" in config.admin
|
||||||
|
|
||||||
|
def test_language(self) -> None:
|
||||||
|
config = Config.load(path=MISSING)
|
||||||
|
assert config.language == "en"
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Tests for Frontmatter parser — TOML frontmatter parsing and serialisation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from volumen.frontmatter import dump, parse
|
||||||
|
|
||||||
|
|
||||||
|
class TestParse:
|
||||||
|
def test_parse_extracts_metadata_and_body(self) -> None:
|
||||||
|
content = '+++\ntitle = "Hello"\ntags = ["a", "b"]\n+++\n\nBody text.\n'
|
||||||
|
metadata, body = parse(content)
|
||||||
|
assert metadata["title"] == "Hello"
|
||||||
|
assert metadata["tags"] == ["a", "b"]
|
||||||
|
assert body == "Body text.\n"
|
||||||
|
|
||||||
|
def test_parse_without_frontmatter(self) -> None:
|
||||||
|
metadata, body = parse("# Just markdown\n")
|
||||||
|
assert metadata == {}
|
||||||
|
assert body == "# Just markdown\n"
|
||||||
|
|
||||||
|
def test_parse_without_closing_delimiter(self) -> None:
|
||||||
|
content = '+++\ntitle = "x"\n\nbody'
|
||||||
|
metadata, body = parse(content)
|
||||||
|
assert metadata == {}
|
||||||
|
assert body == content
|
||||||
|
|
||||||
|
def test_parse_empty_frontmatter(self) -> None:
|
||||||
|
content = "+++\n+++\n\nBody here.\n"
|
||||||
|
metadata, body = parse(content)
|
||||||
|
assert metadata == {}
|
||||||
|
assert body == "Body here.\n"
|
||||||
|
|
||||||
|
def test_parse_draft_false(self) -> None:
|
||||||
|
content = '+++\ntitle = "X"\ndraft = false\n+++\n\nBody\n'
|
||||||
|
metadata, body = parse(content)
|
||||||
|
assert metadata["draft"] is False
|
||||||
|
|
||||||
|
def test_parse_nested_tables(self) -> None:
|
||||||
|
content = '+++\n[meta]\ntitle = "X"\n+++\n\nBody\n'
|
||||||
|
metadata, body = parse(content)
|
||||||
|
assert metadata["meta"]["title"] == "X"
|
||||||
|
|
||||||
|
def test_parse_normalises_crlf(self) -> None:
|
||||||
|
metadata, body = parse('+++\r\ntitle = "x"\r\n+++\r\n\r\nBody\r\n')
|
||||||
|
assert metadata["title"] == "x"
|
||||||
|
assert body == "Body\n"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDump:
|
||||||
|
def test_dump_produces_valid_frontmatter(self) -> None:
|
||||||
|
result = dump({"title": "Hello", "tags": ["a"]}, "Body text.\n")
|
||||||
|
assert result.startswith("+++\n")
|
||||||
|
assert "+++\n\n" in result
|
||||||
|
assert "Body text." in result
|
||||||
|
|
||||||
|
def test_dump_empty_metadata(self) -> None:
|
||||||
|
result = dump({}, "Just body.\n")
|
||||||
|
assert "+++" in result
|
||||||
|
assert "Just body." in result
|
||||||
|
|
||||||
|
def test_round_trip(self) -> None:
|
||||||
|
metadata = {"title": "Hello", "tags": ["intro"], "draft": False}
|
||||||
|
dumped = dump(metadata, "Hello **world**.")
|
||||||
|
parsed_meta, parsed_body = parse(dumped)
|
||||||
|
assert parsed_meta == metadata
|
||||||
|
assert parsed_body.strip() == "Hello **world**."
|
||||||
|
|
||||||
|
def test_round_trip_with_empty_metadata(self) -> None:
|
||||||
|
dumped = dump({}, "Just body.\n")
|
||||||
|
parsed_meta, parsed_body = parse(dumped)
|
||||||
|
assert parsed_meta == {}
|
||||||
|
assert parsed_body.strip() == "Just body."
|
||||||
|
|
||||||
|
def test_round_trip_preserves_dates(self) -> None:
|
||||||
|
metadata = {"title": "X", "date": "2026-01-15"}
|
||||||
|
dumped = dump(metadata, "Body\n")
|
||||||
|
parsed_meta, _ = parse(dumped)
|
||||||
|
assert parsed_meta["date"] == "2026-01-15"
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Tests for Markdown renderer."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from volumen.markdown import render
|
||||||
|
|
||||||
|
|
||||||
|
class TestBasicRendering:
|
||||||
|
def test_renders_bold(self) -> None:
|
||||||
|
assert "<strong>bold</strong>" in render("**bold**")
|
||||||
|
|
||||||
|
def test_renders_italic(self) -> None:
|
||||||
|
result = render("*italic*")
|
||||||
|
assert "<em>italic</em>" in result
|
||||||
|
|
||||||
|
def test_renders_heading(self) -> None:
|
||||||
|
assert "<h1" in render("# Title")
|
||||||
|
|
||||||
|
def test_renders_paragraph(self) -> None:
|
||||||
|
result = render("Simple paragraph.")
|
||||||
|
assert "<p>" in result
|
||||||
|
|
||||||
|
def test_renders_link(self) -> None:
|
||||||
|
result = render("[link](https://example.com)")
|
||||||
|
assert '<a href="https://example.com"' in result
|
||||||
|
|
||||||
|
def test_renders_unordered_list(self) -> None:
|
||||||
|
result = render("- one\n- two\n")
|
||||||
|
assert "<ul>" in result
|
||||||
|
assert "<li>one</li>" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestGFMFeatures:
|
||||||
|
def test_renders_gfm_table(self) -> None:
|
||||||
|
table = "| A | B |\n|---|---|\n| 1 | 2 |\n"
|
||||||
|
assert "<table" in render(table)
|
||||||
|
|
||||||
|
def test_renders_fenced_code_block(self) -> None:
|
||||||
|
code = "```python\nprint(1)\n```\n"
|
||||||
|
html = render(code)
|
||||||
|
assert "<pre" in html or "<code" in html
|
||||||
|
|
||||||
|
def test_renders_task_list(self) -> None:
|
||||||
|
md = "- [x] Done\n- [ ] Todo\n"
|
||||||
|
result = render(md)
|
||||||
|
assert "checkbox" in result.lower() or "checked" in result.lower()
|
||||||
|
|
||||||
|
def test_renders_strikethrough(self) -> None:
|
||||||
|
result = render("~~strike~~")
|
||||||
|
assert "<del>" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestFigureWrapping:
|
||||||
|
def test_image_with_title_wrapped_in_figure(self) -> None:
|
||||||
|
result = render('')
|
||||||
|
assert "<figure>" in result
|
||||||
|
|
||||||
|
def test_image_without_title_no_wrapping(self) -> None:
|
||||||
|
result = render("")
|
||||||
|
assert "<figure>" not in result
|
||||||
|
|
||||||
|
def test_figure_includes_figcaption(self) -> None:
|
||||||
|
result = render('')
|
||||||
|
assert "<figcaption>" in result
|
||||||
|
assert "caption" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdgeCases:
|
||||||
|
def test_empty_input(self) -> None:
|
||||||
|
result = render("")
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
def test_plain_text(self) -> None:
|
||||||
|
result = render("Hello world")
|
||||||
|
assert "Hello world" in result
|
||||||
|
|
||||||
|
def test_html_passthrough(self) -> None:
|
||||||
|
result = render("<span>inline</span>")
|
||||||
|
assert "<span>" in result
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Tests for Password hashing and verification."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from volumen.password import hash_password, verify_password
|
||||||
|
|
||||||
|
|
||||||
|
class TestHash:
|
||||||
|
def test_hash_produces_string(self) -> None:
|
||||||
|
result = hash_password("secret")
|
||||||
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
def test_hash_format(self) -> None:
|
||||||
|
result = hash_password("pw")
|
||||||
|
assert result.startswith("scrypt$16384$8$1$")
|
||||||
|
|
||||||
|
def test_hash_is_deterministic_in_format(self) -> None:
|
||||||
|
result = hash_password("pw")
|
||||||
|
parts = result.split("$")
|
||||||
|
assert len(parts) == 6
|
||||||
|
assert parts[0] == "scrypt"
|
||||||
|
assert parts[1] == "16384"
|
||||||
|
assert parts[2] == "8"
|
||||||
|
assert parts[3] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
class TestVerify:
|
||||||
|
def test_verify_correct_password(self) -> None:
|
||||||
|
encoded = hash_password("s3cret")
|
||||||
|
assert verify_password("s3cret", encoded) is True
|
||||||
|
|
||||||
|
def test_verify_wrong_password(self) -> None:
|
||||||
|
encoded = hash_password("s3cret")
|
||||||
|
assert verify_password("nope", encoded) is False
|
||||||
|
|
||||||
|
def test_verify_empty_string(self) -> None:
|
||||||
|
encoded = hash_password("")
|
||||||
|
assert verify_password("", encoded) is True
|
||||||
|
|
||||||
|
def test_verify_malformed_input(self) -> None:
|
||||||
|
assert verify_password("x", "not-a-hash") is False
|
||||||
|
assert verify_password("x", "") is False
|
||||||
|
|
||||||
|
def test_verify_tampered_hash(self) -> None:
|
||||||
|
encoded = hash_password("s3cret")
|
||||||
|
tampered = encoded[:-1] + ("X" if encoded[-1] != "X" else "Y")
|
||||||
|
assert verify_password("s3cret", tampered) is False
|
||||||
|
|
||||||
|
def test_verify_empty_password_against_hash(self) -> None:
|
||||||
|
encoded = hash_password("secret")
|
||||||
|
assert verify_password("", encoded) is False
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""Tests for Post model — parsing, metadata, rendering, serialisation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from volumen.post import Post
|
||||||
|
|
||||||
|
|
||||||
|
class TestPostParsing:
|
||||||
|
def test_parse_builds_post(self) -> None:
|
||||||
|
content = (
|
||||||
|
"+++\n"
|
||||||
|
'title = "Hello"\n'
|
||||||
|
'slug = "hello"\n'
|
||||||
|
'lang = "en"\n'
|
||||||
|
'tags = ["intro", "ruby"]\n'
|
||||||
|
"date = 2026-01-15\n"
|
||||||
|
"draft = true\n"
|
||||||
|
"+++\n\n"
|
||||||
|
"First paragraph.\n"
|
||||||
|
)
|
||||||
|
post = Post.parse(content)
|
||||||
|
assert post.slug == "hello"
|
||||||
|
assert post.title == "Hello"
|
||||||
|
assert post.lang == "en"
|
||||||
|
assert post.tags == ["intro", "ruby"]
|
||||||
|
assert post.draft is True
|
||||||
|
assert post.date_string == "2026-01-15"
|
||||||
|
|
||||||
|
def test_parse_preserves_body_newlines(self) -> None:
|
||||||
|
content = '+++\ntitle = "X"\nslug = "x"\n+++\n\nLine1\n\nLine2\n'
|
||||||
|
post = Post.parse(content)
|
||||||
|
assert "Line1" in post.body
|
||||||
|
assert "Line2" in post.body
|
||||||
|
|
||||||
|
|
||||||
|
class TestExcerpt:
|
||||||
|
def test_excerpt_from_metadata(self) -> None:
|
||||||
|
post = Post(metadata={"slug": "x", "excerpt": "Custom blurb."}, body="Long body text here.")
|
||||||
|
assert post.excerpt == "Custom blurb."
|
||||||
|
|
||||||
|
def test_excerpt_derived_from_body(self) -> None:
|
||||||
|
post = Post(metadata={"slug": "x"}, body="# Heading\n\nThe body paragraph.")
|
||||||
|
assert post.excerpt == "The body paragraph."
|
||||||
|
|
||||||
|
def test_derived_excerpt_strips_html_tags(self) -> None:
|
||||||
|
post = Post(metadata={"slug": "x"}, body="<strong>Bold</strong> and <em>italic</em>.")
|
||||||
|
assert post.excerpt == "Bold and italic."
|
||||||
|
|
||||||
|
def test_derived_excerpt_skips_heading(self) -> None:
|
||||||
|
post = Post(metadata={"slug": "x"}, body="# Heading\n\nText here.")
|
||||||
|
assert post.excerpt == "Text here."
|
||||||
|
|
||||||
|
def test_derived_excerpt_empty_body(self) -> None:
|
||||||
|
post = Post(metadata={"slug": "x"}, body="")
|
||||||
|
assert post.excerpt == ""
|
||||||
|
|
||||||
|
def test_derived_excerpt_truncates_long(self) -> None:
|
||||||
|
long_text = "A" * 300
|
||||||
|
post = Post(metadata={"slug": "x"}, body=long_text)
|
||||||
|
assert len(post.excerpt) <= 203 # limit + ellipsis
|
||||||
|
assert post.excerpt.endswith("\u2026")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSummaryDict:
|
||||||
|
def test_summary_shape(self) -> None:
|
||||||
|
post = Post(metadata={"slug": "x", "title": "X"}, body="Body")
|
||||||
|
summary = post.summary()
|
||||||
|
assert summary["url"] == "/api/volumen/posts/x"
|
||||||
|
assert summary["title"] == "X"
|
||||||
|
assert summary["slug"] == "x"
|
||||||
|
|
||||||
|
def test_summary_includes_cover_and_reading_time(self) -> None:
|
||||||
|
post = Post(metadata={"slug": "x", "cover": "/media/c.png"}, body="word " * 250)
|
||||||
|
assert post.summary()["cover"] == "/media/c.png"
|
||||||
|
assert post.summary()["reading_time"] == 2
|
||||||
|
|
||||||
|
def test_summary_includes_fediverse_creator(self) -> None:
|
||||||
|
post = Post(
|
||||||
|
metadata={"slug": "x", "fediverse_creator": "@user@example.social"},
|
||||||
|
body="body",
|
||||||
|
)
|
||||||
|
assert post.summary()["fediverse_creator"] == "@user@example.social"
|
||||||
|
|
||||||
|
def test_summary_omits_fediverse_creator_when_absent(self) -> None:
|
||||||
|
post = Post(metadata={"slug": "x"}, body="body")
|
||||||
|
assert post.summary()["fediverse_creator"] is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetailDict:
|
||||||
|
def test_detail_includes_rendered_html(self) -> None:
|
||||||
|
post = Post(metadata={"slug": "x"}, body="**hi**")
|
||||||
|
assert "<strong>hi</strong>" in post.detail()["html"]
|
||||||
|
|
||||||
|
def test_detail_includes_body(self) -> None:
|
||||||
|
post = Post(metadata={"slug": "x"}, body="raw body")
|
||||||
|
assert post.detail()["body"] == "raw body"
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadingTime:
|
||||||
|
def test_reading_time_is_at_least_one(self) -> None:
|
||||||
|
post = Post(metadata={"slug": "x"}, body="short")
|
||||||
|
assert post.reading_time == 1
|
||||||
|
|
||||||
|
def test_reading_time_200_words(self) -> None:
|
||||||
|
post = Post(metadata={"slug": "x"}, body="word " * 200)
|
||||||
|
assert post.reading_time == 1
|
||||||
|
|
||||||
|
def test_reading_time_201_words(self) -> None:
|
||||||
|
post = Post(metadata={"slug": "x"}, body="word " * 201)
|
||||||
|
assert post.reading_time == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduled:
|
||||||
|
def test_scheduled_post_when_publish_at_is_future(self) -> None:
|
||||||
|
future = date.today().replace(year=date.today().year + 1)
|
||||||
|
post = Post(metadata={"publish_at": future})
|
||||||
|
assert post.scheduled is True
|
||||||
|
|
||||||
|
def test_scheduled_post_when_publish_at_is_past(self) -> None:
|
||||||
|
past = date(2000, 1, 1)
|
||||||
|
post = Post(metadata={"publish_at": past})
|
||||||
|
assert post.scheduled is False
|
||||||
|
|
||||||
|
def test_post_without_publish_at_is_not_scheduled(self) -> None:
|
||||||
|
post = Post(metadata={})
|
||||||
|
assert post.scheduled is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestFediverseCreator:
|
||||||
|
def test_fediverse_creator_returns_metadata_value(self) -> None:
|
||||||
|
post = Post(metadata={"slug": "x", "fediverse_creator": "@user@example.social"})
|
||||||
|
assert post.fediverse_creator == "@user@example.social"
|
||||||
|
|
||||||
|
def test_fediverse_creator_is_none_when_missing(self) -> None:
|
||||||
|
post = Post(metadata={"slug": "x"})
|
||||||
|
assert post.fediverse_creator is None
|
||||||
|
|
||||||
|
def test_fediverse_creator_is_none_when_blank(self) -> None:
|
||||||
|
post = Post(metadata={"slug": "x", "fediverse_creator": " "})
|
||||||
|
assert post.fediverse_creator is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"handle",
|
||||||
|
[
|
||||||
|
"@ada@lovelace.org",
|
||||||
|
"@user@example.com",
|
||||||
|
" @user@example.com ",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_valid_fediverse_creator_accepts_well_formed(self, handle: str) -> None:
|
||||||
|
post = Post(metadata={"slug": "x", "fediverse_creator": handle})
|
||||||
|
assert post.valid_fediverse_creator is True
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"handle",
|
||||||
|
[
|
||||||
|
"ada@example.com",
|
||||||
|
"@only-one-at",
|
||||||
|
"@user@example.com extra",
|
||||||
|
"@user@",
|
||||||
|
"@@example.com",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_valid_fediverse_creator_rejects_malformed(self, handle: str) -> None:
|
||||||
|
post = Post(metadata={"slug": "x", "fediverse_creator": handle})
|
||||||
|
assert post.valid_fediverse_creator is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestDateString:
|
||||||
|
def test_date_string_from_string(self) -> None:
|
||||||
|
post = Post(metadata={"date": "2026-01-15"})
|
||||||
|
assert post.date_string == "2026-01-15"
|
||||||
|
|
||||||
|
def test_date_string_from_date(self) -> None:
|
||||||
|
post = Post(metadata={"date": date(2026, 1, 15)})
|
||||||
|
assert post.date_string == "2026-01-15"
|
||||||
|
|
||||||
|
def test_date_string_none_when_missing(self) -> None:
|
||||||
|
post = Post(metadata={})
|
||||||
|
assert post.date_string is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestTranslations:
|
||||||
|
def test_translations_from_metadata(self) -> None:
|
||||||
|
post = Post(metadata={"slug": "x", "translations": {"cs": "ahoj", "de": "hallo"}})
|
||||||
|
assert post.translations == {"cs": "ahoj", "de": "hallo"}
|
||||||
|
|
||||||
|
def test_translations_empty_when_missing(self) -> None:
|
||||||
|
post = Post(metadata={"slug": "x"})
|
||||||
|
assert post.translations == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileSerialization:
|
||||||
|
def test_round_trip(self) -> None:
|
||||||
|
metadata = {"slug": "hello", "title": "Hello", "tags": ["intro"], "draft": False}
|
||||||
|
post = Post(metadata=metadata, body="Hello **world**.\n")
|
||||||
|
serialised = post.to_file()
|
||||||
|
reloaded = Post.parse(serialised)
|
||||||
|
assert reloaded.slug == "hello"
|
||||||
|
assert reloaded.title == "Hello"
|
||||||
|
assert reloaded.tags == ["intro"]
|
||||||
|
assert reloaded.draft is False
|
||||||
|
assert "Hello **world**." in reloaded.body
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"""Tests for the public API endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestSiteEndpoint:
|
||||||
|
def test_site_endpoint(self, client: TestClient) -> None:
|
||||||
|
response = client.get("/api/volumen/site")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["title"] == "My Blog"
|
||||||
|
|
||||||
|
def test_site_endpoint_exposes_fediverse_creator_default(self, client: TestClient) -> None:
|
||||||
|
response = client.get("/api/volumen/site")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "fediverse_creator" in response.json()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPostsEndpoint:
|
||||||
|
def test_posts_excludes_drafts(self, client: TestClient) -> None:
|
||||||
|
response = client.get("/api/volumen/posts")
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 1
|
||||||
|
assert data["posts"][0]["slug"] == "hello"
|
||||||
|
|
||||||
|
def test_post_detail_renders_html(self, client: TestClient) -> None:
|
||||||
|
response = client.get("/api/volumen/posts/hello")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "<strong>world</strong>" in response.json()["html"]
|
||||||
|
|
||||||
|
def test_post_not_found(self, client: TestClient) -> None:
|
||||||
|
response = client.get("/api/volumen/posts/missing")
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json()["detail"]["error"] == "not_found"
|
||||||
|
|
||||||
|
def test_draft_post_detail_returns_error(self, client: TestClient) -> None:
|
||||||
|
response = client.get("/api/volumen/posts/draft")
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json()["detail"]["error"] == "draft"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTagsEndpoint:
|
||||||
|
def test_tags_endpoint(self, client: TestClient) -> None:
|
||||||
|
response = client.get("/api/volumen/tags")
|
||||||
|
data = response.json()
|
||||||
|
assert data["tags"] == [{"name": "intro", "count": 1}]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCORS:
|
||||||
|
def test_cors_header_present(self, client: TestClient) -> None:
|
||||||
|
response = client.get("/api/volumen/site")
|
||||||
|
assert response.headers.get("access-control-allow-origin") == "*"
|
||||||
|
|
||||||
|
def test_cors_preflight(self, client: TestClient) -> None:
|
||||||
|
response = client.options("/api/volumen/site")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["access-control-allow-origin"] == "*"
|
||||||
|
assert "GET" in response.headers["access-control-allow-methods"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestLanguageFilter:
|
||||||
|
def test_all_langs_post_appears_in_other_languages(
|
||||||
|
self, client: TestClient, tmp_content_dir: Path
|
||||||
|
) -> None:
|
||||||
|
(tmp_content_dir / "global.md").write_text(
|
||||||
|
'+++\ntitle = "Global"\nlang = "en"\nall_langs = true\n+++\n\nEverywhere.\n'
|
||||||
|
)
|
||||||
|
response = client.get("/api/volumen/posts?lang=cs")
|
||||||
|
slugs = [p["slug"] for p in response.json()["posts"]]
|
||||||
|
assert "global" in slugs
|
||||||
|
assert "hello" not in slugs
|
||||||
|
|
||||||
|
def test_lang_filter_default_shows_default_lang_posts(self, client: TestClient) -> None:
|
||||||
|
response = client.get("/api/volumen/posts")
|
||||||
|
slugs = [p["slug"] for p in response.json()["posts"]]
|
||||||
|
assert "hello" in slugs
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduledPosts:
|
||||||
|
def test_scheduled_post_is_hidden(self, client: TestClient, tmp_content_dir: Path) -> None:
|
||||||
|
future = date.today().replace(year=date.today().year + 1).isoformat()
|
||||||
|
(tmp_content_dir / "later.md").write_text(
|
||||||
|
f'+++\ntitle = "Later"\npublish_at = {future}\n+++\n\nNot yet.\n'
|
||||||
|
)
|
||||||
|
response = client.get("/api/volumen/posts")
|
||||||
|
slugs = [p["slug"] for p in response.json()["posts"]]
|
||||||
|
assert "later" not in slugs
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearch:
|
||||||
|
def test_search_by_title(self, client: TestClient) -> None:
|
||||||
|
response = client.get("/api/volumen/posts?q=Hello")
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 1
|
||||||
|
assert data["posts"][0]["slug"] == "hello"
|
||||||
|
|
||||||
|
def test_search_by_body(self, client: TestClient) -> None:
|
||||||
|
response = client.get("/api/volumen/posts?q=world")
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 1
|
||||||
|
|
||||||
|
def test_search_no_match(self, client: TestClient) -> None:
|
||||||
|
response = client.get("/api/volumen/posts?q=nonexistent")
|
||||||
|
assert response.json()["total"] == 0
|
||||||
|
|
||||||
|
def test_search_is_case_insensitive(self, client: TestClient) -> None:
|
||||||
|
response = client.get("/api/volumen/posts?q=HELLO")
|
||||||
|
assert response.json()["total"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestPagination:
|
||||||
|
def test_pagination_has_next_and_prev(self, client: TestClient, tmp_content_dir: Path) -> None:
|
||||||
|
for i in range(5):
|
||||||
|
(tmp_content_dir / f"post-{i}.md").write_text(
|
||||||
|
f'+++\ntitle = "Post {i}"\n+++\n\nBody {i}.\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get("/api/volumen/posts?page=1&limit=2")
|
||||||
|
data = resp.json()
|
||||||
|
assert data["has_next"] is True
|
||||||
|
assert data["has_prev"] is False
|
||||||
|
|
||||||
|
resp = client.get("/api/volumen/posts?page=2&limit=2")
|
||||||
|
data = resp.json()
|
||||||
|
assert data["has_next"] is True
|
||||||
|
assert data["has_prev"] is True
|
||||||
|
|
||||||
|
resp = client.get("/api/volumen/posts?page=3&limit=2")
|
||||||
|
data = resp.json()
|
||||||
|
assert data["has_next"] is False
|
||||||
|
assert data["has_prev"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestRSSFeed:
|
||||||
|
def test_feed_endpoint(self, client: TestClient) -> None:
|
||||||
|
response = client.get("/api/volumen/feed.xml")
|
||||||
|
assert response.status_code == 200
|
||||||
|
ct = response.headers.get("content-type", "")
|
||||||
|
assert "application/rss+xml" in ct or "application/xml" in ct
|
||||||
|
body = response.text
|
||||||
|
assert '<rss version="2.0"' in body
|
||||||
|
assert "<title>Hello</title>" in body
|
||||||
|
assert "Draft" not in body
|
||||||
|
assert "<language>en</language>" in body
|
||||||
|
|
||||||
|
def test_rss_feed_includes_dc_creator_when_set(
|
||||||
|
self, client: TestClient, tmp_content_dir: Path
|
||||||
|
) -> None:
|
||||||
|
(tmp_content_dir / "rss.md").write_text(
|
||||||
|
'+++\ntitle = "Rss"\nslug = "rss"\n'
|
||||||
|
'fediverse_creator = "@rss@example.com"\n+++\n\nBody.\n'
|
||||||
|
)
|
||||||
|
response = client.get("/api/volumen/feed.xml")
|
||||||
|
assert "<dc:creator>@rss@example.com</dc:creator>" in response.text
|
||||||
|
assert "xmlns:dc" in response.text
|
||||||
|
|
||||||
|
def test_rss_feed_omits_dc_creator_when_not_set(self, client: TestClient) -> None:
|
||||||
|
response = client.get("/api/volumen/feed.xml")
|
||||||
|
assert "<dc:creator>" not in response.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestJSONFeed:
|
||||||
|
def test_json_feed_endpoint(self, client: TestClient) -> None:
|
||||||
|
response = client.get("/api/volumen/feed.json")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["version"] == "https://jsonfeed.org/version/1.1"
|
||||||
|
assert data["title"] == "My Blog"
|
||||||
|
assert len(data["items"]) == 1
|
||||||
|
assert data["items"][0]["title"] == "Hello"
|
||||||
|
assert "<strong>world</strong>" in data["items"][0]["content_html"]
|
||||||
|
titles = [i["title"] for i in data["items"]]
|
||||||
|
assert "Draft" not in titles
|
||||||
|
|
||||||
|
def test_json_feed_includes_authors_from_post(
|
||||||
|
self, client: TestClient, tmp_content_dir: Path
|
||||||
|
) -> None:
|
||||||
|
(tmp_content_dir / "jsonfed.md").write_text(
|
||||||
|
'+++\ntitle = "Jsonfed"\nslug = "jsonfed"\n'
|
||||||
|
'fediverse_creator = "@jf@example.io"\n+++\n\nBody.\n'
|
||||||
|
)
|
||||||
|
response = client.get("/api/volumen/feed.json")
|
||||||
|
items = response.json()["items"]
|
||||||
|
item = next(i for i in items if i.get("id", "").endswith("jsonfed"))
|
||||||
|
assert item["authors"] == [{"name": "@jf@example.io"}]
|
||||||
|
|
||||||
|
def test_json_feed_falls_back_to_site_default_fediverse_creator(
|
||||||
|
self, client: TestClient, tmp_content_dir: Path
|
||||||
|
) -> None:
|
||||||
|
pass # Requires a custom config with site.fediverse_creator set
|
||||||
|
|
||||||
|
|
||||||
|
class TestSitemap:
|
||||||
|
def test_sitemap_endpoint(self, client: TestClient) -> None:
|
||||||
|
response = client.get("/api/volumen/sitemap.xml")
|
||||||
|
assert response.status_code == 200
|
||||||
|
ct = response.headers.get("content-type", "")
|
||||||
|
assert "application/xml" in ct or "text/xml" in ct
|
||||||
|
body = response.text
|
||||||
|
assert "urlset" in body
|
||||||
|
assert "<loc>https://example.com/hello</loc>" in body
|
||||||
|
assert "draft" not in body
|
||||||
|
|
||||||
|
|
||||||
|
class TestFediverseCreator:
|
||||||
|
def test_post_summary_exposes_fediverse_creator(
|
||||||
|
self, client: TestClient, tmp_content_dir: Path
|
||||||
|
) -> None:
|
||||||
|
(tmp_content_dir / "attributed.md").write_text(
|
||||||
|
'+++\ntitle = "Attributed"\nslug = "attributed"\n'
|
||||||
|
'fediverse_creator = "@ada@lovelace.org"\n+++\n\nBody.\n'
|
||||||
|
)
|
||||||
|
response = client.get("/api/volumen/posts/attributed")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["fediverse_creator"] == "@ada@lovelace.org"
|
||||||
|
|
||||||
|
def test_post_summary_fediverse_creator_is_null_when_not_set(self, client: TestClient) -> None:
|
||||||
|
response = client.get("/api/volumen/posts/hello")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["fediverse_creator"] is None
|
||||||
|
|
||||||
|
def test_posts_list_summary_exposes_fediverse_creator(
|
||||||
|
self, client: TestClient, tmp_content_dir: Path
|
||||||
|
) -> None:
|
||||||
|
(tmp_content_dir / "fed.md").write_text(
|
||||||
|
'+++\ntitle = "Fed"\nslug = "fed"\n'
|
||||||
|
'fediverse_creator = "@user@example.social"\n+++\n\nBody.\n'
|
||||||
|
)
|
||||||
|
response = client.get("/api/volumen/posts")
|
||||||
|
fed = next(p for p in response.json()["posts"] if p["slug"] == "fed")
|
||||||
|
assert fed["fediverse_creator"] == "@user@example.social"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCacheHeaders:
|
||||||
|
def test_cache_control_on_api_responses(self, client: TestClient) -> None:
|
||||||
|
response = client.get("/api/volumen/site")
|
||||||
|
assert "cache-control" in response.headers
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""Tests for Store — post loading, saving, deleting, media handling."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from volumen.post import Post
|
||||||
|
from volumen.store import Store
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoading:
|
||||||
|
def test_all_loads_every_post(self, store: Store) -> None:
|
||||||
|
posts = store.all()
|
||||||
|
assert len(posts) == 2 # hello.md + draft.md
|
||||||
|
|
||||||
|
def test_slug_falls_back_to_filename(self, store: Store) -> None:
|
||||||
|
slugs = sorted(p.slug for p in store.all())
|
||||||
|
assert slugs == ["draft", "hello"]
|
||||||
|
|
||||||
|
def test_lang_derived_from_subdirectory(self, tmp_path: Path) -> None:
|
||||||
|
content = tmp_path / "posts"
|
||||||
|
content.mkdir()
|
||||||
|
(content / "hello.md").write_text('+++\ntitle = "Hello"\n+++\n\nBody\n')
|
||||||
|
cs_dir = content / "cs"
|
||||||
|
cs_dir.mkdir()
|
||||||
|
(cs_dir / "ahoj.md").write_text('+++\ntitle = "Ahoj"\n+++\n\nTelo\n')
|
||||||
|
st = Store(str(content), default_lang="en")
|
||||||
|
assert st.find("ahoj").lang == "cs"
|
||||||
|
assert st.find("hello").lang == "en"
|
||||||
|
|
||||||
|
def test_find_returns_none_when_missing(self, store: Store) -> None:
|
||||||
|
assert store.find("nope") is None
|
||||||
|
|
||||||
|
def test_all_langs_post_visible_in_any_language(self, tmp_content_dir: Path) -> None:
|
||||||
|
(tmp_content_dir / "global.md").write_text(
|
||||||
|
'+++\ntitle = "Global"\nlang = "en"\nall_langs = true\n+++\n\nVisible everywhere.\n'
|
||||||
|
)
|
||||||
|
store = Store(str(tmp_content_dir), default_lang="en")
|
||||||
|
assert store.find("global", lang="cs") is not None
|
||||||
|
assert store.find("global", lang="en") is not None
|
||||||
|
|
||||||
|
def test_draft_post_is_loaded(self, store: Store) -> None:
|
||||||
|
draft = store.find("draft")
|
||||||
|
assert draft is not None
|
||||||
|
assert draft.draft is True
|
||||||
|
|
||||||
|
def test_post_with_missing_slug_derived_from_path(self, tmp_content_dir: Path) -> None:
|
||||||
|
(tmp_content_dir / "noslug.md").write_text('+++\ntitle = "No Slug"\n+++\n\nBody\n')
|
||||||
|
store = Store(str(tmp_content_dir), default_lang="en")
|
||||||
|
post = store.find("noslug")
|
||||||
|
assert post is not None
|
||||||
|
assert post.slug == "noslug"
|
||||||
|
assert post.lang == "en"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSave:
|
||||||
|
def test_save_new_post(self, tmp_content_dir: Path) -> None:
|
||||||
|
store = Store(str(tmp_content_dir), default_lang="en")
|
||||||
|
post = Post(metadata={"slug": "newpost", "title": "New"}, body="Content")
|
||||||
|
store.save(post)
|
||||||
|
assert os.path.isfile(os.path.join(str(tmp_content_dir), "newpost.md"))
|
||||||
|
|
||||||
|
def test_save_updates_existing_post(self, tmp_content_dir: Path) -> None:
|
||||||
|
store = Store(str(tmp_content_dir), default_lang="en")
|
||||||
|
post = store.find("hello")
|
||||||
|
assert post is not None
|
||||||
|
post.body = "Updated content."
|
||||||
|
store.save(post)
|
||||||
|
reloaded = store.find("hello")
|
||||||
|
assert reloaded is not None
|
||||||
|
assert "Updated content." in reloaded.body
|
||||||
|
|
||||||
|
def test_save_rejects_path_traversal_slug(self, tmp_content_dir: Path) -> None:
|
||||||
|
store = Store(str(tmp_content_dir))
|
||||||
|
post = Post(metadata={"slug": "../../etc/passwd"}, body="x")
|
||||||
|
with pytest.raises(ValueError, match="slug escapes"):
|
||||||
|
store.save(post)
|
||||||
|
|
||||||
|
def test_save_uses_lang_subdirectory(self, tmp_content_dir: Path) -> None:
|
||||||
|
store = Store(str(tmp_content_dir), default_lang="en")
|
||||||
|
post = Post(metadata={"slug": "bonjour", "lang": "fr"}, body="x")
|
||||||
|
store.save(post)
|
||||||
|
assert os.path.isfile(os.path.join(str(tmp_content_dir), "fr", "bonjour.md"))
|
||||||
|
|
||||||
|
def test_save_uses_root_for_default_language(self, tmp_content_dir: Path) -> None:
|
||||||
|
store = Store(str(tmp_content_dir), default_lang="en")
|
||||||
|
post = Post(metadata={"slug": "hello-new", "lang": "en"}, body="x")
|
||||||
|
store.save(post)
|
||||||
|
assert os.path.isfile(os.path.join(str(tmp_content_dir), "hello-new.md"))
|
||||||
|
assert not os.path.isfile(os.path.join(str(tmp_content_dir), "en", "hello-new.md"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestDelete:
|
||||||
|
def test_delete_post(self, tmp_content_dir: Path) -> None:
|
||||||
|
store = Store(str(tmp_content_dir), default_lang="en")
|
||||||
|
path = os.path.join(str(tmp_content_dir), "hello.md")
|
||||||
|
assert os.path.isfile(path)
|
||||||
|
store.delete("hello")
|
||||||
|
assert not os.path.isfile(path)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMedia:
|
||||||
|
def test_media_file_path_traversal_prevention(self, tmp_content_dir: Path) -> None:
|
||||||
|
store = Store(str(tmp_content_dir))
|
||||||
|
bad = store.media_file("../etc/passwd")
|
||||||
|
assert bad is None
|
||||||
|
|
||||||
|
def test_media_file_nonexistent(self, tmp_content_dir: Path) -> None:
|
||||||
|
store = Store(str(tmp_content_dir))
|
||||||
|
assert store.media_file("nonexistent.webp") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestCacheInvalidation:
|
||||||
|
def test_mtime_cache_invalidation(self, tmp_content_dir: Path) -> None:
|
||||||
|
store = Store(str(tmp_content_dir), default_lang="en")
|
||||||
|
initial = len(store.all())
|
||||||
|
|
||||||
|
# Add a new file
|
||||||
|
(tmp_content_dir / "new.md").write_text('+++\ntitle = "New"\n+++\n\nBody\n')
|
||||||
|
assert len(store.all()) == initial + 1
|
||||||
|
|
||||||
|
def test_cache_invalidation_on_save(self, tmp_content_dir: Path) -> None:
|
||||||
|
store = Store(str(tmp_content_dir), default_lang="en")
|
||||||
|
initial = len(store.all())
|
||||||
|
post = Post(metadata={"slug": "extra"}, body="x")
|
||||||
|
store.save(post)
|
||||||
|
assert len(store.all()) == initial + 1
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"""Tests for Users — file-backed admin/author management."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from volumen.users import Users
|
||||||
|
|
||||||
|
|
||||||
|
class TestBootstrap:
|
||||||
|
def test_bootstrap_user_from_hash(self, users_path: str, bootstrap_hash: str) -> None:
|
||||||
|
users = Users(path=users_path, bootstrap_hash=bootstrap_hash)
|
||||||
|
assert len(users.all) == 1
|
||||||
|
assert users.authenticate("admin", "secret") is not None
|
||||||
|
|
||||||
|
def test_bootstrap_user_is_admin(self, users_path: str, bootstrap_hash: str) -> None:
|
||||||
|
users = Users(path=users_path, bootstrap_hash=bootstrap_hash)
|
||||||
|
admin = users.find("admin")
|
||||||
|
assert admin is not None
|
||||||
|
assert admin.role == "admin"
|
||||||
|
|
||||||
|
def test_no_users_without_file_or_hash(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
assert not users.any
|
||||||
|
|
||||||
|
|
||||||
|
class TestFind:
|
||||||
|
def test_find_existing_user(self, users: Users) -> None:
|
||||||
|
assert users.find("admin") is not None
|
||||||
|
|
||||||
|
def test_find_nonexistent_user(self, users: Users) -> None:
|
||||||
|
assert users.find("nobody") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthenticate:
|
||||||
|
def test_authenticate_correct_password(self, users: Users) -> None:
|
||||||
|
assert users.authenticate("admin", "secret") is not None
|
||||||
|
|
||||||
|
def test_authenticate_wrong_password(self, users: Users) -> None:
|
||||||
|
assert users.authenticate("admin", "nope") is None
|
||||||
|
|
||||||
|
def test_authenticate_nonexistent_user(self, users: Users) -> None:
|
||||||
|
assert users.authenticate("nobody", "secret") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdd:
|
||||||
|
def test_add_creates_user(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
result = users.add("alice", "wonderland")
|
||||||
|
assert result is not None
|
||||||
|
assert result.username == "alice"
|
||||||
|
assert os.path.isfile(users_path)
|
||||||
|
|
||||||
|
def test_add_and_authenticate(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
users.add("alice", "wonderland")
|
||||||
|
assert users.authenticate("alice", "wonderland") is not None
|
||||||
|
assert users.authenticate("alice", "nope") is None
|
||||||
|
|
||||||
|
def test_add_rejects_duplicate(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
users.add("alice", "x")
|
||||||
|
assert users.add("alice", "y") is None
|
||||||
|
|
||||||
|
def test_add_rejects_empty_username(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
assert users.add("", "password") is None
|
||||||
|
|
||||||
|
def test_add_with_explicit_role(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
users.add("boss", "x", "admin")
|
||||||
|
assert users.find("boss").role == "admin"
|
||||||
|
|
||||||
|
def test_add_default_role_is_author(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
users.add("alice", "x")
|
||||||
|
assert users.find("alice").role == "author"
|
||||||
|
|
||||||
|
def test_add_invalid_role_falls_back_to_default(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
users.add("alice", "x", "wizard")
|
||||||
|
assert users.find("alice").role == "author"
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdate:
|
||||||
|
def test_update_name(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
users.add("alice", "x")
|
||||||
|
users.update_name("alice", "Alice Smith")
|
||||||
|
assert users.find("alice").name == "Alice Smith"
|
||||||
|
|
||||||
|
def test_update_name_clear(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
users.add("alice", "x")
|
||||||
|
users.update_name("alice", "Alice")
|
||||||
|
users.update_name("alice", None)
|
||||||
|
assert users.find("alice").name is None
|
||||||
|
|
||||||
|
def test_update_fediverse_creator(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
users.add("alice", "x")
|
||||||
|
users.update_fediverse_creator("alice", "@alice@example.com")
|
||||||
|
assert users.find("alice").fediverse_creator == "@alice@example.com"
|
||||||
|
|
||||||
|
def test_update_fediverse_creator_clear(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
users.add("alice", "x")
|
||||||
|
users.update_fediverse_creator("alice", "@alice@example.com")
|
||||||
|
users.update_fediverse_creator("alice", None)
|
||||||
|
assert users.find("alice").fediverse_creator is None
|
||||||
|
|
||||||
|
def test_update_photo(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
users.add("alice", "x")
|
||||||
|
users.update_photo("alice", "/media/photo.webp")
|
||||||
|
assert users.find("alice").photo == "/media/photo.webp"
|
||||||
|
|
||||||
|
def test_update_password(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
users.add("alice", "old")
|
||||||
|
users.update_password("alice", "new")
|
||||||
|
assert users.authenticate("alice", "old") is None
|
||||||
|
assert users.authenticate("alice", "new") is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestRename:
|
||||||
|
def test_rename_user(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
users.add("alice", "x")
|
||||||
|
assert users.rename("alice", "alicia") is not None
|
||||||
|
assert users.find("alice") is None
|
||||||
|
assert users.find("alicia") is not None
|
||||||
|
|
||||||
|
def test_rename_to_existing_username(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
users.add("alice", "x")
|
||||||
|
users.add("bob", "y")
|
||||||
|
assert users.rename("alice", "bob") is None
|
||||||
|
assert users.find("alice") is not None
|
||||||
|
|
||||||
|
def test_rename_empty_new_name(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
users.add("alice", "x")
|
||||||
|
assert users.rename("alice", "") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetRole:
|
||||||
|
def test_set_role(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
users.add("alice", "x", "admin")
|
||||||
|
users.add("bob", "y", "author")
|
||||||
|
assert users.set_role("bob", "admin") is not None
|
||||||
|
assert users.find("bob").role == "admin"
|
||||||
|
|
||||||
|
def test_cannot_demote_last_admin(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
users.add("alice", "x", "admin")
|
||||||
|
users.add("bob", "y", "author")
|
||||||
|
assert users.set_role("alice", "author") is None
|
||||||
|
assert users.find("alice").role == "admin"
|
||||||
|
|
||||||
|
def test_set_role_invalid(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
users.add("alice", "x")
|
||||||
|
assert users.set_role("alice", "wizard") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestDelete:
|
||||||
|
def test_delete_user(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
users.add("alice", "x")
|
||||||
|
users.add("bob", "y")
|
||||||
|
assert users.delete("bob") == "bob"
|
||||||
|
assert users.find("bob") is None
|
||||||
|
|
||||||
|
def test_cannot_delete_last_user(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
users.add("alice", "x")
|
||||||
|
assert users.delete("alice") is None
|
||||||
|
assert users.find("alice") is not None
|
||||||
|
|
||||||
|
def test_cannot_delete_last_admin(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
users.add("alice", "x", "admin")
|
||||||
|
users.add("bob", "y", "author")
|
||||||
|
assert users.delete("alice") is None
|
||||||
|
# After adding another admin, deletion works
|
||||||
|
users.add("carol", "z", "admin")
|
||||||
|
assert users.delete("alice") == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPersistence:
|
||||||
|
def test_persistence_write_and_read(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
users.add("alice", "wonderland")
|
||||||
|
users.add("bob", "builder", "admin")
|
||||||
|
users.update_name("alice", "Alice")
|
||||||
|
|
||||||
|
# Read back from disk with a fresh instance
|
||||||
|
fresh = Users(path=users_path)
|
||||||
|
assert len(fresh.all) == 2
|
||||||
|
alice = fresh.find("alice")
|
||||||
|
assert alice is not None
|
||||||
|
assert alice.name == "Alice"
|
||||||
|
assert alice.role == "author"
|
||||||
|
bob = fresh.find("bob")
|
||||||
|
assert bob is not None
|
||||||
|
assert bob.role == "admin"
|
||||||
|
|
||||||
|
def test_password_survives_persistence(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
users.add("alice", "secret123")
|
||||||
|
fresh = Users(path=users_path)
|
||||||
|
assert fresh.authenticate("alice", "secret123") is not None
|
||||||
|
assert fresh.authenticate("alice", "wrong") is None
|
||||||
|
|
||||||
|
def test_mtime_cache_invalidation(self, users_path: str) -> None:
|
||||||
|
users = Users(path=users_path)
|
||||||
|
users.add("alice", "x")
|
||||||
|
assert len(users.all) == 1
|
||||||
|
|
||||||
|
# Directly modify the file behind the cache
|
||||||
|
with open(users_path, "w") as fh:
|
||||||
|
fh.write("")
|
||||||
|
|
||||||
|
# Should detect file change and return empty
|
||||||
|
assert len(users.all) == 0
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require_relative "lib/volumen/version"
|
|
||||||
|
|
||||||
Gem::Specification.new do |spec|
|
|
||||||
spec.name = "volumen"
|
|
||||||
spec.version = Volumen::VERSION
|
|
||||||
spec.authors = ["Petr Balvín"]
|
|
||||||
spec.email = ["petrbalvin@yandex.com"]
|
|
||||||
|
|
||||||
spec.summary = "A small, file-based Markdown blog engine with a built-in admin."
|
|
||||||
spec.description = "volumen serves Markdown posts (TOML frontmatter) as a JSON API " \
|
|
||||||
"and ships a server-rendered admin with both a Markdown source " \
|
|
||||||
"editor and a visual editor. File-based, single-admin, " \
|
|
||||||
"dependency-light."
|
|
||||||
spec.homepage = "https://codeberg.org/petrbalvin/volumen"
|
|
||||||
spec.license = "MIT"
|
|
||||||
spec.required_ruby_version = ">= 3.3"
|
|
||||||
|
|
||||||
spec.metadata["homepage_uri"] = spec.homepage
|
|
||||||
spec.metadata["rubygems_mfa_required"] = "true"
|
|
||||||
|
|
||||||
spec.files = Dir[
|
|
||||||
"lib/**/*",
|
|
||||||
"exe/*",
|
|
||||||
"README.md",
|
|
||||||
"LICENSE"
|
|
||||||
]
|
|
||||||
spec.bindir = "exe"
|
|
||||||
spec.executables = ["volumen"]
|
|
||||||
spec.require_paths = ["lib"]
|
|
||||||
|
|
||||||
spec.add_dependency "kramdown", "~> 2.5"
|
|
||||||
spec.add_dependency "kramdown-parser-gfm", "~> 1.1"
|
|
||||||
spec.add_dependency "puma", "~> 8.0"
|
|
||||||
spec.add_dependency "rackup", "~> 2.3"
|
|
||||||
spec.add_dependency "sinatra", "~> 4.2"
|
|
||||||
spec.add_dependency "toml-rb", "~> 4.2"
|
|
||||||
end
|
|
||||||
Reference in New Issue
Block a user