From dd3efe870e6c2e0371fa1e7a6755b7325b303fa5 Mon Sep 17 00:00:00 2001 From: Petr Date: Sun, 21 Jun 2026 13:15:06 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20release=20of=20volumen=20?= =?UTF-8?q?=E2=80=94=20a=20file-based=20Markdown=20blog=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 43 ++ .forgejo/workflows/release.yml | 129 ++++++ .forgejo/workflows/test.yml | 39 ++ .gitignore | 24 ++ .rubocop.yml | 32 ++ CHANGELOG.md | 96 +++++ CONTRIBUTING.md | 257 ++++++++++++ Gemfile | 13 + Gemfile.lock | 136 +++++++ LICENSE | 21 + README.md | 247 ++++++++++++ Rakefile | 12 + assets/volumen-icon.svg | 159 ++++++++ assets/volumen-logo.svg | 167 ++++++++ config.toml | 23 ++ docs/api-reference.md | 93 +++++ docs/architecture.md | 58 +++ docs/configuration.md | 52 +++ docs/deployment.md | 287 +++++++++++++ docs/security.md | 98 +++++ exe/volumen | 7 + install.sh | 178 ++++++++ justfile | 38 ++ lib/volumen.rb | 21 + lib/volumen/cli.rb | 87 ++++ lib/volumen/config.rb | 136 +++++++ lib/volumen/frontmatter.rb | 68 ++++ lib/volumen/markdown.rb | 23 ++ lib/volumen/password.rb | 52 +++ lib/volumen/post.rb | 119 ++++++ lib/volumen/server.rb | 513 ++++++++++++++++++++++++ lib/volumen/store.rb | 104 +++++ lib/volumen/users.rb | 128 ++++++ lib/volumen/version.rb | 5 + lib/volumen/web/public/volumen-logo.svg | 167 ++++++++ lib/volumen/web/views/form.erb | 465 +++++++++++++++++++++ lib/volumen/web/views/layout.erb | 254 ++++++++++++ lib/volumen/web/views/list.erb | 34 ++ lib/volumen/web/views/login.erb | 23 ++ lib/volumen/web/views/settings.erb | 86 ++++ posts/ahoj.md | 21 + posts/hello.md | 21 + test/admin_test.rb | 251 ++++++++++++ test/config_test.rb | 57 +++ test/frontmatter_test.rb | 47 +++ test/markdown_test.rb | 33 ++ test/password_test.rb | 24 ++ test/post_test.rb | 59 +++ test/server_test.rb | 95 +++++ test/store_test.rb | 52 +++ test/test_helper.rb | 4 + test/users_test.rb | 114 ++++++ test/volumen_test.rb | 13 + volumen.gemspec | 39 ++ 54 files changed, 5324 insertions(+) create mode 100644 .editorconfig create mode 100644 .forgejo/workflows/release.yml create mode 100644 .forgejo/workflows/test.yml create mode 100644 .gitignore create mode 100644 .rubocop.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 LICENSE create mode 100644 README.md create mode 100644 Rakefile create mode 100644 assets/volumen-icon.svg create mode 100644 assets/volumen-logo.svg create mode 100644 config.toml create mode 100644 docs/api-reference.md create mode 100644 docs/architecture.md create mode 100644 docs/configuration.md create mode 100644 docs/deployment.md create mode 100644 docs/security.md create mode 100755 exe/volumen create mode 100755 install.sh create mode 100644 justfile create mode 100644 lib/volumen.rb create mode 100644 lib/volumen/cli.rb create mode 100644 lib/volumen/config.rb create mode 100644 lib/volumen/frontmatter.rb create mode 100644 lib/volumen/markdown.rb create mode 100644 lib/volumen/password.rb create mode 100644 lib/volumen/post.rb create mode 100644 lib/volumen/server.rb create mode 100644 lib/volumen/store.rb create mode 100644 lib/volumen/users.rb create mode 100644 lib/volumen/version.rb create mode 100644 lib/volumen/web/public/volumen-logo.svg create mode 100644 lib/volumen/web/views/form.erb create mode 100644 lib/volumen/web/views/layout.erb create mode 100644 lib/volumen/web/views/list.erb create mode 100644 lib/volumen/web/views/login.erb create mode 100644 lib/volumen/web/views/settings.erb create mode 100644 posts/ahoj.md create mode 100644 posts/hello.md create mode 100644 test/admin_test.rb create mode 100644 test/config_test.rb create mode 100644 test/frontmatter_test.rb create mode 100644 test/markdown_test.rb create mode 100644 test/password_test.rb create mode 100644 test/post_test.rb create mode 100644 test/server_test.rb create mode 100644 test/store_test.rb create mode 100644 test/test_helper.rb create mode 100644 test/users_test.rb create mode 100644 test/volumen_test.rb create mode 100644 volumen.gemspec 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