commit dd3efe870e6c2e0371fa1e7a6755b7325b303fa5 Author: Petr Date: Sun Jun 21 13:15:06 2026 +0200 feat: initial release of volumen — a file-based Markdown blog engine diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8310d60 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..98a36ac --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -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}." diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml new file mode 100644 index 0000000..3c65669 --- /dev/null +++ b/.forgejo/workflows/test.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..594d958 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..2f9f435 --- /dev/null +++ b/.rubocop.yml @@ -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" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9761538 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..225381f --- /dev/null +++ b/CONTRIBUTING.md @@ -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 +. + +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__`; 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 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. diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..36f5a15 --- /dev/null +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..c190a76 --- /dev/null +++ b/Gemfile.lock @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6b583c5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Petr Balvín (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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..744fe7d --- /dev/null +++ b/README.md @@ -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 and the API at +. + +--- + +## 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/.md` (default language), or +- `content_dir//.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": "

Welcome to volumen

…", + "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) diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..41b7c02 --- /dev/null +++ b/Rakefile @@ -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 diff --git a/assets/volumen-icon.svg b/assets/volumen-icon.svg new file mode 100644 index 0000000..b2cb61d --- /dev/null +++ b/assets/volumen-icon.svg @@ -0,0 +1,159 @@ + + + volumen + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/volumen-logo.svg b/assets/volumen-logo.svg new file mode 100644 index 0000000..2ef3dc8 --- /dev/null +++ b/assets/volumen-logo.svg @@ -0,0 +1,167 @@ + + + volumen — blog engine + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + volumen + + + + + + BLOG ENGINE + diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..34f734d --- /dev/null +++ b/config.toml @@ -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 diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..ca728db --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,93 @@ +# API Reference + +> Status: the public read API (`/api/volumen`) and the admin (`/admin`) are +> implemented. The admin is server-rendered HTML, not part of the JSON API. + +Base path: `/api/volumen`. All responses are JSON unless noted. Read endpoints +send `Access-Control-Allow-Origin: *`. + +## `GET /api/volumen/site` + +Returns site metadata from the configuration. + +```json +{ + "title": "My Blog", + "description": "A blog powered by volumen.", + "base_url": "https://example.com", + "language": "en", + "author": "Anonymous" +} +``` + +## `GET /api/volumen/posts` + +Paginated list of published posts (drafts are excluded), newest first. +Posts with `all_langs = true` in their frontmatter match every `lang` filter. + +| Name | Type | Default | Description | +|-------|--------|---------|--------------------| +| lang | string | — | Filter by language | +| tag | string | — | Filter by tag | +| page | int | 1 | Page number | +| limit | int | 20 | Per page (1–100) | + +```json +{ + "page": 1, + "page_size": 20, + "total": 1, + "posts": [ + { + "slug": "hello", + "title": "Hello", + "excerpt": "Hello world.", + "date": "2026-01-15", + "lang": "en", + "tags": ["intro"], + "author": null, + "cover": null, + "reading_time": 1, + "translations": {}, + "url": "/api/volumen/posts/hello" + } + ] +} +``` + +## `GET /api/volumen/posts/{slug}` + +Single post including the raw Markdown body and rendered HTML. Optional +`?lang=` selects a translation. Returns `404` with `{"error":"not_found"}` if +no match. + +```json +{ + "slug": "hello", + "title": "Hello", + "excerpt": "Hello world.", + "date": "2026-01-15", + "lang": "en", + "tags": ["intro"], + "author": null, + "cover": null, + "reading_time": 1, + "translations": {}, + "url": "/api/volumen/posts/hello", + "body": "Hello **world**.", + "html": "

Hello world.

\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`. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..d18b1bf --- /dev/null +++ b/docs/architecture.md @@ -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. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..4749120 --- /dev/null +++ b/docs/configuration.md @@ -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). diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..087b14e --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,287 @@ +# Deployment + +> Status: production deployment on Linux with systemd + nginx. + +volumen runs as a single Ruby process (Puma) bound to localhost, behind nginx +which terminates TLS and reverse-proxies the API and admin. The public website +(e.g. a Vue SPA) is served separately and consumes the JSON API. + +## Requirements + +- **Ruby ≥ 3.3** with a C toolchain (Puma compiles a native extension). + - Fedora: `sudo dnf install ruby ruby-devel @development-tools openssl-devel` + - Debian/Ubuntu: `sudo apt install ruby-full build-essential libssl-dev` +- **nginx** and a TLS certificate (e.g. via certbot). +- A dedicated system user, e.g. `volumen`. + +## Layout + +| Path | Purpose | +|------|---------| +| `/opt/volumen` | source checkout (or installed gem) | +| `/etc/volumen/config.toml` | configuration (auto-generated on first run) | +| `/var/lib/volumen/posts` | Markdown posts | +| `/var/lib/volumen/users.toml` | admin users (created from Settings) | +| `/var/lib/volumen/posts/media` | uploaded images | + +```bash +sudo useradd --system --home /var/lib/volumen --shell /usr/sbin/nologin volumen +sudo mkdir -p /etc/volumen /var/lib/volumen/posts +sudo chown -R volumen:volumen /var/lib/volumen +``` + +## Install + +### Quick install (Linux + systemd) + +From a cloned repository, run the installer as root. It installs the Ruby +toolchain from the distro repositories on **Fedora** and **openEuler** (prints +guidance on other distros), creates the `volumen` user and directories, runs +`bundle install`, generates `config.toml`, and installs and enables the systemd +unit: + +```bash +sudo git clone https://codeberg.org/petrbalvin/volumen.git /opt/volumen +cd /opt/volumen +sudo ./install.sh +``` + +The service is enabled but not started — finish by setting the admin password +(printed steps), then `sudo systemctl start volumen`. The manual steps below +describe what the script does. + +### Manual install + +From a source checkout (reproducible, no publishing needed): + +```bash +sudo git clone https://codeberg.org/petrbalvin/volumen.git /opt/volumen +cd /opt/volumen +sudo bundle install --deployment +``` + +Alternatively build and install the gem (`just build` produces `pkg/volumen.gem`, +then `gem install pkg/volumen.gem` puts `volumen` on the PATH). + +## First run and the admin password + +The first `serve` writes a commented `config.toml` if it is missing: + +```bash +sudo -u volumen bundle exec exe/volumen serve \ + --config /etc/volumen/config.toml \ + --content /var/lib/volumen/posts +# → "volumen: wrote a default config to /etc/volumen/config.toml; review it and restart." +``` + +Generate a password hash and a stable session secret, then edit the config: + +```bash +bundle exec exe/volumen hash-password # → scrypt$... +openssl rand -hex 64 # → session_key +sudo -u volumen $EDITOR /etc/volumen/config.toml +``` + +Set `host = "127.0.0.1"`, your `site.base_url`, the `admin.password_hash`, and a +`admin.session_key` (so sessions survive restarts). You can now sign in as user +`admin`; from the **Settings** page you can add more users (roles `admin` / +`author`) and change passwords. From then on `users.toml` is the source of +truth. + +## systemd + +`/etc/systemd/system/volumen.service`: + +```ini +[Unit] +Description=volumen blog engine +After=network.target + +[Service] +User=volumen +Group=volumen +WorkingDirectory=/opt/volumen +ExecStart=/usr/bin/bundle exec exe/volumen serve --config /etc/volumen/config.toml --content /var/lib/volumen/posts +Restart=on-failure +RestartSec=2 +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/volumen +PrivateTmp=true + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now volumen +sudo systemctl status volumen +journalctl -u volumen -f +``` + +## FreeBSD (rc.d) + +volumen runs on FreeBSD too — it is plain CRuby plus gems; only Puma (and its +`nio4r` dependency) build a native extension, which compiles with the base +clang toolchain. + +```sh +pkg install ruby +# then install volumen as above (git clone + bundle install) +``` + +Conventional FreeBSD paths: config in `/usr/local/etc/volumen/`, data in +`/var/db/volumen/`. Service script `/usr/local/etc/rc.d/volumen`: + +```sh +#!/bin/sh +# +# PROVIDE: volumen +# REQUIRE: NETWORKING +# KEYWORD: shutdown + +. /etc/rc.subr + +name="volumen" +rcvar="volumen_enable" +load_rc_config $name + +: ${volumen_enable:="NO"} +: ${volumen_user:="volumen"} +: ${volumen_dir:="/usr/local/volumen"} +: ${volumen_config:="/usr/local/etc/volumen/config.toml"} +: ${volumen_content:="/var/db/volumen/posts"} + +pidfile="/var/run/${name}.pid" +command="/usr/sbin/daemon" +command_args="-f -r -P ${pidfile} -u ${volumen_user} \ + /bin/sh -c 'cd ${volumen_dir} && exec bundle exec exe/volumen serve --config ${volumen_config} --content ${volumen_content}'" + +run_rc_command "$1" +``` + +Enable and start: + +```sh +sysrc volumen_enable=YES +service volumen start +``` + +## nginx + +volumen only owns `/api/volumen`, `/admin`, and `/media`. Serve your public +site from the same hostname and proxy those prefixes to the backend, so the +admin runs same-origin (session cookies and CSRF work without extra +configuration): + +```nginx +server { + listen 443 ssl http2; + server_name blog.example.com; + + ssl_certificate /etc/letsencrypt/live/blog.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/blog.example.com/privkey.pem; + + # Public site (e.g. the built Vue SPA). + root /var/www/blog; + location / { + try_files $uri $uri/ /index.html; + } + + # volumen API, admin and media. + location ~ ^/(api/volumen|admin|media)(/|$) { + proxy_pass http://127.0.0.1:9090; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +server { + listen 80; + server_name blog.example.com; + return 301 https://$host$request_uri; +} +``` + +### Hardening the admin + +The admin lives at `/admin` on your public domain and is protected by login, +sessions, CSRF, and roles — but the engine has **no rate limit** on +`/admin/login`. Add brute-force throttling (and, optionally, an IP allowlist) +at nginx. + +**1. A rate-limit zone** in the `http { }` context (e.g. +`/etc/nginx/conf.d/volumen.conf`): + +```nginx +limit_req_zone $binary_remote_addr zone=volumen_login:10m rate=5r/m; +``` + +**2. A reusable proxy snippet** at `/etc/nginx/snippets/volumen-proxy.conf`: + +```nginx +proxy_pass http://127.0.0.1:9090; +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; +``` + +**3. Split the single proxy location** in the `server { }` block into three — +public API/media, the throttled login, and the rest of the admin: + +```nginx + # Public API and uploaded media — open to everyone. + location ~ ^/(api/volumen|media)(/|$) { + include snippets/volumen-proxy.conf; + } + + # Login: throttle brute-force attempts. Only this endpoint is rate-limited, + # so the editor's live preview (/admin/preview) is unaffected. + location = /admin/login { + # Optional IP allowlist (uncomment to restrict): + # allow 203.0.113.0/24; + # deny all; + limit_req zone=volumen_login burst=10 nodelay; + include snippets/volumen-proxy.conf; + } + + # The rest of the admin. + location ^~ /admin { + # Optional IP allowlist (must be repeated — locations do not inherit it): + # allow 203.0.113.0/24; + # deny all; + include snippets/volumen-proxy.conf; + } +``` + +nginx matches `= /admin/login` (exact) first, then `^~ /admin` (which stops +regex matching), then the `~` regex for API/media — so each path lands in the +right block. Apply with `sudo nginx -t && sudo systemctl reload nginx`. + +## Updating + +```bash +cd /opt/volumen +sudo git pull +sudo bundle install --deployment +sudo systemctl restart volumen +``` + +## Security notes + +- Bind to `127.0.0.1`; only nginx is public. TLS is terminated by nginx. +- Session cookies are `HttpOnly` and `SameSite=Strict`, and gain the `Secure` + attribute automatically on HTTPS requests (detected via `X-Forwarded-Proto`, + which the nginx snippet above sets) — so the cookie never travels over plain + HTTP in production, while local HTTP testing still works. Combined with + per-form CSRF tokens this protects the admin. Set a stable `admin.session_key` + so sessions survive restarts. +- Keep `config.toml` and `users.toml` readable only by the `volumen` user + (`chmod 600`). Both may contain password hashes. +- `volumen hash-password` reads the password without echo; never pass passwords + on the command line. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..ffb081d --- /dev/null +++ b/docs/security.md @@ -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 (`-.`), 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. diff --git a/exe/volumen b/exe/volumen new file mode 100755 index 0000000..681d9eb --- /dev/null +++ b/exe/volumen @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "volumen" +require "volumen/cli" + +Volumen::CLI.run(ARGV) diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..e3785f9 --- /dev/null +++ b/install.sh @@ -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" < { "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 diff --git a/lib/volumen/frontmatter.rb b/lib/volumen/frontmatter.rb new file mode 100644 index 0000000..e3be013 --- /dev/null +++ b/lib/volumen/frontmatter.rb @@ -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 diff --git a/lib/volumen/markdown.rb b/lib/volumen/markdown.rb new file mode 100644 index 0000000..a61c885 --- /dev/null +++ b/lib/volumen/markdown.rb @@ -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 diff --git a/lib/volumen/password.rb b/lib/volumen/password.rb new file mode 100644 index 0000000..6e57355 --- /dev/null +++ b/lib/volumen/password.rb @@ -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 diff --git a/lib/volumen/post.rb b/lib/volumen/post.rb new file mode 100644 index 0000000..f7775ec --- /dev/null +++ b/lib/volumen/post.rb @@ -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 diff --git a/lib/volumen/server.rb b/lib/volumen/server.rb new file mode 100644 index 0000000..e7846c6 --- /dev/null +++ b/lib/volumen/server.rb @@ -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 + + + + #{escape(site["title"])} + #{escape(base_url)} + #{escape(site["description"])} + #{items} + + + XML + end + + def feed_item(post) + link = "#{base_url}/#{post.slug}" + <<~XML.chomp + + #{escape(post.title)} + #{escape(link)} + #{escape(link)} + #{escape(post.excerpt)} + + XML + end + + def sitemap_xml + urls = published_posts.map do |post| + " #{escape("#{base_url}/#{post.slug}")}" + end.join("\n") + <<~XML + + + #{urls} + + 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 diff --git a/lib/volumen/store.rb b/lib/volumen/store.rb new file mode 100644 index 0000000..c729197 --- /dev/null +++ b/lib/volumen/store.rb @@ -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/.md` (default language) or at + # `content_dir//.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 diff --git a/lib/volumen/users.rb b/lib/volumen/users.rb new file mode 100644 index 0000000..ef261fa --- /dev/null +++ b/lib/volumen/users.rb @@ -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 diff --git a/lib/volumen/version.rb b/lib/volumen/version.rb new file mode 100644 index 0000000..af4da6e --- /dev/null +++ b/lib/volumen/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Volumen + VERSION = "0.1.0" +end diff --git a/lib/volumen/web/public/volumen-logo.svg b/lib/volumen/web/public/volumen-logo.svg new file mode 100644 index 0000000..2ef3dc8 --- /dev/null +++ b/lib/volumen/web/public/volumen-logo.svg @@ -0,0 +1,167 @@ + + + volumen — blog engine + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + volumen + + + + + + BLOG ENGINE + diff --git a/lib/volumen/web/views/form.erb b/lib/volumen/web/views/form.erb new file mode 100644 index 0000000..2848300 --- /dev/null +++ b/lib/volumen/web/views/form.erb @@ -0,0 +1,465 @@ +

<%= @mode == :new ? "New post" : "Edit post" %>

+ +<% if @error %> +

<%= h(@error) %>

+<% end %> + +
"> + + +
+ + + + + + + + +
+ +
+ +
+ + + +
+ + +
+ +
+ + +
+ + + +
+
+
+
+ + + + + + + + + +
+ +
+
+
Preview
+
+
+
+
+ + + +
+ + diff --git a/lib/volumen/web/views/layout.erb b/lib/volumen/web/views/layout.erb new file mode 100644 index 0000000..c6e3524 --- /dev/null +++ b/lib/volumen/web/views/layout.erb @@ -0,0 +1,254 @@ + + + + + + volumen admin + + + + +
+
+ <%= yield %> +
+
+ + diff --git a/lib/volumen/web/views/list.erb b/lib/volumen/web/views/list.erb new file mode 100644 index 0000000..8b580e9 --- /dev/null +++ b/lib/volumen/web/views/list.erb @@ -0,0 +1,34 @@ +
+

Posts

+ ">New post +
+ +<% if @posts.empty? %> +

No posts yet. Create your first one.

+<% else %> + + + + + + <% @posts.each do |post| %> + + + + + + + + + <% end %> + +
TitleSlugLangDateDraft
<%= h(post.title) %><%= h(post.slug) %><%= h(post.lang) %><%= h(post.date_string) %><%= post.draft? ? "yes" : "" %> + ">Edit +
" + onsubmit="return confirm('Delete this post?');"> + + +
+
+<% end %> diff --git a/lib/volumen/web/views/login.erb b/lib/volumen/web/views/login.erb new file mode 100644 index 0000000..80d7c3d --- /dev/null +++ b/lib/volumen/web/views/login.erb @@ -0,0 +1,23 @@ + diff --git a/lib/volumen/web/views/settings.erb b/lib/volumen/web/views/settings.erb new file mode 100644 index 0000000..b0bb5bb --- /dev/null +++ b/lib/volumen/web/views/settings.erb @@ -0,0 +1,86 @@ +

Settings

+ +<% if @notice %>

<%= h(@notice) %>

<% end %> +<% if @error %>

<%= h(@error) %>

<% end %> + +
+

Your account

+

Signed in as <%= h(current_user) %> (<%= h(current_role) %>).

+ +
"> + +

Change password

+ + + +
+ +
"> + +

Change username

+ + +
+
+ +<% if admin? %> +
+

Users

+ + + + <% @users.each do |user| %> + + + + + + <% end %> + +
UsernameRole
<%= h(user.username) %><%= user.username == current_user ? " (you)" : "" %> + <% if user.username == current_user %> + <%= h(user.role) %> + <% else %> +
"> + + +
+ <% end %> +
+ <% unless user.username == current_user %> +
" + onsubmit="return confirm('Remove this user?');"> + + +
+ <% end %> +
+ +
"> + +

Add user

+
+ + + +
+ +
+
+<% end %> diff --git a/posts/ahoj.md b/posts/ahoj.md new file mode 100644 index 0000000..2d6a7f1 --- /dev/null +++ b/posts/ahoj.md @@ -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). diff --git a/posts/hello.md b/posts/hello.md new file mode 100644 index 0000000..6a87b16 --- /dev/null +++ b/posts/hello.md @@ -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). diff --git a/test/admin_test.rb b/test/admin_test.rb new file mode 100644 index 0000000..51ea93a --- /dev/null +++ b/test/admin_test.rb @@ -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 diff --git a/test/config_test.rb b/test/config_test.rb new file mode 100644 index 0000000..bc18a6d --- /dev/null +++ b/test/config_test.rb @@ -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 diff --git a/test/frontmatter_test.rb b/test/frontmatter_test.rb new file mode 100644 index 0000000..d8497f8 --- /dev/null +++ b/test/frontmatter_test.rb @@ -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 diff --git a/test/markdown_test.rb b/test/markdown_test.rb new file mode 100644 index 0000000..d462940 --- /dev/null +++ b/test/markdown_test.rb @@ -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**"), "bold" + end + + def test_renders_heading + assert_includes Volumen::Markdown.render("# Title"), " "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"], "hi" + 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 diff --git a/test/server_test.rb b/test/server_test.rb new file mode 100644 index 0000000..36d174f --- /dev/null +++ b/test/server_test.rb @@ -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"], "world" + 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 diff --git a/test/store_test.rb b/test/store_test.rb new file mode 100644 index 0000000..85d1e1b --- /dev/null +++ b/test/store_test.rb @@ -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 diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..a98a924 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "volumen" diff --git a/test/users_test.rb b/test/users_test.rb new file mode 100644 index 0000000..a37b937 --- /dev/null +++ b/test/users_test.rb @@ -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 diff --git a/test/volumen_test.rb b/test/volumen_test.rb new file mode 100644 index 0000000..88ce4e9 --- /dev/null +++ b/test/volumen_test.rb @@ -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 diff --git a/volumen.gemspec b/volumen.gemspec new file mode 100644 index 0000000..aa8009a --- /dev/null +++ b/volumen.gemspec @@ -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