feat: initial release of volumen — a file-based Markdown blog engine

This commit is contained in:
2026-06-21 13:15:06 +02:00
commit dd3efe870e
54 changed files with 5324 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
# EditorConfig: https://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.{rb,erb,gemspec}]
indent_style = space
indent_size = 2
[{Gemfile,Rakefile}]
indent_style = space
indent_size = 2
[*.{js,html,css,vue}]
indent_style = space
indent_size = 2
[*.{yml,yaml,json,toml}]
indent_style = space
indent_size = 2
[*.{rs,cj}]
indent_style = space
indent_size = 4
[*.go]
indent_style = tab
indent_size = 4
[justfile]
indent_style = tab
[*.md]
indent_style = space
indent_size = 2
trim_trailing_whitespace = false
+129
View File
@@ -0,0 +1,129 @@
name: Release
on:
push:
tags: ["v*"]
jobs:
release:
runs-on: codeberg-small
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- uses: https://github.com/ruby/setup-ruby@v1
with:
ruby-version: "3.4"
bundler-cache: true
- name: Resolve and verify version
id: version
env:
REF: ${{ forgejo.ref }}
run: |
set -euo pipefail
VERSION="${REF##*/}"
if [[ ! "$VERSION" =~ ^v[0-9]+(\.[0-9]+){0,2}([-+].*)?$ ]]; then
echo "ERROR: expected a semver tag like v1.2.3, got: '$VERSION' (ref: '$REF')"
exit 1
fi
VERSION_NO_V="${VERSION#v}"
GEM_VERSION="$(ruby -Ilib -rvolumen/version -e 'print Volumen::VERSION')"
if [ "$VERSION_NO_V" != "$GEM_VERSION" ]; then
echo "ERROR: tag ${VERSION} does not match Volumen::VERSION (${GEM_VERSION})"
exit 1
fi
echo "version_no_v=${VERSION_NO_V}" >> "$FORGEJO_OUTPUT"
- name: Quality gate
run: |
set -euo pipefail
bundle exec rubocop
bundle exec rake test
- name: Build gem
env:
VERSION_NO_V: ${{ steps.version.outputs.version_no_v }}
run: |
set -euo pipefail
gem build volumen.gemspec --output "volumen-${VERSION_NO_V}.gem"
ls -lh "volumen-${VERSION_NO_V}.gem"
- name: Extract CHANGELOG section
env:
VERSION_NO_V: ${{ steps.version.outputs.version_no_v }}
run: |
set -euo pipefail
sed -n "/^## \[${VERSION_NO_V}\] /,/^## \[/p" CHANGELOG.md \
| sed '$d' \
| tail -n +2 \
> release-body.md
if [ ! -s release-body.md ]; then
echo "ERROR: no CHANGELOG section found for ${VERSION_NO_V}"
echo "Expected a heading like: ## [${VERSION_NO_V}] — YYYY-MM-DD"
exit 1
fi
echo "Release body preview:"
head -n 20 release-body.md
- name: Create release
id: create
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
run: |
set -euo pipefail
BODY="$(ruby -rjson -e 'print JSON.generate($stdin.read)' < release-body.md)"
response="$(curl -sS -w '\n%{http_code}' \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-X POST \
"${FORGEJO_SERVER_URL}/api/v1/repos/${FORGEJO_REPOSITORY}/releases" \
-d "{
\"tag_name\": \"${FORGEJO_REF_NAME}\",
\"name\": \"${FORGEJO_REF_NAME}\",
\"body\": ${BODY},
\"draft\": false,
\"prerelease\": false
}")"
http_code="$(echo "$response" | tail -1)"
payload="$(echo "$response" | sed '$d')"
echo "HTTP ${http_code}"
if [ "$http_code" != "201" ]; then
echo "Failed to create release: ${payload}"
exit 1
fi
RELEASE_ID="$(echo "$payload" | ruby -rjson -e 'print JSON.parse($stdin.read)["id"]')"
echo "Created release ID=${RELEASE_ID}"
echo "release_id=${RELEASE_ID}" >> "$FORGEJO_OUTPUT"
- name: Upload gem asset
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
VERSION_NO_V: ${{ steps.version.outputs.version_no_v }}
RELEASE_ID: ${{ steps.create.outputs.release_id }}
run: |
set -euo pipefail
GEM="volumen-${VERSION_NO_V}.gem"
http_code="$(curl -sS -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/octet-stream" \
-X POST \
--data-binary "@${GEM}" \
"${FORGEJO_SERVER_URL}/api/v1/repos/${FORGEJO_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${GEM}")"
echo "HTTP ${http_code}"
if [ "$http_code" != "201" ]; then
echo "Failed to upload ${GEM}"
exit 1
fi
echo "Release ${FORGEJO_REF_NAME} is live with ${GEM}."
+39
View File
@@ -0,0 +1,39 @@
name: Test
on:
push:
branches: [development]
pull_request:
branches: [development]
jobs:
lint:
runs-on: codeberg-small
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- uses: https://github.com/ruby/setup-ruby@v1
with:
ruby-version: "3.4"
bundler-cache: true
- name: RuboCop
run: bundle exec rubocop
test:
runs-on: codeberg-small
needs: lint
strategy:
fail-fast: false
matrix:
ruby-version: ["3.3", "3.4"]
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- uses: https://github.com/ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby-version }}
bundler-cache: true
- name: Tests
run: bundle exec rake test
+24
View File
@@ -0,0 +1,24 @@
# Bundler
/.bundle/
/vendor/bundle/
# Built gems
/pkg/
*.gem
# Test / coverage
/coverage/
/tmp/
# Local development sandbox (posts, generated config)
/.volumen/
# Uploaded media in the dev content directory
/posts/media/
# Local users database (dev)
/users.toml
# Editor / OS
*.swp
.DS_Store
+32
View File
@@ -0,0 +1,32 @@
AllCops:
TargetRubyVersion: 3.3
NewCops: enable
SuggestExtensions: false
Style/StringLiterals:
EnforcedStyle: double_quotes
Style/StringLiteralsInInterpolation:
EnforcedStyle: double_quotes
Style/Documentation:
Exclude:
- "test/**/*"
- "exe/**/*"
Layout/LineLength:
Max: 100
Metrics/AbcSize:
Max: 30
Metrics/MethodLength:
Max: 25
Metrics/ClassLength:
Max: 400
Metrics/BlockLength:
Exclude:
- "test/**/*"
- "volumen.gemspec"
+96
View File
@@ -0,0 +1,96 @@
# Changelog
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).
## [0.1.0] — 2026-06-20
First public release: a small, file-based Markdown blog engine in CRuby. Posts
are plain files, the engine exposes them as a JSON API and ships a self-hosted
admin — no database, no external services.
### Added
**Content & storage**
- File-based store: each post is a `.md` file with a TOML frontmatter block
delimited by `+++`. No database.
- Post fields: `title`, `slug`, `date`, `lang`, `author`, `tags`, `draft`,
`excerpt`, `cover`, `all_langs`, and `translations` (a `lang → slug` map).
- Drafts (`draft = true`) are hidden from the public API.
- Excerpt falls back to the first paragraph (~200 chars) when none is set.
- Estimated reading time (~200 words/min) computed per post.
- Multilingual posts: per-language slugs via `translations`, plus `all_langs`
to surface a single post under every language.
**Markdown rendering**
- GFM rendering via kramdown: tables, fenced code blocks, task lists, and
strikethrough.
- Automatic heading IDs for in-page anchors.
- Raw HTML in post bodies is passed through (authors are the trusted admin).
**Public JSON API (`/api/volumen`)**
- Read-only endpoints: `site`, `posts`, `posts/{slug}`, `tags`, `feed.xml`
(RSS), and `sitemap.xml`.
- `posts` supports pagination (`page`, `limit`, returning `total` and
`page_size`) and filtering by `lang` and `tag`.
- Permissive CORS so a separate front end can consume the API.
**Admin (`/admin`)**
- Server-rendered admin with a sidebar layout and full post CRUD.
- Dual-mode editor: Markdown source (primary) and a visual WYSIWYG, switchable
per post, with a toggleable live preview persisted across sessions.
- Image upload from the editor toolbar into `content_dir/media`; any image
format is accepted (WebP and AVIF included) and served with the correct
`Content-Type`.
- Cover image field with upload, URL, clear, and preview.
**Multi-user & roles**
- Multiple admin users stored in `users.toml`, managed from a settings page.
- Two roles: `admin` (full access incl. user management) and `author` (posts
only).
- Change password and username; last-admin and self-deletion safeguards.
**Configuration & CLI**
- TOML configuration, auto-generated with a commented template on first run.
- Configurable `host`/`port` (defaults to `9090`), `content_dir`,
`users_file`, and `site` metadata.
- CLI: `serve`, `hash-password`, `version`, `help`, with `--config`,
`--content`, `--host`, and `--port` overrides.
**Deployment**
- `install.sh` installs the Ruby toolchain from distro packages on Fedora and
openEuler (and prints guidance on other distros), creates the service user
and directories, and installs a systemd unit.
- Deployment docs for systemd and FreeBSD (`rc.d`), plus an nginx reverse-proxy
setup that runs the admin same-origin, with brute-force rate-limiting and an
optional IP allowlist on the login endpoint.
**Project & tooling**
- Documentation set: architecture, configuration, API reference, deployment,
and security.
- `just` task set (`install`, `run`, `dev`, `build`, `test`, `uninstall`) and
Forgejo Actions CI (RuboCop + tests on Ruby 3.3/3.4, gem build on release).
- `CONTRIBUTING.md` and a minitest suite; clean RuboCop.
### Security
- Passwords hashed with scrypt and verified in constant time.
- Per-form CSRF tokens on every state-changing admin request.
- Session cookies are `HttpOnly` and `SameSite=Strict`, and gain the `Secure`
attribute automatically on HTTPS requests (detected via `request.ssl?` /
`X-Forwarded-Proto`), so the cookie never travels over plain HTTP in
production.
- Configurable, stable `session_key` so sessions survive restarts.
- Generic login error message to avoid username enumeration.
[0.1.0]: https://codeberg.org/petrbalvin/volumen/releases/tag/v0.1.0
+257
View File
@@ -0,0 +1,257 @@
# Contributing
Thank you for considering contributing to **volumen** — a small, file-based
Markdown blog engine written in CRuby, with a built-in admin and a public JSON
API.
## Development Setup
### 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`
- [`just`](https://github.com/casey/just) — the `justfile` is the source of
truth for install / run / build / test / uninstall.
- Linux, macOS, or FreeBSD.
### Quick Start
```bash
git clone https://codeberg.org/petrbalvin/volumen.git
cd volumen
just install # bundle install
just test # bundle exec rake test (minitest)
just build # rubocop (zero warnings) + gem build → pkg/volumen.gem
just dev # run against ./posts and ./config.toml on :9090
```
`just dev` serves the bundled sample posts and a local `config.toml`. The dev
admin password is `admin` (the hash lives in `./config.toml`). Open
<http://localhost:9090/admin/>.
For a fresh `serve` (without `--config`), the first run writes a commented
template to `/etc/volumen/config.toml`; see [`docs/deployment.md`](docs/deployment.md).
### Running Tests
```bash
just test
```
The minitest suite (`test/`) covers:
- `frontmatter``+++` TOML block parse/serialise round-trips.
- `post` / `store` — the Post model, reading/writing posts and media.
- `markdown` — GFM rendering (tables, fenced code).
- `config` — defaults, overrides, template generation.
- `password` / `users` — scrypt hashing, roles, last-admin safeguards.
- `server` / `admin` — the JSON API and the admin (auth, CSRF, CRUD, uploads,
roles) via `rack-test`.
### Linting
RuboCop is the source of truth for style (`.rubocop.yml`). The project enforces
**zero offenses**:
```bash
bundle exec rubocop # check (also run as part of `just build`)
bundle exec rubocop -A # autocorrect
```
## Code Style
- Idiomatic Ruby; let RuboCop guide you. CI fails on any offense.
- **Two-space** indentation, **double quotes**, and `# frozen_string_literal: true`
at the top of every Ruby file.
- Document every class/module with a short comment (`Style/Documentation` is on).
- Keep lines ≤ 100 characters (`Layout/LineLength`).
- Prefer guard clauses; return `nil`/objects for expected "not found" cases
rather than raising.
- Keep the dependency set small and deliberate — runtime gems are `sinatra`,
`kramdown` (+ `kramdown-parser-gfm`), `toml-rb`, `puma`, `rackup`. Crypto uses
the standard library (`openssl`, `securerandom`). New runtime dependencies
should be discussed in an issue first.
- Admin content is authored by trusted users; see [`docs/security.md`](docs/security.md)
for the trust model before touching rendering or uploads.
### Testing
- Put tests in `test/*_test.rb`, each starting with `require "test_helper"`.
- Use `Dir.mktmpdir` for anything touching the filesystem; never assume a clean
working directory.
- Exercise the HTTP layer with `Rack::Test` (`Volumen::Server.configured(...)`).
- Name tests `test_<thing>_<scenario>`; cover both the happy and the error path.
## Commit Conventions
We follow [Conventional Commits](https://www.conventionalcommits.org/).
| Type | Use |
|------------|----------------------------------------------|
| `feat` | New feature |
| `fix` | Bug fix |
| `refactor` | Restructuring without behaviour change |
| `docs` | Documentation |
| `test` | Adding or updating tests |
| `chore` | Maintenance, CI, tooling |
| `perf` | Performance improvement |
| `style` | Formatting / lint-only changes |
| `security` | Hardening without a user-visible fix |
```
feat: add author role with last-admin safeguards
fix: rename Server.build to .configured to avoid a Sinatra clash
refactor: extract frontmatter parsing into its own module
docs: document the dual-mode editor
test: cover GFM table and fenced code rendering
chore: pin gem versions to latest
```
- English, lowercase subject, imperative mood (`add`, not `added`).
- No trailing period, max 72 characters.
- No version numbers in commit messages — versions belong to tags.
## Pull Request Flow
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:
- `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
`development` into `main` and pushing a `vX.Y.Z` tag — see
[Cutting a release](#cutting-a-release).
## Project Structure
```
volumen/
├── exe/
│ └── volumen # CLI: serve, hash-password, version, help
├── lib/
│ ├── volumen.rb # library entry (requires the core modules)
│ └── volumen/
│ ├── version.rb
│ ├── frontmatter.rb # parse/serialise the +++ TOML frontmatter block
│ ├── markdown.rb # kramdown (GFM) → HTML
│ ├── post.rb # Post model (metadata + body + rendered HTML)
│ ├── store.rb # read/write posts and media on disk
│ ├── config.rb # load/merge config.toml, generate template
│ ├── password.rb # scrypt hashing/verification (OpenSSL::KDF)
│ ├── users.rb # users.toml, admin/author roles
│ ├── server.rb # Sinatra app: public API + admin
│ ├── cli.rb # command dispatcher
│ └── web/
│ ├── views/ # ERB templates (layout, login, list, form, settings)
│ └── public/ # static assets (volumen-logo.svg)
├── test/ # minitest suite
├── docs/ # architecture, configuration, api-reference, deployment, security
├── .forgejo/workflows/ # test.yml, release.yml (Forgejo Actions)
├── justfile # install, run, dev, build, test, uninstall
├── volumen.gemspec
├── Gemfile
├── CHANGELOG.md
└── LICENSE
```
## Architecture Overview
```mermaid
graph TD
Browser[Browser / Vue SPA]
Nginx[nginx reverse proxy]
Server[Volumen::Server -- Sinatra]
Store[Volumen::Store]
Markdown[Volumen::Markdown -- kramdown GFM]
Users[Volumen::Users]
Posts[(content_dir/*.md)]
UsersFile[(users.toml)]
Browser -->|GET /api/volumen/*| Nginx
Browser -->|/admin| Nginx
Nginx --> Server
Server -->|read/write posts| Store
Server -->|render Markdown| Markdown
Server -->|auth + roles| Users
Store --> Posts
Users --> UsersFile
```
### Key Design Decisions
- **File-based, no database** — every post is a `.md` file with TOML
frontmatter; users live in a `users.toml`. Everything is plain text, safe to
commit and back up.
- **Headless for the front-end** — volumen serves a read-only JSON API and its
own server-rendered admin; the public site is a separate consumer.
- **Single universal gem** — volumen is pure Ruby, so `gem build` produces one
platform-independent gem. Only the `puma` dependency has a native extension,
compiled at install time on the host.
- **Trusted authors** — kramdown passes raw HTML through on purpose; content is
only as safe as who can log in (see `docs/security.md`).
- **Roles, server-enforced** — `admin` manages users, `author` manages posts
and their own account; `require_admin!` gates user management on the server,
with last-admin safeguards.
## CI
volumen uses **Forgejo Actions**; both workflows live under `.forgejo/workflows/`
and run on the `codeberg-small` runner.
### Test — `.forgejo/workflows/test.yml`
Triggered on push and pull requests targeting `development`.
| Job | What it does |
|--------|-----------------------------------------------|
| `lint` | `bundle exec rubocop` (must report zero offenses) |
| `test` | `bundle exec rake test` on a Ruby `3.3` / `3.4` matrix |
Run the same locally before opening a PR:
```bash
bundle exec rubocop
just test
```
### Release — `.forgejo/workflows/release.yml`
Triggered by pushing a `v*` tag. It verifies the tag matches `Volumen::VERSION`,
runs the quality gate (rubocop + tests), builds `volumen-X.Y.Z.gem`, extracts
the matching `CHANGELOG.md` section, creates a Forgejo release via the REST API,
and attaches the gem. It does **not** publish to RubyGems.
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`.
3. Ensure CI is green on `development`.
4. Merge `development` into `main` (the only time `main` changes outside a tag).
5. Tag and push:
```bash
git tag -a vX.Y.Z -m "volumen vX.Y.Z"
git push origin main
git push origin vX.Y.Z
```
6. The release workflow builds the gem and publishes the release from the
CHANGELOG section.
## Report a Bug
Open an issue at <https://codeberg.org/petrbalvin/volumen/issues> with:
- volumen version (`volumen version`).
- Operating system and architecture.
- Exact steps (request or admin action) that reproduce it.
- Expected vs actual behaviour, and the relevant slice of `journalctl -u volumen`.
For **security issues**, please email **opensource@petrbalvin.org** rather than
opening a public issue.
+13
View File
@@ -0,0 +1,13 @@
# frozen_string_literal: true
source "https://rubygems.org"
# Runtime dependencies are declared in the gemspec.
gemspec
group :development, :test do
gem "minitest", "~> 6.0"
gem "rack-test", "~> 2.2"
gem "rake", "~> 13.4"
gem "rubocop", "~> 1.88", require: false
end
+136
View File
@@ -0,0 +1,136 @@
PATH
remote: .
specs:
volumen (0.1.0)
kramdown (~> 2.5)
kramdown-parser-gfm (~> 1.1)
puma (~> 8.0)
rackup (~> 2.3)
sinatra (~> 4.2)
toml-rb (~> 4.2)
GEM
remote: https://rubygems.org/
specs:
ast (2.4.3)
base64 (0.3.0)
citrus (3.0.2)
drb (2.2.3)
json (2.19.9)
kramdown (2.5.2)
rexml (>= 3.4.4)
kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0)
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
mustermann (3.1.1)
nio4r (2.7.5)
parallel (2.1.0)
parser (3.3.11.1)
ast (~> 2.4.1)
racc
prism (1.9.0)
puma (8.0.2)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.6)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rainbow (3.1.1)
rake (13.4.2)
regexp_parser (2.12.0)
rexml (3.4.4)
rubocop (1.88.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (>= 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.1)
parser (>= 3.3.7.2)
prism (~> 1.7)
ruby-progressbar (1.13.0)
sinatra (4.2.1)
logger (>= 1.6.0)
mustermann (~> 3.0)
rack (>= 3.0.0, < 4)
rack-protection (= 4.2.1)
rack-session (>= 2.0.0, < 3)
tilt (~> 2.0)
tilt (2.7.0)
toml-rb (4.2.0)
citrus (~> 3.0, > 3.0)
racc (~> 1.7)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
PLATFORMS
ruby
x86_64-linux
DEPENDENCIES
minitest (~> 6.0)
rack-test (~> 2.2)
rake (~> 13.4)
rubocop (~> 1.88)
volumen!
CHECKSUMS
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
citrus (3.0.2) sha256=4ec2412fc389ad186735f4baee1460f7900a8e130ffe3f216b30d4f9c684f650
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a
kramdown (2.5.2) sha256=1ba542204c66b6f9111ff00dcc26075b95b220b07f2905d8261740c82f7f02fa
kramdown-parser-gfm (1.1.0) sha256=fb39745516427d2988543bf01fc4cf0ab1149476382393e0e9c48592f6581729
language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
mustermann (3.1.1) sha256=4c6170c7234d5499c345562ba7c7dfe73e1754286dcc1abb053064d66a127198
nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356
parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2
rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac
rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8
rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
rubocop (1.88.0) sha256=e420ddf1662d0ef34bc8a2910ac4b396a7ddda0b51a708264405241734b08e0b
rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080
tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3
toml-rb (4.2.0) sha256=10a48c91613e20cf63483a7a776767dfb3cd7d70e9327c0237443da601e13776
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
volumen (0.1.0)
BUNDLED WITH
4.0.10
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://www.petrbalvin.org)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+247
View File
@@ -0,0 +1,247 @@
# volumen — file-based Markdown blog engine
**volumen** is a small, dependency-light blog engine written in **Ruby**. It
serves your Markdown posts as a JSON API and includes a built-in, server-
rendered admin with both a Markdown source editor and a visual editor — so any
front-end (Vue, React, Svelte, plain HTML) can consume your content without
re-implementing the engine.
It is designed to be:
- **File-based** — every post is a plain `.md` file with TOML frontmatter, safe
to commit to Git and edit in your favourite editor.
- **Self-contained** — runs as a single Ruby process; no Node build step is
required to operate the engine or its admin.
- **Multi-user with roles** — username + password login; **admin** (full
access, manages users) and **author** (posts and own account) roles.
- **Multi-site friendly** — one instance per blog, configurable per instance.
- **Markdown-first** — the visual editor round-trips through the same renderer
that powers the public API, so what you store is always Markdown.
---
## Stack
| Concern | Choice |
|----------------|------------------------------------------|
| Language | CRuby (≥ 3.2) |
| Web framework | [Sinatra](https://sinatrarb.com) |
| Markdown | [kramdown](https://kramdown.gettalong.org) |
| Frontmatter | TOML via [`toml-rb`](https://github.com/emancu/toml-rb) |
| App server | [Puma](https://puma.io) |
| Packaging | a Ruby gem with an `exe/volumen` CLI |
---
## Features (target)
- Markdown posts with TOML frontmatter (`title`, `date`, `lang`, `tags`,
`draft`, `translations`, …)
- Multi-language posts with a translations map
- Clean JSON API at `/api/volumen/`:
- `GET /site` — site metadata
- `GET /posts` — paginated list with `lang`, `tag`, `page`, `limit` filters
- `GET /posts/{slug}` — single post (raw Markdown + rendered HTML)
- `GET /tags` — tag cloud with counts
- `GET /feed.xml` — RSS 2.0 feed
- `GET /sitemap.xml` — sitemap
- Server-rendered admin at `/admin/`:
- Username + password login (scrypt-hashed via Ruby's `OpenSSL::KDF`)
- Settings page: change password, change username, manage users and roles
(admin only)
- Session cookies (HttpOnly, SameSite=Strict) + CSRF tokens
- List, create, edit, delete posts
- **Markdown source** editor and a **visual** editor with live preview, both
storing Markdown
- "Reload from disk" to pick up out-of-band edits
- Permissive CORS for the public API, same-origin only for the admin
> Status: the public read API (`/api/volumen`) and the server-rendered admin
> (`/admin`, login + CRUD + Markdown editor with live preview) are implemented
> and tested. See [`docs/api-reference.md`](docs/api-reference.md).
---
## Quick start
```bash
# 1. Install dependencies (Ruby ≥ 3.2)
just install
# 2. Run
just run
```
The admin will live at <http://localhost:9090/admin/> and the API at
<http://localhost:9090/api/volumen/posts>.
---
## Post format
Each post is a Markdown file with a TOML frontmatter block delimited by `+++`:
```markdown
+++
title = "My post title"
slug = "my-post" # optional, defaults to the filename
date = 2026-01-15 # first-class TOML date
lang = "en" # language code
author = "Petr Balvín" # optional
tags = ["ruby", "web"] # optional
draft = false # optional
excerpt = "Short summary" # optional, auto-derived from body if missing
all_langs = false # optional, show this post in every language
[translations] # optional, maps lang → slug
cs = "muj-prispevek"
+++
# Heading
Body in **Markdown**, rendered by kramdown.
```
Posts are organised on disk as either:
- `content_dir/<slug>.md` (default language), or
- `content_dir/<lang>/<slug>.md` (per-language subdirectory)
The `translations` table links posts in different languages together so a
front-end can offer a language switcher.
Set `all_langs = true` on a post to show it in **every** language (useful for
posts that have no per-language translations).
### Why TOML instead of YAML
- **First-class dates** — `date = 2026-01-15` is a real date, not a guess.
- **Explicit typing** — no YAML implicit-coercion footguns (`lang = no`
becoming `false`).
- **No whitespace pitfalls** — indentation is not significant.
---
## Configuration
A single TOML file, auto-generated at `/etc/volumen/config.toml` on first
start. See [`docs/configuration.md`](docs/configuration.md) for the full
schema.
```toml
[server]
host = "0.0.0.0"
port = 9090
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]
password_hash = ""
session_key = ""
session_ttl = 86400
```
---
## Public API
The API is at `/api/volumen/`. CORS is open (`*`) for read endpoints; admin routes
are same-origin only.
### `GET /api/volumen/posts`
| Name | Type | Default | Description |
|-------|--------|---------|----------------------|
| lang | string | — | Filter by language |
| tag | string | — | Filter by tag |
| page | int | 1 | Page number |
| limit | int | 20 | Posts per page |
```json
{
"page": 1,
"page_size": 20,
"total": 42,
"posts": [
{
"slug": "welcome",
"title": "Welcome to volumen",
"excerpt": "A short introduction…",
"date": "2026-01-15",
"lang": "en",
"tags": ["intro", "ruby"],
"author": "Petr Balvín",
"translations": { "cs": "vitame-vas" },
"url": "/api/volumen/posts/welcome"
}
]
}
```
### `GET /api/volumen/posts/{slug}?lang=en`
Returns the full post including the raw Markdown body and the rendered HTML.
```json
{
"slug": "welcome",
"title": "Welcome to volumen",
"date": "2026-01-15",
"lang": "en",
"tags": ["intro", "ruby"],
"excerpt": "…",
"body": "# Welcome to **volumen**\n\n…",
"html": "<h1>Welcome to <strong>volumen</strong></h1>…",
"translations": { "cs": "vitame-vas" }
}
```
---
## Development
```bash
just # list recipes
just install # bundle install
just run # run the dev server (exe/volumen serve)
just build # rubocop (zero warnings) + build the gem → pkg/volumen.gem
just test # bundle exec rake test (minitest)
just uninstall # remove build artifacts (pkg/, *.gem)
```
---
## Public site integration
volumen is headless for the front-end: it does not render your public site, it
only renders its own admin. The public website (e.g.
[`petrbalvin.org`](https://petrbalvin.org), a Vue 3 + Vite + Tailwind SPA)
consumes the JSON API. If you proxy `/api/volumen/*` through nginx on the same
origin, no front-end configuration is required.
---
## Documentation
- [`docs/architecture.md`](docs/architecture.md) — components and request flow
- [`docs/configuration.md`](docs/configuration.md) — config schema (TOML)
- [`docs/api-reference.md`](docs/api-reference.md) — public JSON API
- [`docs/deployment.md`](docs/deployment.md) — systemd, nginx, first run
- [`docs/security.md`](docs/security.md) — threat model and controls
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, code style,
commit conventions, the pull request flow, and how releases are cut.
## License
MIT — see [LICENSE](LICENSE).
Copyright © 2026 [Petr Balvín](https://petrbalvin.org)
+12
View File
@@ -0,0 +1,12 @@
# frozen_string_literal: true
require "rake/testtask"
Rake::TestTask.new(:test) do |t|
t.libs << "test"
t.libs << "lib"
t.test_files = FileList["test/**/*_test.rb"]
t.warning = false
end
task default: :test
+159
View File
@@ -0,0 +1,159 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="volumen">
<title>volumen</title>
<defs>
<!-- Background gradient: deep night sky -->
<radialGradient id="bg" cx="50%" cy="35%" r="80%">
<stop offset="0%" stop-color="#4a5bd8"/>
<stop offset="55%" stop-color="#2a2d6e"/>
<stop offset="100%" stop-color="#15163a"/>
</radialGradient>
<!-- Subtle inner ring gradient -->
<linearGradient id="ring" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#8aa0ff" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#3b3f8a" stop-opacity="0.6"/>
</linearGradient>
<!-- Glossy highlight at the top -->
<radialGradient id="gloss" cx="50%" cy="15%" r="55%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.35"/>
<stop offset="60%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
<!-- Wave stroke gradient -->
<linearGradient id="wave" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#7afcff" stop-opacity="0.15"/>
<stop offset="50%" stop-color="#7afcff" stop-opacity="0.95"/>
<stop offset="100%" stop-color="#7afcff" stop-opacity="0.15"/>
</linearGradient>
<linearGradient id="waveDim" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
</linearGradient>
<!-- Central V fill gradient -->
<linearGradient id="vFill" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#fdfdff"/>
<stop offset="100%" stop-color="#b8c4ff"/>
</linearGradient>
<!-- Soft drop shadow -->
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="2" result="off"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.5"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- Outer glow for the disc -->
<filter id="outerGlow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="4"/>
</filter>
<!-- Reusable wave path (sinusoidal, centered around y=128) -->
<path id="wavePath"
d="M 36 128
C 56 96, 76 96, 96 128
S 136 160, 156 128
S 196 96, 216 128"
fill="none"/>
</defs>
<!-- ===== Outer glow halo ===== -->
<circle cx="128" cy="128" r="116" fill="url(#bg)" opacity="0.55" filter="url(#outerGlow)"/>
<!-- ===== Main disc ===== -->
<circle cx="128" cy="128" r="112" fill="url(#bg)"/>
<!-- Inner decorative ring -->
<circle cx="128" cy="128" r="104" fill="none" stroke="url(#ring)" stroke-width="1.2" opacity="0.7"/>
<!-- Dotted ring (tick marks) -->
<g fill="#8aa0ff" opacity="0.55">
<circle cx="128" cy="22" r="1.4"/>
<circle cx="180" cy="32" r="1.2"/>
<circle cx="220" cy="62" r="1.4"/>
<circle cx="234" cy="110" r="1.2"/>
<circle cx="234" cy="146" r="1.4"/>
<circle cx="220" cy="194" r="1.2"/>
<circle cx="180" cy="224" r="1.4"/>
<circle cx="128" cy="234" r="1.2"/>
<circle cx="76" cy="224" r="1.4"/>
<circle cx="36" cy="194" r="1.2"/>
<circle cx="22" cy="146" r="1.4"/>
<circle cx="22" cy="110" r="1.2"/>
<circle cx="36" cy="62" r="1.4"/>
<circle cx="76" cy="32" r="1.2"/>
</g>
<!-- ===== Waves (background layer) ===== -->
<g stroke-linecap="round" fill="none">
<use href="#wavePath" stroke="url(#waveDim)" stroke-width="1.2"
transform="translate(0,-46)" opacity="0.6"/>
<use href="#wavePath" stroke="url(#waveDim)" stroke-width="1.2"
transform="translate(0,46)" opacity="0.6"/>
</g>
<!-- ===== Central stylized "V" ===== -->
<g filter="url(#softShadow)">
<!-- Main V shape, geometric, with a small notch at the bottom for a leaf/quill hint -->
<path d="
M 78 70
L 110 70
L 128 142
L 146 70
L 178 70
L 138 186
L 118 186
Z"
fill="url(#vFill)"/>
<!-- Inner accent line on the V -->
<path d="M 118 186 L 128 142 L 138 186"
fill="none" stroke="#2a2d6e" stroke-width="1.2" stroke-linejoin="round" opacity="0.35"/>
<!-- Subtle highlight stripe on the left arm of V -->
<path d="M 82 74 L 108 74 L 100 100 Z"
fill="#ffffff" opacity="0.25"/>
</g>
<!-- ===== Foreground waves crossing the V ===== -->
<g stroke-linecap="round" fill="none">
<use href="#wavePath" stroke="url(#wave)" stroke-width="3.2" opacity="0.9"/>
<use href="#wavePath" stroke="url(#wave)" stroke-width="1.6"
transform="translate(0,18)" opacity="0.7"/>
<use href="#wavePath" stroke="url(#wave)" stroke-width="1.6"
transform="translate(0,-18)" opacity="0.7"/>
</g>
<!-- ===== Sparkles / dots ===== -->
<g fill="#ffffff">
<circle cx="60" cy="92" r="1.8" opacity="0.95"/>
<circle cx="196" cy="92" r="1.6" opacity="0.85"/>
<circle cx="200" cy="168" r="1.4" opacity="0.8"/>
<circle cx="58" cy="172" r="1.4" opacity="0.8"/>
<circle cx="156" cy="58" r="1.2" opacity="0.7"/>
<circle cx="100" cy="206" r="1.2" opacity="0.7"/>
</g>
<!-- Four-point sparkle (north star) -->
<g transform="translate(70,70)" fill="#ffffff" opacity="0.9">
<path d="M 0 -7 L 1.4 -1.4 L 7 0 L 1.4 1.4 L 0 7 L -1.4 1.4 L -7 0 L -1.4 -1.4 Z"/>
</g>
<g transform="translate(190,190) scale(0.7)" fill="#ffffff" opacity="0.8">
<path d="M 0 -7 L 1.4 -1.4 L 7 0 L 1.4 1.4 L 0 7 L -1.4 1.4 L -7 0 L -1.4 -1.4 Z"/>
</g>
<!-- ===== Glossy top highlight ===== -->
<circle cx="128" cy="128" r="112" fill="url(#gloss)"/>
</svg>

After

Width:  |  Height:  |  Size: 5.8 KiB

+167
View File
@@ -0,0 +1,167 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 160" role="img" aria-label="volumen — blog engine">
<title>volumen — blog engine</title>
<defs>
<!-- Background gradient: deep night sky -->
<radialGradient id="bg" cx="50%" cy="35%" r="80%">
<stop offset="0%" stop-color="#4a5bd8"/>
<stop offset="55%" stop-color="#2a2d6e"/>
<stop offset="100%" stop-color="#15163a"/>
</radialGradient>
<linearGradient id="ring" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#8aa0ff" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#3b3f8a" stop-opacity="0.6"/>
</linearGradient>
<radialGradient id="gloss" cx="50%" cy="15%" r="55%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.35"/>
<stop offset="60%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
<linearGradient id="wave" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#7afcff" stop-opacity="0.15"/>
<stop offset="50%" stop-color="#7afcff" stop-opacity="0.95"/>
<stop offset="100%" stop-color="#7afcff" stop-opacity="0.15"/>
</linearGradient>
<linearGradient id="waveDim" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
</linearGradient>
<linearGradient id="vFill" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#fdfdff"/>
<stop offset="100%" stop-color="#b8c4ff"/>
</linearGradient>
<!-- Wordmark gradient: echoes the icon background -->
<linearGradient id="wordmark" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#2a2d6e"/>
<stop offset="100%" stop-color="#4a5bd8"/>
</linearGradient>
<linearGradient id="wordmarkAccent" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#2a2d6e" stop-opacity="0"/>
<stop offset="50%" stop-color="#4a5bd8" stop-opacity="0.7"/>
<stop offset="100%" stop-color="#2a2d6e" stop-opacity="0"/>
</linearGradient>
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="2" result="off"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.5"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<filter id="outerGlow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="4"/>
</filter>
<!-- Reusable wave path (sinusoidal, centered around y=128 in 256x256 space) -->
<path id="wavePath"
d="M 36 128
C 56 96, 76 96, 96 128
S 136 160, 156 128
S 196 96, 216 128"
fill="none"/>
<!-- Full icon as a reusable symbol (256x256 source) -->
<symbol id="icon" viewBox="0 0 256 256">
<circle cx="128" cy="128" r="116" fill="url(#bg)" opacity="0.55" filter="url(#outerGlow)"/>
<circle cx="128" cy="128" r="112" fill="url(#bg)"/>
<circle cx="128" cy="128" r="104" fill="none" stroke="url(#ring)" stroke-width="1.2" opacity="0.7"/>
<g fill="#8aa0ff" opacity="0.55">
<circle cx="128" cy="22" r="1.4"/>
<circle cx="180" cy="32" r="1.2"/>
<circle cx="220" cy="62" r="1.4"/>
<circle cx="234" cy="110" r="1.2"/>
<circle cx="234" cy="146" r="1.4"/>
<circle cx="220" cy="194" r="1.2"/>
<circle cx="180" cy="224" r="1.4"/>
<circle cx="128" cy="234" r="1.2"/>
<circle cx="76" cy="224" r="1.4"/>
<circle cx="36" cy="194" r="1.2"/>
<circle cx="22" cy="146" r="1.4"/>
<circle cx="22" cy="110" r="1.2"/>
<circle cx="36" cy="62" r="1.4"/>
<circle cx="76" cy="32" r="1.2"/>
</g>
<g stroke-linecap="round" fill="none">
<use href="#wavePath" stroke="url(#waveDim)" stroke-width="1.2"
transform="translate(0,-46)" opacity="0.6"/>
<use href="#wavePath" stroke="url(#waveDim)" stroke-width="1.2"
transform="translate(0,46)" opacity="0.6"/>
</g>
<g filter="url(#softShadow)">
<path d="M 78 70 L 110 70 L 128 142 L 146 70 L 178 70 L 138 186 L 118 186 Z"
fill="url(#vFill)"/>
<path d="M 118 186 L 128 142 L 138 186"
fill="none" stroke="#2a2d6e" stroke-width="1.2" stroke-linejoin="round" opacity="0.35"/>
<path d="M 82 74 L 108 74 L 100 100 Z" fill="#ffffff" opacity="0.25"/>
</g>
<g stroke-linecap="round" fill="none">
<use href="#wavePath" stroke="url(#wave)" stroke-width="3.2" opacity="0.9"/>
<use href="#wavePath" stroke="url(#wave)" stroke-width="1.6"
transform="translate(0,18)" opacity="0.7"/>
<use href="#wavePath" stroke="url(#wave)" stroke-width="1.6"
transform="translate(0,-18)" opacity="0.7"/>
</g>
<g fill="#ffffff">
<circle cx="60" cy="92" r="1.8" opacity="0.95"/>
<circle cx="196" cy="92" r="1.6" opacity="0.85"/>
<circle cx="200" cy="168" r="1.4" opacity="0.8"/>
<circle cx="58" cy="172" r="1.4" opacity="0.8"/>
<circle cx="156" cy="58" r="1.2" opacity="0.7"/>
<circle cx="100" cy="206" r="1.2" opacity="0.7"/>
</g>
<g transform="translate(70,70)" fill="#ffffff" opacity="0.9">
<path d="M 0 -7 L 1.4 -1.4 L 7 0 L 1.4 1.4 L 0 7 L -1.4 1.4 L -7 0 L -1.4 -1.4 Z"/>
</g>
<g transform="translate(190,190) scale(0.7)" fill="#ffffff" opacity="0.8">
<path d="M 0 -7 L 1.4 -1.4 L 7 0 L 1.4 1.4 L 0 7 L -1.4 1.4 L -7 0 L -1.4 -1.4 Z"/>
</g>
<circle cx="128" cy="128" r="112" fill="url(#gloss)"/>
</symbol>
</defs>
<!-- ===== Icon mark (scaled down) ===== -->
<use href="#icon" x="8" y="8" width="144" height="144"/>
<!-- ===== Wordmark ===== -->
<!-- "volumen" — main wordmark -->
<text x="172" y="92"
font-family="Inter, 'Helvetica Neue', Helvetica, Arial, sans-serif"
font-size="72" font-weight="700" letter-spacing="-2"
fill="url(#wordmark)">volumen</text>
<!-- Decorative wave line under the wordmark -->
<path d="M 172 108
C 196 100, 220 116, 244 108
S 292 100, 316 108
S 364 116, 388 108
S 436 100, 460 108"
fill="none" stroke="url(#wordmarkAccent)" stroke-width="1.4"
stroke-linecap="round" opacity="0.85"/>
<!-- Tagline: BLOG ENGINE -->
<text x="172" y="132"
font-family="Inter, 'Helvetica Neue', Helvetica, Arial, sans-serif"
font-size="14" font-weight="500" letter-spacing="6"
fill="#4a5bd8" opacity="0.85">BLOG ENGINE</text>
</svg>

After

Width:  |  Height:  |  Size: 6.8 KiB

+23
View File
@@ -0,0 +1,23 @@
# Local development configuration for `just dev`.
# Not shipped with the gem; safe to edit. Keep real admin hashes out of Git.
[server]
host = "127.0.0.1"
port = 9090
content_dir = "./posts"
users_file = "./users.toml"
[site]
title = "volumen (dev)"
description = "Local development instance of volumen."
base_url = "http://localhost:9090"
language = "cs"
author = "Petr Balvín"
[admin]
# Dev credentials: password is "admin". Replace before any real use
# (generate with `volumen hash-password`).
password_hash = "scrypt$16384$8$1$I7XEC8EpyfaptaPxmBQSUg==$gK7TRZAJnYmOSfKJ7xg+cOaYeBV4r5jlqcoL5+jNlSs="
session_key = ""
session_ttl = 86400
+93
View File
@@ -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 (1100) |
```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`.
+58
View File
@@ -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.
+52
View File
@@ -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).
+287
View File
@@ -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.
+98
View File
@@ -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.
Executable
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require "volumen"
require "volumen/cli"
Volumen::CLI.run(ARGV)
Executable
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env bash
#
# volumen one-shot installer for Linux + systemd.
#
# Run from a cloned repository as root:
# sudo ./install.sh
#
# The Ruby toolchain is installed automatically from the distro repositories on
# Fedora and openEuler; on other distributions the script prints guidance and
# expects Ruby >= 3.3 with a C toolchain to be present already.
#
# FreeBSD uses rc.d instead of systemd — see docs/deployment.md.
set -euo pipefail
VOLUMEN_USER="volumen"
CONFIG_DIR="/etc/volumen"
CONFIG_FILE="${CONFIG_DIR}/config.toml"
DATA_DIR="/var/lib/volumen"
CONTENT_DIR="${DATA_DIR}/posts"
USERS_FILE="${DATA_DIR}/users.toml"
SERVICE_FILE="/etc/systemd/system/volumen.service"
MIN_RUBY="3.3"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_DIR="${SCRIPT_DIR}"
log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mWARN:\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
require_root() {
[ "$(id -u)" -eq 0 ] || die "Run as root: sudo ./install.sh"
}
detect_distro() {
[ -r /etc/os-release ] || { printf 'unknown'; return; }
# shellcheck disable=SC1091
. /etc/os-release
printf '%s' "$(printf '%s' "${ID:-unknown}" | tr '[:upper:]' '[:lower:]')"
}
install_toolchain() {
case "$1" in
fedora)
log "Installing Ruby toolchain via dnf (Fedora)…"
dnf install -y ruby ruby-devel gcc gcc-c++ make redhat-rpm-config openssl-devel
;;
openeuler)
log "Installing Ruby toolchain via dnf (openEuler)…"
dnf install -y ruby ruby-devel gcc gcc-c++ make openssl-devel
;;
*)
warn "Automatic toolchain install is wired only for Fedora and openEuler."
cat >&2 <<'EOF'
Install Ruby >= 3.3 and a C toolchain manually, then re-run this script:
Debian/Ubuntu: apt install ruby-full build-essential libssl-dev
Arch: pacman -S ruby base-devel openssl
openSUSE: zypper install ruby ruby-devel gcc make libopenssl-devel
EOF
;;
esac
}
check_ruby() {
command -v ruby >/dev/null 2>&1 || die "Ruby not found; install Ruby >= ${MIN_RUBY} and re-run."
local version
version="$(ruby -e 'print RUBY_VERSION')"
if ! MIN="$MIN_RUBY" ruby -e 'exit(Gem::Version.new(RUBY_VERSION) >= Gem::Version.new(ENV["MIN"]))'; then
die "Ruby ${version} is too old; volumen needs >= ${MIN_RUBY}."
fi
log "Ruby ${version} OK."
}
ensure_bundler() {
if ! command -v bundle >/dev/null 2>&1; then
log "Installing bundler…"
gem install --no-document bundler
fi
}
create_user() {
if id "$VOLUMEN_USER" >/dev/null 2>&1; then
log "User ${VOLUMEN_USER} already exists."
else
log "Creating system user ${VOLUMEN_USER}"
useradd --system --home "$DATA_DIR" --shell /usr/sbin/nologin "$VOLUMEN_USER"
fi
}
create_dirs() {
log "Creating ${CONFIG_DIR} and ${CONTENT_DIR}"
mkdir -p "$CONFIG_DIR" "$CONTENT_DIR"
chown -R "$VOLUMEN_USER:$VOLUMEN_USER" "$DATA_DIR"
}
install_gems() {
log "Installing gem dependencies…"
( cd "$APP_DIR" && bundle install )
}
generate_config() {
if [ -f "$CONFIG_FILE" ]; then
log "Config ${CONFIG_FILE} already exists; leaving it untouched."
return
fi
log "Generating ${CONFIG_FILE}"
( cd "$APP_DIR" && bundle exec ruby -Ilib -rvolumen/config \
-e 'Volumen::Config.generate_default(ARGV[0])' "$CONFIG_FILE" )
sed -i 's/^host = .*/host = "127.0.0.1"/' "$CONFIG_FILE"
sed -i "s#^content_dir = .*#content_dir = \"${CONTENT_DIR}\"#" "$CONFIG_FILE"
sed -i "s#^users_file = .*#users_file = \"${USERS_FILE}\"#" "$CONFIG_FILE"
chown "$VOLUMEN_USER:$VOLUMEN_USER" "$CONFIG_FILE"
chmod 600 "$CONFIG_FILE"
}
install_service() {
log "Installing systemd unit ${SERVICE_FILE}"
cat > "$SERVICE_FILE" <<EOF
[Unit]
Description=volumen blog engine
After=network.target
[Service]
User=${VOLUMEN_USER}
Group=${VOLUMEN_USER}
WorkingDirectory=${APP_DIR}
ExecStart=$(command -v bundle) exec exe/volumen serve --config ${CONFIG_FILE} --content ${CONTENT_DIR}
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=${DATA_DIR}
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable volumen
}
final_notes() {
cat <<EOF
volumen is installed and the service is enabled (not started yet).
Next steps:
1. Create the first admin password (you will log in as user "admin"):
cd ${APP_DIR}
sudo -u ${VOLUMEN_USER} $(command -v bundle) exec exe/volumen hash-password
Paste the printed hash into ${CONFIG_FILE} under [admin] password_hash.
2. (Recommended) set a stable admin.session_key in the config:
openssl rand -hex 64
3. Start the service:
sudo systemctl start volumen
4. Put nginx in front of 127.0.0.1:9090 — see docs/deployment.md.
EOF
}
main() {
require_root
local distro
distro="$(detect_distro)"
log "Detected distribution: ${distro}"
install_toolchain "$distro"
check_ruby
ensure_bundler
create_user
create_dirs
install_gems
generate_config
install_service
final_notes
}
main "$@"
+38
View File
@@ -0,0 +1,38 @@
set dotenv-load := false
set positional-arguments := false
default:
@just --list
# Install Ruby dependencies
install:
@echo "→ Installing Ruby dependencies…"
bundle install
# Run the development server
run:
@echo "→ Starting volumen…"
bundle exec exe/volumen serve
# Run the development server against ./posts and ./config.toml
dev:
@echo "→ Starting volumen (dev) on http://localhost:9090 …"
bundle exec exe/volumen serve --config ./config.toml --content ./posts
# Lint and package the gem (must pass with zero warnings)
build:
@echo "→ Linting…"
bundle exec rubocop
@echo "→ Building gem…"
@mkdir -p pkg
gem build volumen.gemspec --output pkg/volumen.gem
# Run the test suite
test:
@echo "→ Running tests…"
bundle exec rake test
# Remove build artifacts
uninstall:
@echo "→ Removing build artifacts…"
rm -rf pkg *.gem
+21
View File
@@ -0,0 +1,21 @@
# frozen_string_literal: true
require_relative "volumen/version"
require_relative "volumen/frontmatter"
require_relative "volumen/markdown"
require_relative "volumen/post"
require_relative "volumen/store"
require_relative "volumen/config"
require_relative "volumen/password"
require_relative "volumen/users"
# volumen is a small, file-based Markdown blog engine.
#
# Posts are plain `.md` files with a TOML frontmatter block delimited by
# `+++`. The engine exposes them as a JSON API and ships a server-rendered
# admin with a Markdown source editor and a visual editor.
#
# The Sinatra app (`Volumen::Server`) and the CLI (`Volumen::CLI`) are loaded
# on demand to keep the core library light.
module Volumen
end
+87
View File
@@ -0,0 +1,87 @@
# frozen_string_literal: true
require "optparse"
require_relative "version"
require_relative "config"
require_relative "store"
require_relative "password"
module Volumen
# Command-line interface: serve, hash-password, version, help.
class CLI
USAGE = <<~TEXT.freeze
volumen #{Volumen::VERSION} — a small, file-based Markdown blog engine.
Usage:
volumen serve [--config PATH] [--content DIR] [--host HOST] [--port PORT]
volumen hash-password
volumen version
volumen help
TEXT
def self.run(argv)
new(argv).run
end
def initialize(argv)
@argv = argv.dup
end
def run
command = @argv.shift
case command
when "version", "--version", "-v" then puts Volumen::VERSION
when "help", "--help", "-h", nil then puts USAGE
when "serve" then serve
when "hash-password" then hash_password
else
warn "volumen: unknown command #{command.inspect}\n\n#{USAGE}"
exit 1
end
end
private
def serve
options = parse_serve_options
prepare_config(options[:config])
config = Config.load(path: options[:config], overrides: options)
require_relative "server"
store = Store.new(config.content_dir, default_lang: config.language)
app = Server.configured(config: config, store: store)
app.run!(bind: config.host, port: config.port)
end
def parse_serve_options
options = { config: Config::DEFAULT_PATH }
OptionParser.new do |parser|
parser.on("--config PATH") { |value| options[:config] = value }
parser.on("--content DIR") { |value| options[:content] = value }
parser.on("--host HOST") { |value| options[:host] = value }
parser.on("--port PORT", Integer) { |value| options[:port] = value }
end.parse!(@argv)
options
end
def prepare_config(path)
if Config.generate_default(path)
warn "volumen: wrote a default config to #{path}; review it and restart."
elsif !File.exist?(path)
warn "volumen: config #{path} not found; using built-in defaults."
end
end
def hash_password
require "io/console"
print "Password: "
password = ($stdin.noecho(&:gets) || "").chomp
puts
if password.empty?
warn "volumen: empty password"
exit 1
end
puts Password.hash(password)
end
end
end
+136
View File
@@ -0,0 +1,136 @@
# frozen_string_literal: true
require "toml-rb"
require "fileutils"
module Volumen
# Loads and merges volumen configuration from a TOML file, then applies
# command-line overrides on top of the file values and built-in defaults.
class Config
DEFAULT_PATH = "/etc/volumen/config.toml"
DEFAULTS = {
"server" => { "host" => "0.0.0.0", "port" => 9090 },
"content_dir" => "/var/lib/volumen/posts",
"users_file" => "/var/lib/volumen/users.toml",
"site" => {
"title" => "My Blog",
"description" => "A blog powered by volumen.",
"base_url" => "https://example.com",
"language" => "en",
"author" => "Anonymous"
},
"admin" => {
"password_hash" => "",
"session_key" => "",
"session_ttl" => 86_400
}
}.freeze
TEMPLATE = <<~TOML
# volumen configuration.
[server]
host = "0.0.0.0"
port = 9090
# Directory of your Markdown posts (.md with TOML frontmatter).
content_dir = "/var/lib/volumen/posts"
# File storing admin users (managed from the admin Settings page).
users_file = "/var/lib/volumen/users.toml"
[site]
title = "My Blog"
description = "A blog powered by volumen."
base_url = "https://example.com"
language = "en"
author = "Anonymous"
[admin]
# Bootstrap admin login: paste a hash from `volumen hash-password` to sign
# in as user "admin". Once you manage users in Settings, users_file wins.
password_hash = ""
# Secret signing session cookies (>= 64 bytes). Empty = random per start.
session_key = ""
# Session lifetime in seconds (24h default).
session_ttl = 86400
TOML
def self.load(path: DEFAULT_PATH, overrides: {})
data = File.exist?(path) ? TomlRB.load_file(path) : {}
merged = deep_merge(DEFAULTS, data)
new(apply_overrides(merged, overrides))
end
# Writes the commented template to `path` if it does not exist yet.
# Returns the path on success, or nil if it already existed or could not
# be created.
def self.generate_default(path)
return nil if File.exist?(path)
FileUtils.mkdir_p(File.dirname(path))
File.write(path, TEMPLATE)
path
rescue SystemCallError
nil
end
def self.deep_merge(base, override)
base.merge(override) do |_key, 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
server["host"] = overrides[:host] if overrides[:host]
server["port"] = overrides[:port] if overrides[:port]
result = data.dup
result["server"] = server
result["content_dir"] = overrides[:content] if overrides[:content]
result
end
private_class_method :deep_merge, :apply_overrides
attr_reader :data
def initialize(data)
@data = data
end
def host
@data["server"]["host"]
end
def port
@data["server"]["port"]
end
def content_dir
@data["content_dir"]
end
def users_file
@data["users_file"]
end
def site
@data["site"]
end
def language
@data["site"]["language"]
end
def admin
@data["admin"]
end
end
end
+68
View File
@@ -0,0 +1,68 @@
# frozen_string_literal: true
require "toml-rb"
module Volumen
# Parses and serialises the TOML frontmatter block of a Markdown file.
#
# A post file starts with a `+++` line, contains a TOML document, is closed
# by another `+++` line, and is followed by the Markdown body:
#
# +++
# title = "Hello"
# +++
#
# Body in **Markdown**.
module Frontmatter
DELIMITER = "+++"
# Returns `[metadata_hash, body_string]`. If the content has no
# frontmatter, the metadata hash is empty and the body is the full input.
def self.parse(content)
text = normalize(content)
lines = text.lines
return [{}, text] unless delimiter?(lines.first)
closing = closing_index(lines)
return [{}, text] if closing.nil?
toml = lines[1...closing].join
body = lines[(closing + 1)..]&.join.to_s
metadata = toml.strip.empty? ? {} : TomlRB.parse(toml)
[metadata, body.sub(/\A\r?\n/, "")]
end
# Serialises metadata + body back into a single post file string.
def self.dump(metadata, body)
toml = serialize(metadata)
buffer = "#{DELIMITER}\n"
buffer << toml
buffer << "\n" unless toml.empty? || toml.end_with?("\n")
buffer << "#{DELIMITER}\n\n"
buffer << body.to_s.sub(/\A\s+/, "")
buffer.end_with?("\n") ? buffer : "#{buffer}\n"
end
def self.normalize(content)
content.to_s.gsub("\r\n", "\n")
end
def self.closing_index(lines)
index = lines[1..].index { |line| delimiter?(line) }
index.nil? ? nil : index + 1
end
def self.delimiter?(line)
line&.chomp&.strip == DELIMITER
end
def self.serialize(metadata)
data = metadata || {}
return "" if data.empty?
TomlRB.dump(data)
end
private_class_method :normalize, :closing_index, :delimiter?, :serialize
end
end
+23
View File
@@ -0,0 +1,23 @@
# frozen_string_literal: true
require "kramdown"
module Volumen
# Renders Markdown to HTML using kramdown's GFM parser (tables, fenced code,
# task lists, strikethrough).
#
# Posts are authored by the single trusted admin, so kramdown's default
# behaviour of passing raw HTML through is intentional.
module Markdown
OPTIONS = {
input: "GFM",
auto_ids: true,
hard_wrap: false,
syntax_highlighter: nil
}.freeze
def self.render(text)
::Kramdown::Document.new(text.to_s, OPTIONS).to_html
end
end
end
+52
View File
@@ -0,0 +1,52 @@
# frozen_string_literal: true
require "openssl"
require "securerandom"
module Volumen
# Hashes and verifies admin passwords with scrypt (via OpenSSL::KDF).
#
# Encoded form: "scrypt$N$r$p$salt_base64$hash_base64".
module Password
COST_N = 16_384
BLOCK_R = 8
PARALLEL_P = 1
KEY_LENGTH = 32
SALT_BYTES = 16
PREFIX = "scrypt"
def self.hash(password)
salt = SecureRandom.bytes(SALT_BYTES)
derived = OpenSSL::KDF.scrypt(
password.to_s, salt: salt, N: COST_N, r: BLOCK_R, p: PARALLEL_P, length: KEY_LENGTH
)
[PREFIX, COST_N, BLOCK_R, PARALLEL_P, encode(salt), encode(derived)].join("$")
end
def self.verify(password, encoded)
parts = encoded.to_s.split("$")
return false unless parts.length == 6 && parts.first == PREFIX
_, cost, block, parallel, salt_b64, hash_b64 = parts
salt = decode(salt_b64)
expected = decode(hash_b64)
derived = OpenSSL::KDF.scrypt(
password.to_s, salt: salt,
N: cost.to_i, r: block.to_i, p: parallel.to_i, length: expected.bytesize
)
OpenSSL.fixed_length_secure_compare(derived, expected)
rescue ArgumentError, OpenSSL::KDF::KDFError
false
end
def self.encode(bytes)
[bytes].pack("m0")
end
def self.decode(string)
string.to_s.unpack1("m0")
end
private_class_method :encode, :decode
end
end
+119
View File
@@ -0,0 +1,119 @@
# frozen_string_literal: true
require "date"
require_relative "frontmatter"
require_relative "markdown"
module Volumen
# A single blog post: TOML frontmatter metadata plus a Markdown body.
class Post
attr_reader :metadata, :body
attr_accessor :path
def initialize(metadata: {}, body: "")
@metadata = (metadata || {}).transform_keys(&:to_s)
@body = body.to_s
end
def self.parse(content)
metadata, body = Frontmatter.parse(content)
new(metadata: metadata, body: body)
end
def slug
metadata["slug"]
end
def title
metadata["title"]
end
def lang
metadata["lang"]
end
def author
metadata["author"]
end
def tags
Array(metadata["tags"]).map(&:to_s)
end
def draft?
metadata["draft"] == true
end
def all_langs?
metadata["all_langs"] == true
end
def translations
metadata["translations"] || {}
end
def cover
metadata["cover"]
end
def reading_time
words = body.split(/\s+/).count { |word| !word.empty? }
[(words / 200.0).ceil, 1].max
end
def date_string
value = metadata["date"]
case value
when nil then nil
when Date, Time then value.strftime("%Y-%m-%d")
else value.to_s
end
end
def excerpt
text = metadata["excerpt"]
return text if text && !text.to_s.empty?
derived_excerpt
end
def html
Markdown.render(body)
end
def to_file
Frontmatter.dump(metadata, body)
end
def summary
{
"slug" => slug,
"title" => title,
"excerpt" => excerpt,
"date" => date_string,
"lang" => lang,
"tags" => tags,
"author" => author,
"cover" => cover,
"reading_time" => reading_time,
"translations" => translations,
"url" => "/api/volumen/posts/#{slug}"
}
end
def detail
summary.merge("body" => body, "html" => html)
end
private
def derived_excerpt(limit = 200)
paragraph = body.split(/\n\s*\n/).map(&:strip)
.find { |para| !para.empty? && !para.start_with?("#") }
return "" if paragraph.nil?
stripped = paragraph.gsub(/[*_`>#]/, "").strip
stripped.length > limit ? "#{stripped[0, limit].rstrip}" : stripped
end
end
end
+513
View File
@@ -0,0 +1,513 @@
# frozen_string_literal: true
require "sinatra/base"
require "json"
require "date"
require "securerandom"
require_relative "config"
require_relative "store"
require_relative "markdown"
require_relative "password"
module Volumen
# Sinatra application: the public JSON API at /api/volumen and the
# server-rendered admin at /admin.
class Server < Sinatra::Base
configure do
set :show_exceptions, false
set :raise_errors, false
set :host_authorization, { permitted_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)
Class.new(self) do
set :volumen_config, config
set :volumen_store, store
set :sessions, httponly: true, same_site: :strict
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)
end
# Mark the session cookie Secure only when the request is HTTPS (directly or
# via a trusted X-Forwarded-Proto from nginx). Over plain HTTP (local dev)
# the cookie stays usable so the admin login round-trips.
before do
options = request.env["rack.session.options"]
options[:secure] = request.ssl? if options
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
settings.volumen_config
end
def store
settings.volumen_store
end
# --- API helpers -----------------------------------------------------
def json(data)
JSON.generate(data)
end
def published_posts
store.all.reject(&:draft?).sort_by { |post| post.date_string || "" }.reverse
end
def site_payload
config.site.slice("title", "description", "base_url", "language", "author")
end
def posts_payload
posts = filtered_posts
page = positive_int(params["page"], 1)
limit = positive_int(params["limit"], 20).clamp(1, 100)
offset = (page - 1) * limit
{
"page" => page,
"page_size" => limit,
"total" => posts.length,
"posts" => posts[offset, limit].to_a.map(&:summary)
}
end
def filtered_posts
posts = published_posts
if params["lang"]
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
end
def tag_counts
counts = Hash.new(0)
published_posts.each { |post| post.tags.each { |tag| counts[tag] += 1 } }
counts.sort_by { |name, count| [-count, name] }
.map { |name, count| { "name" => name, "count" => count } }
end
def positive_int(value, default)
number = value.to_i
number.positive? ? number : default
end
def base_url
config.site["base_url"].to_s.chomp("/")
end
def feed_xml
site = config.site
items = published_posts.first(20).map { |post| feed_item(post) }.join("\n")
<<~XML
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>#{escape(site["title"])}</title>
<link>#{escape(base_url)}</link>
<description>#{escape(site["description"])}</description>
#{items}
</channel>
</rss>
XML
end
def feed_item(post)
link = "#{base_url}/#{post.slug}"
<<~XML.chomp
<item>
<title>#{escape(post.title)}</title>
<link>#{escape(link)}</link>
<guid>#{escape(link)}</guid>
<description>#{escape(post.excerpt)}</description>
</item>
XML
end
def sitemap_xml
urls = published_posts.map do |post|
" <url><loc>#{escape("#{base_url}/#{post.slug}")}</loc></url>"
end.join("\n")
<<~XML
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
#{urls}
</urlset>
XML
end
def escape(text)
Rack::Utils.escape_html(text.to_s)
end
alias h escape
# --- Admin helpers ---------------------------------------------------
def users
Users.new(path: config.users_file, bootstrap_hash: config.admin["password_hash"])
end
def authenticated?
!session[:user].to_s.empty?
end
def current_user
session[:user]
end
def current_user_record
users.find(current_user)
end
def current_role
current_user_record&.role
end
def admin?
current_role == "admin"
end
def require_login!
redirect to("/admin/login") unless authenticated?
end
def require_admin!
halt(403, "Forbidden") unless admin?
end
def csrf_token
session[:csrf] ||= SecureRandom.hex(32)
end
def check_csrf!
token = params["_csrf"]
return if token && session[:csrf] && Rack::Utils.secure_compare(session[:csrf], token)
halt(403, "Invalid CSRF token")
end
def render_settings(error: nil, notice: nil)
@error = error
@notice = notice
@users = users.all
erb :settings
end
def change_password
unless users.authenticate(current_user, params["current_password"])
return render_settings(error: "Current password is incorrect.")
end
fresh = presence(params["new_password"])
return render_settings(error: "New password cannot be empty.") if fresh.nil?
users.update_password(current_user, fresh)
render_settings(notice: "Password updated.")
end
def change_username
new_name = presence(params["username"])
return render_settings(error: "Username cannot be empty.") if new_name.nil?
if users.rename(current_user, new_name)
session[:user] = new_name
render_settings(notice: "Username updated.")
else
render_settings(error: "That username is already taken.")
end
end
def create_user
name = presence(params["username"])
password = presence(params["password"])
if name.nil? || password.nil?
return render_settings(error: "Username and password are required.")
end
unless name.match?(/\A[a-zA-Z0-9._-]+\z/)
return render_settings(error: "Username may use letters, numbers, dot, dash, underscore.")
end
if users.add(name, password, params["role"].to_s)
render_settings(notice: "User added.")
else
render_settings(error: "That username already exists.")
end
end
def remove_user(username)
if username == current_user
return render_settings(error: "You cannot delete your own account.")
end
if users.delete(username)
render_settings(notice: "User removed.")
else
render_settings(error: "Cannot remove the last user or the last admin.")
end
end
def change_role(username)
return render_settings(error: "You cannot change your own role.") if username == current_user
if users.set_role(username, params["role"].to_s)
render_settings(notice: "Role updated.")
else
render_settings(error: "Could not change role (last admin?).")
end
end
def sorted_posts
store.all.sort_by { |post| post.date_string || "" }.reverse
end
def create_post
post = post_from_params(params)
error = creation_error(post)
if error
@mode = :new
@post = post
@error = error
return erb(:form)
end
store.save(post)
redirect to("/admin/")
end
def update_post
existing = store.find(params["slug"])
halt(404, "Not found") if existing.nil?
store.save(post_from_params(params, existing: existing))
redirect to("/admin/")
end
def creation_error(post)
return "Slug is required." if presence(post.slug).nil?
return "A post with that slug already exists." if store.find(post.slug)
nil
end
def post_from_params(http_params, existing: nil)
metadata = {
"title" => presence(http_params["title"]),
"slug" => presence(http_params["slug"]) || existing&.slug,
"lang" => presence(http_params["lang"]),
"author" => presence(http_params["author"]),
"date" => parse_date(http_params["date"]),
"tags" => parse_tags(http_params["tags"]),
"excerpt" => presence(http_params["excerpt"]),
"cover" => presence(http_params["cover"]),
"draft" => http_params["draft"] == "on",
"all_langs" => http_params["all_langs"] == "on"
}
post = Post.new(metadata: clean_metadata(metadata), body: http_params["body"].to_s)
post.path = existing.path if existing
post
end
def clean_metadata(metadata)
metadata.reject { |_key, value| value.nil? || value == "" || value == [] || value == false }
end
def presence(value)
text = value.to_s.strip
text.empty? ? nil : text
end
def parse_date(value)
text = presence(value)
text && Date.iso8601(text)
rescue ArgumentError
nil
end
def parse_tags(value)
presence(value).to_s.split(",").map(&:strip).reject(&:empty?)
end
end
end
+104
View File
@@ -0,0 +1,104 @@
# frozen_string_literal: true
require "fileutils"
require "securerandom"
require_relative "post"
module Volumen
# Reads and writes Markdown posts in a content directory.
#
# Posts live either at `content_dir/<slug>.md` (default language) or at
# `content_dir/<lang>/<slug>.md` (per-language subdirectory). Missing
# `slug`/`lang` frontmatter is derived from the file path.
class Store
attr_reader :content_dir
def initialize(content_dir, default_lang: nil)
@content_dir = File.expand_path(content_dir.to_s)
@default_lang = default_lang
end
def all
markdown_files.filter_map { |path| load_post(path) }
end
def find(slug, lang: nil)
all.find do |post|
post.slug == slug && (lang.nil? || post.lang == lang || post.all_langs?)
end
end
def save(post)
target = post.path || default_path_for(post)
FileUtils.mkdir_p(File.dirname(target))
File.write(target, post.to_file)
post.path = target
post
end
def delete(slug, lang: nil)
post = find(slug, lang: lang)
File.delete(post.path) if post&.path
post
end
def media_dir
File.join(@content_dir, "media")
end
def store_upload(original_name, source)
FileUtils.mkdir_p(media_dir)
name = safe_media_name(original_name)
File.open(File.join(media_dir, name), "wb") { |file| IO.copy_stream(source, file) }
"/media/#{name}"
end
def media_file(name)
path = File.join(media_dir, File.basename(name.to_s))
return nil unless File.file?(path) && within?(media_dir, path)
path
end
private
def markdown_files
Dir.glob(File.join(@content_dir, "**", "*.md"))
end
def load_post(path)
post = Post.parse(File.read(path))
post.metadata["slug"] ||= File.basename(path, ".md")
post.metadata["lang"] ||= lang_from_path(path) || @default_lang
post.path = path
post
rescue StandardError => e
warn "volumen: skipping #{path}: #{e.message}"
nil
end
def lang_from_path(path)
parent = File.dirname(File.expand_path(path))
return nil if parent == @content_dir
File.basename(parent)
end
def default_path_for(post)
File.join(@content_dir, "#{post.slug}.md")
end
def safe_media_name(original)
base = File.basename(original.to_s)
ext = File.extname(base).gsub(/[^a-zA-Z0-9.]/, "").downcase
stem = File.basename(base, File.extname(base))
.gsub(/[^a-zA-Z0-9_-]+/, "-").gsub(/-+/, "-").downcase
stem = "file" if stem.empty?
"#{SecureRandom.hex(4)}-#{stem}#{ext}"
end
def within?(dir, path)
File.expand_path(path).start_with?(File.expand_path(dir) + File::SEPARATOR)
end
end
end
+128
View File
@@ -0,0 +1,128 @@
# frozen_string_literal: true
require "fileutils"
require "toml-rb"
require_relative "password"
module Volumen
# File-backed set of admin users, stored as a TOML `[[users]]` array.
#
# Each user has a role: "admin" (full access, including user management) or
# "author" (posts and own account only). If the file does not exist yet, a
# single bootstrap "admin" user is synthesised from the legacy
# `admin.password_hash` config value.
class Users
ROLES = %w[admin author].freeze
DEFAULT_ROLE = "author"
User = Struct.new(:username, :password_hash, :role)
def initialize(path:, bootstrap_hash: nil)
@path = path
@bootstrap_hash = bootstrap_hash.to_s
end
def all
return read_file if File.exist?(@path)
return [] if @bootstrap_hash.empty?
[User.new("admin", @bootstrap_hash, "admin")]
end
def any?
all.any?
end
def find(username)
all.find { |user| user.username == username }
end
def authenticate(username, password)
user = find(username)
user if user && Password.verify(password, user.password_hash)
end
def add(username, password, role = DEFAULT_ROLE)
return nil if username.to_s.empty? || find(username)
user = User.new(username, Password.hash(password), normalize_role(role))
persist(all + [user])
user
end
def update_password(username, password)
mutate(username) { |user| user.password_hash = Password.hash(password) }
end
def rename(current_name, new_name)
return nil if new_name.to_s.empty? || find(new_name)
mutate(current_name) { |user| user.username = new_name }
end
def set_role(username, role)
return nil unless ROLES.include?(role)
users = all
user = users.find { |entry| entry.username == username }
return nil if user.nil? || demoting_last_admin?(users, user, role)
user.role = role
persist(users)
user
end
def delete(username)
users = all
target = users.find { |entry| entry.username == username }
return nil if target.nil? || users.length <= 1 || last_admin?(users, target)
persist(users.reject { |entry| entry.username == username })
username
end
private
def mutate(username)
users = all
user = users.find { |entry| entry.username == username }
return nil if user.nil?
yield user
persist(users)
user
end
def normalize_role(role)
ROLES.include?(role) ? role : DEFAULT_ROLE
end
def admins(users)
users.select { |user| user.role == "admin" }
end
def last_admin?(users, user)
user.role == "admin" && admins(users).length <= 1
end
def demoting_last_admin?(users, user, role)
user.role == "admin" && role != "admin" && admins(users).length <= 1
end
def read_file
data = TomlRB.load_file(@path)
Array(data["users"]).map do |entry|
User.new(entry["username"], entry["password_hash"], entry["role"] || "admin")
end
end
def persist(users)
FileUtils.mkdir_p(File.dirname(@path))
File.write(@path, TomlRB.dump("users" => users.map { |user| user_hash(user) }))
end
def user_hash(user)
{ "username" => user.username, "password_hash" => user.password_hash, "role" => user.role }
end
end
end
+5
View File
@@ -0,0 +1,5 @@
# frozen_string_literal: true
module Volumen
VERSION = "0.1.0"
end
+167
View File
@@ -0,0 +1,167 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 160" role="img" aria-label="volumen — blog engine">
<title>volumen — blog engine</title>
<defs>
<!-- Background gradient: deep night sky -->
<radialGradient id="bg" cx="50%" cy="35%" r="80%">
<stop offset="0%" stop-color="#4a5bd8"/>
<stop offset="55%" stop-color="#2a2d6e"/>
<stop offset="100%" stop-color="#15163a"/>
</radialGradient>
<linearGradient id="ring" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#8aa0ff" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#3b3f8a" stop-opacity="0.6"/>
</linearGradient>
<radialGradient id="gloss" cx="50%" cy="15%" r="55%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.35"/>
<stop offset="60%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
<linearGradient id="wave" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#7afcff" stop-opacity="0.15"/>
<stop offset="50%" stop-color="#7afcff" stop-opacity="0.95"/>
<stop offset="100%" stop-color="#7afcff" stop-opacity="0.15"/>
</linearGradient>
<linearGradient id="waveDim" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
</linearGradient>
<linearGradient id="vFill" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#fdfdff"/>
<stop offset="100%" stop-color="#b8c4ff"/>
</linearGradient>
<!-- Wordmark gradient: echoes the icon background -->
<linearGradient id="wordmark" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#2a2d6e"/>
<stop offset="100%" stop-color="#4a5bd8"/>
</linearGradient>
<linearGradient id="wordmarkAccent" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#2a2d6e" stop-opacity="0"/>
<stop offset="50%" stop-color="#4a5bd8" stop-opacity="0.7"/>
<stop offset="100%" stop-color="#2a2d6e" stop-opacity="0"/>
</linearGradient>
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="2" result="off"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.5"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<filter id="outerGlow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="4"/>
</filter>
<!-- Reusable wave path (sinusoidal, centered around y=128 in 256x256 space) -->
<path id="wavePath"
d="M 36 128
C 56 96, 76 96, 96 128
S 136 160, 156 128
S 196 96, 216 128"
fill="none"/>
<!-- Full icon as a reusable symbol (256x256 source) -->
<symbol id="icon" viewBox="0 0 256 256">
<circle cx="128" cy="128" r="116" fill="url(#bg)" opacity="0.55" filter="url(#outerGlow)"/>
<circle cx="128" cy="128" r="112" fill="url(#bg)"/>
<circle cx="128" cy="128" r="104" fill="none" stroke="url(#ring)" stroke-width="1.2" opacity="0.7"/>
<g fill="#8aa0ff" opacity="0.55">
<circle cx="128" cy="22" r="1.4"/>
<circle cx="180" cy="32" r="1.2"/>
<circle cx="220" cy="62" r="1.4"/>
<circle cx="234" cy="110" r="1.2"/>
<circle cx="234" cy="146" r="1.4"/>
<circle cx="220" cy="194" r="1.2"/>
<circle cx="180" cy="224" r="1.4"/>
<circle cx="128" cy="234" r="1.2"/>
<circle cx="76" cy="224" r="1.4"/>
<circle cx="36" cy="194" r="1.2"/>
<circle cx="22" cy="146" r="1.4"/>
<circle cx="22" cy="110" r="1.2"/>
<circle cx="36" cy="62" r="1.4"/>
<circle cx="76" cy="32" r="1.2"/>
</g>
<g stroke-linecap="round" fill="none">
<use href="#wavePath" stroke="url(#waveDim)" stroke-width="1.2"
transform="translate(0,-46)" opacity="0.6"/>
<use href="#wavePath" stroke="url(#waveDim)" stroke-width="1.2"
transform="translate(0,46)" opacity="0.6"/>
</g>
<g filter="url(#softShadow)">
<path d="M 78 70 L 110 70 L 128 142 L 146 70 L 178 70 L 138 186 L 118 186 Z"
fill="url(#vFill)"/>
<path d="M 118 186 L 128 142 L 138 186"
fill="none" stroke="#2a2d6e" stroke-width="1.2" stroke-linejoin="round" opacity="0.35"/>
<path d="M 82 74 L 108 74 L 100 100 Z" fill="#ffffff" opacity="0.25"/>
</g>
<g stroke-linecap="round" fill="none">
<use href="#wavePath" stroke="url(#wave)" stroke-width="3.2" opacity="0.9"/>
<use href="#wavePath" stroke="url(#wave)" stroke-width="1.6"
transform="translate(0,18)" opacity="0.7"/>
<use href="#wavePath" stroke="url(#wave)" stroke-width="1.6"
transform="translate(0,-18)" opacity="0.7"/>
</g>
<g fill="#ffffff">
<circle cx="60" cy="92" r="1.8" opacity="0.95"/>
<circle cx="196" cy="92" r="1.6" opacity="0.85"/>
<circle cx="200" cy="168" r="1.4" opacity="0.8"/>
<circle cx="58" cy="172" r="1.4" opacity="0.8"/>
<circle cx="156" cy="58" r="1.2" opacity="0.7"/>
<circle cx="100" cy="206" r="1.2" opacity="0.7"/>
</g>
<g transform="translate(70,70)" fill="#ffffff" opacity="0.9">
<path d="M 0 -7 L 1.4 -1.4 L 7 0 L 1.4 1.4 L 0 7 L -1.4 1.4 L -7 0 L -1.4 -1.4 Z"/>
</g>
<g transform="translate(190,190) scale(0.7)" fill="#ffffff" opacity="0.8">
<path d="M 0 -7 L 1.4 -1.4 L 7 0 L 1.4 1.4 L 0 7 L -1.4 1.4 L -7 0 L -1.4 -1.4 Z"/>
</g>
<circle cx="128" cy="128" r="112" fill="url(#gloss)"/>
</symbol>
</defs>
<!-- ===== Icon mark (scaled down) ===== -->
<use href="#icon" x="8" y="8" width="144" height="144"/>
<!-- ===== Wordmark ===== -->
<!-- "volumen" — main wordmark -->
<text x="172" y="92"
font-family="Inter, 'Helvetica Neue', Helvetica, Arial, sans-serif"
font-size="72" font-weight="700" letter-spacing="-2"
fill="url(#wordmark)">volumen</text>
<!-- Decorative wave line under the wordmark -->
<path d="M 172 108
C 196 100, 220 116, 244 108
S 292 100, 316 108
S 364 116, 388 108
S 436 100, 460 108"
fill="none" stroke="url(#wordmarkAccent)" stroke-width="1.4"
stroke-linecap="round" opacity="0.85"/>
<!-- Tagline: BLOG ENGINE -->
<text x="172" y="132"
font-family="Inter, 'Helvetica Neue', Helvetica, Arial, sans-serif"
font-size="14" font-weight="500" letter-spacing="6"
fill="#4a5bd8" opacity="0.85">BLOG ENGINE</text>
</svg>

After

Width:  |  Height:  |  Size: 6.8 KiB

+465
View File
@@ -0,0 +1,465 @@
<h1><%= @mode == :new ? "New post" : "Edit post" %></h1>
<% if @error %>
<p class="error"><%= h(@error) %></p>
<% end %>
<form id="post-form" method="post"
action="<%= @mode == :new ? to("/admin/posts") : to("/admin/posts/#{@post.slug}") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<div class="fields">
<label>Title <input type="text" name="title" value="<%= h(@post.title) %>"></label>
<label>Slug
<input type="text" name="slug" value="<%= h(@post.slug) %>"
<%= @mode == :edit ? "readonly" : "required" %>>
</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>Date <input type="date" name="date" value="<%= h(@post.date_string) %>"></label>
<label>Tags
<input type="text" name="tags" value="<%= h(@post.tags.join(", ")) %>"
placeholder="comma, separated">
</label>
<label class="check">
<input type="checkbox" name="draft" <%= @post.draft? ? "checked" : "" %>> Draft
</label>
<label class="check">
<input type="checkbox" name="all_langs" <%= @post.all_langs? ? "checked" : "" %>> All languages
</label>
</div>
<div class="cover-field">
<label for="cover-input">Cover image</label>
<div class="cover-row">
<input type="text" id="cover-input" name="cover" value="<%= h(@post.cover) %>"
placeholder="/media/… or https://…">
<button type="button" id="cover-upload">Upload</button>
<button type="button" id="cover-clear">Clear</button>
</div>
<img id="cover-preview" class="cover-preview" alt="" hidden>
<input type="file" id="cover-file" accept="image/*" hidden>
</div>
<div class="editor-tabs">
<button type="button" data-tab="markdown" class="active">Markdown</button>
<button type="button" data-tab="visual">Visual</button>
</div>
<div id="visual-view" hidden>
<div class="pane">
<div class="ed-toolbar">
<button type="button" data-rich="bold" title="Bold (Ctrl+B)"><b>B</b></button>
<button type="button" data-rich="italic" title="Italic (Ctrl+I)"><i>I</i></button>
<button type="button" data-rich="h2" title="Heading 2">H2</button>
<button type="button" data-rich="h3" title="Heading 3">H3</button>
<button type="button" data-rich="link" title="Link (Ctrl+K)">Link</button>
<button type="button" data-rich="ul" title="Bullet list">&bull; List</button>
<button type="button" data-rich="ol" title="Numbered list">1. List</button>
<button type="button" data-rich="quote" title="Quote">&ldquo;</button>
<button type="button" data-rich="code" title="Inline code">&lt;/&gt;</button>
<button type="button" data-rich="image" title="Image">Img</button>
<button type="button" data-rich="clear" title="Paragraph">P</button>
</div>
<div id="wysiwyg" class="markdown" contenteditable="true"></div>
</div>
</div>
<div id="markdown-view">
<div class="editor">
<div class="pane">
<div class="ed-toolbar">
<button type="button" data-md="bold" title="Bold (Ctrl+B)"><b>B</b></button>
<button type="button" data-md="italic" title="Italic (Ctrl+I)"><i>I</i></button>
<button type="button" data-md="h2" title="Heading">H2</button>
<button type="button" data-md="link" title="Link (Ctrl+K)">Link</button>
<button type="button" data-md="ul" title="List">List</button>
<button type="button" data-md="quote" title="Quote">&ldquo;</button>
<button type="button" data-md="code" title="Code">&lt;/&gt;</button>
<button type="button" data-md="image" title="Image">Img</button>
<button type="button" id="toggle-preview" class="toggle" title="Toggle preview">Preview</button>
</div>
<textarea id="body" name="body" rows="22"><%= h(@post.body) %></textarea>
</div>
<div class="pane preview-pane">
<div class="ed-toolbar"><span>Preview</span></div>
<div id="preview" class="markdown"></div>
</div>
</div>
</div>
<input type="file" id="image-input" accept="image/*" hidden>
<button type="submit" class="primary">Save</button>
</form>
<script>
(function () {
var form = document.getElementById("post-form");
var textarea = document.getElementById("body");
var preview = document.getElementById("preview");
var wysiwyg = document.getElementById("wysiwyg");
var visualView = document.getElementById("visual-view");
var markdownView = document.getElementById("markdown-view");
var imageInput = document.getElementById("image-input");
var coverInput = document.getElementById("cover-input");
var coverFile = document.getElementById("cover-file");
var coverPreview = document.getElementById("cover-preview");
var previewUrl = "<%= to("/admin/preview") %>";
var uploadUrl = "<%= to("/admin/uploads") %>";
var csrf = "<%= csrf_token %>";
var timer = null;
try { document.execCommand("defaultParagraphSeparator", false, "p"); } catch (e) {}
// --- Markdown <-> HTML -------------------------------------------------
function mdToHtml(md) {
var data = new URLSearchParams();
data.set("body", md);
data.set("_csrf", csrf);
return fetch(previewUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: data.toString()
}).then(function (response) { return response.text(); });
}
function inlineToMd(node) {
var out = "";
node.childNodes.forEach(function (child) { out += inlineNode(child); });
return out;
}
function inlineNode(node) {
if (node.nodeType === 3) { return node.nodeValue.replace(/\s+/g, " "); }
if (node.nodeType !== 1) { return ""; }
var tag = node.tagName.toLowerCase();
var inner = inlineToMd(node);
if (tag === "strong" || tag === "b") { return "**" + inner.trim() + "**"; }
if (tag === "em" || tag === "i") { return "*" + inner.trim() + "*"; }
if (tag === "code") { return "`" + node.textContent + "`"; }
if (tag === "a") { return "[" + inner + "](" + (node.getAttribute("href") || "") + ")"; }
if (tag === "img") {
return "![" + (node.getAttribute("alt") || "") + "](" + (node.getAttribute("src") || "") + ")";
}
if (tag === "br") { return "\n"; }
return inner;
}
function listToMd(list, ordered) {
var index = 1;
var items = [];
list.childNodes.forEach(function (li) {
if (li.nodeType === 1 && li.tagName.toLowerCase() === "li") {
var marker = ordered ? (index++ + ". ") : "- ";
items.push(marker + inlineToMd(li).trim());
}
});
return items.join("\n");
}
function htmlToMd(root) {
var blocks = [];
root.childNodes.forEach(function (node) {
if (node.nodeType === 3) {
var text = node.nodeValue.trim();
if (text) { blocks.push(text); }
return;
}
if (node.nodeType !== 1) { return; }
var tag = node.tagName.toLowerCase();
if (/^h[1-6]$/.test(tag)) {
blocks.push("#".repeat(Number(tag.charAt(1))) + " " + inlineToMd(node).trim());
} else if (tag === "ul" || tag === "ol") {
blocks.push(listToMd(node, tag === "ol"));
} else if (tag === "blockquote") {
blocks.push(inlineToMd(node).trim().split("\n").map(function (line) {
return "> " + line;
}).join("\n"));
} else if (tag === "pre") {
blocks.push("```\n" + node.textContent.replace(/\n$/, "") + "\n```");
} else if (tag === "hr") {
blocks.push("---");
} else {
var content = inlineToMd(node).trim();
if (content) { blocks.push(content); }
}
});
return blocks.join("\n\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
}
// --- Live preview (markdown mode) -------------------------------------
function renderPreview() {
mdToHtml(textarea.value).then(function (html) { preview.innerHTML = html; });
}
function schedule() {
clearTimeout(timer);
timer = setTimeout(renderPreview, 250);
}
// --- Image upload ------------------------------------------------------
function uploadImage(file, onDone) {
var data = new FormData();
data.append("file", file);
data.append("_csrf", csrf);
fetch(uploadUrl, { method: "POST", body: data })
.then(function (response) { return response.json(); })
.then(function (json) { if (json.url) { onDone(json.url); } });
}
imageInput.addEventListener("change", function () {
var file = imageInput.files[0];
if (!file) { return; }
uploadImage(file, function (url) {
if (visualView.hidden) {
insertIntoTextarea("![](" + url + ")");
} else {
exec("insertImage", url);
}
});
imageInput.value = "";
});
// --- Cover image -------------------------------------------------------
function refreshCoverPreview() {
if (coverInput.value) {
coverPreview.src = coverInput.value;
coverPreview.hidden = false;
} else {
coverPreview.hidden = true;
coverPreview.removeAttribute("src");
}
}
document.getElementById("cover-upload").addEventListener("click", function () {
coverFile.click();
});
document.getElementById("cover-clear").addEventListener("click", function () {
coverInput.value = "";
refreshCoverPreview();
});
coverFile.addEventListener("change", function () {
var file = coverFile.files[0];
if (!file) { return; }
uploadImage(file, function (url) {
coverInput.value = url;
refreshCoverPreview();
});
coverFile.value = "";
});
coverInput.addEventListener("input", refreshCoverPreview);
refreshCoverPreview();
// --- Markdown toolbar --------------------------------------------------
function insertIntoTextarea(text) {
var start = textarea.selectionStart;
textarea.value = textarea.value.slice(0, start) + text + textarea.value.slice(textarea.selectionEnd);
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = start + text.length;
schedule();
}
function wrap(before, after) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var selected = textarea.value.slice(start, end);
textarea.value = textarea.value.slice(0, start) + before + selected + after +
textarea.value.slice(end);
textarea.focus();
textarea.selectionStart = start + before.length;
textarea.selectionEnd = end + before.length;
schedule();
}
function linePrefix(prefix) {
var start = textarea.selectionStart;
var lineStart = textarea.value.lastIndexOf("\n", start - 1) + 1;
textarea.value = textarea.value.slice(0, lineStart) + prefix + textarea.value.slice(lineStart);
textarea.focus();
schedule();
}
var mdActions = {
bold: function () { wrap("**", "**"); },
italic: function () { wrap("*", "*"); },
code: function () { wrap("`", "`"); },
link: function () { wrap("[", "](https://)"); },
h2: function () { linePrefix("## "); },
ul: function () { linePrefix("- "); },
quote: function () { linePrefix("> "); },
image: function () { imageInput.click(); }
};
// --- Visual toolbar ----------------------------------------------------
function exec(command, value) {
document.execCommand(command, false, value || null);
wysiwyg.focus();
updateToolbarState();
}
function wrapInline(tagName) {
var selection = window.getSelection();
if (!selection.rangeCount) { return; }
var element = document.createElement(tagName);
try { selection.getRangeAt(0).surroundContents(element); } catch (e) { /* spans blocks */ }
wysiwyg.focus();
updateToolbarState();
}
var richActions = {
bold: function () { exec("bold"); },
italic: function () { exec("italic"); },
h2: function () { exec("formatBlock", "H2"); },
h3: function () { exec("formatBlock", "H3"); },
quote: function () { exec("formatBlock", "BLOCKQUOTE"); },
clear: function () { exec("formatBlock", "P"); },
ul: function () { exec("insertUnorderedList"); },
ol: function () { exec("insertOrderedList"); },
code: function () { wrapInline("code"); },
image: function () { imageInput.click(); },
link: function () { var url = prompt("Link URL", "https://"); if (url) { exec("createLink", url); } }
};
// --- Toolbar active state ----------------------------------------------
function queryState(command) {
try { return document.queryCommandState(command); } catch (e) { return false; }
}
function currentBlock() {
try { return (document.queryCommandValue("formatBlock") || "").toLowerCase().replace(/[<>]/g, ""); }
catch (e) { return ""; }
}
function selectionInEditor() {
var node = window.getSelection().anchorNode;
while (node) { if (node === wysiwyg) { return true; } node = node.parentNode; }
return false;
}
function isInside(tagName) {
var node = window.getSelection().anchorNode;
while (node && node !== wysiwyg) {
if (node.nodeType === 1 && node.tagName.toLowerCase() === tagName) { return true; }
node = node.parentNode;
}
return false;
}
function updateToolbarState() {
if (visualView.hidden) { return; }
var active = {};
if (selectionInEditor()) {
var block = currentBlock();
active = {
bold: queryState("bold"),
italic: queryState("italic"),
ul: queryState("insertUnorderedList"),
ol: queryState("insertOrderedList"),
h2: block === "h2",
h3: block === "h3",
quote: block === "blockquote",
link: isInside("a"),
code: isInside("code")
};
}
document.querySelectorAll("[data-rich]").forEach(function (button) {
button.classList.toggle("active", !!active[button.dataset.rich]);
});
}
// --- Mode switching ----------------------------------------------------
function setActiveTab(name) {
document.querySelectorAll(".editor-tabs button").forEach(function (button) {
button.classList.toggle("active", button.dataset.tab === name);
});
}
function showVisual() {
mdToHtml(textarea.value).then(function (html) { wysiwyg.innerHTML = html; });
visualView.hidden = false;
markdownView.hidden = true;
setActiveTab("visual");
}
function showMarkdown() {
textarea.value = htmlToMd(wysiwyg);
renderPreview();
visualView.hidden = true;
markdownView.hidden = false;
setActiveTab("markdown");
}
// --- Keyboard shortcuts ------------------------------------------------
function shortcut(actions) {
return function (event) {
if (!(event.ctrlKey || event.metaKey)) { return; }
var key = event.key.toLowerCase();
if (key === "b") { event.preventDefault(); actions.bold(); }
else if (key === "i") { event.preventDefault(); actions.italic(); }
else if (key === "k") { event.preventDefault(); actions.link(); }
};
}
// --- Wiring ------------------------------------------------------------
document.querySelectorAll("[data-md]").forEach(function (button) {
button.addEventListener("click", function () { mdActions[button.dataset.md](); });
});
document.querySelectorAll("[data-rich]").forEach(function (button) {
button.addEventListener("click", function () { richActions[button.dataset.rich](); });
});
document.querySelectorAll(".editor-tabs button").forEach(function (button) {
button.addEventListener("click", function () {
if (button.dataset.tab === "visual") { showVisual(); } else { showMarkdown(); }
});
});
wysiwyg.addEventListener("paste", function (event) {
event.preventDefault();
var text = (event.clipboardData || window.clipboardData).getData("text/plain");
document.execCommand("insertText", false, text);
});
wysiwyg.addEventListener("keydown", shortcut(richActions));
wysiwyg.addEventListener("keyup", updateToolbarState);
wysiwyg.addEventListener("mouseup", updateToolbarState);
document.addEventListener("selectionchange", updateToolbarState);
textarea.addEventListener("input", schedule);
textarea.addEventListener("keydown", shortcut(mdActions));
form.addEventListener("submit", function () {
if (!visualView.hidden) { textarea.value = htmlToMd(wysiwyg); }
});
// --- Preview toggle (persisted) ---------------------------------------
var editor = markdownView.querySelector(".editor");
var togglePreviewBtn = document.getElementById("toggle-preview");
function applyPreviewPreference() {
var off = false;
try { off = localStorage.getItem("volumen.preview") === "off"; } catch (e) {}
editor.classList.toggle("no-preview", off);
togglePreviewBtn.classList.toggle("active", !off);
if (!off) { renderPreview(); }
}
togglePreviewBtn.addEventListener("click", function () {
var turningOff = !editor.classList.contains("no-preview");
try { localStorage.setItem("volumen.preview", turningOff ? "off" : "on"); } catch (e) {}
applyPreviewPreference();
});
// Start in Markdown mode (primary).
applyPreviewPreference();
})();
</script>
+254
View File
@@ -0,0 +1,254 @@
<!DOCTYPE html>
<html lang="<%= h(config.language) %>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>volumen admin</title>
<style>
:root {
color-scheme: light dark;
--fg: #1b1b1f; --muted: #6b7280; --bg: #f6f7f9; --card: #ffffff;
--border: #e4e4ec; --accent: #4f46e5; --accent-strong: #4338ca;
--accent-weak: #eef2ff; --on-accent: #ffffff;
--danger: #dc2626; --danger-weak: #fef2f2;
--ok: #16a34a; --ok-weak: #f0fdf4;
--ring: 0 0 0 3px rgba(79, 70, 229, 0.30);
--shadow-sm: 0 1px 2px rgba(17, 17, 26, 0.06);
--shadow: 0 1px 3px rgba(17, 17, 26, 0.06), 0 10px 30px -16px rgba(17, 17, 26, 0.25);
--radius: 12px;
}
@media (prefers-color-scheme: dark) {
:root {
--fg: #e7e7ea; --muted: #9aa0aa; --bg: #121217; --card: #1c1c22;
--border: #2c2c35; --accent: #818cf8; --accent-strong: #a5b4fc;
--accent-weak: rgba(129, 140, 248, 0.16); --on-accent: #14141a;
--danger: #f87171; --danger-weak: rgba(248, 113, 113, 0.14);
--ok: #4ade80; --ok-weak: rgba(74, 222, 128, 0.14);
--ring: 0 0 0 3px rgba(129, 140, 248, 0.40);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 10px 30px -16px rgba(0, 0, 0, 0.7);
}
.brand-logo { background: #f4f4f6; border-radius: 8px; padding: 4px 8px; }
}
* { box-sizing: border-box; }
body { margin: 0; display: flex; min-height: 100vh; background: var(--bg); }
/* Sidebar */
.sidebar {
width: 230px; flex-shrink: 0; position: sticky; top: 0; height: 100vh;
overflow-y: auto; background: var(--card); border-right: 1px solid var(--border);
display: flex; flex-direction: column; padding: 1.25rem 0;
}
.sidebar .brand { display: flex; padding: 0 1.25rem; margin-bottom: 1rem; text-decoration: none; }
.brand-logo { height: 28px; display: block; }
.sidebar .who {
margin: 0 1.25rem; padding: 0.3rem 0.6rem; border: 1px solid var(--border);
border-radius: 999px; font-size: 0.78rem; color: var(--muted); align-self: flex-start;
}
.sidenav { display: flex; flex-direction: column; gap: 1px; padding: 0.75rem 0.75rem; flex: 1; }
.sidenav a, .sidenav button {
display: flex; align-items: center; text-decoration: none; color: var(--fg);
font: inherit; font-size: 0.9rem; font-weight: 500; padding: 0.55rem 0.8rem;
border-radius: 9px; border: none; background: none; cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.sidenav a:hover, .sidenav button:hover { background: var(--accent-weak); color: var(--accent-strong); }
.sidenav a.active { background: var(--accent); color: var(--on-accent); }
.sidebar-footer { padding: 0.75rem 1rem; border-top: 1px solid var(--border); }
/* Main */
main {
flex: 1; overflow: auto; padding: 2rem 2rem 4rem;
color: var(--fg); font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
.main-inner { max-width: 1060px; margin: 0 auto; }
::selection { background: var(--accent-weak); }
a { color: var(--accent); }
h1 { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.01em; margin: 0 0 1.25rem; }
h2 { font-size: 1.15rem; font-weight: 650; margin: 0 0 0.75rem; }
h3 { font-size: 0.95rem; font-weight: 650; }
.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; }
.toolbar h1 { margin: 0; }
/* Buttons */
button, .button {
font: inherit; font-weight: 550; cursor: pointer; display: inline-flex;
align-items: center; gap: 0.4rem; border: 1px solid var(--border);
background: var(--card); color: var(--fg); border-radius: 9px;
padding: 0.45rem 0.9rem; text-decoration: none;
transition: background 0.15s, border-color 0.15s, transform 0.05s, box-shadow 0.15s;
}
button:hover, .button:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }
button:active, .button:active { transform: translateY(1px); }
button:focus-visible, .button:focus-visible,
input:focus-visible, select:focus-visible, textarea:focus-visible,
[contenteditable]:focus-visible, .sidenav a:focus-visible, .sidenav button:focus-visible {
outline: none; box-shadow: var(--ring);
}
button.primary, .button.primary {
background: var(--accent); border-color: var(--accent); color: var(--on-accent);
box-shadow: var(--shadow-sm);
}
button.primary:hover, .button.primary:hover {
background: var(--accent-strong); border-color: var(--accent-strong);
}
button.danger {
color: var(--danger); border-color: color-mix(in srgb, var(--danger) 35%, var(--border));
background: transparent;
}
button.danger:hover { background: var(--danger); color: #fff; border-color: var(--danger); }
form.inline { display: inline; margin: 0; }
/* Cards */
.card {
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
padding: 1.5rem; margin-bottom: 1.5rem; box-shadow: var(--shadow);
}
.card h2 { margin-top: 0; }
.card h3 { margin: 1.5rem 0 0.6rem; }
.card form { margin-bottom: 1.25rem; }
.card label { max-width: 380px; margin: 0.5rem 0; }
/* Forms */
.fields { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.9rem; margin-bottom: 1.25rem; }
label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.8rem;
font-weight: 550; color: var(--muted); }
label.check { flex-direction: row; align-items: center; gap: 0.5rem; }
.cover-field { margin-bottom: 1rem; }
.cover-field > label { display: block; font-size: 0.8rem; font-weight: 550;
color: var(--muted); margin-bottom: 0.3rem; }
.cover-row { display: flex; gap: 0.5rem; align-items: center; }
.cover-row input { flex: 1; }
.cover-preview { margin-top: 0.6rem; max-height: 120px; border-radius: 8px;
border: 1px solid var(--border); display: block; }
input[type=text], input[type=password], input[type=date], input:not([type]), select, textarea {
font: inherit; color: var(--fg); background: var(--bg);
border: 1px solid var(--border); border-radius: 9px; padding: 0.5rem 0.7rem;
transition: border-color 0.15s, box-shadow 0.15s;
}
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
/* Tables */
table {
width: 100%; border-collapse: collapse; background: var(--card);
border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
box-shadow: var(--shadow); margin-bottom: 1.5rem;
}
th, td { text-align: left; padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
th { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted);
background: color-mix(in srgb, var(--bg) 60%, var(--card)); }
tbody tr:hover td { background: color-mix(in srgb, var(--accent-weak) 50%, transparent); }
tr:last-child td { border-bottom: none; }
td.actions { display: flex; gap: 0.5rem; align-items: center; }
/* Alerts */
.error, .notice {
border: 1px solid; border-radius: 10px; padding: 0.7rem 0.95rem; margin: 0 0 1.25rem;
font-size: 0.9rem;
}
.error { color: var(--danger); background: var(--danger-weak);
border-color: color-mix(in srgb, var(--danger) 30%, transparent); }
.notice { color: var(--ok); background: var(--ok-weak);
border-color: color-mix(in srgb, var(--ok) 30%, transparent); }
.muted { color: var(--muted); }
/* Login */
.login {
max-width: 380px; margin: 4rem auto; background: var(--card);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 2rem; box-shadow: var(--shadow);
}
.login h1 { text-align: center; }
.login label, .login button { width: 100%; }
.login button { margin-top: 1.25rem; justify-content: center; }
/* Editor */
.editor-tabs {
display: inline-flex; gap: 2px; background: var(--card); padding: 3px;
border: 1px solid var(--border); border-radius: 10px; margin-bottom: 0.9rem;
}
.editor-tabs button { border: none; background: transparent; color: var(--muted);
border-radius: 7px; padding: 0.35rem 0.95rem; font-size: 0.85rem; }
.editor-tabs button.active { background: var(--accent); color: var(--on-accent); }
.editor { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.25rem; }
.editor.no-preview { grid-template-columns: 1fr; }
.editor.no-preview .preview-pane { display: none; }
.pane { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
background: var(--card); box-shadow: var(--shadow-sm); }
.ed-toolbar { display: flex; gap: 0.3rem; padding: 0.45rem; align-items: center;
border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--bg) 50%, var(--card)); }
.ed-toolbar button { padding: 0.28rem 0.55rem; font-size: 0.82rem; border-radius: 7px;
background: transparent; }
.ed-toolbar button:hover { background: var(--accent-weak); }
.ed-toolbar button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.ed-toolbar span { color: var(--muted); font-size: 0.78rem; font-weight: 550; }
.ed-toolbar .toggle { margin-left: auto; }
textarea { width: 100%; border: none; border-radius: 0; padding: 0.85rem 1rem;
font: 13.5px/1.65 ui-monospace, SFMono-Regular, Menlo, monospace; resize: vertical;
background: var(--card); color: var(--fg); }
textarea:focus { box-shadow: none; }
#preview, #wysiwyg { padding: 0.85rem 1.1rem; min-height: 22rem; }
#wysiwyg { outline: none; }
#wysiwyg:focus { box-shadow: inset 0 0 0 2px var(--accent); }
#preview img, #wysiwyg img { max-width: 100%; }
[hidden] { display: none !important; }
/* Rendered Markdown */
.markdown { line-height: 1.7; }
.markdown > :first-child { margin-top: 0; }
.markdown h1, .markdown h2, .markdown h3, .markdown h4 { line-height: 1.25; margin: 1.4em 0 0.5em; }
.markdown h1 { font-size: 1.6rem; }
.markdown h2 { font-size: 1.3rem; }
.markdown h3 { font-size: 1.1rem; }
.markdown p { margin: 0.7em 0; }
.markdown a { color: var(--accent); }
.markdown ul, .markdown ol { padding-left: 1.5rem; margin: 0.7em 0; }
.markdown li { margin: 0.2em 0; }
.markdown blockquote { margin: 0.9em 0; padding: 0.3em 1rem; border-left: 3px solid var(--accent);
color: var(--muted); background: var(--accent-weak); border-radius: 0 8px 8px 0; }
.markdown code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em;
background: var(--bg); border: 1px solid var(--border); border-radius: 5px; padding: 0.1em 0.35em; }
.markdown pre { background: var(--bg); border: 1px solid var(--border); border-radius: 10px;
padding: 0.9rem 1rem; overflow-x: auto; margin: 0.9em 0; }
.markdown pre code { background: none; border: none; padding: 0; font-size: 0.88em; }
.markdown table { border-collapse: collapse; width: 100%; margin: 0.9em 0; font-size: 0.95em;
box-shadow: none; }
.markdown th, .markdown td { border: 1px solid var(--border); padding: 0.45rem 0.7rem; text-align: left; }
.markdown thead th { background: var(--accent-weak); color: var(--fg); }
.markdown tbody tr:hover td { background: transparent; }
.markdown hr { border: none; border-top: 1px solid var(--border); margin: 1.4em 0; }
.markdown img { max-width: 100%; border-radius: 8px; }
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}
</style>
</head>
<body>
<aside class="sidebar">
<a class="brand" href="<%= to("/admin/") %>"><img class="brand-logo" src="<%= to("/admin/logo.svg") %>" alt="volumen"></a>
<% if authenticated? %>
<span class="who"><%= h(current_user) %> · <%= h(current_role) %></span>
<% settings_active = request.path_info.start_with?("/admin/settings") %>
<nav class="sidenav">
<a href="<%= to("/admin/") %>" class="<%= settings_active ? "" : "active" %>">Posts</a>
<a href="<%= to("/admin/settings") %>" class="<%= settings_active ? "active" : "" %>">Settings</a>
</nav>
<div class="sidebar-footer">
<form method="post" action="<%= to("/admin/logout") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<button type="submit">Log out</button>
</form>
</div>
<% end %>
</aside>
<main>
<div class="main-inner">
<%= yield %>
</div>
</main>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
<div class="toolbar">
<h1>Posts</h1>
<a class="button primary" href="<%= to("/admin/posts/new") %>">New post</a>
</div>
<% if @posts.empty? %>
<p>No posts yet. Create your first one.</p>
<% else %>
<table>
<thead>
<tr><th>Title</th><th>Slug</th><th>Lang</th><th>Date</th><th>Draft</th><th></th></tr>
</thead>
<tbody>
<% @posts.each do |post| %>
<tr>
<td><%= h(post.title) %></td>
<td><%= h(post.slug) %></td>
<td><%= h(post.lang) %></td>
<td><%= h(post.date_string) %></td>
<td><%= post.draft? ? "yes" : "" %></td>
<td class="actions">
<a href="<%= to("/admin/posts/#{post.slug}/edit") %>">Edit</a>
<form class="inline" method="post"
action="<%= 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>
</form>
</td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
+23
View File
@@ -0,0 +1,23 @@
<div class="login">
<h1>Sign in</h1>
<% if @error %>
<p class="error"><%= h(@error) %></p>
<% end %>
<% unless users.any? %>
<p class="error">
No users are configured. Set <code>admin.password_hash</code> in your
config (you will sign in as <code>admin</code>) or generate one with
<code>volumen hash-password</code>.
</p>
<% end %>
<form method="post" action="<%= to("/admin/login") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<label>Username
<input type="text" name="username" autofocus autocomplete="username">
</label>
<label>Password
<input type="password" name="password" autocomplete="current-password">
</label>
<button type="submit" class="primary">Sign in</button>
</form>
</div>
+86
View File
@@ -0,0 +1,86 @@
<h1>Settings</h1>
<% if @notice %><p class="notice"><%= h(@notice) %></p><% end %>
<% if @error %><p class="error"><%= h(@error) %></p><% end %>
<section class="card">
<h2>Your account</h2>
<p class="muted">Signed in as <strong><%= h(current_user) %></strong> (<%= h(current_role) %>).</p>
<form method="post" action="<%= to("/admin/settings/password") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<h3>Change password</h3>
<label>Current password
<input type="password" name="current_password" autocomplete="current-password">
</label>
<label>New password
<input type="password" name="new_password" autocomplete="new-password">
</label>
<button type="submit" class="primary">Update password</button>
</form>
<form method="post" action="<%= to("/admin/settings/username") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<h3>Change username</h3>
<label>New username <input type="text" name="username" value="<%= h(current_user) %>"></label>
<button type="submit">Update username</button>
</form>
</section>
<% if admin? %>
<section class="card">
<h2>Users</h2>
<table>
<thead><tr><th>Username</th><th>Role</th><th></th></tr></thead>
<tbody>
<% @users.each do |user| %>
<tr>
<td><%= h(user.username) %><%= user.username == current_user ? " (you)" : "" %></td>
<td>
<% if user.username == current_user %>
<%= h(user.role) %>
<% else %>
<form class="inline" method="post"
action="<%= 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| %>
<option value="<%= role %>" <%= user.role == role ? "selected" : "" %>><%= role %></option>
<% end %>
</select>
</form>
<% end %>
</td>
<td class="actions">
<% unless user.username == current_user %>
<form class="inline" method="post"
action="<%= 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>
</form>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
<form method="post" action="<%= to("/admin/settings/users") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<h3>Add user</h3>
<div class="fields">
<label>Username <input type="text" name="username"></label>
<label>Password <input type="password" name="password" autocomplete="new-password"></label>
<label>Role
<select name="role">
<% Volumen::Users::ROLES.each do |role| %>
<option value="<%= role %>" <%= role == Volumen::Users::DEFAULT_ROLE ? "selected" : "" %>><%= role %></option>
<% end %>
</select>
</label>
</div>
<button type="submit" class="primary">Add user</button>
</form>
</section>
<% end %>
+21
View File
@@ -0,0 +1,21 @@
+++
title = "Ahoj, světe"
slug = "ahoj"
lang = "cs"
author = "Petr Balvín"
tags = ["úvod", "ruby"]
date = 2026-06-19
[translations]
en = "hello"
+++
# Ahoj, světe
Vítej na blogu poháněném **volumenem**.
- Markdown s TOML frontmatterem
- Žádná databáze — každý příspěvek je jeden `.md` soubor
- JSON API na `/api/volumen`
Zkus si otevřít [seznam příspěvků](/api/volumen/posts).
+21
View File
@@ -0,0 +1,21 @@
+++
title = "Hello, world"
slug = "hello"
lang = "en"
author = "Petr Balvín"
tags = ["intro", "ruby"]
date = 2026-06-19
[translations]
cs = "ahoj"
+++
# Hello, world
Welcome to a blog powered by **volumen**.
- Markdown with TOML frontmatter
- No database — each post is a single `.md` file
- A JSON API at `/api/volumen`
Try the [post list](/api/volumen/posts).
+251
View File
@@ -0,0 +1,251 @@
# frozen_string_literal: true
require "test_helper"
require "volumen/server"
require "rack/test"
require "tmpdir"
require "fileutils"
class AdminTest < Minitest::Test
include Rack::Test::Methods
def app
Volumen::Server.configured(config: @config, store: @store)
end
def setup
@dir = Dir.mktmpdir
@posts_dir = File.join(@dir, "posts")
FileUtils.mkdir_p(@posts_dir)
@password = "secret"
@hash = Volumen::Password.hash(@password)
@users_path = File.join(@dir, "users.toml")
config_path = File.join(@dir, "config.toml")
File.write(config_path, <<~TOML)
content_dir = "#{@posts_dir}"
users_file = "#{@users_path}"
[admin]
password_hash = "#{@hash}"
TOML
@config = Volumen::Config.load(path: config_path)
@store = Volumen::Store.new(@posts_dir, default_lang: "en")
end
def teardown
FileUtils.remove_entry(@dir)
end
def test_admin_requires_login
get "/admin/"
assert_equal 302, last_response.status
end
def test_login_page_renders
get "/admin/login"
assert_predicate last_response, :ok?
assert_includes last_response.body, "Sign in"
end
def test_session_cookie_is_secure_over_https
get "https://example.org/admin/login"
post "https://example.org/admin/login",
"username" => "admin", "password" => @password, "_csrf" => csrf(last_response.body)
cookie = last_response["Set-Cookie"].to_s
assert_match(/;\s*secure/i, cookie)
assert_match(/;\s*httponly/i, cookie)
assert_match(/;\s*samesite=strict/i, cookie)
end
def test_session_cookie_plain_http_is_not_secure
login
refute_match(/;\s*secure/i, last_response["Set-Cookie"].to_s)
end
def test_wrong_password_is_rejected
get "/admin/login"
post "/admin/login",
"username" => "admin", "password" => "nope", "_csrf" => csrf(last_response.body)
assert_includes last_response.body, "Invalid username or password"
end
def test_login_then_list
login
assert_equal 302, last_response.status
follow_redirect!
assert_includes last_response.body, "Posts"
end
def test_create_post_writes_file
login
get "/admin/posts/new"
post "/admin/posts",
"_csrf" => csrf(last_response.body), "title" => "Test", "slug" => "test",
"lang" => "en", "tags" => "a, b", "body" => "Hello **world**."
assert_equal 302, last_response.status
path = File.join(@posts_dir, "test.md")
assert File.exist?(path)
saved = Volumen::Post.parse(File.read(path))
assert_equal "Test", saved.title
assert_equal %w[a b], saved.tags
end
def test_create_post_with_all_langs
login
get "/admin/posts/new"
post "/admin/posts",
"_csrf" => csrf(last_response.body), "title" => "Global", "slug" => "global",
"lang" => "en", "all_langs" => "on", "body" => "Hi"
assert_equal 302, last_response.status
saved = Volumen::Post.parse(File.read(File.join(@posts_dir, "global.md")))
assert_predicate saved, :all_langs?
end
def test_create_post_with_cover
login
get "/admin/posts/new"
post "/admin/posts",
"_csrf" => csrf(last_response.body), "title" => "Covered", "slug" => "covered",
"lang" => "en", "cover" => "/media/c.png", "body" => "Hi"
assert_equal 302, last_response.status
saved = Volumen::Post.parse(File.read(File.join(@posts_dir, "covered.md")))
assert_equal "/media/c.png", saved.cover
end
def test_preview_requires_login
post "/admin/preview", "body" => "**hi**"
assert_equal 302, last_response.status
end
def test_csrf_is_enforced
login
post "/admin/posts", "title" => "X", "slug" => "x", "body" => "y"
assert_equal 403, last_response.status
end
def test_image_upload_and_serving
login
get "/admin/posts/new"
token = csrf(last_response.body)
image_path = File.join(@dir, "pic.png")
File.binwrite(image_path, "PNGDATA")
post "/admin/uploads",
"_csrf" => token,
"file" => Rack::Test::UploadedFile.new(image_path, "image/png")
assert_predicate last_response, :ok?
url = JSON.parse(last_response.body)["url"]
assert_match(%r{\A/media/}, url)
get url
assert_predicate last_response, :ok?
assert_equal "PNGDATA", last_response.body
end
def test_settings_requires_login
get "/admin/settings"
assert_equal 302, last_response.status
end
def test_change_password
login
get "/admin/settings"
post "/admin/settings/password",
"_csrf" => csrf(last_response.body),
"current_password" => @password, "new_password" => "brand-new"
assert_includes last_response.body, "Password updated"
fresh = Volumen::Users.new(path: @users_path)
refute fresh.authenticate("admin", @password)
assert fresh.authenticate("admin", "brand-new")
end
def test_add_and_login_as_new_user
login
get "/admin/settings"
post "/admin/settings/users",
"_csrf" => csrf(last_response.body), "username" => "editor", "password" => "pw12345"
assert_includes last_response.body, "User added"
post "/admin/logout", "_csrf" => csrf(last_response.body)
get "/admin/login"
post "/admin/login",
"username" => "editor", "password" => "pw12345", "_csrf" => csrf(last_response.body)
assert_equal 302, last_response.status
end
def test_cannot_delete_self
login
get "/admin/settings"
post "/admin/settings/users/admin/delete", "_csrf" => csrf(last_response.body)
assert_includes last_response.body, "cannot delete your own account"
end
def test_admin_can_assign_role
login
add_user("writer", "pw12345", "author")
assert_includes last_response.body, "User added"
assert_equal "author", Volumen::Users.new(path: @users_path).find("writer").role
end
def test_author_cannot_manage_users
login
add_user("writer", "pw12345", "author")
logout
login_as("writer", "pw12345")
follow_redirect!
post "/admin/settings/users",
"_csrf" => csrf(last_response.body), "username" => "intruder", "password" => "pw12345"
assert_equal 403, last_response.status
end
def test_author_settings_omits_user_management
login
add_user("writer", "pw12345", "author")
logout
login_as("writer", "pw12345")
follow_redirect!
get "/admin/settings"
refute_includes last_response.body, "Add user"
end
def test_author_can_create_post
login
add_user("writer", "pw12345", "author")
logout
login_as("writer", "pw12345")
get "/admin/posts/new"
post "/admin/posts",
"_csrf" => csrf(last_response.body), "title" => "By author", "slug" => "by-author",
"lang" => "en", "body" => "Hi"
assert_equal 302, last_response.status
assert File.exist?(File.join(@posts_dir, "by-author.md"))
end
private
def csrf(body)
body[/name="_csrf" value="([^"]+)"/, 1]
end
def login
get "/admin/login"
post "/admin/login",
"username" => "admin", "password" => @password, "_csrf" => csrf(last_response.body)
end
def login_as(username, password)
get "/admin/login"
post "/admin/login",
"username" => username, "password" => password, "_csrf" => csrf(last_response.body)
end
def logout
post "/admin/logout", "_csrf" => csrf(last_response.body)
end
def add_user(username, password, role)
get "/admin/settings"
post "/admin/settings/users",
"_csrf" => csrf(last_response.body),
"username" => username, "password" => password, "role" => role
end
end
+57
View File
@@ -0,0 +1,57 @@
# frozen_string_literal: true
require "test_helper"
require "tmpdir"
class ConfigTest < Minitest::Test
MISSING = "/nonexistent/volumen.toml"
def test_defaults_when_file_missing
config = Volumen::Config.load(path: MISSING)
assert_equal 9090, config.port
assert_equal "en", config.language
end
def test_overrides_apply
config = Volumen::Config.load(
path: MISSING,
overrides: { host: "127.0.0.1", port: 9000, content: "/srv/posts" }
)
assert_equal "127.0.0.1", config.host
assert_equal 9000, config.port
assert_equal "/srv/posts", config.content_dir
end
def test_does_not_mutate_defaults
Volumen::Config.load(path: MISSING, overrides: { host: "10.0.0.1" })
assert_equal "0.0.0.0", Volumen::Config.load(path: MISSING).host
end
def test_loads_and_merges_file
Dir.mktmpdir do |dir|
path = File.join(dir, "config.toml")
File.write(path, "[site]\ntitle = \"Custom\"\n")
config = Volumen::Config.load(path: path)
assert_equal "Custom", config.site["title"]
assert_equal 9090, config.port
end
end
def test_generate_default_writes_template
Dir.mktmpdir do |dir|
path = File.join(dir, "sub", "config.toml")
assert_equal path, Volumen::Config.generate_default(path)
assert_path_exists path
assert_equal 9090, Volumen::Config.load(path: path).port
end
end
def test_generate_default_skips_existing
Dir.mktmpdir do |dir|
path = File.join(dir, "config.toml")
File.write(path, "[server]\nport = 9999\n")
assert_nil Volumen::Config.generate_default(path)
assert_equal 9999, Volumen::Config.load(path: path).port
end
end
end
+47
View File
@@ -0,0 +1,47 @@
# frozen_string_literal: true
require "test_helper"
class FrontmatterTest < Minitest::Test
def test_parse_extracts_metadata_and_body
content = <<~POST
+++
title = "Hello"
tags = ["a", "b"]
+++
Body text.
POST
metadata, body = Volumen::Frontmatter.parse(content)
assert_equal "Hello", metadata["title"]
assert_equal %w[a b], metadata["tags"]
assert_equal "Body text.\n", body
end
def test_parse_without_frontmatter
metadata, body = Volumen::Frontmatter.parse("# Just markdown\n")
assert_empty metadata
assert_equal "# Just markdown\n", body
end
def test_parse_without_closing_delimiter
content = "+++\ntitle = \"x\"\n\nbody"
metadata, body = Volumen::Frontmatter.parse(content)
assert_empty metadata
assert_equal content, body
end
def test_round_trip
metadata = { "title" => "Hello", "tags" => ["intro"], "draft" => false }
dumped = Volumen::Frontmatter.dump(metadata, "Hello **world**.")
parsed_meta, parsed_body = Volumen::Frontmatter.parse(dumped)
assert_equal metadata, parsed_meta
assert_equal "Hello **world**.", parsed_body.strip
end
def test_parse_normalises_crlf
metadata, body = Volumen::Frontmatter.parse("+++\r\ntitle = \"x\"\r\n+++\r\n\r\nBody\r\n")
assert_equal "x", metadata["title"]
assert_equal "Body\n", body
end
end
+33
View File
@@ -0,0 +1,33 @@
# frozen_string_literal: true
require "test_helper"
class MarkdownTest < Minitest::Test
def test_renders_bold
assert_includes Volumen::Markdown.render("**bold**"), "<strong>bold</strong>"
end
def test_renders_heading
assert_includes Volumen::Markdown.render("# Title"), "<h1"
end
def test_renders_gfm_table
table = <<~MD
| A | B |
|---|---|
| 1 | 2 |
MD
assert_includes Volumen::Markdown.render(table), "<table"
end
def test_renders_fenced_code_block
code = <<~MD
```ruby
puts 1
```
MD
html = Volumen::Markdown.render(code)
assert_includes html, "<pre"
assert_includes html, "<code"
end
end
+24
View File
@@ -0,0 +1,24 @@
# frozen_string_literal: true
require "test_helper"
class PasswordTest < Minitest::Test
def test_hash_and_verify_round_trip
encoded = Volumen::Password.hash("s3cret")
assert Volumen::Password.verify("s3cret", encoded)
end
def test_verify_rejects_wrong_password
encoded = Volumen::Password.hash("s3cret")
refute Volumen::Password.verify("nope", encoded)
end
def test_verify_rejects_malformed_input
refute Volumen::Password.verify("x", "not-a-hash")
refute Volumen::Password.verify("x", "")
end
def test_encoded_format
assert Volumen::Password.hash("pw").start_with?("scrypt$16384$8$1$")
end
end
+59
View File
@@ -0,0 +1,59 @@
# frozen_string_literal: true
require "test_helper"
class PostTest < Minitest::Test
def test_parse_builds_post
content = <<~POST
+++
title = "Hello"
slug = "hello"
lang = "en"
tags = ["intro", "ruby"]
date = 2026-01-15
draft = true
+++
First paragraph.
POST
post = Volumen::Post.parse(content)
assert_equal "hello", post.slug
assert_equal "Hello", post.title
assert_equal "en", post.lang
assert_equal %w[intro ruby], post.tags
assert_predicate post, :draft?
assert_equal "2026-01-15", post.date_string
end
def test_excerpt_derived_from_body
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "# Heading\n\nThe body paragraph.")
assert_equal "The body paragraph.", post.excerpt
end
def test_summary_shape
post = Volumen::Post.new(metadata: { "slug" => "x", "title" => "X" }, body: "Body")
summary = post.summary
assert_equal "/api/volumen/posts/x", summary["url"]
assert_equal "X", summary["title"]
refute_predicate post, :draft?
end
def test_detail_includes_rendered_html
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "**hi**")
assert_includes post.detail["html"], "<strong>hi</strong>"
end
def test_summary_includes_cover_and_reading_time
post = Volumen::Post.new(
metadata: { "slug" => "x", "cover" => "/media/c.png" },
body: ("word " * 250)
)
assert_equal "/media/c.png", post.summary["cover"]
assert_equal 2, post.summary["reading_time"]
end
def test_reading_time_is_at_least_one
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "short")
assert_equal 1, post.reading_time
end
end
+95
View File
@@ -0,0 +1,95 @@
# frozen_string_literal: true
require "test_helper"
require "volumen/server"
require "rack/test"
require "tmpdir"
require "fileutils"
require "json"
class ServerTest < Minitest::Test
include Rack::Test::Methods
def app
Volumen::Server.configured(config: @config, store: @store)
end
def setup
@dir = Dir.mktmpdir
File.write(File.join(@dir, "hello.md"), <<~POST)
+++
title = "Hello"
tags = ["intro"]
+++
Hello **world**.
POST
File.write(File.join(@dir, "draft.md"), <<~POST)
+++
title = "Draft"
draft = true
+++
Secret.
POST
@config = Volumen::Config.load(path: "/nonexistent", overrides: { content: @dir })
@store = Volumen::Store.new(@dir, default_lang: "en")
end
def teardown
FileUtils.remove_entry(@dir)
end
def test_site_endpoint
get "/api/volumen/site"
assert_predicate last_response, :ok?
assert_equal "My Blog", JSON.parse(last_response.body)["title"]
end
def test_posts_excludes_drafts
get "/api/volumen/posts"
data = JSON.parse(last_response.body)
assert_equal 1, data["total"]
assert_equal "hello", data["posts"].first["slug"]
end
def test_post_detail_renders_html
get "/api/volumen/posts/hello"
assert_predicate last_response, :ok?
assert_includes JSON.parse(last_response.body)["html"], "<strong>world</strong>"
end
def test_post_not_found
get "/api/volumen/posts/missing"
assert_equal 404, last_response.status
assert_equal "not_found", JSON.parse(last_response.body)["error"]
end
def test_tags_endpoint
get "/api/volumen/tags"
assert_equal [{ "name" => "intro", "count" => 1 }], JSON.parse(last_response.body)["tags"]
end
def test_cors_header_present
get "/api/volumen/site"
header = last_response.headers["Access-Control-Allow-Origin"] ||
last_response.headers["access-control-allow-origin"]
assert_equal "*", header
end
def test_all_langs_post_appears_in_other_languages
File.write(File.join(@dir, "global.md"), <<~POST)
+++
title = "Global"
lang = "en"
all_langs = true
+++
Everywhere.
POST
get "/api/volumen/posts?lang=cs"
slugs = JSON.parse(last_response.body)["posts"].map { |post| post["slug"] }
assert_includes slugs, "global"
refute_includes slugs, "hello"
end
end
+52
View File
@@ -0,0 +1,52 @@
# frozen_string_literal: true
require "test_helper"
require "tmpdir"
require "fileutils"
class StoreTest < Minitest::Test
def setup
@dir = Dir.mktmpdir
File.write(File.join(@dir, "hello.md"), "+++\ntitle = \"Hello\"\n+++\n\nBody\n")
FileUtils.mkdir_p(File.join(@dir, "cs"))
File.write(File.join(@dir, "cs", "ahoj.md"), "+++\ntitle = \"Ahoj\"\n+++\n\nTelo\n")
end
def teardown
FileUtils.remove_entry(@dir)
end
def test_all_loads_every_post
assert_equal 2, Volumen::Store.new(@dir).all.length
end
def test_slug_falls_back_to_filename
slugs = Volumen::Store.new(@dir).all.map(&:slug).sort
assert_equal %w[ahoj hello], slugs
end
def test_lang_derived_from_subdirectory
store = Volumen::Store.new(@dir, default_lang: "en")
assert_equal "cs", store.find("ahoj").lang
assert_equal "en", store.find("hello").lang
end
def test_find_returns_nil_when_missing
assert_nil Volumen::Store.new(@dir).find("nope")
end
def test_all_langs_post_is_visible_in_any_language
File.write(File.join(@dir, "global.md"), <<~POST)
+++
title = "Global"
lang = "en"
all_langs = true
+++
Visible everywhere.
POST
store = Volumen::Store.new(@dir, default_lang: "en")
assert store.find("global", lang: "cs")
assert store.find("global", lang: "en")
end
end
+4
View File
@@ -0,0 +1,4 @@
# frozen_string_literal: true
require "minitest/autorun"
require "volumen"
+114
View File
@@ -0,0 +1,114 @@
# frozen_string_literal: true
require "test_helper"
require "tmpdir"
require "fileutils"
class UsersTest < Minitest::Test
def setup
@dir = Dir.mktmpdir
@path = File.join(@dir, "users.toml")
end
def teardown
FileUtils.remove_entry(@dir)
end
def test_bootstrap_user_from_hash
hash = Volumen::Password.hash("secret")
users = Volumen::Users.new(path: @path, bootstrap_hash: hash)
assert_equal 1, users.all.length
assert users.authenticate("admin", "secret")
end
def test_no_users_without_file_or_hash
refute_predicate Volumen::Users.new(path: @path), :any?
end
def test_add_and_authenticate
users = Volumen::Users.new(path: @path)
assert users.add("alice", "wonderland")
assert_path_exists @path
assert users.authenticate("alice", "wonderland")
refute users.authenticate("alice", "nope")
end
def test_add_rejects_duplicate
users = Volumen::Users.new(path: @path)
users.add("alice", "x")
assert_nil users.add("alice", "y")
end
def test_update_password
users = Volumen::Users.new(path: @path)
users.add("alice", "old")
users.update_password("alice", "new")
refute users.authenticate("alice", "old")
assert users.authenticate("alice", "new")
end
def test_rename
users = Volumen::Users.new(path: @path)
users.add("alice", "x")
assert users.rename("alice", "alicia")
assert_nil users.find("alice")
assert users.find("alicia")
end
def test_delete_keeps_last_user
users = Volumen::Users.new(path: @path)
users.add("alice", "x")
assert_nil users.delete("alice")
users.add("bob", "y")
assert users.delete("bob")
assert_nil users.find("bob")
end
def test_default_role_is_author
users = Volumen::Users.new(path: @path)
users.add("alice", "x")
assert_equal "author", users.find("alice").role
end
def test_add_with_explicit_role
users = Volumen::Users.new(path: @path)
users.add("boss", "x", "admin")
assert_equal "admin", users.find("boss").role
end
def test_invalid_role_falls_back_to_default
users = Volumen::Users.new(path: @path)
users.add("alice", "x", "wizard")
assert_equal "author", users.find("alice").role
end
def test_bootstrap_user_is_admin
users = Volumen::Users.new(path: @path, bootstrap_hash: Volumen::Password.hash("s"))
assert_equal "admin", users.find("admin").role
end
def test_set_role
users = Volumen::Users.new(path: @path)
users.add("alice", "x", "admin")
users.add("bob", "y", "author")
assert users.set_role("bob", "admin")
assert_equal "admin", users.find("bob").role
end
def test_cannot_demote_last_admin
users = Volumen::Users.new(path: @path)
users.add("alice", "x", "admin")
users.add("bob", "y", "author")
assert_nil users.set_role("alice", "author")
assert_equal "admin", users.find("alice").role
end
def test_cannot_delete_last_admin
users = Volumen::Users.new(path: @path)
users.add("alice", "x", "admin")
users.add("bob", "y", "author")
assert_nil users.delete("alice")
users.add("carol", "z", "admin")
assert users.delete("alice")
end
end
+13
View File
@@ -0,0 +1,13 @@
# frozen_string_literal: true
require "test_helper"
class VolumenTest < Minitest::Test
def test_version_is_defined
refute_nil Volumen::VERSION
end
def test_version_is_semver
assert_match(/\A\d+\.\d+\.\d+\z/, Volumen::VERSION)
end
end
+39
View File
@@ -0,0 +1,39 @@
# frozen_string_literal: true
require_relative "lib/volumen/version"
Gem::Specification.new do |spec|
spec.name = "volumen"
spec.version = Volumen::VERSION
spec.authors = ["Petr Balvín"]
spec.email = ["petrbalvin@yandex.com"]
spec.summary = "A small, file-based Markdown blog engine with a built-in admin."
spec.description = "volumen serves Markdown posts (TOML frontmatter) as a JSON API " \
"and ships a server-rendered admin with both a Markdown source " \
"editor and a visual editor. File-based, single-admin, " \
"dependency-light."
spec.homepage = "https://codeberg.org/petrbalvin/volumen"
spec.license = "MIT"
spec.required_ruby_version = ">= 3.3"
spec.metadata["homepage_uri"] = spec.homepage
spec.metadata["rubygems_mfa_required"] = "true"
spec.files = Dir[
"lib/**/*",
"exe/*",
"README.md",
"LICENSE"
]
spec.bindir = "exe"
spec.executables = ["volumen"]
spec.require_paths = ["lib"]
spec.add_dependency "kramdown", "~> 2.5"
spec.add_dependency "kramdown-parser-gfm", "~> 1.1"
spec.add_dependency "puma", "~> 8.0"
spec.add_dependency "rackup", "~> 2.3"
spec.add_dependency "sinatra", "~> 4.2"
spec.add_dependency "toml-rb", "~> 4.2"
end