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:
|
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/**/*"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
+24
-9
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user