Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1440e9a1fd | ||
|
|
b24a8cc377 | ||
|
|
599c63e5bc | ||
|
|
73be893bf8 | ||
|
|
52be2bb996 | ||
|
|
5b8236c0a4 | ||
|
|
51e425e6dd | ||
|
|
ce57410044 | ||
|
|
afd8ab4bfb | ||
|
|
ad00872895 | ||
|
|
d7f9813484 | ||
|
|
263e794669 | ||
|
|
1ca0212197 | ||
|
|
d26d3b5a5b | ||
|
|
4597c685da | ||
|
|
2181538691 | ||
|
|
9dd55adc30 | ||
|
|
7a31b50ca7 | ||
|
|
b2b8156648 | ||
|
|
140b16b55a | ||
|
|
2348352fab | ||
|
|
184aa9b3d8 | ||
|
|
0a70b74b55 |
@@ -19,13 +19,23 @@ Layout/LineLength:
|
||||
|
||||
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: 400
|
||||
|
||||
Metrics/ModuleLength:
|
||||
Exclude:
|
||||
- "lib/volumen/admin_routes.rb"
|
||||
|
||||
Metrics/BlockLength:
|
||||
Exclude:
|
||||
- "test/**/*"
|
||||
|
||||
@@ -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/),
|
||||
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
|
||||
|
||||
First public release: a small, file-based Markdown blog engine in CRuby. Posts
|
||||
|
||||
+9
-5
@@ -116,15 +116,18 @@ chore: pin gem versions to latest
|
||||
|
||||
1. Create a feature branch from `development`.
|
||||
2. Make your changes with Conventional-Commits messages.
|
||||
3. Ensure `bundle exec rubocop` reports zero offenses and `just test` passes.
|
||||
4. Add or update tests for your change.
|
||||
5. Update documentation if the public API, configuration, or behaviour changes:
|
||||
3. Record your changes under the `## [development]` section at the top of
|
||||
`CHANGELOG.md`, using the Keep a Changelog categories (`Added`, `Changed`,
|
||||
`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.
|
||||
- `docs/configuration.md` — config keys.
|
||||
- `docs/api-reference.md` — JSON API behaviour.
|
||||
- `docs/architecture.md` — structural changes.
|
||||
- `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
|
||||
[Cutting a release](#cutting-a-release).
|
||||
|
||||
@@ -232,7 +235,8 @@ Requires a `FORGEJO_TOKEN` secret with permission to create releases.
|
||||
### Cutting a release
|
||||
|
||||
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`.
|
||||
4. Merge `development` into `main` (the only time `main` changes outside a tag).
|
||||
5. Tag and push:
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
PATH
|
||||
remote: .
|
||||
specs:
|
||||
volumen (0.1.0)
|
||||
volumen (0.2.0)
|
||||
kramdown (~> 2.5)
|
||||
kramdown-parser-gfm (~> 1.1)
|
||||
puma (~> 8.0)
|
||||
@@ -130,7 +130,7 @@ CHECKSUMS
|
||||
toml-rb (4.2.0) sha256=10a48c91613e20cf63483a7a776767dfb3cd7d70e9327c0237443da601e13776
|
||||
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
|
||||
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
|
||||
volumen (0.1.0)
|
||||
volumen (0.2.0)
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.10
|
||||
|
||||
@@ -18,4 +18,24 @@ require_relative "volumen/users"
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
+20
-5
@@ -10,7 +10,7 @@ module Volumen
|
||||
DEFAULT_PATH = "/etc/volumen/config.toml"
|
||||
|
||||
DEFAULTS = {
|
||||
"server" => { "host" => "0.0.0.0", "port" => 9090 },
|
||||
"server" => { "host" => "0.0.0.0", "port" => 9090 }.freeze,
|
||||
"content_dir" => "/var/lib/volumen/posts",
|
||||
"users_file" => "/var/lib/volumen/users.toml",
|
||||
"site" => {
|
||||
@@ -19,12 +19,12 @@ module Volumen
|
||||
"base_url" => "https://example.com",
|
||||
"language" => "en",
|
||||
"author" => "Anonymous"
|
||||
},
|
||||
}.freeze,
|
||||
"admin" => {
|
||||
"password_hash" => "",
|
||||
"session_key" => "",
|
||||
"session_ttl" => 86_400
|
||||
}
|
||||
}.freeze
|
||||
}.freeze
|
||||
|
||||
TEMPLATE = <<~TOML
|
||||
@@ -77,14 +77,29 @@ module Volumen
|
||||
end
|
||||
|
||||
def self.deep_merge(base, override)
|
||||
base.merge(override) do |_key, base_val, override_val|
|
||||
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
|
||||
end
|
||||
|
||||
def self.apply_overrides(data, overrides)
|
||||
server = data["server"].dup
|
||||
|
||||
+19
-1
@@ -56,6 +56,24 @@ module Volumen
|
||||
metadata["cover"]
|
||||
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
|
||||
@@ -112,7 +130,7 @@ module Volumen
|
||||
.find { |para| !para.empty? && !para.start_with?("#") }
|
||||
return "" if paragraph.nil?
|
||||
|
||||
stripped = paragraph.gsub(/[*_`>#]/, "").strip
|
||||
stripped = paragraph.gsub(/<[^>]+>/, "").gsub(/[*_`>#]/, "").strip
|
||||
stripped.length > limit ? "#{stripped[0, limit].rstrip}…" : stripped
|
||||
end
|
||||
end
|
||||
|
||||
+125
-183
@@ -8,32 +8,50 @@ require_relative "config"
|
||||
require_relative "store"
|
||||
require_relative "markdown"
|
||||
require_relative "password"
|
||||
require_relative "api_routes"
|
||||
require_relative "admin_routes"
|
||||
|
||||
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
|
||||
SLUG_REGEX = /\A[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\z/
|
||||
|
||||
register ApiRoutes
|
||||
register AdminRoutes
|
||||
|
||||
configure do
|
||||
set :show_exceptions, 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__)
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
def config
|
||||
@@ -233,7 +79,8 @@ module Volumen
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def site_payload
|
||||
@@ -255,14 +102,29 @@ module Volumen
|
||||
|
||||
def filtered_posts
|
||||
posts = published_posts
|
||||
if params["lang"]
|
||||
lang = params["lang"]
|
||||
posts = posts.select { |post| post.lang == lang || post.all_langs? }
|
||||
end
|
||||
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 } }
|
||||
@@ -289,6 +151,7 @@ module Volumen
|
||||
<title>#{escape(site["title"])}</title>
|
||||
<link>#{escape(base_url)}</link>
|
||||
<description>#{escape(site["description"])}</description>
|
||||
<language>#{escape(site["language"])}</language>
|
||||
#{items}
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -297,19 +160,69 @@ module Volumen
|
||||
|
||||
def feed_item(post)
|
||||
link = "#{base_url}/#{post.slug}"
|
||||
pub = if post.metadata["date"]
|
||||
"\n <pubDate>#{escape(rfc822_date(post))}</pubDate>"
|
||||
else
|
||||
""
|
||||
end
|
||||
<<~XML.chomp
|
||||
<item>
|
||||
<title>#{escape(post.title)}</title>
|
||||
<link>#{escape(link)}</link>
|
||||
<guid>#{escape(link)}</guid>
|
||||
<description>#{escape(post.excerpt)}</description>
|
||||
<description>#{escape(post.excerpt)}</description>#{pub}
|
||||
</item>
|
||||
XML
|
||||
end
|
||||
|
||||
def rfc822_date(post)
|
||||
value = post.metadata["date"]
|
||||
case value
|
||||
when Date then value.to_time.rfc822
|
||||
when Time then value.rfc822
|
||||
end
|
||||
end
|
||||
|
||||
def feed_json
|
||||
site = config.site
|
||||
feed_url = "#{base_url}/api/volumen/feed.json"
|
||||
{
|
||||
"version" => "https://jsonfeed.org/version/1.1",
|
||||
"title" => site["title"],
|
||||
"home_page_url" => base_url,
|
||||
"feed_url" => feed_url,
|
||||
"description" => site["description"],
|
||||
"language" => site["language"],
|
||||
"items" => published_posts.first(20).map { |post| json_feed_item(post) }
|
||||
}
|
||||
end
|
||||
|
||||
def json_feed_item(post)
|
||||
link = "#{base_url}/#{post.slug}"
|
||||
item = {
|
||||
"id" => link,
|
||||
"url" => link,
|
||||
"title" => post.title,
|
||||
"content_html" => post.html,
|
||||
"summary" => post.excerpt,
|
||||
"date_published" => iso_date(post.metadata["date"]),
|
||||
"tags" => post.tags
|
||||
}
|
||||
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><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")
|
||||
<<~XML
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -352,7 +265,15 @@ module Volumen
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def require_admin!
|
||||
@@ -370,6 +291,20 @@ module Volumen
|
||||
halt(403, "Invalid CSRF token")
|
||||
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
|
||||
@@ -392,6 +327,11 @@ module Volumen
|
||||
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
|
||||
@@ -467,6 +407,7 @@ module Volumen
|
||||
|
||||
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)
|
||||
|
||||
nil
|
||||
@@ -479,6 +420,7 @@ module Volumen
|
||||
"lang" => presence(http_params["lang"]),
|
||||
"author" => presence(http_params["author"]),
|
||||
"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"]),
|
||||
|
||||
+25
-5
@@ -16,11 +16,17 @@ module Volumen
|
||||
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
|
||||
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
|
||||
|
||||
def find(slug, lang: nil)
|
||||
all.find do |post|
|
||||
@@ -31,14 +37,20 @@ module Volumen
|
||||
def save(post)
|
||||
target = post.path || default_path_for(post)
|
||||
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
|
||||
@all = nil
|
||||
post
|
||||
end
|
||||
|
||||
def delete(slug, lang: nil)
|
||||
post = find(slug, lang: lang)
|
||||
File.delete(post.path) if post&.path
|
||||
if post&.path
|
||||
File.delete(post.path)
|
||||
@all = nil
|
||||
end
|
||||
post
|
||||
end
|
||||
|
||||
@@ -55,9 +67,10 @@ module Volumen
|
||||
|
||||
def media_file(name)
|
||||
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
|
||||
|
||||
private
|
||||
@@ -85,7 +98,14 @@ module Volumen
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def safe_media_name(original)
|
||||
|
||||
+15
-4
@@ -20,14 +20,23 @@ module Volumen
|
||||
def initialize(path:, bootstrap_hash: nil)
|
||||
@path = path
|
||||
@bootstrap_hash = bootstrap_hash.to_s
|
||||
@all = nil
|
||||
end
|
||||
|
||||
def all
|
||||
return read_file if File.exist?(@path)
|
||||
return [] if @bootstrap_hash.empty?
|
||||
|
||||
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")]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def any?
|
||||
all.any?
|
||||
@@ -118,7 +127,9 @@ module Volumen
|
||||
|
||||
def persist(users)
|
||||
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
|
||||
|
||||
def user_hash(user)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Volumen
|
||||
VERSION = "0.1.0"
|
||||
VERSION = "0.2.0"
|
||||
end
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<% end %>
|
||||
|
||||
<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 %>">
|
||||
|
||||
<div class="fields">
|
||||
@@ -17,6 +17,11 @@
|
||||
<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>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
|
||||
<input type="text" name="tags" value="<%= h(@post.tags.join(", ")) %>"
|
||||
placeholder="comma, separated">
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
<td><%= h(post.date_string) %></td>
|
||||
<td><%= post.draft? ? "yes" : "" %></td>
|
||||
<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"
|
||||
action="<%= to("/admin/posts/#{post.slug}/delete") %>"
|
||||
action="<%= h(to("/admin/posts/#{post.slug}/delete")) %>"
|
||||
onsubmit="return confirm('Delete this post?');">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<button type="submit" class="danger">Delete</button>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<%= h(user.role) %>
|
||||
<% else %>
|
||||
<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 %>">
|
||||
<select name="role" onchange="this.form.submit()">
|
||||
<% Volumen::Users::ROLES.each do |role| %>
|
||||
@@ -54,7 +54,7 @@
|
||||
<td class="actions">
|
||||
<% unless user.username == current_user %>
|
||||
<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?');">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<button type="submit" class="danger">Remove</button>
|
||||
|
||||
@@ -30,6 +30,7 @@ class AdminTest < Minitest::Test
|
||||
TOML
|
||||
@config = Volumen::Config.load(path: config_path)
|
||||
@store = Volumen::Store.new(@posts_dir, default_lang: "en")
|
||||
Volumen.reset_login_attempts!
|
||||
end
|
||||
|
||||
def teardown
|
||||
@@ -220,6 +221,67 @@ class AdminTest < Minitest::Test
|
||||
assert File.exist?(File.join(@posts_dir, "by-author.md"))
|
||||
end
|
||||
|
||||
def test_login_rate_limit_sweep_removes_stale_entries
|
||||
Volumen.login_attempts["1.2.3.4"] = [Time.now.to_f - 120]
|
||||
Volumen.instance_variable_set(:@last_cleanup, Time.now.to_f - 600)
|
||||
Volumen.sweep_login_attempts!
|
||||
refute Volumen.login_attempts.key?("1.2.3.4"),
|
||||
"sweep should remove IPs with only stale entries"
|
||||
end
|
||||
|
||||
def test_create_post_rejects_invalid_slug
|
||||
login
|
||||
get "/admin/posts/new"
|
||||
post "/admin/posts",
|
||||
"_csrf" => csrf(last_response.body), "title" => "X", "slug" => "../../etc/passwd",
|
||||
"lang" => "en", "body" => "y"
|
||||
assert_includes last_response.body, "Invalid slug"
|
||||
end
|
||||
|
||||
def test_create_post_rejects_xss_in_slug
|
||||
login
|
||||
get "/admin/posts/new"
|
||||
post "/admin/posts",
|
||||
"_csrf" => csrf(last_response.body), "title" => "X", "slug" => "foo\" onclick",
|
||||
"lang" => "en", "body" => "y"
|
||||
assert_includes last_response.body, "Invalid slug"
|
||||
end
|
||||
|
||||
def test_change_username_rejects_special_chars
|
||||
login
|
||||
get "/admin/settings"
|
||||
post "/admin/settings/username",
|
||||
"_csrf" => csrf(last_response.body), "username" => "foo\" bar"
|
||||
assert_includes last_response.body, "Username may use letters"
|
||||
end
|
||||
|
||||
def test_change_username_accepts_same_name
|
||||
login
|
||||
get "/admin/settings"
|
||||
post "/admin/settings/username",
|
||||
"_csrf" => csrf(last_response.body), "username" => "admin"
|
||||
assert_includes last_response.body, "Username unchanged"
|
||||
end
|
||||
|
||||
def test_preview_requires_csrf
|
||||
login
|
||||
get "/admin/posts/new"
|
||||
post "/admin/preview", "body" => "**hi**"
|
||||
assert_equal 403, last_response.status
|
||||
end
|
||||
|
||||
def test_orphaned_session_is_rejected
|
||||
login
|
||||
session_for = -> { rack_mock_session.cookie_jar["rack.session"].to_s }
|
||||
refute_empty session_for.call, "expected a session cookie after login"
|
||||
|
||||
# Simulate a deleted user by clearing users.toml while keeping the cookie.
|
||||
File.write(@users_path, "")
|
||||
get "/admin/"
|
||||
assert_equal 302, last_response.status
|
||||
assert_equal "http://example.org/admin/login", last_response.location
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def csrf(body)
|
||||
|
||||
@@ -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
|
||||
@@ -27,6 +27,14 @@ class ConfigTest < Minitest::Test
|
||||
assert_equal "0.0.0.0", Volumen::Config.load(path: MISSING).host
|
||||
end
|
||||
|
||||
def test_does_not_mutate_default_inner_hashes
|
||||
config = Volumen::Config.load(path: MISSING)
|
||||
config.site["title"] = "MUTATED"
|
||||
fresh = Volumen::Config.load(path: MISSING)
|
||||
assert_equal "My Blog", fresh.site["title"],
|
||||
"mutating a loaded config must not poison DEFAULTS for later loads"
|
||||
end
|
||||
|
||||
def test_loads_and_merges_file
|
||||
Dir.mktmpdir do |dir|
|
||||
path = File.join(dir, "config.toml")
|
||||
|
||||
@@ -30,6 +30,12 @@ class PostTest < Minitest::Test
|
||||
assert_equal "The body paragraph.", post.excerpt
|
||||
end
|
||||
|
||||
def test_derived_excerpt_strips_html_tags
|
||||
body = "<strong>Bold</strong> and <em>italic</em>."
|
||||
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: body)
|
||||
assert_equal "Bold and italic.", post.excerpt
|
||||
end
|
||||
|
||||
def test_summary_shape
|
||||
post = Volumen::Post.new(metadata: { "slug" => "x", "title" => "X" }, body: "Body")
|
||||
summary = post.summary
|
||||
@@ -56,4 +62,19 @@ class PostTest < Minitest::Test
|
||||
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "short")
|
||||
assert_equal 1, post.reading_time
|
||||
end
|
||||
|
||||
def test_scheduled_post_is_not_scheduled_when_publish_at_is_past
|
||||
post = Volumen::Post.new(metadata: { "publish_at" => Date.today - 1 })
|
||||
refute_predicate post, :scheduled?
|
||||
end
|
||||
|
||||
def test_scheduled_post_is_scheduled_when_publish_at_is_future
|
||||
post = Volumen::Post.new(metadata: { "publish_at" => Date.today + 7 })
|
||||
assert_predicate post, :scheduled?
|
||||
end
|
||||
|
||||
def test_post_without_publish_at_is_not_scheduled
|
||||
post = Volumen::Post.new(metadata: {})
|
||||
refute_predicate post, :scheduled?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -92,4 +92,90 @@ class ServerTest < Minitest::Test
|
||||
assert_includes slugs, "global"
|
||||
refute_includes slugs, "hello"
|
||||
end
|
||||
|
||||
def test_feed_endpoint
|
||||
get "/api/volumen/feed.xml"
|
||||
assert_predicate last_response, :ok?
|
||||
assert_equal "application/rss+xml", last_response["Content-Type"]&.split(";")&.first
|
||||
body = last_response.body
|
||||
assert_includes body, "<rss version=\"2.0\">"
|
||||
assert_includes body, "<title>Hello</title>"
|
||||
refute_includes body, "Draft"
|
||||
assert_includes body, "<language>en</language>"
|
||||
end
|
||||
|
||||
def test_draft_post_detail_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
|
||||
|
||||
@@ -49,4 +49,25 @@ class StoreTest < Minitest::Test
|
||||
assert store.find("global", lang: "cs")
|
||||
assert store.find("global", lang: "en")
|
||||
end
|
||||
|
||||
def test_save_rejects_path_traversal_slug
|
||||
store = Volumen::Store.new(@dir)
|
||||
post = Volumen::Post.new(metadata: { "slug" => "../../etc/passwd" }, body: "x")
|
||||
assert_raises(ArgumentError, "slug escapes content directory") { store.save(post) }
|
||||
end
|
||||
|
||||
def test_save_uses_lang_subdirectory_for_non_default_language
|
||||
store = Volumen::Store.new(@dir, default_lang: "en")
|
||||
post = Volumen::Post.new(metadata: { "slug" => "bonjour", "lang" => "fr" }, body: "x")
|
||||
store.save(post)
|
||||
assert File.exist?(File.join(@dir, "fr", "bonjour.md"))
|
||||
end
|
||||
|
||||
def test_save_uses_root_for_default_language
|
||||
store = Volumen::Store.new(@dir, default_lang: "en")
|
||||
post = Volumen::Post.new(metadata: { "slug" => "hello-new", "lang" => "en" }, body: "x")
|
||||
store.save(post)
|
||||
assert File.exist?(File.join(@dir, "hello-new.md"))
|
||||
refute File.exist?(File.join(@dir, "en", "hello-new.md"))
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user