diff --git a/.gitignore b/.gitignore
index adc2474..5886a92 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,11 @@
-# Bundler
-/.bundle/
-/vendor/bundle/
-
-# Built gems
-/pkg/
-*.gem
+# Python
+__pycache__/
+*.pyc
+*.egg-info/
+.venv/
+.pytest_cache/
+.ruff_cache/
+dist/
# Test / coverage
/coverage/
diff --git a/.rubocop.yml b/.rubocop.yml
deleted file mode 100644
index 2843a5a..0000000
--- a/.rubocop.yml
+++ /dev/null
@@ -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"
diff --git a/Gemfile b/Gemfile
deleted file mode 100644
index 36f5a15..0000000
--- a/Gemfile
+++ /dev/null
@@ -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
diff --git a/Gemfile.lock b/Gemfile.lock
deleted file mode 100644
index 1f51b09..0000000
--- a/Gemfile.lock
+++ /dev/null
@@ -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
diff --git a/Rakefile b/Rakefile
deleted file mode 100644
index 41b7c02..0000000
--- a/Rakefile
+++ /dev/null
@@ -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
diff --git a/exe/volumen b/exe/volumen
index 681d9eb..ccfaa5f 100755
--- a/exe/volumen
+++ b/exe/volumen
@@ -1,7 +1,3 @@
-#!/usr/bin/env ruby
-# frozen_string_literal: true
-
-require "volumen"
-require "volumen/cli"
-
-Volumen::CLI.run(ARGV)
+#!/usr/bin/env python3
+from volumen.cli import main
+main()
diff --git a/justfile b/justfile
index 6f7033c..e6d7c34 100644
--- a/justfile
+++ b/justfile
@@ -1,48 +1,25 @@
-set dotenv-load := false
-set positional-arguments := false
-
default:
- @just --list
+ @just --list
-# Install Ruby dependencies
install:
- @echo "→ Installing Ruby dependencies…"
- bundle install
+ uv sync
-# Run the development server
run:
- @echo "→ Starting volumen…"
- bundle exec exe/volumen serve
+ uv run volumen serve
-# Run the development server against ./posts and ./config.toml
dev:
- @echo "→ Starting volumen (dev) on http://localhost:9090 …"
- bundle exec exe/volumen serve --config ./config.toml --content ./posts
+ uv run 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:
- @echo "→ Linting…"
- bundle exec rubocop
- @echo "→ Building gem…"
- @mkdir -p pkg
- gem build volumen.gemspec --output pkg/volumen.gem
+ uv run ruff check src/ tests/
+ uv run ruff format --check src/ tests/
-# Run the test suite
test:
- @echo "→ Running tests…"
- bundle exec rake test
+ uv run pytest
+
+fmt:
+ uv run ruff format src/ tests/
+ uv run ruff check --fix src/ tests/
-# Remove build artifacts
uninstall:
- @echo "→ Removing build artifacts…"
- rm -rf pkg *.gem
+ rm -rf dist/ __pycache__ .pytest_cache .ruff_cache
diff --git a/lib/volumen.rb b/lib/volumen.rb
deleted file mode 100644
index d15d49e..0000000
--- a/lib/volumen.rb
+++ /dev/null
@@ -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
diff --git a/lib/volumen/admin_routes.rb b/lib/volumen/admin_routes.rb
deleted file mode 100644
index 32eacd8..0000000
--- a/lib/volumen/admin_routes.rb
+++ /dev/null
@@ -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
diff --git a/lib/volumen/api_routes.rb b/lib/volumen/api_routes.rb
deleted file mode 100644
index 1576439..0000000
--- a/lib/volumen/api_routes.rb
+++ /dev/null
@@ -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
diff --git a/lib/volumen/cli.rb b/lib/volumen/cli.rb
deleted file mode 100644
index da3f3fc..0000000
--- a/lib/volumen/cli.rb
+++ /dev/null
@@ -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
diff --git a/lib/volumen/config.rb b/lib/volumen/config.rb
deleted file mode 100644
index 98eac7a..0000000
--- a/lib/volumen/config.rb
+++ /dev/null
@@ -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
- # 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
diff --git a/lib/volumen/feed_helpers.rb b/lib/volumen/feed_helpers.rb
deleted file mode 100644
index 60e94b2..0000000
--- a/lib/volumen/feed_helpers.rb
+++ /dev/null
@@ -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
-
-
-
- #{escape(site["title"])}
- #{escape(base_url)}
- #{escape(site["description"])}
- #{escape(site["language"])}
- #{items}
-
-
- XML
- end
-
- def feed_item(post)
- link = "#{base_url}/#{post.slug}"
- pub = if post.metadata["date"]
- "\n #{escape(rfc822_date(post))}"
- else
- ""
- end
- creator = post.fediverse_creator
- creator_tag = creator ? "\n #{escape(creator)}" : ""
- <<~XML.chomp
-
- #{escape(post.title)}
- #{escape(link)}
- #{escape(link)}
- #{escape(post.excerpt)}#{pub}#{creator_tag}
-
- 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 = " #{escape("#{base_url}/#{post.slug}")}"
- url << "#{escape(post.date_string)}" if post.date_string
- url << ""
- end.join("\n")
- <<~XML
-
-
- #{urls}
-
- XML
- end
- end
-end
diff --git a/lib/volumen/frontmatter.rb b/lib/volumen/frontmatter.rb
deleted file mode 100644
index e3be013..0000000
--- a/lib/volumen/frontmatter.rb
+++ /dev/null
@@ -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
diff --git a/lib/volumen/markdown.rb b/lib/volumen/markdown.rb
deleted file mode 100644
index b339f38..0000000
--- a/lib/volumen/markdown.rb
+++ /dev/null
@@ -1,62 +0,0 @@
-# frozen_string_literal: true
-
-require "kramdown"
-require "rexml/document"
-
-module Volumen
- # Renders Markdown to HTML using kramdown's GFM parser (tables, fenced code,
- # task lists, strikethrough).
- #
- # Posts are authored by the single trusted admin, so kramdown's default
- # behaviour of passing raw HTML through is intentional.
- #
- # Images with a title (``) are wrapped in a `
`
- # element with a `` 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 `` inside a `
` with ``.
- # Images without a title are left untouched.
- def self.wrap_figures(html)
- doc = REXML::Document.new("#{html}")
-
- 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 we added.
- result = +""
- doc.root.children.each { |child| result << child.to_s }
- result
- rescue REXML::ParseException
- html
- end
- end
-end
diff --git a/lib/volumen/password.rb b/lib/volumen/password.rb
deleted file mode 100644
index 6e57355..0000000
--- a/lib/volumen/password.rb
+++ /dev/null
@@ -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
diff --git a/lib/volumen/post.rb b/lib/volumen/post.rb
deleted file mode 100644
index d11ece1..0000000
--- a/lib/volumen/post.rb
+++ /dev/null
@@ -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
diff --git a/lib/volumen/server.rb b/lib/volumen/server.rb
deleted file mode 100644
index 15c9632..0000000
--- a/lib/volumen/server.rb
+++ /dev/null
@@ -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
diff --git a/lib/volumen/store.rb b/lib/volumen/store.rb
deleted file mode 100644
index 8a9bd19..0000000
--- a/lib/volumen/store.rb
+++ /dev/null
@@ -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/.md` (default language) or at
- # `content_dir//.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
diff --git a/lib/volumen/users.rb b/lib/volumen/users.rb
deleted file mode 100644
index 4e3bfde..0000000
--- a/lib/volumen/users.rb
+++ /dev/null
@@ -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
diff --git a/lib/volumen/version.rb b/lib/volumen/version.rb
deleted file mode 100644
index e658d11..0000000
--- a/lib/volumen/version.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-# frozen_string_literal: true
-
-module Volumen
- VERSION = "0.3.0"
-end
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..fdeba0e
--- /dev/null
+++ b/pyproject.toml
@@ -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"]
diff --git a/src/volumen/__init__.py b/src/volumen/__init__.py
new file mode 100644
index 0000000..be4208a
--- /dev/null
+++ b/src/volumen/__init__.py
@@ -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
diff --git a/src/volumen/admin_routes.py b/src/volumen/admin_routes.py
new file mode 100644
index 0000000..1d09e12
--- /dev/null
+++ b/src/volumen/admin_routes.py
@@ -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)
diff --git a/src/volumen/api_routes.py b/src/volumen/api_routes.py
new file mode 100644
index 0000000..ba3a11c
--- /dev/null
+++ b/src/volumen/api_routes.py
@@ -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"))
diff --git a/src/volumen/cli.py b/src/volumen/cli.py
new file mode 100644
index 0000000..19cc373
--- /dev/null
+++ b/src/volumen/cli.py
@@ -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")
diff --git a/src/volumen/config.py b/src/volumen/config.py
new file mode 100644
index 0000000..851cba1
--- /dev/null
+++ b/src/volumen/config.py
@@ -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
+# 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"]
diff --git a/src/volumen/feed_helpers.py b/src/volumen/feed_helpers.py
new file mode 100644
index 0000000..345b53c
--- /dev/null
+++ b/src/volumen/feed_helpers.py
@@ -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 (
+ '\n'
+ '\n'
+ " \n"
+ f" {title}\n"
+ f" {link}\n"
+ f" {description}\n"
+ f" {language}\n"
+ f"{items}\n"
+ " \n"
+ ""
+ )
+
+
+def _rss_item(post: Post, base_url: str) -> str:
+ """Render a single RSS element."""
+ permalink = f"{base_url}/{post.slug}"
+ pub_date = ""
+ post_date = post.date_string
+ if post_date:
+ pub_date = f"\n {html.escape(_rfc822_date(post_date))}"
+ creator = post.fediverse_creator
+ creator_tag = ""
+ if creator:
+ creator_tag = f"\n {html.escape(creator)}"
+ return (
+ f" \n"
+ f" {html.escape(post.title or '')}\n"
+ f" {html.escape(permalink)}\n"
+ f" {html.escape(permalink)}\n"
+ f" {html.escape(post.excerpt or '')}"
+ f"{pub_date}{creator_tag}\n"
+ f" "
+ )
+
+
+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" {loc}"
+ if post.date_string:
+ entry += f"{html.escape(post.date_string)}"
+ entry += ""
+ urls.append(entry)
+ url_block = "\n".join(urls)
+ return (
+ '\n'
+ '\n'
+ f"{url_block}\n"
+ ""
+ )
diff --git a/src/volumen/frontmatter.py b/src/volumen/frontmatter.py
new file mode 100644
index 0000000..deeb92e
--- /dev/null
+++ b/src/volumen/frontmatter.py
@@ -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
diff --git a/src/volumen/markdown.py b/src/volumen/markdown.py
new file mode 100644
index 0000000..27e64b5
--- /dev/null
+++ b/src/volumen/markdown.py
@@ -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 inside a
with ."""
+ if 'title="' not in html and "title='" not in html:
+ return html
+
+ try:
+ root = ET.fromstring(f"{html}")
+ 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}>"
diff --git a/src/volumen/password.py b/src/volumen/password.py
new file mode 100644
index 0000000..75e76ec
--- /dev/null
+++ b/src/volumen/password.py
@@ -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")
diff --git a/src/volumen/post.py b/src/volumen/post.py
new file mode 100644
index 0000000..b1d5421
--- /dev/null
+++ b/src/volumen/post.py
@@ -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 ""
diff --git a/src/volumen/server.py b/src/volumen/server.py
new file mode 100644
index 0000000..6fb0bc4
--- /dev/null
+++ b/src/volumen/server.py
@@ -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)
diff --git a/src/volumen/store.py b/src/volumen/store.py
new file mode 100644
index 0000000..bfd7d2e
--- /dev/null
+++ b/src/volumen/store.py
@@ -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
diff --git a/src/volumen/users.py b/src/volumen/users.py
new file mode 100644
index 0000000..938eb7e
--- /dev/null
+++ b/src/volumen/users.py
@@ -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)
diff --git a/src/volumen/version.py b/src/volumen/version.py
new file mode 100644
index 0000000..f33e007
--- /dev/null
+++ b/src/volumen/version.py
@@ -0,0 +1,5 @@
+"""volumen version."""
+
+from __future__ import annotations
+
+VERSION = "0.4.0"
diff --git a/lib/volumen/web/public/volumen-icon.svg b/src/volumen/web/static/volumen-icon.svg
similarity index 100%
rename from lib/volumen/web/public/volumen-icon.svg
rename to src/volumen/web/static/volumen-icon.svg
diff --git a/lib/volumen/web/public/volumen-logo.svg b/src/volumen/web/static/volumen-logo.svg
similarity index 100%
rename from lib/volumen/web/public/volumen-logo.svg
rename to src/volumen/web/static/volumen-logo.svg
diff --git a/lib/volumen/web/views/form.erb b/src/volumen/web/templates/form.html.jinja
similarity index 88%
rename from lib/volumen/web/views/form.erb
rename to src/volumen/web/templates/form.html.jinja
index b9353f9..819fe32 100644
--- a/lib/volumen/web/views/form.erb
+++ b/src/volumen/web/templates/form.html.jinja
@@ -1,20 +1,25 @@
-<%
- crumbs = @mode == :new ? [["Posts", to("/admin/")], ["New post", nil]] : [["Posts", to("/admin/")], [@post.title.to_s.empty? ? "Untitled" : @post.title, nil]]
-%>
+{% extends "layout.html.jinja" %}
+
+{% 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 %}
-
<%= @mode == :new ? "New post" : "Edit post" %>
-
<%= @mode == :new ? "Draft a new article in Markdown." : "Update and publish this post." %>
+
{{ "New post" if mode == "new" else "Edit post" }}
+
{{ "Draft a new article in Markdown." if mode == "new" else "Update and publish this post." }}