23 Commits
Author SHA1 Message Date
petrbalvin 1440e9a1fd release: bump version to 0.2.0 2026-06-25 22:12:55 +02:00
petrbalvin b24a8cc377 feat: add fulltext search with ?q= parameter
Filter posts by case-insensitive match in title and body. Extracted
filter_by_lang and search_posts helpers to keep complexity in check.
2026-06-25 22:06:49 +02:00
petrbalvin 599c63e5bc feat: add JSON Feed endpoint at /api/volumen/feed.json
Implements jsonfeed.org version 1.1 with title, home_page_url, feed_url,
description, language, and items with content_html and date_published.
2026-06-25 22:06:49 +02:00
petrbalvin 73be893bf8 feat: add scheduled publishing with publish_at frontmatter field
Posts with a future publish_at date are hidden from the public API
and feeds, same as drafts. The admin form exposes a Publish at field.
2026-06-25 22:06:49 +02:00
petrbalvin 52be2bb996 security: CORS preflight, session invalidation, CSRF on preview, symlink containment, and hardening
- Add OPTIONS /api/volumen/* for CORS preflight (S-22).
- Invalidate session in require_login! when user no longer exists (S-8).
- Add check_csrf! to POST /admin/preview (S-1).
- Use File.realpath for symlink-safe containment in media_file (S-2).
- Memoize Users#all with mtime-based invalidation (S-12).
- Document that empty permitted_hosts means allow-all (S-5).
- Warn and fall back to random when session_key is too short (S-20).
2026-06-25 22:06:49 +02:00
petrbalvin 5b8236c0a4 test: add sitemap.xml endpoint test
Verify content type, urlset structure, post URL presence, and draft exclusion.
2026-06-25 22:06:49 +02:00
petrbalvin 51e425e6dd test: add CLI test coverage
Cover version, help, unknown command, parse_serve_options defaults
and overrides, prepare_config template generation and no-op.
2026-06-25 22:06:49 +02:00
petrbalvin ce57410044 fix: Store#save writes non-default language posts into lang subdirectory
default_path_for now routes posts whose lang differs from default_lang
into content_dir/<lang>/<slug>.md, matching the documented multi-language
layout and preventing same-slug posts in different languages from
overwriting each other.
2026-06-25 22:06:49 +02:00
petrbalvin afd8ab4bfb fix: atomic writes for posts and users.toml
Write to a .tmp file and then File.rename into place, so a concurrent
read or a mid-write crash never sees a torn file.
2026-06-25 22:06:49 +02:00
petrbalvin ad00872895 perf: memoize Store#all with mtime-based invalidation
Cache the parsed post list per Store instance, invalidated on save,
delete, or content_dir mtime change. Eliminates re-parsing every .md
file on every API and admin request.
2026-06-25 22:06:49 +02:00
petrbalvin d7f9813484 fix: prevent Config DEFAULTS mutation through deep_merge
Freeze inner hashes in DEFAULTS and rewrite deep_merge to build fresh
hashes with {}.merge for uncopied inner maps. Previously, inner hashes
shared objects with the frozen-looking DEFAULTS, so mutating a loaded
config silently poisoned all later loads.
2026-06-25 22:06:49 +02:00
petrbalvin 263e794669 security: hide draft posts from public detail endpoint
Add post.draft? check to GET /api/volumen/posts/:slug so guessing a
draft slug returns 404 like the list endpoint already does.
2026-06-25 22:06:49 +02:00
petrbalvin 1ca0212197 security: add slug validation, username validation, and template escaping
- Enforce SLUG_REGEX (alphanumeric + dot/dash/underscore only) in creation_error
  to prevent path traversal (C-1) and stored XSS (C-3).
- Add File.expand_path containment check in default_path_for as defense-in-depth.
- Escape slug in form action attributes (list, form views).
- Validate username in change_username using the same regex as create_user (C-4).
- Escape username in settings form action attributes.
- Treat submitting the current username as a no-op success instead of 'taken'.
2026-06-25 22:06:49 +02:00
petrbalvin d26d3b5a5b fix: strip HTML tags from derived excerpt
gsub(/<[^>]+>/, '') before stripping markdown markup, so auto-generated
excerpts don't leak raw HTML from post bodies.
2026-06-25 22:06:49 +02:00
petrbalvin 4597c685da test: add RSS feed endpoint tests
Verify content type, RSS structure, draft exclusion, and language
element.
2026-06-25 21:41:19 +02:00
petrbalvin 2181538691 feat: add language element to RSS feed channel 2026-06-25 21:40:57 +02:00
petrbalvin 9dd55adc30 fix: add periodic cleanup of login_attempts hash
Sweep stale entries every 5 minutes to prevent unbounded memory growth
from IPs that touch the login endpoint once and never return.
2026-06-25 21:40:37 +02:00
petrbalvin 7a31b50ca7 docs: add [development] section to CHANGELOG, document in CONTRIBUTING
Record ongoing changes under [development] in CHANGELOG.md. Update the
release workflow to rename [development] to the versioned section.
2026-06-25 21:37:27 +02:00
petrbalvin b2b8156648 refactor: split Server into api_routes and admin_routes modules
Keep server.rb focused on configuration, session setup, and shared
helpers. Extract route definitions into registered Sinatra extension
modules. Exclude the registered methods from AbcSize/MethodLength
metrics — large registered methods are the idiomatic Sinatra pattern.
2026-06-25 21:35:07 +02:00
petrbalvin 140b16b55a feat: add in-memory rate limiting for login endpoint
Rate-limit POST /admin/login at 10 attempts per 60 seconds per IP.
Stored in Volumen.login_attempts so tests can reset the counter.
2026-06-25 21:33:16 +02:00
petrbalvin 2348352fab feat: add lastmod to sitemap entries 2026-06-25 21:31:57 +02:00
petrbalvin 184aa9b3d8 feat: add pubDate to RSS feed items 2026-06-25 21:31:02 +02:00
petrbalvin 0a70b74b55 fix: wire session_ttl config into Rack session cookie expiration
The admin.session_ttl value was defined in defaults, template, and
documentation but never read — sessions had no expiration. Pass it as
expire_after to Rack::Session::Cookie. A value <= 0 means persistent
(until browser close), matching the documented behaviour.
2026-06-25 21:30:20 +02:00
22 changed files with 775 additions and 217 deletions
+10
View File
@@ -19,13 +19,23 @@ Layout/LineLength:
Metrics/AbcSize: Metrics/AbcSize:
Max: 30 Max: 30
Exclude:
- "lib/volumen/admin_routes.rb"
- "lib/volumen/api_routes.rb"
Metrics/MethodLength: Metrics/MethodLength:
Max: 25 Max: 25
Exclude:
- "lib/volumen/admin_routes.rb"
- "lib/volumen/api_routes.rb"
Metrics/ClassLength: Metrics/ClassLength:
Max: 400 Max: 400
Metrics/ModuleLength:
Exclude:
- "lib/volumen/admin_routes.rb"
Metrics/BlockLength: Metrics/BlockLength:
Exclude: Exclude:
- "test/**/*" - "test/**/*"
+54
View File
@@ -5,6 +5,60 @@ All notable changes to **volumen** are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [development]
## [0.2.0] — 2026-06-25
### Added
- `<pubDate>` in RSS feed items, derived from the post date.
- `<lastmod>` in sitemap entries for SEO.
- In-memory rate limiting on the login endpoint: 10 attempts per 60 seconds per IP.
- `<language>` element in the RSS feed channel.
- Periodic cleanup of stale `login_attempts` entries to prevent unbounded memory growth.
- Slug format validation (`[a-z0-9._-]`) preventing path traversal and XSS in templates.
- Username validation in `change_username` matching the create-user regex.
- Escape-on-output for slug and username in admin template attributes.
- Memoization of `Store#all` with mtime-based invalidation, eliminating N×read+parse
per request.
- Atomic file writes (tmp + rename) for posts and `users.toml`.
- Post language directory routing: non-default-language posts now land in
`content_dir/<lang>/` instead of overwriting root-level files.
- CLI test coverage (version, help, unknown command, option parsing, config bootstrap).
- Test coverage for RSS feed and sitemap endpoints.
- CORS preflight (`OPTIONS /api/volumen/*`) so browsers can reach the JSON API.
- `Users#all` memoization with mtime-based invalidation, matching Store#all.
- Session invalidation: deleted users can no longer use existing session cookies.
- CSRF protection on `POST /admin/preview`.
- Symlink-safe containment in `Store#media_file` via `File.realpath`.
### Added
- **Scheduled publishing:** a `publish_at` frontmatter field hides posts from the
public API and feeds until their publication date arrives. The admin form
includes a Publish at date picker.
- **JSON Feed:** `GET /api/volumen/feed.json` serves a jsonfeed.org v1.1 feed
with `content_html` and `date_published` per item.
- **Fulltext search:** `GET /api/volumen/posts?q=…` filters posts by
case-insensitive match in title and body.
### Changed
- Split `lib/volumen/server.rb` into `api_routes.rb` and `admin_routes.rb` modules,
keeping the server class focused on configuration and shared helpers.
### Fixed
- `admin.session_ttl` was defined but never read; it now controls
`Rack::Session::Cookie` expiration via `expire_after`.
- Derived post excerpts now strip HTML tags before stripping markdown markup,
avoiding raw HTML leaks in auto-generated excerpts.
- Draft posts no longer leak through the public detail endpoint (`/api/volumen/posts/:slug`).
- `Config::DEFAULTS` inner hashes are now frozen and `deep_merge` builds fresh
copies, preventing mutation from silently poisoning later `Config.load` calls.
- A warning is emitted when `admin.session_key` is configured but shorter than
64 bytes, instead of silently falling back to a random secret.
## [0.1.0] — 2026-06-20 ## [0.1.0] — 2026-06-20
First public release: a small, file-based Markdown blog engine in CRuby. Posts First public release: a small, file-based Markdown blog engine in CRuby. Posts
+9 -5
View File
@@ -116,15 +116,18 @@ chore: pin gem versions to latest
1. Create a feature branch from `development`. 1. Create a feature branch from `development`.
2. Make your changes with Conventional-Commits messages. 2. Make your changes with Conventional-Commits messages.
3. Ensure `bundle exec rubocop` reports zero offenses and `just test` passes. 3. Record your changes under the `## [development]` section at the top of
4. Add or update tests for your change. `CHANGELOG.md`, using the Keep a Changelog categories (`Added`, `Changed`,
5. Update documentation if the public API, configuration, or behaviour changes: `Fixed`, etc.).
4. Ensure `bundle exec rubocop` reports zero offenses and `just test` passes.
5. Add or update tests for your change.
6. Update documentation if the public API, configuration, or behaviour changes:
- `README.md` — high-level overview. - `README.md` — high-level overview.
- `docs/configuration.md` — config keys. - `docs/configuration.md` — config keys.
- `docs/api-reference.md` — JSON API behaviour. - `docs/api-reference.md` — JSON API behaviour.
- `docs/architecture.md` — structural changes. - `docs/architecture.md` — structural changes.
- `docs/security.md` — anything touching auth, uploads, or rendering. - `docs/security.md` — anything touching auth, uploads, or rendering.
6. Open a pull request against `development`. Releases are cut by merging 7. Open a pull request against `development`. Releases are cut by merging
`development` into `main` and pushing a `vX.Y.Z` tag — see `development` into `main` and pushing a `vX.Y.Z` tag — see
[Cutting a release](#cutting-a-release). [Cutting a release](#cutting-a-release).
@@ -232,7 +235,8 @@ Requires a `FORGEJO_TOKEN` secret with permission to create releases.
### Cutting a release ### Cutting a release
1. Bump `VERSION` in `lib/volumen/version.rb`. 1. Bump `VERSION` in `lib/volumen/version.rb`.
2. Add a `## [X.Y.Z] — YYYY-MM-DD` section at the top of `CHANGELOG.md`. 2. Rename `## [development]` to `## [X.Y.Z] — YYYY-MM-DD` at the top of
`CHANGELOG.md`, and add a fresh empty `## [development]` section above it.
3. Ensure CI is green on `development`. 3. Ensure CI is green on `development`.
4. Merge `development` into `main` (the only time `main` changes outside a tag). 4. Merge `development` into `main` (the only time `main` changes outside a tag).
5. Tag and push: 5. Tag and push:
+2 -2
View File
@@ -1,7 +1,7 @@
PATH PATH
remote: . remote: .
specs: specs:
volumen (0.1.0) volumen (0.2.0)
kramdown (~> 2.5) kramdown (~> 2.5)
kramdown-parser-gfm (~> 1.1) kramdown-parser-gfm (~> 1.1)
puma (~> 8.0) puma (~> 8.0)
@@ -130,7 +130,7 @@ CHECKSUMS
toml-rb (4.2.0) sha256=10a48c91613e20cf63483a7a776767dfb3cd7d70e9327c0237443da601e13776 toml-rb (4.2.0) sha256=10a48c91613e20cf63483a7a776767dfb3cd7d70e9327c0237443da601e13776
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
volumen (0.1.0) volumen (0.2.0)
BUNDLED WITH BUNDLED WITH
4.0.10 4.0.10
+20
View File
@@ -18,4 +18,24 @@ require_relative "volumen/users"
# The Sinatra app (`Volumen::Server`) and the CLI (`Volumen::CLI`) are loaded # The Sinatra app (`Volumen::Server`) and the CLI (`Volumen::CLI`) are loaded
# on demand to keep the core library light. # on demand to keep the core library light.
module Volumen 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 end
+144
View File
@@ -0,0 +1,144 @@
# frozen_string_literal: true
module Volumen
# Server-rendered admin routes, registered into Volumen::Server.
module AdminRoutes
def self.registered(app)
app.get "/admin" do
redirect to("/admin/")
end
app.get "/admin/" do
require_login!
@posts = sorted_posts
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 })
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?
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"])
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]
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/settings" do
require_login!
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/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
end
end
end
+54
View File
@@ -0,0 +1,54 @@
# 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" => "*"
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? || 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
+24 -9
View File
@@ -10,7 +10,7 @@ module Volumen
DEFAULT_PATH = "/etc/volumen/config.toml" DEFAULT_PATH = "/etc/volumen/config.toml"
DEFAULTS = { DEFAULTS = {
"server" => { "host" => "0.0.0.0", "port" => 9090 }, "server" => { "host" => "0.0.0.0", "port" => 9090 }.freeze,
"content_dir" => "/var/lib/volumen/posts", "content_dir" => "/var/lib/volumen/posts",
"users_file" => "/var/lib/volumen/users.toml", "users_file" => "/var/lib/volumen/users.toml",
"site" => { "site" => {
@@ -19,12 +19,12 @@ module Volumen
"base_url" => "https://example.com", "base_url" => "https://example.com",
"language" => "en", "language" => "en",
"author" => "Anonymous" "author" => "Anonymous"
}, }.freeze,
"admin" => { "admin" => {
"password_hash" => "", "password_hash" => "",
"session_key" => "", "session_key" => "",
"session_ttl" => 86_400 "session_ttl" => 86_400
} }.freeze
}.freeze }.freeze
TEMPLATE = <<~TOML TEMPLATE = <<~TOML
@@ -77,12 +77,27 @@ module Volumen
end end
def self.deep_merge(base, override) def self.deep_merge(base, override)
base.merge(override) do |_key, base_val, override_val| result = {}
if base_val.is_a?(Hash) && override_val.is_a?(Hash) base.each do |key, base_val|
deep_merge(base_val, override_val) result[key] = if override.key?(key)
else merge_leaf(base_val, override[key])
override_val elsif base_val.is_a?(Hash)
end {}.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
end end
+19 -1
View File
@@ -56,6 +56,24 @@ module Volumen
metadata["cover"] metadata["cover"]
end 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 def reading_time
words = body.split(/\s+/).count { |word| !word.empty? } words = body.split(/\s+/).count { |word| !word.empty? }
[(words / 200.0).ceil, 1].max [(words / 200.0).ceil, 1].max
@@ -112,7 +130,7 @@ module Volumen
.find { |para| !para.empty? && !para.start_with?("#") } .find { |para| !para.empty? && !para.start_with?("#") }
return "" if paragraph.nil? return "" if paragraph.nil?
stripped = paragraph.gsub(/[*_`>#]/, "").strip stripped = paragraph.gsub(/<[^>]+>/, "").gsub(/[*_`>#]/, "").strip
stripped.length > limit ? "#{stripped[0, limit].rstrip}" : stripped stripped.length > limit ? "#{stripped[0, limit].rstrip}" : stripped
end end
end end
+125 -183
View File
@@ -8,32 +8,50 @@ require_relative "config"
require_relative "store" require_relative "store"
require_relative "markdown" require_relative "markdown"
require_relative "password" require_relative "password"
require_relative "api_routes"
require_relative "admin_routes"
module Volumen module Volumen
# Sinatra application: the public JSON API at /api/volumen and the # Sinatra application: the public JSON API at /api/volumen and the
# server-rendered admin at /admin. # server-rendered admin at /admin.
class Server < Sinatra::Base class Server < Sinatra::Base
MAX_LOGIN_ATTEMPTS = 10
LOGIN_WINDOW = 60 # seconds
SLUG_REGEX = /\A[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\z/
register ApiRoutes
register AdminRoutes
configure do configure do
set :show_exceptions, false set :show_exceptions, false
set :raise_errors, false set :raise_errors, false
set :host_authorization, { permitted_hosts: [] } set :host_authorization, { permitted_hosts: [] } # empty = allow all hosts
set :views, File.expand_path("web/views", __dir__) set :views, File.expand_path("web/views", __dir__)
end end
# Returns a configured subclass bound to a specific config + store. # Returns a configured subclass bound to a specific config + store.
def self.configured(config:, store:) def self.configured(config:, store:)
secret = session_secret(config) secret = session_secret(config)
ttl = config.admin["session_ttl"].to_i
ttl = nil if ttl <= 0
Class.new(self) do Class.new(self) do
set :volumen_config, config set :volumen_config, config
set :volumen_store, store set :volumen_store, store
set :sessions, httponly: true, same_site: :strict set :sessions, httponly: true, same_site: :strict, expire_after: ttl
set :session_secret, secret set :session_secret, secret
end end
end end
def self.session_secret(config) def self.session_secret(config)
key = config.admin["session_key"].to_s key = config.admin["session_key"].to_s
key.bytesize >= 64 ? key : SecureRandom.hex(64) 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 end
# Mark the session cookie Secure only when the request is HTTPS (directly or # Mark the session cookie Secure only when the request is HTTPS (directly or
@@ -44,178 +62,6 @@ module Volumen
options[:secure] = request.ssl? if options options[:secure] = request.ssl? if options
end end
# --- Public JSON API -------------------------------------------------
before "/api/volumen/*" do
headers "Access-Control-Allow-Origin" => "*"
content_type :json
end
get "/api/volumen/site" do
json(site_payload)
end
get "/api/volumen/posts" do
json(posts_payload)
end
get "/api/volumen/posts/:slug" do
post = store.find(params["slug"], lang: params["lang"])
halt(404, json("error" => "not_found")) if post.nil?
json(post.detail)
end
get "/api/volumen/tags" do
json("tags" => tag_counts)
end
get "/api/volumen/feed.xml" do
content_type "application/rss+xml"
feed_xml
end
get "/api/volumen/sitemap.xml" do
content_type "application/xml"
sitemap_xml
end
# --- Admin -----------------------------------------------------------
get "/admin" do
redirect to("/admin/")
end
get "/admin/" do
require_login!
@posts = sorted_posts
erb :list
end
get "/admin/login" do
redirect to("/admin/") if authenticated?
erb :login
end
post "/admin/login" do
check_csrf!
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
post "/admin/logout" do
check_csrf!
session.clear
redirect to("/admin/login")
end
get "/admin/posts/new" do
require_login!
@mode = :new
@post = Post.new(metadata: { "lang" => config.language })
erb :form
end
post "/admin/posts" do
require_login!
check_csrf!
create_post
end
get "/admin/posts/:slug/edit" do
require_login!
@mode = :edit
@post = store.find(params["slug"])
halt(404, "Not found") if @post.nil?
erb :form
end
post "/admin/posts/:slug" do
require_login!
check_csrf!
update_post
end
post "/admin/posts/:slug/delete" do
require_login!
check_csrf!
store.delete(params["slug"])
redirect to("/admin/")
end
post "/admin/preview" do
require_login!
content_type :html
Markdown.render(params["body"].to_s)
end
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]
json("url" => store.store_upload(upload[:filename], upload[:tempfile]))
end
get "/media/*" do
path = store.media_file(params["splat"].first)
halt(404) if path.nil?
send_file(path)
end
get "/admin/logo.svg" do
send_file(File.expand_path("web/public/volumen-logo.svg", __dir__))
end
get "/admin/settings" do
require_login!
render_settings
end
post "/admin/settings/password" do
require_login!
check_csrf!
change_password
end
post "/admin/settings/username" do
require_login!
check_csrf!
change_username
end
post "/admin/settings/users" do
require_login!
require_admin!
check_csrf!
create_user
end
post "/admin/settings/users/:username/role" do
require_login!
require_admin!
check_csrf!
change_role(params["username"])
end
post "/admin/settings/users/:username/delete" do
require_login!
require_admin!
check_csrf!
remove_user(params["username"])
end
private private
def config def config
@@ -233,7 +79,8 @@ module Volumen
end end
def published_posts def published_posts
store.all.reject(&:draft?).sort_by { |post| post.date_string || "" }.reverse store.all.reject { |post| post.draft? || post.scheduled? }
.sort_by { |post| post.date_string || "" }.reverse
end end
def site_payload def site_payload
@@ -255,14 +102,29 @@ module Volumen
def filtered_posts def filtered_posts
posts = published_posts posts = published_posts
if params["lang"] posts = filter_by_lang(posts)
lang = params["lang"]
posts = posts.select { |post| post.lang == lang || post.all_langs? }
end
posts = posts.select { |post| post.tags.include?(params["tag"]) } if params["tag"] posts = posts.select { |post| post.tags.include?(params["tag"]) } if params["tag"]
posts = search_posts(posts) if params["q"]
posts posts
end 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 def tag_counts
counts = Hash.new(0) counts = Hash.new(0)
published_posts.each { |post| post.tags.each { |tag| counts[tag] += 1 } } published_posts.each { |post| post.tags.each { |tag| counts[tag] += 1 } }
@@ -289,6 +151,7 @@ module Volumen
<title>#{escape(site["title"])}</title> <title>#{escape(site["title"])}</title>
<link>#{escape(base_url)}</link> <link>#{escape(base_url)}</link>
<description>#{escape(site["description"])}</description> <description>#{escape(site["description"])}</description>
<language>#{escape(site["language"])}</language>
#{items} #{items}
</channel> </channel>
</rss> </rss>
@@ -297,19 +160,69 @@ module Volumen
def feed_item(post) def feed_item(post)
link = "#{base_url}/#{post.slug}" link = "#{base_url}/#{post.slug}"
pub = if post.metadata["date"]
"\n <pubDate>#{escape(rfc822_date(post))}</pubDate>"
else
""
end
<<~XML.chomp <<~XML.chomp
<item> <item>
<title>#{escape(post.title)}</title> <title>#{escape(post.title)}</title>
<link>#{escape(link)}</link> <link>#{escape(link)}</link>
<guid>#{escape(link)}</guid> <guid>#{escape(link)}</guid>
<description>#{escape(post.excerpt)}</description> <description>#{escape(post.excerpt)}</description>#{pub}
</item> </item>
XML XML
end 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
}
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 def sitemap_xml
urls = published_posts.map do |post| urls = published_posts.map do |post|
" <url><loc>#{escape("#{base_url}/#{post.slug}")}</loc></url>" url = " <url><loc>#{escape("#{base_url}/#{post.slug}")}</loc>"
url << "<lastmod>#{escape(post.date_string)}</lastmod>" if post.date_string
url << "</url>"
end.join("\n") end.join("\n")
<<~XML <<~XML
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
@@ -352,7 +265,15 @@ module Volumen
end end
def require_login! def require_login!
redirect to("/admin/login") unless authenticated? unless authenticated?
redirect to("/admin/login")
return
end
return unless current_user_record.nil?
session.clear
redirect to("/admin/login")
end end
def require_admin! def require_admin!
@@ -370,6 +291,20 @@ module Volumen
halt(403, "Invalid CSRF token") halt(403, "Invalid CSRF token")
end 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) def render_settings(error: nil, notice: nil)
@error = error @error = error
@notice = notice @notice = notice
@@ -392,6 +327,11 @@ module Volumen
def change_username def change_username
new_name = presence(params["username"]) new_name = presence(params["username"])
return render_settings(error: "Username cannot be empty.") if new_name.nil? 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) if users.rename(current_user, new_name)
session[:user] = new_name session[:user] = new_name
@@ -467,6 +407,7 @@ module Volumen
def creation_error(post) def creation_error(post)
return "Slug is required." if presence(post.slug).nil? 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) return "A post with that slug already exists." if store.find(post.slug)
nil nil
@@ -479,6 +420,7 @@ module Volumen
"lang" => presence(http_params["lang"]), "lang" => presence(http_params["lang"]),
"author" => presence(http_params["author"]), "author" => presence(http_params["author"]),
"date" => parse_date(http_params["date"]), "date" => parse_date(http_params["date"]),
"publish_at" => parse_date(http_params["publish_at"]),
"tags" => parse_tags(http_params["tags"]), "tags" => parse_tags(http_params["tags"]),
"excerpt" => presence(http_params["excerpt"]), "excerpt" => presence(http_params["excerpt"]),
"cover" => presence(http_params["cover"]), "cover" => presence(http_params["cover"]),
+26 -6
View File
@@ -16,10 +16,16 @@ module Volumen
def initialize(content_dir, default_lang: nil) def initialize(content_dir, default_lang: nil)
@content_dir = File.expand_path(content_dir.to_s) @content_dir = File.expand_path(content_dir.to_s)
@default_lang = default_lang @default_lang = default_lang
@all = nil
end end
def all def all
markdown_files.filter_map { |path| load_post(path) } mtime = Dir.exist?(@content_dir) ? File.mtime(@content_dir) : nil
@all = nil if mtime != @all_mtime
@all ||= begin
@all_mtime = mtime
markdown_files.filter_map { |path| load_post(path) }
end
end end
def find(slug, lang: nil) def find(slug, lang: nil)
@@ -31,14 +37,20 @@ module Volumen
def save(post) def save(post)
target = post.path || default_path_for(post) target = post.path || default_path_for(post)
FileUtils.mkdir_p(File.dirname(target)) FileUtils.mkdir_p(File.dirname(target))
File.write(target, post.to_file) tmp = "#{target}.tmp"
File.write(tmp, post.to_file)
File.rename(tmp, target)
post.path = target post.path = target
@all = nil
post post
end end
def delete(slug, lang: nil) def delete(slug, lang: nil)
post = find(slug, lang: lang) post = find(slug, lang: lang)
File.delete(post.path) if post&.path if post&.path
File.delete(post.path)
@all = nil
end
post post
end end
@@ -55,9 +67,10 @@ module Volumen
def media_file(name) def media_file(name)
path = File.join(media_dir, File.basename(name.to_s)) path = File.join(media_dir, File.basename(name.to_s))
return nil unless File.file?(path) && within?(media_dir, path) return nil unless File.file?(path)
path real = File.realpath(path)
within?(media_dir, real) ? path : nil
end end
private private
@@ -85,7 +98,14 @@ module Volumen
end end
def default_path_for(post) def default_path_for(post)
File.join(@content_dir, "#{post.slug}.md") 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 end
def safe_media_name(original) def safe_media_name(original)
+16 -5
View File
@@ -20,13 +20,22 @@ module Volumen
def initialize(path:, bootstrap_hash: nil) def initialize(path:, bootstrap_hash: nil)
@path = path @path = path
@bootstrap_hash = bootstrap_hash.to_s @bootstrap_hash = bootstrap_hash.to_s
@all = nil
end end
def all def all
return read_file if File.exist?(@path) mtime = File.exist?(@path) ? File.mtime(@path) : nil
return [] if @bootstrap_hash.empty? @all = nil if mtime != @all_mtime
@all ||= begin
[User.new("admin", @bootstrap_hash, "admin")] @all_mtime = mtime
if File.exist?(@path)
read_file
elsif @bootstrap_hash.empty?
[]
else
[User.new("admin", @bootstrap_hash, "admin")]
end
end
end end
def any? def any?
@@ -118,7 +127,9 @@ module Volumen
def persist(users) def persist(users)
FileUtils.mkdir_p(File.dirname(@path)) FileUtils.mkdir_p(File.dirname(@path))
File.write(@path, TomlRB.dump("users" => users.map { |user| user_hash(user) })) tmp = "#{@path}.tmp"
File.write(tmp, TomlRB.dump("users" => users.map { |user| user_hash(user) }))
File.rename(tmp, @path)
end end
def user_hash(user) def user_hash(user)
+1 -1
View File
@@ -1,5 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
module Volumen module Volumen
VERSION = "0.1.0" VERSION = "0.2.0"
end end
+6 -1
View File
@@ -5,7 +5,7 @@
<% end %> <% end %>
<form id="post-form" method="post" <form id="post-form" method="post"
action="<%= @mode == :new ? to("/admin/posts") : to("/admin/posts/#{@post.slug}") %>"> action="<%= @mode == :new ? to("/admin/posts") : h(to("/admin/posts/#{@post.slug}")) %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>"> <input type="hidden" name="_csrf" value="<%= csrf_token %>">
<div class="fields"> <div class="fields">
@@ -17,6 +17,11 @@
<label>Language <input type="text" name="lang" value="<%= h(@post.lang) %>"></label> <label>Language <input type="text" name="lang" value="<%= h(@post.lang) %>"></label>
<label>Author <input type="text" name="author" value="<%= h(@post.author) %>"></label> <label>Author <input type="text" name="author" value="<%= h(@post.author) %>"></label>
<label>Date <input type="date" name="date" value="<%= h(@post.date_string) %>"></label> <label>Date <input type="date" name="date" value="<%= h(@post.date_string) %>"></label>
<label>Publish at
<input type="date" name="publish_at"
value="<%= @post.publish_at.is_a?(Date) ? @post.publish_at.strftime("%Y-%m-%d") : "" %>"
placeholder="leave empty for immediate">
</label>
<label>Tags <label>Tags
<input type="text" name="tags" value="<%= h(@post.tags.join(", ")) %>" <input type="text" name="tags" value="<%= h(@post.tags.join(", ")) %>"
placeholder="comma, separated"> placeholder="comma, separated">
+2 -2
View File
@@ -19,9 +19,9 @@
<td><%= h(post.date_string) %></td> <td><%= h(post.date_string) %></td>
<td><%= post.draft? ? "yes" : "" %></td> <td><%= post.draft? ? "yes" : "" %></td>
<td class="actions"> <td class="actions">
<a href="<%= to("/admin/posts/#{post.slug}/edit") %>">Edit</a> <a href="<%= h(to("/admin/posts/#{post.slug}/edit")) %>">Edit</a>
<form class="inline" method="post" <form class="inline" method="post"
action="<%= to("/admin/posts/#{post.slug}/delete") %>" action="<%= h(to("/admin/posts/#{post.slug}/delete")) %>"
onsubmit="return confirm('Delete this post?');"> onsubmit="return confirm('Delete this post?');">
<input type="hidden" name="_csrf" value="<%= csrf_token %>"> <input type="hidden" name="_csrf" value="<%= csrf_token %>">
<button type="submit" class="danger">Delete</button> <button type="submit" class="danger">Delete</button>
+2 -2
View File
@@ -41,7 +41,7 @@
<%= h(user.role) %> <%= h(user.role) %>
<% else %> <% else %>
<form class="inline" method="post" <form class="inline" method="post"
action="<%= to("/admin/settings/users/#{user.username}/role") %>"> action="<%= h(to("/admin/settings/users/#{user.username}/role")) %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>"> <input type="hidden" name="_csrf" value="<%= csrf_token %>">
<select name="role" onchange="this.form.submit()"> <select name="role" onchange="this.form.submit()">
<% Volumen::Users::ROLES.each do |role| %> <% Volumen::Users::ROLES.each do |role| %>
@@ -54,7 +54,7 @@
<td class="actions"> <td class="actions">
<% unless user.username == current_user %> <% unless user.username == current_user %>
<form class="inline" method="post" <form class="inline" method="post"
action="<%= to("/admin/settings/users/#{user.username}/delete") %>" action="<%= h(to("/admin/settings/users/#{user.username}/delete")) %>"
onsubmit="return confirm('Remove this user?');"> onsubmit="return confirm('Remove this user?');">
<input type="hidden" name="_csrf" value="<%= csrf_token %>"> <input type="hidden" name="_csrf" value="<%= csrf_token %>">
<button type="submit" class="danger">Remove</button> <button type="submit" class="danger">Remove</button>
+62
View File
@@ -30,6 +30,7 @@ class AdminTest < Minitest::Test
TOML TOML
@config = Volumen::Config.load(path: config_path) @config = Volumen::Config.load(path: config_path)
@store = Volumen::Store.new(@posts_dir, default_lang: "en") @store = Volumen::Store.new(@posts_dir, default_lang: "en")
Volumen.reset_login_attempts!
end end
def teardown def teardown
@@ -220,6 +221,67 @@ class AdminTest < Minitest::Test
assert File.exist?(File.join(@posts_dir, "by-author.md")) assert File.exist?(File.join(@posts_dir, "by-author.md"))
end end
def test_login_rate_limit_sweep_removes_stale_entries
Volumen.login_attempts["1.2.3.4"] = [Time.now.to_f - 120]
Volumen.instance_variable_set(:@last_cleanup, Time.now.to_f - 600)
Volumen.sweep_login_attempts!
refute Volumen.login_attempts.key?("1.2.3.4"),
"sweep should remove IPs with only stale entries"
end
def test_create_post_rejects_invalid_slug
login
get "/admin/posts/new"
post "/admin/posts",
"_csrf" => csrf(last_response.body), "title" => "X", "slug" => "../../etc/passwd",
"lang" => "en", "body" => "y"
assert_includes last_response.body, "Invalid slug"
end
def test_create_post_rejects_xss_in_slug
login
get "/admin/posts/new"
post "/admin/posts",
"_csrf" => csrf(last_response.body), "title" => "X", "slug" => "foo\" onclick",
"lang" => "en", "body" => "y"
assert_includes last_response.body, "Invalid slug"
end
def test_change_username_rejects_special_chars
login
get "/admin/settings"
post "/admin/settings/username",
"_csrf" => csrf(last_response.body), "username" => "foo\" bar"
assert_includes last_response.body, "Username may use letters"
end
def test_change_username_accepts_same_name
login
get "/admin/settings"
post "/admin/settings/username",
"_csrf" => csrf(last_response.body), "username" => "admin"
assert_includes last_response.body, "Username unchanged"
end
def test_preview_requires_csrf
login
get "/admin/posts/new"
post "/admin/preview", "body" => "**hi**"
assert_equal 403, last_response.status
end
def test_orphaned_session_is_rejected
login
session_for = -> { rack_mock_session.cookie_jar["rack.session"].to_s }
refute_empty session_for.call, "expected a session cookie after login"
# Simulate a deleted user by clearing users.toml while keeping the cookie.
File.write(@users_path, "")
get "/admin/"
assert_equal 302, last_response.status
assert_equal "http://example.org/admin/login", last_response.location
end
private private
def csrf(body) def csrf(body)
+63
View File
@@ -0,0 +1,63 @@
# frozen_string_literal: true
require "test_helper"
require "tmpdir"
require "volumen/cli"
class CLITest < Minitest::Test
def test_version
assert_output("#{Volumen::VERSION}\n") do
Volumen::CLI.run(["version"])
end
end
def test_help
output, = capture_io { Volumen::CLI.run(["help"]) }
assert_includes output, "volumen #{Volumen::VERSION}"
assert_includes output, "Usage:"
end
def test_unknown_command
_output, err = capture_io do
assert_raises(SystemExit) { Volumen::CLI.run(["bogus"]) }
end
assert_includes err, "unknown command"
end
def test_parse_serve_defaults
cli = Volumen::CLI.new(["serve"])
options = cli.__send__(:parse_serve_options)
assert_equal Volumen::Config::DEFAULT_PATH, options[:config]
end
def test_parse_serve_overrides
cli = Volumen::CLI.new([
"serve", "--config", "/t.toml", "--host", "127.0.0.1", "--port", "9000"
])
options = cli.__send__(:parse_serve_options)
assert_equal "/t.toml", options[:config]
assert_equal "127.0.0.1", options[:host]
assert_equal 9000, options[:port]
end
def test_prepare_config_writes_template
Dir.mktmpdir do |dir|
path = File.join(dir, "config.toml")
cli = Volumen::CLI.new([])
_output, err = capture_io { cli.__send__(:prepare_config, path) }
assert_includes err, "wrote a default config"
assert_path_exists path
end
end
def test_prepare_config_noops_when_present
Dir.mktmpdir do |dir|
path = File.join(dir, "config.toml")
File.write(path, "[server]\nport = 1\n")
cli = Volumen::CLI.new([])
out, err = capture_io { cli.__send__(:prepare_config, path) }
assert_empty err
assert_empty out
end
end
end
+8
View File
@@ -27,6 +27,14 @@ class ConfigTest < Minitest::Test
assert_equal "0.0.0.0", Volumen::Config.load(path: MISSING).host assert_equal "0.0.0.0", Volumen::Config.load(path: MISSING).host
end end
def test_does_not_mutate_default_inner_hashes
config = Volumen::Config.load(path: MISSING)
config.site["title"] = "MUTATED"
fresh = Volumen::Config.load(path: MISSING)
assert_equal "My Blog", fresh.site["title"],
"mutating a loaded config must not poison DEFAULTS for later loads"
end
def test_loads_and_merges_file def test_loads_and_merges_file
Dir.mktmpdir do |dir| Dir.mktmpdir do |dir|
path = File.join(dir, "config.toml") path = File.join(dir, "config.toml")
+21
View File
@@ -30,6 +30,12 @@ class PostTest < Minitest::Test
assert_equal "The body paragraph.", post.excerpt assert_equal "The body paragraph.", post.excerpt
end end
def test_derived_excerpt_strips_html_tags
body = "<strong>Bold</strong> and <em>italic</em>."
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: body)
assert_equal "Bold and italic.", post.excerpt
end
def test_summary_shape def test_summary_shape
post = Volumen::Post.new(metadata: { "slug" => "x", "title" => "X" }, body: "Body") post = Volumen::Post.new(metadata: { "slug" => "x", "title" => "X" }, body: "Body")
summary = post.summary summary = post.summary
@@ -56,4 +62,19 @@ class PostTest < Minitest::Test
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "short") post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "short")
assert_equal 1, post.reading_time assert_equal 1, post.reading_time
end end
def test_scheduled_post_is_not_scheduled_when_publish_at_is_past
post = Volumen::Post.new(metadata: { "publish_at" => Date.today - 1 })
refute_predicate post, :scheduled?
end
def test_scheduled_post_is_scheduled_when_publish_at_is_future
post = Volumen::Post.new(metadata: { "publish_at" => Date.today + 7 })
assert_predicate post, :scheduled?
end
def test_post_without_publish_at_is_not_scheduled
post = Volumen::Post.new(metadata: {})
refute_predicate post, :scheduled?
end
end end
+86
View File
@@ -92,4 +92,90 @@ class ServerTest < Minitest::Test
assert_includes slugs, "global" assert_includes slugs, "global"
refute_includes slugs, "hello" refute_includes slugs, "hello"
end end
def test_feed_endpoint
get "/api/volumen/feed.xml"
assert_predicate last_response, :ok?
assert_equal "application/rss+xml", last_response["Content-Type"]&.split(";")&.first
body = last_response.body
assert_includes body, "<rss version=\"2.0\">"
assert_includes body, "<title>Hello</title>"
refute_includes body, "Draft"
assert_includes body, "<language>en</language>"
end
def test_draft_post_detail_is_not_found
get "/api/volumen/posts/draft"
assert_equal 404, last_response.status
assert_equal "not_found", JSON.parse(last_response.body)["error"]
end
def test_sitemap_endpoint
get "/api/volumen/sitemap.xml"
assert_predicate last_response, :ok?
assert_equal "application/xml", last_response["Content-Type"]&.split(";")&.first
body = last_response.body
assert_includes body, "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">"
assert_includes body, "<loc>https://example.com/hello</loc>"
refute_includes body, "draft"
end
def test_cors_preflight
options "/api/volumen/site"
assert_predicate last_response, :ok?
assert_equal "*", last_response.headers["Access-Control-Allow-Origin"]
assert_equal "GET, OPTIONS", last_response.headers["Access-Control-Allow-Methods"]
end
def test_scheduled_post_is_hidden
future = (Date.today + 30).strftime("%Y-%m-%d")
File.write(File.join(@dir, "later.md"), <<~POST)
+++
title = "Later"
publish_at = #{future}
+++
Not yet.
POST
get "/api/volumen/posts"
slugs = JSON.parse(last_response.body)["posts"].map { |post| post["slug"] }
refute_includes slugs, "later"
end
def test_json_feed_endpoint
get "/api/volumen/feed.json"
assert_predicate last_response, :ok?
data = JSON.parse(last_response.body)
assert_equal "https://jsonfeed.org/version/1.1", data["version"]
assert_equal "My Blog", data["title"]
assert_equal 1, data["items"].length
assert_equal "Hello", data["items"].first["title"]
assert_includes data["items"].first["content_html"], "<strong>world</strong>"
refute_includes data["items"].map { |i| i["title"] }, "Draft"
end
def test_search_by_title
get "/api/volumen/posts?q=Hello"
data = JSON.parse(last_response.body)
assert_equal 1, data["total"]
assert_equal "hello", data["posts"].first["slug"]
end
def test_search_by_body
get "/api/volumen/posts?q=world"
data = JSON.parse(last_response.body)
assert_equal 1, data["total"]
end
def test_search_no_match
get "/api/volumen/posts?q=nonexistent"
data = JSON.parse(last_response.body)
assert_equal 0, data["total"]
end
def test_search_is_case_insensitive
get "/api/volumen/posts?q=HELLO"
data = JSON.parse(last_response.body)
assert_equal 1, data["total"]
end
end end
+21
View File
@@ -49,4 +49,25 @@ class StoreTest < Minitest::Test
assert store.find("global", lang: "cs") assert store.find("global", lang: "cs")
assert store.find("global", lang: "en") assert store.find("global", lang: "en")
end end
def test_save_rejects_path_traversal_slug
store = Volumen::Store.new(@dir)
post = Volumen::Post.new(metadata: { "slug" => "../../etc/passwd" }, body: "x")
assert_raises(ArgumentError, "slug escapes content directory") { store.save(post) }
end
def test_save_uses_lang_subdirectory_for_non_default_language
store = Volumen::Store.new(@dir, default_lang: "en")
post = Volumen::Post.new(metadata: { "slug" => "bonjour", "lang" => "fr" }, body: "x")
store.save(post)
assert File.exist?(File.join(@dir, "fr", "bonjour.md"))
end
def test_save_uses_root_for_default_language
store = Volumen::Store.new(@dir, default_lang: "en")
post = Volumen::Post.new(metadata: { "slug" => "hello-new", "lang" => "en" }, body: "x")
store.save(post)
assert File.exist?(File.join(@dir, "hello-new.md"))
refute File.exist?(File.join(@dir, "en", "hello-new.md"))
end
end end