feat: initial release of volumen — a file-based Markdown blog engine
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
# API Reference
|
||||
|
||||
> Status: the public read API (`/api/volumen`) and the admin (`/admin`) are
|
||||
> implemented. The admin is server-rendered HTML, not part of the JSON API.
|
||||
|
||||
Base path: `/api/volumen`. All responses are JSON unless noted. Read endpoints
|
||||
send `Access-Control-Allow-Origin: *`.
|
||||
|
||||
## `GET /api/volumen/site`
|
||||
|
||||
Returns site metadata from the configuration.
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "My Blog",
|
||||
"description": "A blog powered by volumen.",
|
||||
"base_url": "https://example.com",
|
||||
"language": "en",
|
||||
"author": "Anonymous"
|
||||
}
|
||||
```
|
||||
|
||||
## `GET /api/volumen/posts`
|
||||
|
||||
Paginated list of published posts (drafts are excluded), newest first.
|
||||
Posts with `all_langs = true` in their frontmatter match every `lang` filter.
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|-------|--------|---------|--------------------|
|
||||
| lang | string | — | Filter by language |
|
||||
| tag | string | — | Filter by tag |
|
||||
| page | int | 1 | Page number |
|
||||
| limit | int | 20 | Per page (1–100) |
|
||||
|
||||
```json
|
||||
{
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"total": 1,
|
||||
"posts": [
|
||||
{
|
||||
"slug": "hello",
|
||||
"title": "Hello",
|
||||
"excerpt": "Hello world.",
|
||||
"date": "2026-01-15",
|
||||
"lang": "en",
|
||||
"tags": ["intro"],
|
||||
"author": null,
|
||||
"cover": null,
|
||||
"reading_time": 1,
|
||||
"translations": {},
|
||||
"url": "/api/volumen/posts/hello"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## `GET /api/volumen/posts/{slug}`
|
||||
|
||||
Single post including the raw Markdown body and rendered HTML. Optional
|
||||
`?lang=` selects a translation. Returns `404` with `{"error":"not_found"}` if
|
||||
no match.
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "hello",
|
||||
"title": "Hello",
|
||||
"excerpt": "Hello world.",
|
||||
"date": "2026-01-15",
|
||||
"lang": "en",
|
||||
"tags": ["intro"],
|
||||
"author": null,
|
||||
"cover": null,
|
||||
"reading_time": 1,
|
||||
"translations": {},
|
||||
"url": "/api/volumen/posts/hello",
|
||||
"body": "Hello **world**.",
|
||||
"html": "<p>Hello <strong>world</strong>.</p>\n"
|
||||
}
|
||||
```
|
||||
|
||||
## `GET /api/volumen/tags`
|
||||
|
||||
Tag cloud with counts (published posts only), most frequent first.
|
||||
|
||||
```json
|
||||
{ "tags": [{ "name": "intro", "count": 1 }] }
|
||||
```
|
||||
|
||||
## `GET /api/volumen/feed.xml`, `GET /api/volumen/sitemap.xml`
|
||||
|
||||
RSS 2.0 feed (latest 20 posts) and a sitemap of post URLs, built from
|
||||
`site.base_url`.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Architecture
|
||||
|
||||
> Status: the core library, the public JSON API, and the server-rendered admin
|
||||
> (login, CSRF, post CRUD, Markdown editor + live preview) are implemented.
|
||||
|
||||
**volumen** is a small, file-based Markdown blog engine written in CRuby. It
|
||||
has no database: every post is a `.md` file on disk with a TOML frontmatter
|
||||
block. The engine reads those files, exposes them as a JSON API, and ships a
|
||||
server-rendered admin for editing them.
|
||||
|
||||
## Design goals
|
||||
|
||||
- **File-based** — posts are plain `.md` files, safe to commit to Git and edit
|
||||
by hand or through the admin.
|
||||
- **Single-admin** — one password, one content directory, one user.
|
||||
- **Self-contained** — runs as a single Ruby process; no Node build step is
|
||||
required to operate the engine or its admin.
|
||||
- **Dependency-light** — a small, well-chosen set of gems (Sinatra, kramdown,
|
||||
toml-rb, puma) rather than a full framework.
|
||||
- **Markdown-first** — the visual editor round-trips through the same renderer
|
||||
that powers the public API, so stored content is always Markdown.
|
||||
|
||||
## Components (planned layout)
|
||||
|
||||
```
|
||||
lib/volumen/
|
||||
version.rb # gem version
|
||||
config.rb # loads /etc/volumen/config.toml, applies CLI overrides
|
||||
frontmatter.rb # parse/serialise the `+++ … +++` TOML block
|
||||
post.rb # Post model: metadata + raw body + rendered HTML
|
||||
store.rb # reads the content directory into Post objects
|
||||
markdown.rb # kramdown wrapper (render + sanitise)
|
||||
server.rb # Sinatra app: JSON API + admin routes
|
||||
web/
|
||||
views/ # ERB templates for the admin
|
||||
public/ # admin CSS + the visual-editor JS island
|
||||
exe/volumen # CLI: serve, hash-password, version, help
|
||||
```
|
||||
|
||||
## Request flow (planned)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[HTTP request] --> B{Route}
|
||||
B -->|/api/volumen/*| C[JSON API]
|
||||
B -->|/admin/*| D[Admin: session + CSRF]
|
||||
C --> E[Store]
|
||||
D --> E
|
||||
E --> F[(content_dir/*.md)]
|
||||
C --> G[Markdown renderer]
|
||||
D --> G
|
||||
```
|
||||
|
||||
## Public site integration
|
||||
|
||||
The public website (e.g. `petrbalvin.org`, a Vue 3 + Vite + Tailwind SPA)
|
||||
consumes volumen's JSON API. volumen does not render the public site itself; it
|
||||
is headless for the front-end and only renders its own admin.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Configuration
|
||||
|
||||
> Status: initial draft. Field semantics are finalised as the loader lands.
|
||||
|
||||
volumen is configured by a single **TOML** file. By default it lives at
|
||||
`/etc/volumen/config.toml` and is auto-generated on first start. CLI flags
|
||||
passed to `volumen serve` override the file.
|
||||
|
||||
## Example `config.toml`
|
||||
|
||||
```toml
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 9090
|
||||
|
||||
# Directory containing your Markdown posts.
|
||||
content_dir = "/var/lib/volumen/posts"
|
||||
|
||||
[site]
|
||||
title = "My Blog"
|
||||
description = "A blog powered by volumen."
|
||||
base_url = "https://example.com"
|
||||
language = "en"
|
||||
author = "Anonymous"
|
||||
|
||||
[admin]
|
||||
# scrypt hash produced by `volumen hash-password`.
|
||||
password_hash = ""
|
||||
# Secret used to sign session cookies. Leave empty to auto-generate per start
|
||||
# (sessions reset on restart).
|
||||
session_key = ""
|
||||
# Session lifetime in seconds (default 24h).
|
||||
session_ttl = 86400
|
||||
```
|
||||
|
||||
## CLI overrides
|
||||
|
||||
```bash
|
||||
volumen serve --config /etc/volumen/config.toml \
|
||||
--content ./posts \
|
||||
--host 127.0.0.1 \
|
||||
--port 9000
|
||||
```
|
||||
|
||||
## Why TOML
|
||||
|
||||
- **First-class dates** — `date = 2026-01-15` is a real date, not a string.
|
||||
- **Explicit typing** — no YAML-style implicit coercion (`no` → false, etc.).
|
||||
- **No whitespace pitfalls** — indentation is not significant.
|
||||
|
||||
The same format is used for post frontmatter; see the post format section in
|
||||
the project [`README.md`](../README.md).
|
||||
@@ -0,0 +1,287 @@
|
||||
# Deployment
|
||||
|
||||
> Status: production deployment on Linux with systemd + nginx.
|
||||
|
||||
volumen runs as a single Ruby process (Puma) bound to localhost, behind nginx
|
||||
which terminates TLS and reverse-proxies the API and admin. The public website
|
||||
(e.g. a Vue SPA) is served separately and consumes the JSON API.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Ruby ≥ 3.3** with a C toolchain (Puma compiles a native extension).
|
||||
- Fedora: `sudo dnf install ruby ruby-devel @development-tools openssl-devel`
|
||||
- Debian/Ubuntu: `sudo apt install ruby-full build-essential libssl-dev`
|
||||
- **nginx** and a TLS certificate (e.g. via certbot).
|
||||
- A dedicated system user, e.g. `volumen`.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `/opt/volumen` | source checkout (or installed gem) |
|
||||
| `/etc/volumen/config.toml` | configuration (auto-generated on first run) |
|
||||
| `/var/lib/volumen/posts` | Markdown posts |
|
||||
| `/var/lib/volumen/users.toml` | admin users (created from Settings) |
|
||||
| `/var/lib/volumen/posts/media` | uploaded images |
|
||||
|
||||
```bash
|
||||
sudo useradd --system --home /var/lib/volumen --shell /usr/sbin/nologin volumen
|
||||
sudo mkdir -p /etc/volumen /var/lib/volumen/posts
|
||||
sudo chown -R volumen:volumen /var/lib/volumen
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
### Quick install (Linux + systemd)
|
||||
|
||||
From a cloned repository, run the installer as root. It installs the Ruby
|
||||
toolchain from the distro repositories on **Fedora** and **openEuler** (prints
|
||||
guidance on other distros), creates the `volumen` user and directories, runs
|
||||
`bundle install`, generates `config.toml`, and installs and enables the systemd
|
||||
unit:
|
||||
|
||||
```bash
|
||||
sudo git clone https://codeberg.org/petrbalvin/volumen.git /opt/volumen
|
||||
cd /opt/volumen
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
The service is enabled but not started — finish by setting the admin password
|
||||
(printed steps), then `sudo systemctl start volumen`. The manual steps below
|
||||
describe what the script does.
|
||||
|
||||
### Manual install
|
||||
|
||||
From a source checkout (reproducible, no publishing needed):
|
||||
|
||||
```bash
|
||||
sudo git clone https://codeberg.org/petrbalvin/volumen.git /opt/volumen
|
||||
cd /opt/volumen
|
||||
sudo bundle install --deployment
|
||||
```
|
||||
|
||||
Alternatively build and install the gem (`just build` produces `pkg/volumen.gem`,
|
||||
then `gem install pkg/volumen.gem` puts `volumen` on the PATH).
|
||||
|
||||
## First run and the admin password
|
||||
|
||||
The first `serve` writes a commented `config.toml` if it is missing:
|
||||
|
||||
```bash
|
||||
sudo -u volumen bundle exec exe/volumen serve \
|
||||
--config /etc/volumen/config.toml \
|
||||
--content /var/lib/volumen/posts
|
||||
# → "volumen: wrote a default config to /etc/volumen/config.toml; review it and restart."
|
||||
```
|
||||
|
||||
Generate a password hash and a stable session secret, then edit the config:
|
||||
|
||||
```bash
|
||||
bundle exec exe/volumen hash-password # → scrypt$...
|
||||
openssl rand -hex 64 # → session_key
|
||||
sudo -u volumen $EDITOR /etc/volumen/config.toml
|
||||
```
|
||||
|
||||
Set `host = "127.0.0.1"`, your `site.base_url`, the `admin.password_hash`, and a
|
||||
`admin.session_key` (so sessions survive restarts). You can now sign in as user
|
||||
`admin`; from the **Settings** page you can add more users (roles `admin` /
|
||||
`author`) and change passwords. From then on `users.toml` is the source of
|
||||
truth.
|
||||
|
||||
## systemd
|
||||
|
||||
`/etc/systemd/system/volumen.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=volumen blog engine
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=volumen
|
||||
Group=volumen
|
||||
WorkingDirectory=/opt/volumen
|
||||
ExecStart=/usr/bin/bundle exec exe/volumen serve --config /etc/volumen/config.toml --content /var/lib/volumen/posts
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/volumen
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now volumen
|
||||
sudo systemctl status volumen
|
||||
journalctl -u volumen -f
|
||||
```
|
||||
|
||||
## FreeBSD (rc.d)
|
||||
|
||||
volumen runs on FreeBSD too — it is plain CRuby plus gems; only Puma (and its
|
||||
`nio4r` dependency) build a native extension, which compiles with the base
|
||||
clang toolchain.
|
||||
|
||||
```sh
|
||||
pkg install ruby
|
||||
# then install volumen as above (git clone + bundle install)
|
||||
```
|
||||
|
||||
Conventional FreeBSD paths: config in `/usr/local/etc/volumen/`, data in
|
||||
`/var/db/volumen/`. Service script `/usr/local/etc/rc.d/volumen`:
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
#
|
||||
# PROVIDE: volumen
|
||||
# REQUIRE: NETWORKING
|
||||
# KEYWORD: shutdown
|
||||
|
||||
. /etc/rc.subr
|
||||
|
||||
name="volumen"
|
||||
rcvar="volumen_enable"
|
||||
load_rc_config $name
|
||||
|
||||
: ${volumen_enable:="NO"}
|
||||
: ${volumen_user:="volumen"}
|
||||
: ${volumen_dir:="/usr/local/volumen"}
|
||||
: ${volumen_config:="/usr/local/etc/volumen/config.toml"}
|
||||
: ${volumen_content:="/var/db/volumen/posts"}
|
||||
|
||||
pidfile="/var/run/${name}.pid"
|
||||
command="/usr/sbin/daemon"
|
||||
command_args="-f -r -P ${pidfile} -u ${volumen_user} \
|
||||
/bin/sh -c 'cd ${volumen_dir} && exec bundle exec exe/volumen serve --config ${volumen_config} --content ${volumen_content}'"
|
||||
|
||||
run_rc_command "$1"
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
|
||||
```sh
|
||||
sysrc volumen_enable=YES
|
||||
service volumen start
|
||||
```
|
||||
|
||||
## nginx
|
||||
|
||||
volumen only owns `/api/volumen`, `/admin`, and `/media`. Serve your public
|
||||
site from the same hostname and proxy those prefixes to the backend, so the
|
||||
admin runs same-origin (session cookies and CSRF work without extra
|
||||
configuration):
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name blog.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/blog.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/blog.example.com/privkey.pem;
|
||||
|
||||
# Public site (e.g. the built Vue SPA).
|
||||
root /var/www/blog;
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# volumen API, admin and media.
|
||||
location ~ ^/(api/volumen|admin|media)(/|$) {
|
||||
proxy_pass http://127.0.0.1:9090;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name blog.example.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
### Hardening the admin
|
||||
|
||||
The admin lives at `/admin` on your public domain and is protected by login,
|
||||
sessions, CSRF, and roles — but the engine has **no rate limit** on
|
||||
`/admin/login`. Add brute-force throttling (and, optionally, an IP allowlist)
|
||||
at nginx.
|
||||
|
||||
**1. A rate-limit zone** in the `http { }` context (e.g.
|
||||
`/etc/nginx/conf.d/volumen.conf`):
|
||||
|
||||
```nginx
|
||||
limit_req_zone $binary_remote_addr zone=volumen_login:10m rate=5r/m;
|
||||
```
|
||||
|
||||
**2. A reusable proxy snippet** at `/etc/nginx/snippets/volumen-proxy.conf`:
|
||||
|
||||
```nginx
|
||||
proxy_pass http://127.0.0.1:9090;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
```
|
||||
|
||||
**3. Split the single proxy location** in the `server { }` block into three —
|
||||
public API/media, the throttled login, and the rest of the admin:
|
||||
|
||||
```nginx
|
||||
# Public API and uploaded media — open to everyone.
|
||||
location ~ ^/(api/volumen|media)(/|$) {
|
||||
include snippets/volumen-proxy.conf;
|
||||
}
|
||||
|
||||
# Login: throttle brute-force attempts. Only this endpoint is rate-limited,
|
||||
# so the editor's live preview (/admin/preview) is unaffected.
|
||||
location = /admin/login {
|
||||
# Optional IP allowlist (uncomment to restrict):
|
||||
# allow 203.0.113.0/24;
|
||||
# deny all;
|
||||
limit_req zone=volumen_login burst=10 nodelay;
|
||||
include snippets/volumen-proxy.conf;
|
||||
}
|
||||
|
||||
# The rest of the admin.
|
||||
location ^~ /admin {
|
||||
# Optional IP allowlist (must be repeated — locations do not inherit it):
|
||||
# allow 203.0.113.0/24;
|
||||
# deny all;
|
||||
include snippets/volumen-proxy.conf;
|
||||
}
|
||||
```
|
||||
|
||||
nginx matches `= /admin/login` (exact) first, then `^~ /admin` (which stops
|
||||
regex matching), then the `~` regex for API/media — so each path lands in the
|
||||
right block. Apply with `sudo nginx -t && sudo systemctl reload nginx`.
|
||||
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
cd /opt/volumen
|
||||
sudo git pull
|
||||
sudo bundle install --deployment
|
||||
sudo systemctl restart volumen
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
- Bind to `127.0.0.1`; only nginx is public. TLS is terminated by nginx.
|
||||
- Session cookies are `HttpOnly` and `SameSite=Strict`, and gain the `Secure`
|
||||
attribute automatically on HTTPS requests (detected via `X-Forwarded-Proto`,
|
||||
which the nginx snippet above sets) — so the cookie never travels over plain
|
||||
HTTP in production, while local HTTP testing still works. Combined with
|
||||
per-form CSRF tokens this protects the admin. Set a stable `admin.session_key`
|
||||
so sessions survive restarts.
|
||||
- Keep `config.toml` and `users.toml` readable only by the `volumen` user
|
||||
(`chmod 600`). Both may contain password hashes.
|
||||
- `volumen hash-password` reads the password without echo; never pass passwords
|
||||
on the command line.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Security
|
||||
|
||||
> Status: threat model and security posture for the engine as implemented.
|
||||
|
||||
volumen is a small, file-based blog engine: a single Ruby process exposing a
|
||||
read-only public JSON API and a session-authenticated admin, behind nginx. This
|
||||
document describes the trust model, the controls in place, and the known
|
||||
limitations.
|
||||
|
||||
If you find a security issue, please email **opensource@petrbalvin.org** rather
|
||||
than opening a public issue.
|
||||
|
||||
## Trust model
|
||||
|
||||
- **Content authors are trusted.** Posts are written by authenticated admin
|
||||
users. kramdown renders Markdown and **passes raw HTML through** on purpose,
|
||||
so an author can embed HTML. The stored/served `html` is therefore only as
|
||||
safe as the people who can log in. If you need to accept posts from untrusted
|
||||
authors, add an HTML sanitiser before serving.
|
||||
- **The public API is read-only.** It exposes published (non-draft) posts, tags,
|
||||
a feed and a sitemap. No public endpoint mutates state.
|
||||
|
||||
## Authentication
|
||||
|
||||
- Passwords are hashed with **scrypt** via `OpenSSL::KDF` (`N=16384`, `r=8`,
|
||||
`p=1`, 32-byte key, 16-byte random salt). Verification uses
|
||||
`OpenSSL.fixed_length_secure_compare` (constant-time).
|
||||
- Login takes a **username + password**. The error message is intentionally
|
||||
generic ("Invalid username or password.") to avoid user enumeration.
|
||||
- Sessions use signed cookies (`Rack::Session::Cookie`) marked **`HttpOnly`**
|
||||
and **`SameSite=Strict`**. The **`Secure`** attribute is added automatically on
|
||||
HTTPS requests (detected via `request.ssl?` / `X-Forwarded-Proto`), so the
|
||||
cookie never travels over plain HTTP in production. The signing secret comes
|
||||
from `admin.session_key`; if it is shorter than 64 bytes a random secret is
|
||||
generated per start (sessions then reset on restart, so set a stable key in
|
||||
production).
|
||||
|
||||
## Authorization (roles)
|
||||
|
||||
- Two roles: **admin** (full access, manages users) and **author** (posts and
|
||||
own account only).
|
||||
- User management (`add`/`delete`/role change) is gated by `require_admin!`
|
||||
**on the server**, not merely hidden in the UI.
|
||||
- Safeguards prevent lockout: the **last admin** cannot be deleted or demoted,
|
||||
and a user cannot delete their own account or change their own role.
|
||||
|
||||
## CSRF
|
||||
|
||||
- Every state-changing form (`POST`) carries a per-session token
|
||||
(`SecureRandom.hex(32)`), validated with `Rack::Utils.secure_compare`.
|
||||
Requests without a valid token get `403`.
|
||||
- `SameSite=Strict` cookies provide a second, independent layer.
|
||||
|
||||
## CORS and Host handling
|
||||
|
||||
- The public read API sends `Access-Control-Allow-Origin: *` (read-only, no
|
||||
credentials) so any front-end may consume it. The admin sends **no** CORS
|
||||
headers and is same-origin only.
|
||||
- `Rack::Protection::HostAuthorization` is disabled (`permitted_hosts: []`)
|
||||
because volumen is intended to run behind nginx, which controls the `Host`
|
||||
header. Do not expose the Puma port directly to the internet.
|
||||
|
||||
## File uploads and path traversal
|
||||
|
||||
- Uploaded media is stored under `content_dir/media` with a **sanitised,
|
||||
randomised** filename (`<hex>-<slug>.<ext>`), so uploads cannot overwrite
|
||||
each other or escape the directory.
|
||||
- `GET /media/*` resolves names with `File.basename` and an explicit
|
||||
containment check, so `../` traversal cannot read files outside the media
|
||||
directory.
|
||||
- Upload content-type is **not** validated (the `accept="image/*"` attribute is
|
||||
only a UI hint); this is acceptable under the trusted-author model.
|
||||
|
||||
## Secrets and files
|
||||
|
||||
- `config.toml` and `users.toml` may contain password hashes — keep them
|
||||
readable only by the service user (`chmod 600`).
|
||||
- `volumen hash-password` reads the password from the terminal without echo;
|
||||
never pass passwords as command-line arguments.
|
||||
|
||||
## Transport
|
||||
|
||||
- Bind to `127.0.0.1` and terminate TLS at nginx. The browser ↔ nginx hop is
|
||||
HTTPS; the nginx ↔ Puma hop is local.
|
||||
|
||||
## Dependencies
|
||||
|
||||
A small, audited set: `sinatra`, `kramdown` (+ `kramdown-parser-gfm`),
|
||||
`toml-rb`, `puma`, `rackup`. Cryptography uses the Ruby standard library
|
||||
(`openssl`, `securerandom`).
|
||||
|
||||
## Known limitations / non-goals
|
||||
|
||||
- **No rate limiting or lockout** on `/admin/login`. Put nginx `limit_req` in
|
||||
front of it, or use fail2ban, to slow brute-force attempts.
|
||||
- **No 2FA** and **no audit log**.
|
||||
- **Raw HTML in posts is trusted** (see the trust model). There is no built-in
|
||||
HTML sanitiser.
|
||||
Reference in New Issue
Block a user