refactor: rewrite from Ruby/Sinatra to Python/FastAPI

This commit is contained in:
2026-07-22 21:12:28 +02:00
parent 6f62d3e3f3
commit 44b879371e
69 changed files with 5852 additions and 3463 deletions
+8 -7
View File
@@ -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/
-42
View File
@@ -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"
-13
View File
@@ -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
View File
@@ -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
-12
View File
@@ -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
View File
@@ -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)
+12 -35
View File
@@ -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
-41
View File
@@ -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
-181
View File
@@ -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
-56
View File
@@ -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
-87
View File
@@ -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
-155
View File
@@ -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
-104
View File
@@ -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
-68
View File
@@ -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
-62
View File
@@ -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 (`![alt](url "caption")`) 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
-52
View File
@@ -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
-162
View File
@@ -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
-488
View File
@@ -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
-144
View File
@@ -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
-162
View File
@@ -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
-5
View File
@@ -1,5 +0,0 @@
# frozen_string_literal: true
module Volumen
VERSION = "0.3.0"
end
+46
View File
@@ -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"]
+45
View File
@@ -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
+719
View File
@@ -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)
+177
View File
@@ -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"))
+64
View File
@@ -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")
+157
View File
@@ -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"]
+135
View File
@@ -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>"
)
+65
View File
@@ -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
+99
View File
@@ -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}>"
+50
View File
@@ -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")
+174
View File
@@ -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 ""
+635
View File
@@ -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)
+167
View File
@@ -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
+169
View File
@@ -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)
+5
View File
@@ -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 %}
@@ -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 %}
-339
View File
@@ -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
-63
View File
@@ -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
-65
View File
@@ -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
-47
View File
@@ -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
-33
View File
@@ -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
-24
View File
@@ -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
-137
View File
@@ -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
-304
View File
@@ -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
-73
View File
@@ -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
-4
View File
@@ -1,4 +0,0 @@
# frozen_string_literal: true
require "minitest/autorun"
require "volumen"
-114
View File
@@ -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
-13
View File
@@ -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
View File
+124
View File
@@ -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."
)
+423
View File
@@ -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)
+89
View File
@@ -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
+112
View File
@@ -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"
+78
View File
@@ -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"
+80
View File
@@ -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('![alt](url "caption")')
assert "<figure>" in result
def test_image_without_title_no_wrapping(self) -> None:
result = render("![alt](url)")
assert "<figure>" not in result
def test_figure_includes_figcaption(self) -> None:
result = render('![alt](url "caption")')
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
+51
View File
@@ -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
+207
View File
@@ -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
+242
View File
@@ -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
+130
View File
@@ -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
+227
View File
@@ -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
Generated
+1155
View File
File diff suppressed because it is too large Load Diff
-39
View File
@@ -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