feat: initial release of volumen — a file-based Markdown blog engine
This commit is contained in:
@@ -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
|
||||
@@ -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}."
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [development]
|
||||
pull_request:
|
||||
branches: [development]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: codeberg-small
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- uses: https://github.com/ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: "3.4"
|
||||
bundler-cache: true
|
||||
|
||||
- name: RuboCop
|
||||
run: bundle exec rubocop
|
||||
|
||||
test:
|
||||
runs-on: codeberg-small
|
||||
needs: lint
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
ruby-version: ["3.3", "3.4"]
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- uses: https://github.com/ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: ${{ matrix.ruby-version }}
|
||||
bundler-cache: true
|
||||
|
||||
- name: Tests
|
||||
run: bundle exec rake test
|
||||
+24
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,96 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to **volumen** are documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.0] — 2026-06-20
|
||||
|
||||
First public release: a small, file-based Markdown blog engine in CRuby. Posts
|
||||
are plain files, the engine exposes them as a JSON API and ships a self-hosted
|
||||
admin — no database, no external services.
|
||||
|
||||
### Added
|
||||
|
||||
**Content & storage**
|
||||
|
||||
- File-based store: each post is a `.md` file with a TOML frontmatter block
|
||||
delimited by `+++`. No database.
|
||||
- Post fields: `title`, `slug`, `date`, `lang`, `author`, `tags`, `draft`,
|
||||
`excerpt`, `cover`, `all_langs`, and `translations` (a `lang → slug` map).
|
||||
- Drafts (`draft = true`) are hidden from the public API.
|
||||
- Excerpt falls back to the first paragraph (~200 chars) when none is set.
|
||||
- Estimated reading time (~200 words/min) computed per post.
|
||||
- Multilingual posts: per-language slugs via `translations`, plus `all_langs`
|
||||
to surface a single post under every language.
|
||||
|
||||
**Markdown rendering**
|
||||
|
||||
- GFM rendering via kramdown: tables, fenced code blocks, task lists, and
|
||||
strikethrough.
|
||||
- Automatic heading IDs for in-page anchors.
|
||||
- Raw HTML in post bodies is passed through (authors are the trusted admin).
|
||||
|
||||
**Public JSON API (`/api/volumen`)**
|
||||
|
||||
- Read-only endpoints: `site`, `posts`, `posts/{slug}`, `tags`, `feed.xml`
|
||||
(RSS), and `sitemap.xml`.
|
||||
- `posts` supports pagination (`page`, `limit`, returning `total` and
|
||||
`page_size`) and filtering by `lang` and `tag`.
|
||||
- Permissive CORS so a separate front end can consume the API.
|
||||
|
||||
**Admin (`/admin`)**
|
||||
|
||||
- Server-rendered admin with a sidebar layout and full post CRUD.
|
||||
- Dual-mode editor: Markdown source (primary) and a visual WYSIWYG, switchable
|
||||
per post, with a toggleable live preview persisted across sessions.
|
||||
- Image upload from the editor toolbar into `content_dir/media`; any image
|
||||
format is accepted (WebP and AVIF included) and served with the correct
|
||||
`Content-Type`.
|
||||
- Cover image field with upload, URL, clear, and preview.
|
||||
|
||||
**Multi-user & roles**
|
||||
|
||||
- Multiple admin users stored in `users.toml`, managed from a settings page.
|
||||
- Two roles: `admin` (full access incl. user management) and `author` (posts
|
||||
only).
|
||||
- Change password and username; last-admin and self-deletion safeguards.
|
||||
|
||||
**Configuration & CLI**
|
||||
|
||||
- TOML configuration, auto-generated with a commented template on first run.
|
||||
- Configurable `host`/`port` (defaults to `9090`), `content_dir`,
|
||||
`users_file`, and `site` metadata.
|
||||
- CLI: `serve`, `hash-password`, `version`, `help`, with `--config`,
|
||||
`--content`, `--host`, and `--port` overrides.
|
||||
|
||||
**Deployment**
|
||||
|
||||
- `install.sh` installs the Ruby toolchain from distro packages on Fedora and
|
||||
openEuler (and prints guidance on other distros), creates the service user
|
||||
and directories, and installs a systemd unit.
|
||||
- Deployment docs for systemd and FreeBSD (`rc.d`), plus an nginx reverse-proxy
|
||||
setup that runs the admin same-origin, with brute-force rate-limiting and an
|
||||
optional IP allowlist on the login endpoint.
|
||||
|
||||
**Project & tooling**
|
||||
|
||||
- Documentation set: architecture, configuration, API reference, deployment,
|
||||
and security.
|
||||
- `just` task set (`install`, `run`, `dev`, `build`, `test`, `uninstall`) and
|
||||
Forgejo Actions CI (RuboCop + tests on Ruby 3.3/3.4, gem build on release).
|
||||
- `CONTRIBUTING.md` and a minitest suite; clean RuboCop.
|
||||
|
||||
### Security
|
||||
|
||||
- Passwords hashed with scrypt and verified in constant time.
|
||||
- Per-form CSRF tokens on every state-changing admin request.
|
||||
- Session cookies are `HttpOnly` and `SameSite=Strict`, and gain the `Secure`
|
||||
attribute automatically on HTTPS requests (detected via `request.ssl?` /
|
||||
`X-Forwarded-Proto`), so the cookie never travels over plain HTTP in
|
||||
production.
|
||||
- Configurable, stable `session_key` so sessions survive restarts.
|
||||
- Generic login error message to avoid username enumeration.
|
||||
|
||||
[0.1.0]: https://codeberg.org/petrbalvin/volumen/releases/tag/v0.1.0
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
# Contributing
|
||||
|
||||
Thank you for considering contributing to **volumen** — a small, file-based
|
||||
Markdown blog engine written in CRuby, with a built-in admin and a public JSON
|
||||
API.
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Requirements
|
||||
|
||||
- **Ruby ≥ 3.3** with a C toolchain (Puma compiles a native extension).
|
||||
- Fedora: `sudo dnf install ruby ruby-devel @development-tools openssl-devel`
|
||||
- Debian/Ubuntu: `sudo apt install ruby-full build-essential libssl-dev`
|
||||
- [`just`](https://github.com/casey/just) — the `justfile` is the source of
|
||||
truth for install / run / build / test / uninstall.
|
||||
- Linux, macOS, or FreeBSD.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://codeberg.org/petrbalvin/volumen.git
|
||||
cd volumen
|
||||
just install # bundle install
|
||||
just test # bundle exec rake test (minitest)
|
||||
just build # rubocop (zero warnings) + gem build → pkg/volumen.gem
|
||||
just dev # run against ./posts and ./config.toml on :9090
|
||||
```
|
||||
|
||||
`just dev` serves the bundled sample posts and a local `config.toml`. The dev
|
||||
admin password is `admin` (the hash lives in `./config.toml`). Open
|
||||
<http://localhost:9090/admin/>.
|
||||
|
||||
For a fresh `serve` (without `--config`), the first run writes a commented
|
||||
template to `/etc/volumen/config.toml`; see [`docs/deployment.md`](docs/deployment.md).
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
just test
|
||||
```
|
||||
|
||||
The minitest suite (`test/`) covers:
|
||||
|
||||
- `frontmatter` — `+++` TOML block parse/serialise round-trips.
|
||||
- `post` / `store` — the Post model, reading/writing posts and media.
|
||||
- `markdown` — GFM rendering (tables, fenced code).
|
||||
- `config` — defaults, overrides, template generation.
|
||||
- `password` / `users` — scrypt hashing, roles, last-admin safeguards.
|
||||
- `server` / `admin` — the JSON API and the admin (auth, CSRF, CRUD, uploads,
|
||||
roles) via `rack-test`.
|
||||
|
||||
### Linting
|
||||
|
||||
RuboCop is the source of truth for style (`.rubocop.yml`). The project enforces
|
||||
**zero offenses**:
|
||||
|
||||
```bash
|
||||
bundle exec rubocop # check (also run as part of `just build`)
|
||||
bundle exec rubocop -A # autocorrect
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
- Idiomatic Ruby; let RuboCop guide you. CI fails on any offense.
|
||||
- **Two-space** indentation, **double quotes**, and `# frozen_string_literal: true`
|
||||
at the top of every Ruby file.
|
||||
- Document every class/module with a short comment (`Style/Documentation` is on).
|
||||
- Keep lines ≤ 100 characters (`Layout/LineLength`).
|
||||
- Prefer guard clauses; return `nil`/objects for expected "not found" cases
|
||||
rather than raising.
|
||||
- Keep the dependency set small and deliberate — runtime gems are `sinatra`,
|
||||
`kramdown` (+ `kramdown-parser-gfm`), `toml-rb`, `puma`, `rackup`. Crypto uses
|
||||
the standard library (`openssl`, `securerandom`). New runtime dependencies
|
||||
should be discussed in an issue first.
|
||||
- Admin content is authored by trusted users; see [`docs/security.md`](docs/security.md)
|
||||
for the trust model before touching rendering or uploads.
|
||||
|
||||
### Testing
|
||||
|
||||
- Put tests in `test/*_test.rb`, each starting with `require "test_helper"`.
|
||||
- Use `Dir.mktmpdir` for anything touching the filesystem; never assume a clean
|
||||
working directory.
|
||||
- Exercise the HTTP layer with `Rack::Test` (`Volumen::Server.configured(...)`).
|
||||
- Name tests `test_<thing>_<scenario>`; cover both the happy and the error path.
|
||||
|
||||
## Commit Conventions
|
||||
|
||||
We follow [Conventional Commits](https://www.conventionalcommits.org/).
|
||||
|
||||
| Type | Use |
|
||||
|------------|----------------------------------------------|
|
||||
| `feat` | New feature |
|
||||
| `fix` | Bug fix |
|
||||
| `refactor` | Restructuring without behaviour change |
|
||||
| `docs` | Documentation |
|
||||
| `test` | Adding or updating tests |
|
||||
| `chore` | Maintenance, CI, tooling |
|
||||
| `perf` | Performance improvement |
|
||||
| `style` | Formatting / lint-only changes |
|
||||
| `security` | Hardening without a user-visible fix |
|
||||
|
||||
```
|
||||
feat: add author role with last-admin safeguards
|
||||
fix: rename Server.build to .configured to avoid a Sinatra clash
|
||||
refactor: extract frontmatter parsing into its own module
|
||||
docs: document the dual-mode editor
|
||||
test: cover GFM table and fenced code rendering
|
||||
chore: pin gem versions to latest
|
||||
```
|
||||
|
||||
- English, lowercase subject, imperative mood (`add`, not `added`).
|
||||
- No trailing period, max 72 characters.
|
||||
- No version numbers in commit messages — versions belong to tags.
|
||||
|
||||
## Pull Request Flow
|
||||
|
||||
1. Create a feature branch from `development`.
|
||||
2. Make your changes with Conventional-Commits messages.
|
||||
3. Ensure `bundle exec rubocop` reports zero offenses and `just test` passes.
|
||||
4. Add or update tests for your change.
|
||||
5. Update documentation if the public API, configuration, or behaviour changes:
|
||||
- `README.md` — high-level overview.
|
||||
- `docs/configuration.md` — config keys.
|
||||
- `docs/api-reference.md` — JSON API behaviour.
|
||||
- `docs/architecture.md` — structural changes.
|
||||
- `docs/security.md` — anything touching auth, uploads, or rendering.
|
||||
6. Open a pull request against `development`. Releases are cut by merging
|
||||
`development` into `main` and pushing a `vX.Y.Z` tag — see
|
||||
[Cutting a release](#cutting-a-release).
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
volumen/
|
||||
├── exe/
|
||||
│ └── volumen # CLI: serve, hash-password, version, help
|
||||
├── lib/
|
||||
│ ├── volumen.rb # library entry (requires the core modules)
|
||||
│ └── volumen/
|
||||
│ ├── version.rb
|
||||
│ ├── frontmatter.rb # parse/serialise the +++ TOML frontmatter block
|
||||
│ ├── markdown.rb # kramdown (GFM) → HTML
|
||||
│ ├── post.rb # Post model (metadata + body + rendered HTML)
|
||||
│ ├── store.rb # read/write posts and media on disk
|
||||
│ ├── config.rb # load/merge config.toml, generate template
|
||||
│ ├── password.rb # scrypt hashing/verification (OpenSSL::KDF)
|
||||
│ ├── users.rb # users.toml, admin/author roles
|
||||
│ ├── server.rb # Sinatra app: public API + admin
|
||||
│ ├── cli.rb # command dispatcher
|
||||
│ └── web/
|
||||
│ ├── views/ # ERB templates (layout, login, list, form, settings)
|
||||
│ └── public/ # static assets (volumen-logo.svg)
|
||||
├── test/ # minitest suite
|
||||
├── docs/ # architecture, configuration, api-reference, deployment, security
|
||||
├── .forgejo/workflows/ # test.yml, release.yml (Forgejo Actions)
|
||||
├── justfile # install, run, dev, build, test, uninstall
|
||||
├── volumen.gemspec
|
||||
├── Gemfile
|
||||
├── CHANGELOG.md
|
||||
└── LICENSE
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Browser[Browser / Vue SPA]
|
||||
Nginx[nginx reverse proxy]
|
||||
Server[Volumen::Server -- Sinatra]
|
||||
Store[Volumen::Store]
|
||||
Markdown[Volumen::Markdown -- kramdown GFM]
|
||||
Users[Volumen::Users]
|
||||
Posts[(content_dir/*.md)]
|
||||
UsersFile[(users.toml)]
|
||||
|
||||
Browser -->|GET /api/volumen/*| Nginx
|
||||
Browser -->|/admin| Nginx
|
||||
Nginx --> Server
|
||||
Server -->|read/write posts| Store
|
||||
Server -->|render Markdown| Markdown
|
||||
Server -->|auth + roles| Users
|
||||
Store --> Posts
|
||||
Users --> UsersFile
|
||||
```
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
- **File-based, no database** — every post is a `.md` file with TOML
|
||||
frontmatter; users live in a `users.toml`. Everything is plain text, safe to
|
||||
commit and back up.
|
||||
- **Headless for the front-end** — volumen serves a read-only JSON API and its
|
||||
own server-rendered admin; the public site is a separate consumer.
|
||||
- **Single universal gem** — volumen is pure Ruby, so `gem build` produces one
|
||||
platform-independent gem. Only the `puma` dependency has a native extension,
|
||||
compiled at install time on the host.
|
||||
- **Trusted authors** — kramdown passes raw HTML through on purpose; content is
|
||||
only as safe as who can log in (see `docs/security.md`).
|
||||
- **Roles, server-enforced** — `admin` manages users, `author` manages posts
|
||||
and their own account; `require_admin!` gates user management on the server,
|
||||
with last-admin safeguards.
|
||||
|
||||
## CI
|
||||
|
||||
volumen uses **Forgejo Actions**; both workflows live under `.forgejo/workflows/`
|
||||
and run on the `codeberg-small` runner.
|
||||
|
||||
### Test — `.forgejo/workflows/test.yml`
|
||||
|
||||
Triggered on push and pull requests targeting `development`.
|
||||
|
||||
| Job | What it does |
|
||||
|--------|-----------------------------------------------|
|
||||
| `lint` | `bundle exec rubocop` (must report zero offenses) |
|
||||
| `test` | `bundle exec rake test` on a Ruby `3.3` / `3.4` matrix |
|
||||
|
||||
Run the same locally before opening a PR:
|
||||
|
||||
```bash
|
||||
bundle exec rubocop
|
||||
just test
|
||||
```
|
||||
|
||||
### Release — `.forgejo/workflows/release.yml`
|
||||
|
||||
Triggered by pushing a `v*` tag. It verifies the tag matches `Volumen::VERSION`,
|
||||
runs the quality gate (rubocop + tests), builds `volumen-X.Y.Z.gem`, extracts
|
||||
the matching `CHANGELOG.md` section, creates a Forgejo release via the REST API,
|
||||
and attaches the gem. It does **not** publish to RubyGems.
|
||||
|
||||
Requires a `FORGEJO_TOKEN` secret with permission to create releases.
|
||||
|
||||
### Cutting a release
|
||||
|
||||
1. Bump `VERSION` in `lib/volumen/version.rb`.
|
||||
2. Add a `## [X.Y.Z] — YYYY-MM-DD` section at the top of `CHANGELOG.md`.
|
||||
3. Ensure CI is green on `development`.
|
||||
4. Merge `development` into `main` (the only time `main` changes outside a tag).
|
||||
5. Tag and push:
|
||||
```bash
|
||||
git tag -a vX.Y.Z -m "volumen vX.Y.Z"
|
||||
git push origin main
|
||||
git push origin vX.Y.Z
|
||||
```
|
||||
6. The release workflow builds the gem and publishes the release from the
|
||||
CHANGELOG section.
|
||||
|
||||
## Report a Bug
|
||||
|
||||
Open an issue at <https://codeberg.org/petrbalvin/volumen/issues> with:
|
||||
|
||||
- volumen version (`volumen version`).
|
||||
- Operating system and architecture.
|
||||
- Exact steps (request or admin action) that reproduce it.
|
||||
- Expected vs actual behaviour, and the relevant slice of `journalctl -u volumen`.
|
||||
|
||||
For **security issues**, please email **opensource@petrbalvin.org** rather than
|
||||
opening a public issue.
|
||||
@@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
source "https://rubygems.org"
|
||||
|
||||
# Runtime dependencies are declared in the gemspec.
|
||||
gemspec
|
||||
|
||||
group :development, :test do
|
||||
gem "minitest", "~> 6.0"
|
||||
gem "rack-test", "~> 2.2"
|
||||
gem "rake", "~> 13.4"
|
||||
gem "rubocop", "~> 1.88", require: false
|
||||
end
|
||||
+136
@@ -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
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://www.petrbalvin.org)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,247 @@
|
||||
# volumen — file-based Markdown blog engine
|
||||
|
||||
**volumen** is a small, dependency-light blog engine written in **Ruby**. It
|
||||
serves your Markdown posts as a JSON API and includes a built-in, server-
|
||||
rendered admin with both a Markdown source editor and a visual editor — so any
|
||||
front-end (Vue, React, Svelte, plain HTML) can consume your content without
|
||||
re-implementing the engine.
|
||||
|
||||
It is designed to be:
|
||||
|
||||
- **File-based** — every post is a plain `.md` file with TOML frontmatter, safe
|
||||
to commit to Git and edit in your favourite editor.
|
||||
- **Self-contained** — runs as a single Ruby process; no Node build step is
|
||||
required to operate the engine or its admin.
|
||||
- **Multi-user with roles** — username + password login; **admin** (full
|
||||
access, manages users) and **author** (posts and own account) roles.
|
||||
- **Multi-site friendly** — one instance per blog, configurable per instance.
|
||||
- **Markdown-first** — the visual editor round-trips through the same renderer
|
||||
that powers the public API, so what you store is always Markdown.
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
| Concern | Choice |
|
||||
|----------------|------------------------------------------|
|
||||
| Language | CRuby (≥ 3.2) |
|
||||
| Web framework | [Sinatra](https://sinatrarb.com) |
|
||||
| Markdown | [kramdown](https://kramdown.gettalong.org) |
|
||||
| Frontmatter | TOML via [`toml-rb`](https://github.com/emancu/toml-rb) |
|
||||
| App server | [Puma](https://puma.io) |
|
||||
| Packaging | a Ruby gem with an `exe/volumen` CLI |
|
||||
|
||||
---
|
||||
|
||||
## Features (target)
|
||||
|
||||
- Markdown posts with TOML frontmatter (`title`, `date`, `lang`, `tags`,
|
||||
`draft`, `translations`, …)
|
||||
- Multi-language posts with a translations map
|
||||
- Clean JSON API at `/api/volumen/`:
|
||||
- `GET /site` — site metadata
|
||||
- `GET /posts` — paginated list with `lang`, `tag`, `page`, `limit` filters
|
||||
- `GET /posts/{slug}` — single post (raw Markdown + rendered HTML)
|
||||
- `GET /tags` — tag cloud with counts
|
||||
- `GET /feed.xml` — RSS 2.0 feed
|
||||
- `GET /sitemap.xml` — sitemap
|
||||
- Server-rendered admin at `/admin/`:
|
||||
- Username + password login (scrypt-hashed via Ruby's `OpenSSL::KDF`)
|
||||
- Settings page: change password, change username, manage users and roles
|
||||
(admin only)
|
||||
- Session cookies (HttpOnly, SameSite=Strict) + CSRF tokens
|
||||
- List, create, edit, delete posts
|
||||
- **Markdown source** editor and a **visual** editor with live preview, both
|
||||
storing Markdown
|
||||
- "Reload from disk" to pick up out-of-band edits
|
||||
- Permissive CORS for the public API, same-origin only for the admin
|
||||
|
||||
> Status: the public read API (`/api/volumen`) and the server-rendered admin
|
||||
> (`/admin`, login + CRUD + Markdown editor with live preview) are implemented
|
||||
> and tested. See [`docs/api-reference.md`](docs/api-reference.md).
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# 1. Install dependencies (Ruby ≥ 3.2)
|
||||
just install
|
||||
|
||||
# 2. Run
|
||||
just run
|
||||
```
|
||||
|
||||
The admin will live at <http://localhost:9090/admin/> and the API at
|
||||
<http://localhost:9090/api/volumen/posts>.
|
||||
|
||||
---
|
||||
|
||||
## Post format
|
||||
|
||||
Each post is a Markdown file with a TOML frontmatter block delimited by `+++`:
|
||||
|
||||
```markdown
|
||||
+++
|
||||
title = "My post title"
|
||||
slug = "my-post" # optional, defaults to the filename
|
||||
date = 2026-01-15 # first-class TOML date
|
||||
lang = "en" # language code
|
||||
author = "Petr Balvín" # optional
|
||||
tags = ["ruby", "web"] # optional
|
||||
draft = false # optional
|
||||
excerpt = "Short summary" # optional, auto-derived from body if missing
|
||||
all_langs = false # optional, show this post in every language
|
||||
|
||||
[translations] # optional, maps lang → slug
|
||||
cs = "muj-prispevek"
|
||||
+++
|
||||
|
||||
# Heading
|
||||
|
||||
Body in **Markdown**, rendered by kramdown.
|
||||
```
|
||||
|
||||
Posts are organised on disk as either:
|
||||
|
||||
- `content_dir/<slug>.md` (default language), or
|
||||
- `content_dir/<lang>/<slug>.md` (per-language subdirectory)
|
||||
|
||||
The `translations` table links posts in different languages together so a
|
||||
front-end can offer a language switcher.
|
||||
|
||||
Set `all_langs = true` on a post to show it in **every** language (useful for
|
||||
posts that have no per-language translations).
|
||||
|
||||
### Why TOML instead of YAML
|
||||
|
||||
- **First-class dates** — `date = 2026-01-15` is a real date, not a guess.
|
||||
- **Explicit typing** — no YAML implicit-coercion footguns (`lang = no`
|
||||
becoming `false`).
|
||||
- **No whitespace pitfalls** — indentation is not significant.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
A single TOML file, auto-generated at `/etc/volumen/config.toml` on first
|
||||
start. See [`docs/configuration.md`](docs/configuration.md) for the full
|
||||
schema.
|
||||
|
||||
```toml
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 9090
|
||||
|
||||
content_dir = "/var/lib/volumen/posts"
|
||||
|
||||
[site]
|
||||
title = "My Blog"
|
||||
description = "A blog powered by volumen."
|
||||
base_url = "https://example.com"
|
||||
language = "en"
|
||||
author = "Anonymous"
|
||||
|
||||
[admin]
|
||||
password_hash = ""
|
||||
session_key = ""
|
||||
session_ttl = 86400
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Public API
|
||||
|
||||
The API is at `/api/volumen/`. CORS is open (`*`) for read endpoints; admin routes
|
||||
are same-origin only.
|
||||
|
||||
### `GET /api/volumen/posts`
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|-------|--------|---------|----------------------|
|
||||
| lang | string | — | Filter by language |
|
||||
| tag | string | — | Filter by tag |
|
||||
| page | int | 1 | Page number |
|
||||
| limit | int | 20 | Posts per page |
|
||||
|
||||
```json
|
||||
{
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"total": 42,
|
||||
"posts": [
|
||||
{
|
||||
"slug": "welcome",
|
||||
"title": "Welcome to volumen",
|
||||
"excerpt": "A short introduction…",
|
||||
"date": "2026-01-15",
|
||||
"lang": "en",
|
||||
"tags": ["intro", "ruby"],
|
||||
"author": "Petr Balvín",
|
||||
"translations": { "cs": "vitame-vas" },
|
||||
"url": "/api/volumen/posts/welcome"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/volumen/posts/{slug}?lang=en`
|
||||
|
||||
Returns the full post including the raw Markdown body and the rendered HTML.
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "welcome",
|
||||
"title": "Welcome to volumen",
|
||||
"date": "2026-01-15",
|
||||
"lang": "en",
|
||||
"tags": ["intro", "ruby"],
|
||||
"excerpt": "…",
|
||||
"body": "# Welcome to **volumen**\n\n…",
|
||||
"html": "<h1>Welcome to <strong>volumen</strong></h1>…",
|
||||
"translations": { "cs": "vitame-vas" }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
just # list recipes
|
||||
just install # bundle install
|
||||
just run # run the dev server (exe/volumen serve)
|
||||
just build # rubocop (zero warnings) + build the gem → pkg/volumen.gem
|
||||
just test # bundle exec rake test (minitest)
|
||||
just uninstall # remove build artifacts (pkg/, *.gem)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Public site integration
|
||||
|
||||
volumen is headless for the front-end: it does not render your public site, it
|
||||
only renders its own admin. The public website (e.g.
|
||||
[`petrbalvin.org`](https://petrbalvin.org), a Vue 3 + Vite + Tailwind SPA)
|
||||
consumes the JSON API. If you proxy `/api/volumen/*` through nginx on the same
|
||||
origin, no front-end configuration is required.
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`docs/architecture.md`](docs/architecture.md) — components and request flow
|
||||
- [`docs/configuration.md`](docs/configuration.md) — config schema (TOML)
|
||||
- [`docs/api-reference.md`](docs/api-reference.md) — public JSON API
|
||||
- [`docs/deployment.md`](docs/deployment.md) — systemd, nginx, first run
|
||||
- [`docs/security.md`](docs/security.md) — threat model and controls
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, code style,
|
||||
commit conventions, the pull request flow, and how releases are cut.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
Copyright © 2026 [Petr Balvín](https://petrbalvin.org)
|
||||
@@ -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
|
||||
@@ -0,0 +1,159 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="volumen">
|
||||
<title>volumen</title>
|
||||
|
||||
<defs>
|
||||
<!-- Background gradient: deep night sky -->
|
||||
<radialGradient id="bg" cx="50%" cy="35%" r="80%">
|
||||
<stop offset="0%" stop-color="#4a5bd8"/>
|
||||
<stop offset="55%" stop-color="#2a2d6e"/>
|
||||
<stop offset="100%" stop-color="#15163a"/>
|
||||
</radialGradient>
|
||||
|
||||
<!-- Subtle inner ring gradient -->
|
||||
<linearGradient id="ring" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#8aa0ff" stop-opacity="0.9"/>
|
||||
<stop offset="100%" stop-color="#3b3f8a" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Glossy highlight at the top -->
|
||||
<radialGradient id="gloss" cx="50%" cy="15%" r="55%">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.35"/>
|
||||
<stop offset="60%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
|
||||
<!-- Wave stroke gradient -->
|
||||
<linearGradient id="wave" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#7afcff" stop-opacity="0.15"/>
|
||||
<stop offset="50%" stop-color="#7afcff" stop-opacity="0.95"/>
|
||||
<stop offset="100%" stop-color="#7afcff" stop-opacity="0.15"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="waveDim" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.35"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Central V fill gradient -->
|
||||
<linearGradient id="vFill" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#fdfdff"/>
|
||||
<stop offset="100%" stop-color="#b8c4ff"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Soft drop shadow -->
|
||||
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
|
||||
<feOffset dx="0" dy="2" result="off"/>
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.5"/>
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<!-- Outer glow for the disc -->
|
||||
<filter id="outerGlow" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feGaussianBlur stdDeviation="4"/>
|
||||
</filter>
|
||||
|
||||
<!-- Reusable wave path (sinusoidal, centered around y=128) -->
|
||||
<path id="wavePath"
|
||||
d="M 36 128
|
||||
C 56 96, 76 96, 96 128
|
||||
S 136 160, 156 128
|
||||
S 196 96, 216 128"
|
||||
fill="none"/>
|
||||
</defs>
|
||||
|
||||
<!-- ===== Outer glow halo ===== -->
|
||||
<circle cx="128" cy="128" r="116" fill="url(#bg)" opacity="0.55" filter="url(#outerGlow)"/>
|
||||
|
||||
<!-- ===== Main disc ===== -->
|
||||
<circle cx="128" cy="128" r="112" fill="url(#bg)"/>
|
||||
|
||||
<!-- Inner decorative ring -->
|
||||
<circle cx="128" cy="128" r="104" fill="none" stroke="url(#ring)" stroke-width="1.2" opacity="0.7"/>
|
||||
|
||||
<!-- Dotted ring (tick marks) -->
|
||||
<g fill="#8aa0ff" opacity="0.55">
|
||||
<circle cx="128" cy="22" r="1.4"/>
|
||||
<circle cx="180" cy="32" r="1.2"/>
|
||||
<circle cx="220" cy="62" r="1.4"/>
|
||||
<circle cx="234" cy="110" r="1.2"/>
|
||||
<circle cx="234" cy="146" r="1.4"/>
|
||||
<circle cx="220" cy="194" r="1.2"/>
|
||||
<circle cx="180" cy="224" r="1.4"/>
|
||||
<circle cx="128" cy="234" r="1.2"/>
|
||||
<circle cx="76" cy="224" r="1.4"/>
|
||||
<circle cx="36" cy="194" r="1.2"/>
|
||||
<circle cx="22" cy="146" r="1.4"/>
|
||||
<circle cx="22" cy="110" r="1.2"/>
|
||||
<circle cx="36" cy="62" r="1.4"/>
|
||||
<circle cx="76" cy="32" r="1.2"/>
|
||||
</g>
|
||||
|
||||
<!-- ===== Waves (background layer) ===== -->
|
||||
<g stroke-linecap="round" fill="none">
|
||||
<use href="#wavePath" stroke="url(#waveDim)" stroke-width="1.2"
|
||||
transform="translate(0,-46)" opacity="0.6"/>
|
||||
<use href="#wavePath" stroke="url(#waveDim)" stroke-width="1.2"
|
||||
transform="translate(0,46)" opacity="0.6"/>
|
||||
</g>
|
||||
|
||||
<!-- ===== Central stylized "V" ===== -->
|
||||
<g filter="url(#softShadow)">
|
||||
<!-- Main V shape, geometric, with a small notch at the bottom for a leaf/quill hint -->
|
||||
<path d="
|
||||
M 78 70
|
||||
L 110 70
|
||||
L 128 142
|
||||
L 146 70
|
||||
L 178 70
|
||||
L 138 186
|
||||
L 118 186
|
||||
Z"
|
||||
fill="url(#vFill)"/>
|
||||
|
||||
<!-- Inner accent line on the V -->
|
||||
<path d="M 118 186 L 128 142 L 138 186"
|
||||
fill="none" stroke="#2a2d6e" stroke-width="1.2" stroke-linejoin="round" opacity="0.35"/>
|
||||
|
||||
<!-- Subtle highlight stripe on the left arm of V -->
|
||||
<path d="M 82 74 L 108 74 L 100 100 Z"
|
||||
fill="#ffffff" opacity="0.25"/>
|
||||
</g>
|
||||
|
||||
<!-- ===== Foreground waves crossing the V ===== -->
|
||||
<g stroke-linecap="round" fill="none">
|
||||
<use href="#wavePath" stroke="url(#wave)" stroke-width="3.2" opacity="0.9"/>
|
||||
<use href="#wavePath" stroke="url(#wave)" stroke-width="1.6"
|
||||
transform="translate(0,18)" opacity="0.7"/>
|
||||
<use href="#wavePath" stroke="url(#wave)" stroke-width="1.6"
|
||||
transform="translate(0,-18)" opacity="0.7"/>
|
||||
</g>
|
||||
|
||||
<!-- ===== Sparkles / dots ===== -->
|
||||
<g fill="#ffffff">
|
||||
<circle cx="60" cy="92" r="1.8" opacity="0.95"/>
|
||||
<circle cx="196" cy="92" r="1.6" opacity="0.85"/>
|
||||
<circle cx="200" cy="168" r="1.4" opacity="0.8"/>
|
||||
<circle cx="58" cy="172" r="1.4" opacity="0.8"/>
|
||||
<circle cx="156" cy="58" r="1.2" opacity="0.7"/>
|
||||
<circle cx="100" cy="206" r="1.2" opacity="0.7"/>
|
||||
</g>
|
||||
|
||||
<!-- Four-point sparkle (north star) -->
|
||||
<g transform="translate(70,70)" fill="#ffffff" opacity="0.9">
|
||||
<path d="M 0 -7 L 1.4 -1.4 L 7 0 L 1.4 1.4 L 0 7 L -1.4 1.4 L -7 0 L -1.4 -1.4 Z"/>
|
||||
</g>
|
||||
<g transform="translate(190,190) scale(0.7)" fill="#ffffff" opacity="0.8">
|
||||
<path d="M 0 -7 L 1.4 -1.4 L 7 0 L 1.4 1.4 L 0 7 L -1.4 1.4 L -7 0 L -1.4 -1.4 Z"/>
|
||||
</g>
|
||||
|
||||
<!-- ===== Glossy top highlight ===== -->
|
||||
<circle cx="128" cy="128" r="112" fill="url(#gloss)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.8 KiB |
@@ -0,0 +1,167 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 160" role="img" aria-label="volumen — blog engine">
|
||||
<title>volumen — blog engine</title>
|
||||
|
||||
<defs>
|
||||
<!-- Background gradient: deep night sky -->
|
||||
<radialGradient id="bg" cx="50%" cy="35%" r="80%">
|
||||
<stop offset="0%" stop-color="#4a5bd8"/>
|
||||
<stop offset="55%" stop-color="#2a2d6e"/>
|
||||
<stop offset="100%" stop-color="#15163a"/>
|
||||
</radialGradient>
|
||||
|
||||
<linearGradient id="ring" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#8aa0ff" stop-opacity="0.9"/>
|
||||
<stop offset="100%" stop-color="#3b3f8a" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
|
||||
<radialGradient id="gloss" cx="50%" cy="15%" r="55%">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.35"/>
|
||||
<stop offset="60%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
|
||||
<linearGradient id="wave" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#7afcff" stop-opacity="0.15"/>
|
||||
<stop offset="50%" stop-color="#7afcff" stop-opacity="0.95"/>
|
||||
<stop offset="100%" stop-color="#7afcff" stop-opacity="0.15"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="waveDim" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.35"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="vFill" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#fdfdff"/>
|
||||
<stop offset="100%" stop-color="#b8c4ff"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Wordmark gradient: echoes the icon background -->
|
||||
<linearGradient id="wordmark" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#2a2d6e"/>
|
||||
<stop offset="100%" stop-color="#4a5bd8"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="wordmarkAccent" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#2a2d6e" stop-opacity="0"/>
|
||||
<stop offset="50%" stop-color="#4a5bd8" stop-opacity="0.7"/>
|
||||
<stop offset="100%" stop-color="#2a2d6e" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
|
||||
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
|
||||
<feOffset dx="0" dy="2" result="off"/>
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.5"/>
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<filter id="outerGlow" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feGaussianBlur stdDeviation="4"/>
|
||||
</filter>
|
||||
|
||||
<!-- Reusable wave path (sinusoidal, centered around y=128 in 256x256 space) -->
|
||||
<path id="wavePath"
|
||||
d="M 36 128
|
||||
C 56 96, 76 96, 96 128
|
||||
S 136 160, 156 128
|
||||
S 196 96, 216 128"
|
||||
fill="none"/>
|
||||
|
||||
<!-- Full icon as a reusable symbol (256x256 source) -->
|
||||
<symbol id="icon" viewBox="0 0 256 256">
|
||||
<circle cx="128" cy="128" r="116" fill="url(#bg)" opacity="0.55" filter="url(#outerGlow)"/>
|
||||
<circle cx="128" cy="128" r="112" fill="url(#bg)"/>
|
||||
<circle cx="128" cy="128" r="104" fill="none" stroke="url(#ring)" stroke-width="1.2" opacity="0.7"/>
|
||||
|
||||
<g fill="#8aa0ff" opacity="0.55">
|
||||
<circle cx="128" cy="22" r="1.4"/>
|
||||
<circle cx="180" cy="32" r="1.2"/>
|
||||
<circle cx="220" cy="62" r="1.4"/>
|
||||
<circle cx="234" cy="110" r="1.2"/>
|
||||
<circle cx="234" cy="146" r="1.4"/>
|
||||
<circle cx="220" cy="194" r="1.2"/>
|
||||
<circle cx="180" cy="224" r="1.4"/>
|
||||
<circle cx="128" cy="234" r="1.2"/>
|
||||
<circle cx="76" cy="224" r="1.4"/>
|
||||
<circle cx="36" cy="194" r="1.2"/>
|
||||
<circle cx="22" cy="146" r="1.4"/>
|
||||
<circle cx="22" cy="110" r="1.2"/>
|
||||
<circle cx="36" cy="62" r="1.4"/>
|
||||
<circle cx="76" cy="32" r="1.2"/>
|
||||
</g>
|
||||
|
||||
<g stroke-linecap="round" fill="none">
|
||||
<use href="#wavePath" stroke="url(#waveDim)" stroke-width="1.2"
|
||||
transform="translate(0,-46)" opacity="0.6"/>
|
||||
<use href="#wavePath" stroke="url(#waveDim)" stroke-width="1.2"
|
||||
transform="translate(0,46)" opacity="0.6"/>
|
||||
</g>
|
||||
|
||||
<g filter="url(#softShadow)">
|
||||
<path d="M 78 70 L 110 70 L 128 142 L 146 70 L 178 70 L 138 186 L 118 186 Z"
|
||||
fill="url(#vFill)"/>
|
||||
<path d="M 118 186 L 128 142 L 138 186"
|
||||
fill="none" stroke="#2a2d6e" stroke-width="1.2" stroke-linejoin="round" opacity="0.35"/>
|
||||
<path d="M 82 74 L 108 74 L 100 100 Z" fill="#ffffff" opacity="0.25"/>
|
||||
</g>
|
||||
|
||||
<g stroke-linecap="round" fill="none">
|
||||
<use href="#wavePath" stroke="url(#wave)" stroke-width="3.2" opacity="0.9"/>
|
||||
<use href="#wavePath" stroke="url(#wave)" stroke-width="1.6"
|
||||
transform="translate(0,18)" opacity="0.7"/>
|
||||
<use href="#wavePath" stroke="url(#wave)" stroke-width="1.6"
|
||||
transform="translate(0,-18)" opacity="0.7"/>
|
||||
</g>
|
||||
|
||||
<g fill="#ffffff">
|
||||
<circle cx="60" cy="92" r="1.8" opacity="0.95"/>
|
||||
<circle cx="196" cy="92" r="1.6" opacity="0.85"/>
|
||||
<circle cx="200" cy="168" r="1.4" opacity="0.8"/>
|
||||
<circle cx="58" cy="172" r="1.4" opacity="0.8"/>
|
||||
<circle cx="156" cy="58" r="1.2" opacity="0.7"/>
|
||||
<circle cx="100" cy="206" r="1.2" opacity="0.7"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(70,70)" fill="#ffffff" opacity="0.9">
|
||||
<path d="M 0 -7 L 1.4 -1.4 L 7 0 L 1.4 1.4 L 0 7 L -1.4 1.4 L -7 0 L -1.4 -1.4 Z"/>
|
||||
</g>
|
||||
<g transform="translate(190,190) scale(0.7)" fill="#ffffff" opacity="0.8">
|
||||
<path d="M 0 -7 L 1.4 -1.4 L 7 0 L 1.4 1.4 L 0 7 L -1.4 1.4 L -7 0 L -1.4 -1.4 Z"/>
|
||||
</g>
|
||||
|
||||
<circle cx="128" cy="128" r="112" fill="url(#gloss)"/>
|
||||
</symbol>
|
||||
</defs>
|
||||
|
||||
<!-- ===== Icon mark (scaled down) ===== -->
|
||||
<use href="#icon" x="8" y="8" width="144" height="144"/>
|
||||
|
||||
<!-- ===== Wordmark ===== -->
|
||||
<!-- "volumen" — main wordmark -->
|
||||
<text x="172" y="92"
|
||||
font-family="Inter, 'Helvetica Neue', Helvetica, Arial, sans-serif"
|
||||
font-size="72" font-weight="700" letter-spacing="-2"
|
||||
fill="url(#wordmark)">volumen</text>
|
||||
|
||||
<!-- Decorative wave line under the wordmark -->
|
||||
<path d="M 172 108
|
||||
C 196 100, 220 116, 244 108
|
||||
S 292 100, 316 108
|
||||
S 364 116, 388 108
|
||||
S 436 100, 460 108"
|
||||
fill="none" stroke="url(#wordmarkAccent)" stroke-width="1.4"
|
||||
stroke-linecap="round" opacity="0.85"/>
|
||||
|
||||
<!-- Tagline: BLOG ENGINE -->
|
||||
<text x="172" y="132"
|
||||
font-family="Inter, 'Helvetica Neue', Helvetica, Arial, sans-serif"
|
||||
font-size="14" font-weight="500" letter-spacing="6"
|
||||
fill="#4a5bd8" opacity="0.85">BLOG ENGINE</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.8 KiB |
+23
@@ -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
|
||||
@@ -0,0 +1,93 @@
|
||||
# API Reference
|
||||
|
||||
> Status: the public read API (`/api/volumen`) and the admin (`/admin`) are
|
||||
> implemented. The admin is server-rendered HTML, not part of the JSON API.
|
||||
|
||||
Base path: `/api/volumen`. All responses are JSON unless noted. Read endpoints
|
||||
send `Access-Control-Allow-Origin: *`.
|
||||
|
||||
## `GET /api/volumen/site`
|
||||
|
||||
Returns site metadata from the configuration.
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "My Blog",
|
||||
"description": "A blog powered by volumen.",
|
||||
"base_url": "https://example.com",
|
||||
"language": "en",
|
||||
"author": "Anonymous"
|
||||
}
|
||||
```
|
||||
|
||||
## `GET /api/volumen/posts`
|
||||
|
||||
Paginated list of published posts (drafts are excluded), newest first.
|
||||
Posts with `all_langs = true` in their frontmatter match every `lang` filter.
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|-------|--------|---------|--------------------|
|
||||
| lang | string | — | Filter by language |
|
||||
| tag | string | — | Filter by tag |
|
||||
| page | int | 1 | Page number |
|
||||
| limit | int | 20 | Per page (1–100) |
|
||||
|
||||
```json
|
||||
{
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"total": 1,
|
||||
"posts": [
|
||||
{
|
||||
"slug": "hello",
|
||||
"title": "Hello",
|
||||
"excerpt": "Hello world.",
|
||||
"date": "2026-01-15",
|
||||
"lang": "en",
|
||||
"tags": ["intro"],
|
||||
"author": null,
|
||||
"cover": null,
|
||||
"reading_time": 1,
|
||||
"translations": {},
|
||||
"url": "/api/volumen/posts/hello"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## `GET /api/volumen/posts/{slug}`
|
||||
|
||||
Single post including the raw Markdown body and rendered HTML. Optional
|
||||
`?lang=` selects a translation. Returns `404` with `{"error":"not_found"}` if
|
||||
no match.
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "hello",
|
||||
"title": "Hello",
|
||||
"excerpt": "Hello world.",
|
||||
"date": "2026-01-15",
|
||||
"lang": "en",
|
||||
"tags": ["intro"],
|
||||
"author": null,
|
||||
"cover": null,
|
||||
"reading_time": 1,
|
||||
"translations": {},
|
||||
"url": "/api/volumen/posts/hello",
|
||||
"body": "Hello **world**.",
|
||||
"html": "<p>Hello <strong>world</strong>.</p>\n"
|
||||
}
|
||||
```
|
||||
|
||||
## `GET /api/volumen/tags`
|
||||
|
||||
Tag cloud with counts (published posts only), most frequent first.
|
||||
|
||||
```json
|
||||
{ "tags": [{ "name": "intro", "count": 1 }] }
|
||||
```
|
||||
|
||||
## `GET /api/volumen/feed.xml`, `GET /api/volumen/sitemap.xml`
|
||||
|
||||
RSS 2.0 feed (latest 20 posts) and a sitemap of post URLs, built from
|
||||
`site.base_url`.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Architecture
|
||||
|
||||
> Status: the core library, the public JSON API, and the server-rendered admin
|
||||
> (login, CSRF, post CRUD, Markdown editor + live preview) are implemented.
|
||||
|
||||
**volumen** is a small, file-based Markdown blog engine written in CRuby. It
|
||||
has no database: every post is a `.md` file on disk with a TOML frontmatter
|
||||
block. The engine reads those files, exposes them as a JSON API, and ships a
|
||||
server-rendered admin for editing them.
|
||||
|
||||
## Design goals
|
||||
|
||||
- **File-based** — posts are plain `.md` files, safe to commit to Git and edit
|
||||
by hand or through the admin.
|
||||
- **Single-admin** — one password, one content directory, one user.
|
||||
- **Self-contained** — runs as a single Ruby process; no Node build step is
|
||||
required to operate the engine or its admin.
|
||||
- **Dependency-light** — a small, well-chosen set of gems (Sinatra, kramdown,
|
||||
toml-rb, puma) rather than a full framework.
|
||||
- **Markdown-first** — the visual editor round-trips through the same renderer
|
||||
that powers the public API, so stored content is always Markdown.
|
||||
|
||||
## Components (planned layout)
|
||||
|
||||
```
|
||||
lib/volumen/
|
||||
version.rb # gem version
|
||||
config.rb # loads /etc/volumen/config.toml, applies CLI overrides
|
||||
frontmatter.rb # parse/serialise the `+++ … +++` TOML block
|
||||
post.rb # Post model: metadata + raw body + rendered HTML
|
||||
store.rb # reads the content directory into Post objects
|
||||
markdown.rb # kramdown wrapper (render + sanitise)
|
||||
server.rb # Sinatra app: JSON API + admin routes
|
||||
web/
|
||||
views/ # ERB templates for the admin
|
||||
public/ # admin CSS + the visual-editor JS island
|
||||
exe/volumen # CLI: serve, hash-password, version, help
|
||||
```
|
||||
|
||||
## Request flow (planned)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[HTTP request] --> B{Route}
|
||||
B -->|/api/volumen/*| C[JSON API]
|
||||
B -->|/admin/*| D[Admin: session + CSRF]
|
||||
C --> E[Store]
|
||||
D --> E
|
||||
E --> F[(content_dir/*.md)]
|
||||
C --> G[Markdown renderer]
|
||||
D --> G
|
||||
```
|
||||
|
||||
## Public site integration
|
||||
|
||||
The public website (e.g. `petrbalvin.org`, a Vue 3 + Vite + Tailwind SPA)
|
||||
consumes volumen's JSON API. volumen does not render the public site itself; it
|
||||
is headless for the front-end and only renders its own admin.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Configuration
|
||||
|
||||
> Status: initial draft. Field semantics are finalised as the loader lands.
|
||||
|
||||
volumen is configured by a single **TOML** file. By default it lives at
|
||||
`/etc/volumen/config.toml` and is auto-generated on first start. CLI flags
|
||||
passed to `volumen serve` override the file.
|
||||
|
||||
## Example `config.toml`
|
||||
|
||||
```toml
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 9090
|
||||
|
||||
# Directory containing your Markdown posts.
|
||||
content_dir = "/var/lib/volumen/posts"
|
||||
|
||||
[site]
|
||||
title = "My Blog"
|
||||
description = "A blog powered by volumen."
|
||||
base_url = "https://example.com"
|
||||
language = "en"
|
||||
author = "Anonymous"
|
||||
|
||||
[admin]
|
||||
# scrypt hash produced by `volumen hash-password`.
|
||||
password_hash = ""
|
||||
# Secret used to sign session cookies. Leave empty to auto-generate per start
|
||||
# (sessions reset on restart).
|
||||
session_key = ""
|
||||
# Session lifetime in seconds (default 24h).
|
||||
session_ttl = 86400
|
||||
```
|
||||
|
||||
## CLI overrides
|
||||
|
||||
```bash
|
||||
volumen serve --config /etc/volumen/config.toml \
|
||||
--content ./posts \
|
||||
--host 127.0.0.1 \
|
||||
--port 9000
|
||||
```
|
||||
|
||||
## Why TOML
|
||||
|
||||
- **First-class dates** — `date = 2026-01-15` is a real date, not a string.
|
||||
- **Explicit typing** — no YAML-style implicit coercion (`no` → false, etc.).
|
||||
- **No whitespace pitfalls** — indentation is not significant.
|
||||
|
||||
The same format is used for post frontmatter; see the post format section in
|
||||
the project [`README.md`](../README.md).
|
||||
@@ -0,0 +1,287 @@
|
||||
# Deployment
|
||||
|
||||
> Status: production deployment on Linux with systemd + nginx.
|
||||
|
||||
volumen runs as a single Ruby process (Puma) bound to localhost, behind nginx
|
||||
which terminates TLS and reverse-proxies the API and admin. The public website
|
||||
(e.g. a Vue SPA) is served separately and consumes the JSON API.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Ruby ≥ 3.3** with a C toolchain (Puma compiles a native extension).
|
||||
- Fedora: `sudo dnf install ruby ruby-devel @development-tools openssl-devel`
|
||||
- Debian/Ubuntu: `sudo apt install ruby-full build-essential libssl-dev`
|
||||
- **nginx** and a TLS certificate (e.g. via certbot).
|
||||
- A dedicated system user, e.g. `volumen`.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `/opt/volumen` | source checkout (or installed gem) |
|
||||
| `/etc/volumen/config.toml` | configuration (auto-generated on first run) |
|
||||
| `/var/lib/volumen/posts` | Markdown posts |
|
||||
| `/var/lib/volumen/users.toml` | admin users (created from Settings) |
|
||||
| `/var/lib/volumen/posts/media` | uploaded images |
|
||||
|
||||
```bash
|
||||
sudo useradd --system --home /var/lib/volumen --shell /usr/sbin/nologin volumen
|
||||
sudo mkdir -p /etc/volumen /var/lib/volumen/posts
|
||||
sudo chown -R volumen:volumen /var/lib/volumen
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
### Quick install (Linux + systemd)
|
||||
|
||||
From a cloned repository, run the installer as root. It installs the Ruby
|
||||
toolchain from the distro repositories on **Fedora** and **openEuler** (prints
|
||||
guidance on other distros), creates the `volumen` user and directories, runs
|
||||
`bundle install`, generates `config.toml`, and installs and enables the systemd
|
||||
unit:
|
||||
|
||||
```bash
|
||||
sudo git clone https://codeberg.org/petrbalvin/volumen.git /opt/volumen
|
||||
cd /opt/volumen
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
The service is enabled but not started — finish by setting the admin password
|
||||
(printed steps), then `sudo systemctl start volumen`. The manual steps below
|
||||
describe what the script does.
|
||||
|
||||
### Manual install
|
||||
|
||||
From a source checkout (reproducible, no publishing needed):
|
||||
|
||||
```bash
|
||||
sudo git clone https://codeberg.org/petrbalvin/volumen.git /opt/volumen
|
||||
cd /opt/volumen
|
||||
sudo bundle install --deployment
|
||||
```
|
||||
|
||||
Alternatively build and install the gem (`just build` produces `pkg/volumen.gem`,
|
||||
then `gem install pkg/volumen.gem` puts `volumen` on the PATH).
|
||||
|
||||
## First run and the admin password
|
||||
|
||||
The first `serve` writes a commented `config.toml` if it is missing:
|
||||
|
||||
```bash
|
||||
sudo -u volumen bundle exec exe/volumen serve \
|
||||
--config /etc/volumen/config.toml \
|
||||
--content /var/lib/volumen/posts
|
||||
# → "volumen: wrote a default config to /etc/volumen/config.toml; review it and restart."
|
||||
```
|
||||
|
||||
Generate a password hash and a stable session secret, then edit the config:
|
||||
|
||||
```bash
|
||||
bundle exec exe/volumen hash-password # → scrypt$...
|
||||
openssl rand -hex 64 # → session_key
|
||||
sudo -u volumen $EDITOR /etc/volumen/config.toml
|
||||
```
|
||||
|
||||
Set `host = "127.0.0.1"`, your `site.base_url`, the `admin.password_hash`, and a
|
||||
`admin.session_key` (so sessions survive restarts). You can now sign in as user
|
||||
`admin`; from the **Settings** page you can add more users (roles `admin` /
|
||||
`author`) and change passwords. From then on `users.toml` is the source of
|
||||
truth.
|
||||
|
||||
## systemd
|
||||
|
||||
`/etc/systemd/system/volumen.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=volumen blog engine
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=volumen
|
||||
Group=volumen
|
||||
WorkingDirectory=/opt/volumen
|
||||
ExecStart=/usr/bin/bundle exec exe/volumen serve --config /etc/volumen/config.toml --content /var/lib/volumen/posts
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/volumen
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now volumen
|
||||
sudo systemctl status volumen
|
||||
journalctl -u volumen -f
|
||||
```
|
||||
|
||||
## FreeBSD (rc.d)
|
||||
|
||||
volumen runs on FreeBSD too — it is plain CRuby plus gems; only Puma (and its
|
||||
`nio4r` dependency) build a native extension, which compiles with the base
|
||||
clang toolchain.
|
||||
|
||||
```sh
|
||||
pkg install ruby
|
||||
# then install volumen as above (git clone + bundle install)
|
||||
```
|
||||
|
||||
Conventional FreeBSD paths: config in `/usr/local/etc/volumen/`, data in
|
||||
`/var/db/volumen/`. Service script `/usr/local/etc/rc.d/volumen`:
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
#
|
||||
# PROVIDE: volumen
|
||||
# REQUIRE: NETWORKING
|
||||
# KEYWORD: shutdown
|
||||
|
||||
. /etc/rc.subr
|
||||
|
||||
name="volumen"
|
||||
rcvar="volumen_enable"
|
||||
load_rc_config $name
|
||||
|
||||
: ${volumen_enable:="NO"}
|
||||
: ${volumen_user:="volumen"}
|
||||
: ${volumen_dir:="/usr/local/volumen"}
|
||||
: ${volumen_config:="/usr/local/etc/volumen/config.toml"}
|
||||
: ${volumen_content:="/var/db/volumen/posts"}
|
||||
|
||||
pidfile="/var/run/${name}.pid"
|
||||
command="/usr/sbin/daemon"
|
||||
command_args="-f -r -P ${pidfile} -u ${volumen_user} \
|
||||
/bin/sh -c 'cd ${volumen_dir} && exec bundle exec exe/volumen serve --config ${volumen_config} --content ${volumen_content}'"
|
||||
|
||||
run_rc_command "$1"
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
|
||||
```sh
|
||||
sysrc volumen_enable=YES
|
||||
service volumen start
|
||||
```
|
||||
|
||||
## nginx
|
||||
|
||||
volumen only owns `/api/volumen`, `/admin`, and `/media`. Serve your public
|
||||
site from the same hostname and proxy those prefixes to the backend, so the
|
||||
admin runs same-origin (session cookies and CSRF work without extra
|
||||
configuration):
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name blog.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/blog.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/blog.example.com/privkey.pem;
|
||||
|
||||
# Public site (e.g. the built Vue SPA).
|
||||
root /var/www/blog;
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# volumen API, admin and media.
|
||||
location ~ ^/(api/volumen|admin|media)(/|$) {
|
||||
proxy_pass http://127.0.0.1:9090;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name blog.example.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
### Hardening the admin
|
||||
|
||||
The admin lives at `/admin` on your public domain and is protected by login,
|
||||
sessions, CSRF, and roles — but the engine has **no rate limit** on
|
||||
`/admin/login`. Add brute-force throttling (and, optionally, an IP allowlist)
|
||||
at nginx.
|
||||
|
||||
**1. A rate-limit zone** in the `http { }` context (e.g.
|
||||
`/etc/nginx/conf.d/volumen.conf`):
|
||||
|
||||
```nginx
|
||||
limit_req_zone $binary_remote_addr zone=volumen_login:10m rate=5r/m;
|
||||
```
|
||||
|
||||
**2. A reusable proxy snippet** at `/etc/nginx/snippets/volumen-proxy.conf`:
|
||||
|
||||
```nginx
|
||||
proxy_pass http://127.0.0.1:9090;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
```
|
||||
|
||||
**3. Split the single proxy location** in the `server { }` block into three —
|
||||
public API/media, the throttled login, and the rest of the admin:
|
||||
|
||||
```nginx
|
||||
# Public API and uploaded media — open to everyone.
|
||||
location ~ ^/(api/volumen|media)(/|$) {
|
||||
include snippets/volumen-proxy.conf;
|
||||
}
|
||||
|
||||
# Login: throttle brute-force attempts. Only this endpoint is rate-limited,
|
||||
# so the editor's live preview (/admin/preview) is unaffected.
|
||||
location = /admin/login {
|
||||
# Optional IP allowlist (uncomment to restrict):
|
||||
# allow 203.0.113.0/24;
|
||||
# deny all;
|
||||
limit_req zone=volumen_login burst=10 nodelay;
|
||||
include snippets/volumen-proxy.conf;
|
||||
}
|
||||
|
||||
# The rest of the admin.
|
||||
location ^~ /admin {
|
||||
# Optional IP allowlist (must be repeated — locations do not inherit it):
|
||||
# allow 203.0.113.0/24;
|
||||
# deny all;
|
||||
include snippets/volumen-proxy.conf;
|
||||
}
|
||||
```
|
||||
|
||||
nginx matches `= /admin/login` (exact) first, then `^~ /admin` (which stops
|
||||
regex matching), then the `~` regex for API/media — so each path lands in the
|
||||
right block. Apply with `sudo nginx -t && sudo systemctl reload nginx`.
|
||||
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
cd /opt/volumen
|
||||
sudo git pull
|
||||
sudo bundle install --deployment
|
||||
sudo systemctl restart volumen
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
- Bind to `127.0.0.1`; only nginx is public. TLS is terminated by nginx.
|
||||
- Session cookies are `HttpOnly` and `SameSite=Strict`, and gain the `Secure`
|
||||
attribute automatically on HTTPS requests (detected via `X-Forwarded-Proto`,
|
||||
which the nginx snippet above sets) — so the cookie never travels over plain
|
||||
HTTP in production, while local HTTP testing still works. Combined with
|
||||
per-form CSRF tokens this protects the admin. Set a stable `admin.session_key`
|
||||
so sessions survive restarts.
|
||||
- Keep `config.toml` and `users.toml` readable only by the `volumen` user
|
||||
(`chmod 600`). Both may contain password hashes.
|
||||
- `volumen hash-password` reads the password without echo; never pass passwords
|
||||
on the command line.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Security
|
||||
|
||||
> Status: threat model and security posture for the engine as implemented.
|
||||
|
||||
volumen is a small, file-based blog engine: a single Ruby process exposing a
|
||||
read-only public JSON API and a session-authenticated admin, behind nginx. This
|
||||
document describes the trust model, the controls in place, and the known
|
||||
limitations.
|
||||
|
||||
If you find a security issue, please email **opensource@petrbalvin.org** rather
|
||||
than opening a public issue.
|
||||
|
||||
## Trust model
|
||||
|
||||
- **Content authors are trusted.** Posts are written by authenticated admin
|
||||
users. kramdown renders Markdown and **passes raw HTML through** on purpose,
|
||||
so an author can embed HTML. The stored/served `html` is therefore only as
|
||||
safe as the people who can log in. If you need to accept posts from untrusted
|
||||
authors, add an HTML sanitiser before serving.
|
||||
- **The public API is read-only.** It exposes published (non-draft) posts, tags,
|
||||
a feed and a sitemap. No public endpoint mutates state.
|
||||
|
||||
## Authentication
|
||||
|
||||
- Passwords are hashed with **scrypt** via `OpenSSL::KDF` (`N=16384`, `r=8`,
|
||||
`p=1`, 32-byte key, 16-byte random salt). Verification uses
|
||||
`OpenSSL.fixed_length_secure_compare` (constant-time).
|
||||
- Login takes a **username + password**. The error message is intentionally
|
||||
generic ("Invalid username or password.") to avoid user enumeration.
|
||||
- Sessions use signed cookies (`Rack::Session::Cookie`) marked **`HttpOnly`**
|
||||
and **`SameSite=Strict`**. The **`Secure`** attribute is added automatically on
|
||||
HTTPS requests (detected via `request.ssl?` / `X-Forwarded-Proto`), so the
|
||||
cookie never travels over plain HTTP in production. The signing secret comes
|
||||
from `admin.session_key`; if it is shorter than 64 bytes a random secret is
|
||||
generated per start (sessions then reset on restart, so set a stable key in
|
||||
production).
|
||||
|
||||
## Authorization (roles)
|
||||
|
||||
- Two roles: **admin** (full access, manages users) and **author** (posts and
|
||||
own account only).
|
||||
- User management (`add`/`delete`/role change) is gated by `require_admin!`
|
||||
**on the server**, not merely hidden in the UI.
|
||||
- Safeguards prevent lockout: the **last admin** cannot be deleted or demoted,
|
||||
and a user cannot delete their own account or change their own role.
|
||||
|
||||
## CSRF
|
||||
|
||||
- Every state-changing form (`POST`) carries a per-session token
|
||||
(`SecureRandom.hex(32)`), validated with `Rack::Utils.secure_compare`.
|
||||
Requests without a valid token get `403`.
|
||||
- `SameSite=Strict` cookies provide a second, independent layer.
|
||||
|
||||
## CORS and Host handling
|
||||
|
||||
- The public read API sends `Access-Control-Allow-Origin: *` (read-only, no
|
||||
credentials) so any front-end may consume it. The admin sends **no** CORS
|
||||
headers and is same-origin only.
|
||||
- `Rack::Protection::HostAuthorization` is disabled (`permitted_hosts: []`)
|
||||
because volumen is intended to run behind nginx, which controls the `Host`
|
||||
header. Do not expose the Puma port directly to the internet.
|
||||
|
||||
## File uploads and path traversal
|
||||
|
||||
- Uploaded media is stored under `content_dir/media` with a **sanitised,
|
||||
randomised** filename (`<hex>-<slug>.<ext>`), so uploads cannot overwrite
|
||||
each other or escape the directory.
|
||||
- `GET /media/*` resolves names with `File.basename` and an explicit
|
||||
containment check, so `../` traversal cannot read files outside the media
|
||||
directory.
|
||||
- Upload content-type is **not** validated (the `accept="image/*"` attribute is
|
||||
only a UI hint); this is acceptable under the trusted-author model.
|
||||
|
||||
## Secrets and files
|
||||
|
||||
- `config.toml` and `users.toml` may contain password hashes — keep them
|
||||
readable only by the service user (`chmod 600`).
|
||||
- `volumen hash-password` reads the password from the terminal without echo;
|
||||
never pass passwords as command-line arguments.
|
||||
|
||||
## Transport
|
||||
|
||||
- Bind to `127.0.0.1` and terminate TLS at nginx. The browser ↔ nginx hop is
|
||||
HTTPS; the nginx ↔ Puma hop is local.
|
||||
|
||||
## Dependencies
|
||||
|
||||
A small, audited set: `sinatra`, `kramdown` (+ `kramdown-parser-gfm`),
|
||||
`toml-rb`, `puma`, `rackup`. Cryptography uses the Ruby standard library
|
||||
(`openssl`, `securerandom`).
|
||||
|
||||
## Known limitations / non-goals
|
||||
|
||||
- **No rate limiting or lockout** on `/admin/login`. Put nginx `limit_req` in
|
||||
front of it, or use fail2ban, to slow brute-force attempts.
|
||||
- **No 2FA** and **no audit log**.
|
||||
- **Raw HTML in posts is trusted** (see the trust model). There is no built-in
|
||||
HTML sanitiser.
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "volumen"
|
||||
require "volumen/cli"
|
||||
|
||||
Volumen::CLI.run(ARGV)
|
||||
Executable
+178
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# volumen one-shot installer for Linux + systemd.
|
||||
#
|
||||
# Run from a cloned repository as root:
|
||||
# sudo ./install.sh
|
||||
#
|
||||
# The Ruby toolchain is installed automatically from the distro repositories on
|
||||
# Fedora and openEuler; on other distributions the script prints guidance and
|
||||
# expects Ruby >= 3.3 with a C toolchain to be present already.
|
||||
#
|
||||
# FreeBSD uses rc.d instead of systemd — see docs/deployment.md.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VOLUMEN_USER="volumen"
|
||||
CONFIG_DIR="/etc/volumen"
|
||||
CONFIG_FILE="${CONFIG_DIR}/config.toml"
|
||||
DATA_DIR="/var/lib/volumen"
|
||||
CONTENT_DIR="${DATA_DIR}/posts"
|
||||
USERS_FILE="${DATA_DIR}/users.toml"
|
||||
SERVICE_FILE="/etc/systemd/system/volumen.service"
|
||||
MIN_RUBY="3.3"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP_DIR="${SCRIPT_DIR}"
|
||||
|
||||
log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mWARN:\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
require_root() {
|
||||
[ "$(id -u)" -eq 0 ] || die "Run as root: sudo ./install.sh"
|
||||
}
|
||||
|
||||
detect_distro() {
|
||||
[ -r /etc/os-release ] || { printf 'unknown'; return; }
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
printf '%s' "$(printf '%s' "${ID:-unknown}" | tr '[:upper:]' '[:lower:]')"
|
||||
}
|
||||
|
||||
install_toolchain() {
|
||||
case "$1" in
|
||||
fedora)
|
||||
log "Installing Ruby toolchain via dnf (Fedora)…"
|
||||
dnf install -y ruby ruby-devel gcc gcc-c++ make redhat-rpm-config openssl-devel
|
||||
;;
|
||||
openeuler)
|
||||
log "Installing Ruby toolchain via dnf (openEuler)…"
|
||||
dnf install -y ruby ruby-devel gcc gcc-c++ make openssl-devel
|
||||
;;
|
||||
*)
|
||||
warn "Automatic toolchain install is wired only for Fedora and openEuler."
|
||||
cat >&2 <<'EOF'
|
||||
Install Ruby >= 3.3 and a C toolchain manually, then re-run this script:
|
||||
Debian/Ubuntu: apt install ruby-full build-essential libssl-dev
|
||||
Arch: pacman -S ruby base-devel openssl
|
||||
openSUSE: zypper install ruby ruby-devel gcc make libopenssl-devel
|
||||
EOF
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
check_ruby() {
|
||||
command -v ruby >/dev/null 2>&1 || die "Ruby not found; install Ruby >= ${MIN_RUBY} and re-run."
|
||||
local version
|
||||
version="$(ruby -e 'print RUBY_VERSION')"
|
||||
if ! MIN="$MIN_RUBY" ruby -e 'exit(Gem::Version.new(RUBY_VERSION) >= Gem::Version.new(ENV["MIN"]))'; then
|
||||
die "Ruby ${version} is too old; volumen needs >= ${MIN_RUBY}."
|
||||
fi
|
||||
log "Ruby ${version} OK."
|
||||
}
|
||||
|
||||
ensure_bundler() {
|
||||
if ! command -v bundle >/dev/null 2>&1; then
|
||||
log "Installing bundler…"
|
||||
gem install --no-document bundler
|
||||
fi
|
||||
}
|
||||
|
||||
create_user() {
|
||||
if id "$VOLUMEN_USER" >/dev/null 2>&1; then
|
||||
log "User ${VOLUMEN_USER} already exists."
|
||||
else
|
||||
log "Creating system user ${VOLUMEN_USER}…"
|
||||
useradd --system --home "$DATA_DIR" --shell /usr/sbin/nologin "$VOLUMEN_USER"
|
||||
fi
|
||||
}
|
||||
|
||||
create_dirs() {
|
||||
log "Creating ${CONFIG_DIR} and ${CONTENT_DIR}…"
|
||||
mkdir -p "$CONFIG_DIR" "$CONTENT_DIR"
|
||||
chown -R "$VOLUMEN_USER:$VOLUMEN_USER" "$DATA_DIR"
|
||||
}
|
||||
|
||||
install_gems() {
|
||||
log "Installing gem dependencies…"
|
||||
( cd "$APP_DIR" && bundle install )
|
||||
}
|
||||
|
||||
generate_config() {
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
log "Config ${CONFIG_FILE} already exists; leaving it untouched."
|
||||
return
|
||||
fi
|
||||
log "Generating ${CONFIG_FILE}…"
|
||||
( cd "$APP_DIR" && bundle exec ruby -Ilib -rvolumen/config \
|
||||
-e 'Volumen::Config.generate_default(ARGV[0])' "$CONFIG_FILE" )
|
||||
sed -i 's/^host = .*/host = "127.0.0.1"/' "$CONFIG_FILE"
|
||||
sed -i "s#^content_dir = .*#content_dir = \"${CONTENT_DIR}\"#" "$CONFIG_FILE"
|
||||
sed -i "s#^users_file = .*#users_file = \"${USERS_FILE}\"#" "$CONFIG_FILE"
|
||||
chown "$VOLUMEN_USER:$VOLUMEN_USER" "$CONFIG_FILE"
|
||||
chmod 600 "$CONFIG_FILE"
|
||||
}
|
||||
|
||||
install_service() {
|
||||
log "Installing systemd unit ${SERVICE_FILE}…"
|
||||
cat > "$SERVICE_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=volumen blog engine
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=${VOLUMEN_USER}
|
||||
Group=${VOLUMEN_USER}
|
||||
WorkingDirectory=${APP_DIR}
|
||||
ExecStart=$(command -v bundle) exec exe/volumen serve --config ${CONFIG_FILE} --content ${CONTENT_DIR}
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=${DATA_DIR}
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable volumen
|
||||
}
|
||||
|
||||
final_notes() {
|
||||
cat <<EOF
|
||||
|
||||
volumen is installed and the service is enabled (not started yet).
|
||||
|
||||
Next steps:
|
||||
1. Create the first admin password (you will log in as user "admin"):
|
||||
cd ${APP_DIR}
|
||||
sudo -u ${VOLUMEN_USER} $(command -v bundle) exec exe/volumen hash-password
|
||||
Paste the printed hash into ${CONFIG_FILE} under [admin] password_hash.
|
||||
2. (Recommended) set a stable admin.session_key in the config:
|
||||
openssl rand -hex 64
|
||||
3. Start the service:
|
||||
sudo systemctl start volumen
|
||||
4. Put nginx in front of 127.0.0.1:9090 — see docs/deployment.md.
|
||||
EOF
|
||||
}
|
||||
|
||||
main() {
|
||||
require_root
|
||||
local distro
|
||||
distro="$(detect_distro)"
|
||||
log "Detected distribution: ${distro}"
|
||||
install_toolchain "$distro"
|
||||
check_ruby
|
||||
ensure_bundler
|
||||
create_user
|
||||
create_dirs
|
||||
install_gems
|
||||
generate_config
|
||||
install_service
|
||||
final_notes
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,38 @@
|
||||
set dotenv-load := false
|
||||
set positional-arguments := false
|
||||
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# Install Ruby dependencies
|
||||
install:
|
||||
@echo "→ Installing Ruby dependencies…"
|
||||
bundle install
|
||||
|
||||
# Run the development server
|
||||
run:
|
||||
@echo "→ Starting volumen…"
|
||||
bundle exec exe/volumen serve
|
||||
|
||||
# Run the development server against ./posts and ./config.toml
|
||||
dev:
|
||||
@echo "→ Starting volumen (dev) on http://localhost:9090 …"
|
||||
bundle exec exe/volumen serve --config ./config.toml --content ./posts
|
||||
|
||||
# Lint and package the gem (must pass with zero warnings)
|
||||
build:
|
||||
@echo "→ Linting…"
|
||||
bundle exec rubocop
|
||||
@echo "→ Building gem…"
|
||||
@mkdir -p pkg
|
||||
gem build volumen.gemspec --output pkg/volumen.gem
|
||||
|
||||
# Run the test suite
|
||||
test:
|
||||
@echo "→ Running tests…"
|
||||
bundle exec rake test
|
||||
|
||||
# Remove build artifacts
|
||||
uninstall:
|
||||
@echo "→ Removing build artifacts…"
|
||||
rm -rf pkg *.gem
|
||||
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "volumen/version"
|
||||
require_relative "volumen/frontmatter"
|
||||
require_relative "volumen/markdown"
|
||||
require_relative "volumen/post"
|
||||
require_relative "volumen/store"
|
||||
require_relative "volumen/config"
|
||||
require_relative "volumen/password"
|
||||
require_relative "volumen/users"
|
||||
|
||||
# volumen is a small, file-based Markdown blog engine.
|
||||
#
|
||||
# Posts are plain `.md` files with a TOML frontmatter block delimited by
|
||||
# `+++`. The engine exposes them as a JSON API and ships a server-rendered
|
||||
# admin with a Markdown source editor and a visual editor.
|
||||
#
|
||||
# The Sinatra app (`Volumen::Server`) and the CLI (`Volumen::CLI`) are loaded
|
||||
# on demand to keep the core library light.
|
||||
module Volumen
|
||||
end
|
||||
@@ -0,0 +1,87 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "optparse"
|
||||
require_relative "version"
|
||||
require_relative "config"
|
||||
require_relative "store"
|
||||
require_relative "password"
|
||||
|
||||
module Volumen
|
||||
# Command-line interface: serve, hash-password, version, help.
|
||||
class CLI
|
||||
USAGE = <<~TEXT.freeze
|
||||
volumen #{Volumen::VERSION} — a small, file-based Markdown blog engine.
|
||||
|
||||
Usage:
|
||||
volumen serve [--config PATH] [--content DIR] [--host HOST] [--port PORT]
|
||||
volumen hash-password
|
||||
volumen version
|
||||
volumen help
|
||||
TEXT
|
||||
|
||||
def self.run(argv)
|
||||
new(argv).run
|
||||
end
|
||||
|
||||
def initialize(argv)
|
||||
@argv = argv.dup
|
||||
end
|
||||
|
||||
def run
|
||||
command = @argv.shift
|
||||
case command
|
||||
when "version", "--version", "-v" then puts Volumen::VERSION
|
||||
when "help", "--help", "-h", nil then puts USAGE
|
||||
when "serve" then serve
|
||||
when "hash-password" then hash_password
|
||||
else
|
||||
warn "volumen: unknown command #{command.inspect}\n\n#{USAGE}"
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def serve
|
||||
options = parse_serve_options
|
||||
prepare_config(options[:config])
|
||||
config = Config.load(path: options[:config], overrides: options)
|
||||
|
||||
require_relative "server"
|
||||
store = Store.new(config.content_dir, default_lang: config.language)
|
||||
app = Server.configured(config: config, store: store)
|
||||
app.run!(bind: config.host, port: config.port)
|
||||
end
|
||||
|
||||
def parse_serve_options
|
||||
options = { config: Config::DEFAULT_PATH }
|
||||
OptionParser.new do |parser|
|
||||
parser.on("--config PATH") { |value| options[:config] = value }
|
||||
parser.on("--content DIR") { |value| options[:content] = value }
|
||||
parser.on("--host HOST") { |value| options[:host] = value }
|
||||
parser.on("--port PORT", Integer) { |value| options[:port] = value }
|
||||
end.parse!(@argv)
|
||||
options
|
||||
end
|
||||
|
||||
def prepare_config(path)
|
||||
if Config.generate_default(path)
|
||||
warn "volumen: wrote a default config to #{path}; review it and restart."
|
||||
elsif !File.exist?(path)
|
||||
warn "volumen: config #{path} not found; using built-in defaults."
|
||||
end
|
||||
end
|
||||
|
||||
def hash_password
|
||||
require "io/console"
|
||||
print "Password: "
|
||||
password = ($stdin.noecho(&:gets) || "").chomp
|
||||
puts
|
||||
if password.empty?
|
||||
warn "volumen: empty password"
|
||||
exit 1
|
||||
end
|
||||
puts Password.hash(password)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,136 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "toml-rb"
|
||||
require "fileutils"
|
||||
|
||||
module Volumen
|
||||
# Loads and merges volumen configuration from a TOML file, then applies
|
||||
# command-line overrides on top of the file values and built-in defaults.
|
||||
class Config
|
||||
DEFAULT_PATH = "/etc/volumen/config.toml"
|
||||
|
||||
DEFAULTS = {
|
||||
"server" => { "host" => "0.0.0.0", "port" => 9090 },
|
||||
"content_dir" => "/var/lib/volumen/posts",
|
||||
"users_file" => "/var/lib/volumen/users.toml",
|
||||
"site" => {
|
||||
"title" => "My Blog",
|
||||
"description" => "A blog powered by volumen.",
|
||||
"base_url" => "https://example.com",
|
||||
"language" => "en",
|
||||
"author" => "Anonymous"
|
||||
},
|
||||
"admin" => {
|
||||
"password_hash" => "",
|
||||
"session_key" => "",
|
||||
"session_ttl" => 86_400
|
||||
}
|
||||
}.freeze
|
||||
|
||||
TEMPLATE = <<~TOML
|
||||
# volumen configuration.
|
||||
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 9090
|
||||
|
||||
# Directory of your Markdown posts (.md with TOML frontmatter).
|
||||
content_dir = "/var/lib/volumen/posts"
|
||||
|
||||
# File storing admin users (managed from the admin Settings page).
|
||||
users_file = "/var/lib/volumen/users.toml"
|
||||
|
||||
[site]
|
||||
title = "My Blog"
|
||||
description = "A blog powered by volumen."
|
||||
base_url = "https://example.com"
|
||||
language = "en"
|
||||
author = "Anonymous"
|
||||
|
||||
[admin]
|
||||
# Bootstrap admin login: paste a hash from `volumen hash-password` to sign
|
||||
# in as user "admin". Once you manage users in Settings, users_file wins.
|
||||
password_hash = ""
|
||||
# Secret signing session cookies (>= 64 bytes). Empty = random per start.
|
||||
session_key = ""
|
||||
# Session lifetime in seconds (24h default).
|
||||
session_ttl = 86400
|
||||
TOML
|
||||
|
||||
def self.load(path: DEFAULT_PATH, overrides: {})
|
||||
data = File.exist?(path) ? TomlRB.load_file(path) : {}
|
||||
merged = deep_merge(DEFAULTS, data)
|
||||
new(apply_overrides(merged, overrides))
|
||||
end
|
||||
|
||||
# Writes the commented template to `path` if it does not exist yet.
|
||||
# Returns the path on success, or nil if it already existed or could not
|
||||
# be created.
|
||||
def self.generate_default(path)
|
||||
return nil if File.exist?(path)
|
||||
|
||||
FileUtils.mkdir_p(File.dirname(path))
|
||||
File.write(path, TEMPLATE)
|
||||
path
|
||||
rescue SystemCallError
|
||||
nil
|
||||
end
|
||||
|
||||
def self.deep_merge(base, override)
|
||||
base.merge(override) do |_key, base_val, override_val|
|
||||
if base_val.is_a?(Hash) && override_val.is_a?(Hash)
|
||||
deep_merge(base_val, override_val)
|
||||
else
|
||||
override_val
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.apply_overrides(data, overrides)
|
||||
server = data["server"].dup
|
||||
server["host"] = overrides[:host] if overrides[:host]
|
||||
server["port"] = overrides[:port] if overrides[:port]
|
||||
|
||||
result = data.dup
|
||||
result["server"] = server
|
||||
result["content_dir"] = overrides[:content] if overrides[:content]
|
||||
result
|
||||
end
|
||||
|
||||
private_class_method :deep_merge, :apply_overrides
|
||||
|
||||
attr_reader :data
|
||||
|
||||
def initialize(data)
|
||||
@data = data
|
||||
end
|
||||
|
||||
def host
|
||||
@data["server"]["host"]
|
||||
end
|
||||
|
||||
def port
|
||||
@data["server"]["port"]
|
||||
end
|
||||
|
||||
def content_dir
|
||||
@data["content_dir"]
|
||||
end
|
||||
|
||||
def users_file
|
||||
@data["users_file"]
|
||||
end
|
||||
|
||||
def site
|
||||
@data["site"]
|
||||
end
|
||||
|
||||
def language
|
||||
@data["site"]["language"]
|
||||
end
|
||||
|
||||
def admin
|
||||
@data["admin"]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,513 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "sinatra/base"
|
||||
require "json"
|
||||
require "date"
|
||||
require "securerandom"
|
||||
require_relative "config"
|
||||
require_relative "store"
|
||||
require_relative "markdown"
|
||||
require_relative "password"
|
||||
|
||||
module Volumen
|
||||
# Sinatra application: the public JSON API at /api/volumen and the
|
||||
# server-rendered admin at /admin.
|
||||
class Server < Sinatra::Base
|
||||
configure do
|
||||
set :show_exceptions, false
|
||||
set :raise_errors, false
|
||||
set :host_authorization, { permitted_hosts: [] }
|
||||
set :views, File.expand_path("web/views", __dir__)
|
||||
end
|
||||
|
||||
# Returns a configured subclass bound to a specific config + store.
|
||||
def self.configured(config:, store:)
|
||||
secret = session_secret(config)
|
||||
Class.new(self) do
|
||||
set :volumen_config, config
|
||||
set :volumen_store, store
|
||||
set :sessions, httponly: true, same_site: :strict
|
||||
set :session_secret, secret
|
||||
end
|
||||
end
|
||||
|
||||
def self.session_secret(config)
|
||||
key = config.admin["session_key"].to_s
|
||||
key.bytesize >= 64 ? key : SecureRandom.hex(64)
|
||||
end
|
||||
|
||||
# Mark the session cookie Secure only when the request is HTTPS (directly or
|
||||
# via a trusted X-Forwarded-Proto from nginx). Over plain HTTP (local dev)
|
||||
# the cookie stays usable so the admin login round-trips.
|
||||
before do
|
||||
options = request.env["rack.session.options"]
|
||||
options[:secure] = request.ssl? if options
|
||||
end
|
||||
|
||||
# --- Public JSON API -------------------------------------------------
|
||||
|
||||
before "/api/volumen/*" do
|
||||
headers "Access-Control-Allow-Origin" => "*"
|
||||
content_type :json
|
||||
end
|
||||
|
||||
get "/api/volumen/site" do
|
||||
json(site_payload)
|
||||
end
|
||||
|
||||
get "/api/volumen/posts" do
|
||||
json(posts_payload)
|
||||
end
|
||||
|
||||
get "/api/volumen/posts/:slug" do
|
||||
post = store.find(params["slug"], lang: params["lang"])
|
||||
halt(404, json("error" => "not_found")) if post.nil?
|
||||
|
||||
json(post.detail)
|
||||
end
|
||||
|
||||
get "/api/volumen/tags" do
|
||||
json("tags" => tag_counts)
|
||||
end
|
||||
|
||||
get "/api/volumen/feed.xml" do
|
||||
content_type "application/rss+xml"
|
||||
feed_xml
|
||||
end
|
||||
|
||||
get "/api/volumen/sitemap.xml" do
|
||||
content_type "application/xml"
|
||||
sitemap_xml
|
||||
end
|
||||
|
||||
# --- Admin -----------------------------------------------------------
|
||||
|
||||
get "/admin" do
|
||||
redirect to("/admin/")
|
||||
end
|
||||
|
||||
get "/admin/" do
|
||||
require_login!
|
||||
@posts = sorted_posts
|
||||
erb :list
|
||||
end
|
||||
|
||||
get "/admin/login" do
|
||||
redirect to("/admin/") if authenticated?
|
||||
|
||||
erb :login
|
||||
end
|
||||
|
||||
post "/admin/login" do
|
||||
check_csrf!
|
||||
user = users.authenticate(params["username"], params["password"])
|
||||
if user
|
||||
session[:user] = user.username
|
||||
redirect to("/admin/")
|
||||
else
|
||||
@error = "Invalid username or password."
|
||||
erb :login
|
||||
end
|
||||
end
|
||||
|
||||
post "/admin/logout" do
|
||||
check_csrf!
|
||||
session.clear
|
||||
redirect to("/admin/login")
|
||||
end
|
||||
|
||||
get "/admin/posts/new" do
|
||||
require_login!
|
||||
@mode = :new
|
||||
@post = Post.new(metadata: { "lang" => config.language })
|
||||
erb :form
|
||||
end
|
||||
|
||||
post "/admin/posts" do
|
||||
require_login!
|
||||
check_csrf!
|
||||
create_post
|
||||
end
|
||||
|
||||
get "/admin/posts/:slug/edit" do
|
||||
require_login!
|
||||
@mode = :edit
|
||||
@post = store.find(params["slug"])
|
||||
halt(404, "Not found") if @post.nil?
|
||||
|
||||
erb :form
|
||||
end
|
||||
|
||||
post "/admin/posts/:slug" do
|
||||
require_login!
|
||||
check_csrf!
|
||||
update_post
|
||||
end
|
||||
|
||||
post "/admin/posts/:slug/delete" do
|
||||
require_login!
|
||||
check_csrf!
|
||||
store.delete(params["slug"])
|
||||
redirect to("/admin/")
|
||||
end
|
||||
|
||||
post "/admin/preview" do
|
||||
require_login!
|
||||
content_type :html
|
||||
Markdown.render(params["body"].to_s)
|
||||
end
|
||||
|
||||
post "/admin/uploads" do
|
||||
require_login!
|
||||
check_csrf!
|
||||
content_type :json
|
||||
upload = params["file"]
|
||||
halt(400, json("error" => "no_file")) unless upload.is_a?(Hash) && upload[:tempfile]
|
||||
|
||||
json("url" => store.store_upload(upload[:filename], upload[:tempfile]))
|
||||
end
|
||||
|
||||
get "/media/*" do
|
||||
path = store.media_file(params["splat"].first)
|
||||
halt(404) if path.nil?
|
||||
|
||||
send_file(path)
|
||||
end
|
||||
|
||||
get "/admin/logo.svg" do
|
||||
send_file(File.expand_path("web/public/volumen-logo.svg", __dir__))
|
||||
end
|
||||
|
||||
get "/admin/settings" do
|
||||
require_login!
|
||||
render_settings
|
||||
end
|
||||
|
||||
post "/admin/settings/password" do
|
||||
require_login!
|
||||
check_csrf!
|
||||
change_password
|
||||
end
|
||||
|
||||
post "/admin/settings/username" do
|
||||
require_login!
|
||||
check_csrf!
|
||||
change_username
|
||||
end
|
||||
|
||||
post "/admin/settings/users" do
|
||||
require_login!
|
||||
require_admin!
|
||||
check_csrf!
|
||||
create_user
|
||||
end
|
||||
|
||||
post "/admin/settings/users/:username/role" do
|
||||
require_login!
|
||||
require_admin!
|
||||
check_csrf!
|
||||
change_role(params["username"])
|
||||
end
|
||||
|
||||
post "/admin/settings/users/:username/delete" do
|
||||
require_login!
|
||||
require_admin!
|
||||
check_csrf!
|
||||
remove_user(params["username"])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def config
|
||||
settings.volumen_config
|
||||
end
|
||||
|
||||
def store
|
||||
settings.volumen_store
|
||||
end
|
||||
|
||||
# --- API helpers -----------------------------------------------------
|
||||
|
||||
def json(data)
|
||||
JSON.generate(data)
|
||||
end
|
||||
|
||||
def published_posts
|
||||
store.all.reject(&:draft?).sort_by { |post| post.date_string || "" }.reverse
|
||||
end
|
||||
|
||||
def site_payload
|
||||
config.site.slice("title", "description", "base_url", "language", "author")
|
||||
end
|
||||
|
||||
def posts_payload
|
||||
posts = filtered_posts
|
||||
page = positive_int(params["page"], 1)
|
||||
limit = positive_int(params["limit"], 20).clamp(1, 100)
|
||||
offset = (page - 1) * limit
|
||||
{
|
||||
"page" => page,
|
||||
"page_size" => limit,
|
||||
"total" => posts.length,
|
||||
"posts" => posts[offset, limit].to_a.map(&:summary)
|
||||
}
|
||||
end
|
||||
|
||||
def filtered_posts
|
||||
posts = published_posts
|
||||
if params["lang"]
|
||||
lang = params["lang"]
|
||||
posts = posts.select { |post| post.lang == lang || post.all_langs? }
|
||||
end
|
||||
posts = posts.select { |post| post.tags.include?(params["tag"]) } if params["tag"]
|
||||
posts
|
||||
end
|
||||
|
||||
def tag_counts
|
||||
counts = Hash.new(0)
|
||||
published_posts.each { |post| post.tags.each { |tag| counts[tag] += 1 } }
|
||||
counts.sort_by { |name, count| [-count, name] }
|
||||
.map { |name, count| { "name" => name, "count" => count } }
|
||||
end
|
||||
|
||||
def positive_int(value, default)
|
||||
number = value.to_i
|
||||
number.positive? ? number : default
|
||||
end
|
||||
|
||||
def base_url
|
||||
config.site["base_url"].to_s.chomp("/")
|
||||
end
|
||||
|
||||
def feed_xml
|
||||
site = config.site
|
||||
items = published_posts.first(20).map { |post| feed_item(post) }.join("\n")
|
||||
<<~XML
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>#{escape(site["title"])}</title>
|
||||
<link>#{escape(base_url)}</link>
|
||||
<description>#{escape(site["description"])}</description>
|
||||
#{items}
|
||||
</channel>
|
||||
</rss>
|
||||
XML
|
||||
end
|
||||
|
||||
def feed_item(post)
|
||||
link = "#{base_url}/#{post.slug}"
|
||||
<<~XML.chomp
|
||||
<item>
|
||||
<title>#{escape(post.title)}</title>
|
||||
<link>#{escape(link)}</link>
|
||||
<guid>#{escape(link)}</guid>
|
||||
<description>#{escape(post.excerpt)}</description>
|
||||
</item>
|
||||
XML
|
||||
end
|
||||
|
||||
def sitemap_xml
|
||||
urls = published_posts.map do |post|
|
||||
" <url><loc>#{escape("#{base_url}/#{post.slug}")}</loc></url>"
|
||||
end.join("\n")
|
||||
<<~XML
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
#{urls}
|
||||
</urlset>
|
||||
XML
|
||||
end
|
||||
|
||||
def escape(text)
|
||||
Rack::Utils.escape_html(text.to_s)
|
||||
end
|
||||
|
||||
alias h escape
|
||||
|
||||
# --- Admin helpers ---------------------------------------------------
|
||||
|
||||
def users
|
||||
Users.new(path: config.users_file, bootstrap_hash: config.admin["password_hash"])
|
||||
end
|
||||
|
||||
def authenticated?
|
||||
!session[:user].to_s.empty?
|
||||
end
|
||||
|
||||
def current_user
|
||||
session[:user]
|
||||
end
|
||||
|
||||
def current_user_record
|
||||
users.find(current_user)
|
||||
end
|
||||
|
||||
def current_role
|
||||
current_user_record&.role
|
||||
end
|
||||
|
||||
def admin?
|
||||
current_role == "admin"
|
||||
end
|
||||
|
||||
def require_login!
|
||||
redirect to("/admin/login") unless authenticated?
|
||||
end
|
||||
|
||||
def require_admin!
|
||||
halt(403, "Forbidden") unless admin?
|
||||
end
|
||||
|
||||
def csrf_token
|
||||
session[:csrf] ||= SecureRandom.hex(32)
|
||||
end
|
||||
|
||||
def check_csrf!
|
||||
token = params["_csrf"]
|
||||
return if token && session[:csrf] && Rack::Utils.secure_compare(session[:csrf], token)
|
||||
|
||||
halt(403, "Invalid CSRF token")
|
||||
end
|
||||
|
||||
def render_settings(error: nil, notice: nil)
|
||||
@error = error
|
||||
@notice = notice
|
||||
@users = users.all
|
||||
erb :settings
|
||||
end
|
||||
|
||||
def change_password
|
||||
unless users.authenticate(current_user, params["current_password"])
|
||||
return render_settings(error: "Current password is incorrect.")
|
||||
end
|
||||
|
||||
fresh = presence(params["new_password"])
|
||||
return render_settings(error: "New password cannot be empty.") if fresh.nil?
|
||||
|
||||
users.update_password(current_user, fresh)
|
||||
render_settings(notice: "Password updated.")
|
||||
end
|
||||
|
||||
def change_username
|
||||
new_name = presence(params["username"])
|
||||
return render_settings(error: "Username cannot be empty.") if new_name.nil?
|
||||
|
||||
if users.rename(current_user, new_name)
|
||||
session[:user] = new_name
|
||||
render_settings(notice: "Username updated.")
|
||||
else
|
||||
render_settings(error: "That username is already taken.")
|
||||
end
|
||||
end
|
||||
|
||||
def create_user
|
||||
name = presence(params["username"])
|
||||
password = presence(params["password"])
|
||||
if name.nil? || password.nil?
|
||||
return render_settings(error: "Username and password are required.")
|
||||
end
|
||||
unless name.match?(/\A[a-zA-Z0-9._-]+\z/)
|
||||
return render_settings(error: "Username may use letters, numbers, dot, dash, underscore.")
|
||||
end
|
||||
|
||||
if users.add(name, password, params["role"].to_s)
|
||||
render_settings(notice: "User added.")
|
||||
else
|
||||
render_settings(error: "That username already exists.")
|
||||
end
|
||||
end
|
||||
|
||||
def remove_user(username)
|
||||
if username == current_user
|
||||
return render_settings(error: "You cannot delete your own account.")
|
||||
end
|
||||
|
||||
if users.delete(username)
|
||||
render_settings(notice: "User removed.")
|
||||
else
|
||||
render_settings(error: "Cannot remove the last user or the last admin.")
|
||||
end
|
||||
end
|
||||
|
||||
def change_role(username)
|
||||
return render_settings(error: "You cannot change your own role.") if username == current_user
|
||||
|
||||
if users.set_role(username, params["role"].to_s)
|
||||
render_settings(notice: "Role updated.")
|
||||
else
|
||||
render_settings(error: "Could not change role (last admin?).")
|
||||
end
|
||||
end
|
||||
|
||||
def sorted_posts
|
||||
store.all.sort_by { |post| post.date_string || "" }.reverse
|
||||
end
|
||||
|
||||
def create_post
|
||||
post = post_from_params(params)
|
||||
error = creation_error(post)
|
||||
if error
|
||||
@mode = :new
|
||||
@post = post
|
||||
@error = error
|
||||
return erb(:form)
|
||||
end
|
||||
store.save(post)
|
||||
redirect to("/admin/")
|
||||
end
|
||||
|
||||
def update_post
|
||||
existing = store.find(params["slug"])
|
||||
halt(404, "Not found") if existing.nil?
|
||||
|
||||
store.save(post_from_params(params, existing: existing))
|
||||
redirect to("/admin/")
|
||||
end
|
||||
|
||||
def creation_error(post)
|
||||
return "Slug is required." if presence(post.slug).nil?
|
||||
return "A post with that slug already exists." if store.find(post.slug)
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def post_from_params(http_params, existing: nil)
|
||||
metadata = {
|
||||
"title" => presence(http_params["title"]),
|
||||
"slug" => presence(http_params["slug"]) || existing&.slug,
|
||||
"lang" => presence(http_params["lang"]),
|
||||
"author" => presence(http_params["author"]),
|
||||
"date" => parse_date(http_params["date"]),
|
||||
"tags" => parse_tags(http_params["tags"]),
|
||||
"excerpt" => presence(http_params["excerpt"]),
|
||||
"cover" => presence(http_params["cover"]),
|
||||
"draft" => http_params["draft"] == "on",
|
||||
"all_langs" => http_params["all_langs"] == "on"
|
||||
}
|
||||
post = Post.new(metadata: clean_metadata(metadata), body: http_params["body"].to_s)
|
||||
post.path = existing.path if existing
|
||||
post
|
||||
end
|
||||
|
||||
def clean_metadata(metadata)
|
||||
metadata.reject { |_key, value| value.nil? || value == "" || value == [] || value == false }
|
||||
end
|
||||
|
||||
def presence(value)
|
||||
text = value.to_s.strip
|
||||
text.empty? ? nil : text
|
||||
end
|
||||
|
||||
def parse_date(value)
|
||||
text = presence(value)
|
||||
text && Date.iso8601(text)
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
|
||||
def parse_tags(value)
|
||||
presence(value).to_s.split(",").map(&:strip).reject(&:empty?)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,104 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "fileutils"
|
||||
require "securerandom"
|
||||
require_relative "post"
|
||||
|
||||
module Volumen
|
||||
# Reads and writes Markdown posts in a content directory.
|
||||
#
|
||||
# Posts live either at `content_dir/<slug>.md` (default language) or at
|
||||
# `content_dir/<lang>/<slug>.md` (per-language subdirectory). Missing
|
||||
# `slug`/`lang` frontmatter is derived from the file path.
|
||||
class Store
|
||||
attr_reader :content_dir
|
||||
|
||||
def initialize(content_dir, default_lang: nil)
|
||||
@content_dir = File.expand_path(content_dir.to_s)
|
||||
@default_lang = default_lang
|
||||
end
|
||||
|
||||
def all
|
||||
markdown_files.filter_map { |path| load_post(path) }
|
||||
end
|
||||
|
||||
def find(slug, lang: nil)
|
||||
all.find do |post|
|
||||
post.slug == slug && (lang.nil? || post.lang == lang || post.all_langs?)
|
||||
end
|
||||
end
|
||||
|
||||
def save(post)
|
||||
target = post.path || default_path_for(post)
|
||||
FileUtils.mkdir_p(File.dirname(target))
|
||||
File.write(target, post.to_file)
|
||||
post.path = target
|
||||
post
|
||||
end
|
||||
|
||||
def delete(slug, lang: nil)
|
||||
post = find(slug, lang: lang)
|
||||
File.delete(post.path) if post&.path
|
||||
post
|
||||
end
|
||||
|
||||
def media_dir
|
||||
File.join(@content_dir, "media")
|
||||
end
|
||||
|
||||
def store_upload(original_name, source)
|
||||
FileUtils.mkdir_p(media_dir)
|
||||
name = safe_media_name(original_name)
|
||||
File.open(File.join(media_dir, name), "wb") { |file| IO.copy_stream(source, file) }
|
||||
"/media/#{name}"
|
||||
end
|
||||
|
||||
def media_file(name)
|
||||
path = File.join(media_dir, File.basename(name.to_s))
|
||||
return nil unless File.file?(path) && within?(media_dir, path)
|
||||
|
||||
path
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def markdown_files
|
||||
Dir.glob(File.join(@content_dir, "**", "*.md"))
|
||||
end
|
||||
|
||||
def load_post(path)
|
||||
post = Post.parse(File.read(path))
|
||||
post.metadata["slug"] ||= File.basename(path, ".md")
|
||||
post.metadata["lang"] ||= lang_from_path(path) || @default_lang
|
||||
post.path = path
|
||||
post
|
||||
rescue StandardError => e
|
||||
warn "volumen: skipping #{path}: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
def lang_from_path(path)
|
||||
parent = File.dirname(File.expand_path(path))
|
||||
return nil if parent == @content_dir
|
||||
|
||||
File.basename(parent)
|
||||
end
|
||||
|
||||
def default_path_for(post)
|
||||
File.join(@content_dir, "#{post.slug}.md")
|
||||
end
|
||||
|
||||
def safe_media_name(original)
|
||||
base = File.basename(original.to_s)
|
||||
ext = File.extname(base).gsub(/[^a-zA-Z0-9.]/, "").downcase
|
||||
stem = File.basename(base, File.extname(base))
|
||||
.gsub(/[^a-zA-Z0-9_-]+/, "-").gsub(/-+/, "-").downcase
|
||||
stem = "file" if stem.empty?
|
||||
"#{SecureRandom.hex(4)}-#{stem}#{ext}"
|
||||
end
|
||||
|
||||
def within?(dir, path)
|
||||
File.expand_path(path).start_with?(File.expand_path(dir) + File::SEPARATOR)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Volumen
|
||||
VERSION = "0.1.0"
|
||||
end
|
||||
@@ -0,0 +1,167 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 160" role="img" aria-label="volumen — blog engine">
|
||||
<title>volumen — blog engine</title>
|
||||
|
||||
<defs>
|
||||
<!-- Background gradient: deep night sky -->
|
||||
<radialGradient id="bg" cx="50%" cy="35%" r="80%">
|
||||
<stop offset="0%" stop-color="#4a5bd8"/>
|
||||
<stop offset="55%" stop-color="#2a2d6e"/>
|
||||
<stop offset="100%" stop-color="#15163a"/>
|
||||
</radialGradient>
|
||||
|
||||
<linearGradient id="ring" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#8aa0ff" stop-opacity="0.9"/>
|
||||
<stop offset="100%" stop-color="#3b3f8a" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
|
||||
<radialGradient id="gloss" cx="50%" cy="15%" r="55%">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.35"/>
|
||||
<stop offset="60%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
|
||||
<linearGradient id="wave" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#7afcff" stop-opacity="0.15"/>
|
||||
<stop offset="50%" stop-color="#7afcff" stop-opacity="0.95"/>
|
||||
<stop offset="100%" stop-color="#7afcff" stop-opacity="0.15"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="waveDim" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.35"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="vFill" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#fdfdff"/>
|
||||
<stop offset="100%" stop-color="#b8c4ff"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Wordmark gradient: echoes the icon background -->
|
||||
<linearGradient id="wordmark" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#2a2d6e"/>
|
||||
<stop offset="100%" stop-color="#4a5bd8"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="wordmarkAccent" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#2a2d6e" stop-opacity="0"/>
|
||||
<stop offset="50%" stop-color="#4a5bd8" stop-opacity="0.7"/>
|
||||
<stop offset="100%" stop-color="#2a2d6e" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
|
||||
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
|
||||
<feOffset dx="0" dy="2" result="off"/>
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.5"/>
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<filter id="outerGlow" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feGaussianBlur stdDeviation="4"/>
|
||||
</filter>
|
||||
|
||||
<!-- Reusable wave path (sinusoidal, centered around y=128 in 256x256 space) -->
|
||||
<path id="wavePath"
|
||||
d="M 36 128
|
||||
C 56 96, 76 96, 96 128
|
||||
S 136 160, 156 128
|
||||
S 196 96, 216 128"
|
||||
fill="none"/>
|
||||
|
||||
<!-- Full icon as a reusable symbol (256x256 source) -->
|
||||
<symbol id="icon" viewBox="0 0 256 256">
|
||||
<circle cx="128" cy="128" r="116" fill="url(#bg)" opacity="0.55" filter="url(#outerGlow)"/>
|
||||
<circle cx="128" cy="128" r="112" fill="url(#bg)"/>
|
||||
<circle cx="128" cy="128" r="104" fill="none" stroke="url(#ring)" stroke-width="1.2" opacity="0.7"/>
|
||||
|
||||
<g fill="#8aa0ff" opacity="0.55">
|
||||
<circle cx="128" cy="22" r="1.4"/>
|
||||
<circle cx="180" cy="32" r="1.2"/>
|
||||
<circle cx="220" cy="62" r="1.4"/>
|
||||
<circle cx="234" cy="110" r="1.2"/>
|
||||
<circle cx="234" cy="146" r="1.4"/>
|
||||
<circle cx="220" cy="194" r="1.2"/>
|
||||
<circle cx="180" cy="224" r="1.4"/>
|
||||
<circle cx="128" cy="234" r="1.2"/>
|
||||
<circle cx="76" cy="224" r="1.4"/>
|
||||
<circle cx="36" cy="194" r="1.2"/>
|
||||
<circle cx="22" cy="146" r="1.4"/>
|
||||
<circle cx="22" cy="110" r="1.2"/>
|
||||
<circle cx="36" cy="62" r="1.4"/>
|
||||
<circle cx="76" cy="32" r="1.2"/>
|
||||
</g>
|
||||
|
||||
<g stroke-linecap="round" fill="none">
|
||||
<use href="#wavePath" stroke="url(#waveDim)" stroke-width="1.2"
|
||||
transform="translate(0,-46)" opacity="0.6"/>
|
||||
<use href="#wavePath" stroke="url(#waveDim)" stroke-width="1.2"
|
||||
transform="translate(0,46)" opacity="0.6"/>
|
||||
</g>
|
||||
|
||||
<g filter="url(#softShadow)">
|
||||
<path d="M 78 70 L 110 70 L 128 142 L 146 70 L 178 70 L 138 186 L 118 186 Z"
|
||||
fill="url(#vFill)"/>
|
||||
<path d="M 118 186 L 128 142 L 138 186"
|
||||
fill="none" stroke="#2a2d6e" stroke-width="1.2" stroke-linejoin="round" opacity="0.35"/>
|
||||
<path d="M 82 74 L 108 74 L 100 100 Z" fill="#ffffff" opacity="0.25"/>
|
||||
</g>
|
||||
|
||||
<g stroke-linecap="round" fill="none">
|
||||
<use href="#wavePath" stroke="url(#wave)" stroke-width="3.2" opacity="0.9"/>
|
||||
<use href="#wavePath" stroke="url(#wave)" stroke-width="1.6"
|
||||
transform="translate(0,18)" opacity="0.7"/>
|
||||
<use href="#wavePath" stroke="url(#wave)" stroke-width="1.6"
|
||||
transform="translate(0,-18)" opacity="0.7"/>
|
||||
</g>
|
||||
|
||||
<g fill="#ffffff">
|
||||
<circle cx="60" cy="92" r="1.8" opacity="0.95"/>
|
||||
<circle cx="196" cy="92" r="1.6" opacity="0.85"/>
|
||||
<circle cx="200" cy="168" r="1.4" opacity="0.8"/>
|
||||
<circle cx="58" cy="172" r="1.4" opacity="0.8"/>
|
||||
<circle cx="156" cy="58" r="1.2" opacity="0.7"/>
|
||||
<circle cx="100" cy="206" r="1.2" opacity="0.7"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(70,70)" fill="#ffffff" opacity="0.9">
|
||||
<path d="M 0 -7 L 1.4 -1.4 L 7 0 L 1.4 1.4 L 0 7 L -1.4 1.4 L -7 0 L -1.4 -1.4 Z"/>
|
||||
</g>
|
||||
<g transform="translate(190,190) scale(0.7)" fill="#ffffff" opacity="0.8">
|
||||
<path d="M 0 -7 L 1.4 -1.4 L 7 0 L 1.4 1.4 L 0 7 L -1.4 1.4 L -7 0 L -1.4 -1.4 Z"/>
|
||||
</g>
|
||||
|
||||
<circle cx="128" cy="128" r="112" fill="url(#gloss)"/>
|
||||
</symbol>
|
||||
</defs>
|
||||
|
||||
<!-- ===== Icon mark (scaled down) ===== -->
|
||||
<use href="#icon" x="8" y="8" width="144" height="144"/>
|
||||
|
||||
<!-- ===== Wordmark ===== -->
|
||||
<!-- "volumen" — main wordmark -->
|
||||
<text x="172" y="92"
|
||||
font-family="Inter, 'Helvetica Neue', Helvetica, Arial, sans-serif"
|
||||
font-size="72" font-weight="700" letter-spacing="-2"
|
||||
fill="url(#wordmark)">volumen</text>
|
||||
|
||||
<!-- Decorative wave line under the wordmark -->
|
||||
<path d="M 172 108
|
||||
C 196 100, 220 116, 244 108
|
||||
S 292 100, 316 108
|
||||
S 364 116, 388 108
|
||||
S 436 100, 460 108"
|
||||
fill="none" stroke="url(#wordmarkAccent)" stroke-width="1.4"
|
||||
stroke-linecap="round" opacity="0.85"/>
|
||||
|
||||
<!-- Tagline: BLOG ENGINE -->
|
||||
<text x="172" y="132"
|
||||
font-family="Inter, 'Helvetica Neue', Helvetica, Arial, sans-serif"
|
||||
font-size="14" font-weight="500" letter-spacing="6"
|
||||
fill="#4a5bd8" opacity="0.85">BLOG ENGINE</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,465 @@
|
||||
<h1><%= @mode == :new ? "New post" : "Edit post" %></h1>
|
||||
|
||||
<% if @error %>
|
||||
<p class="error"><%= h(@error) %></p>
|
||||
<% end %>
|
||||
|
||||
<form id="post-form" method="post"
|
||||
action="<%= @mode == :new ? to("/admin/posts") : to("/admin/posts/#{@post.slug}") %>">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
|
||||
<div class="fields">
|
||||
<label>Title <input type="text" name="title" value="<%= h(@post.title) %>"></label>
|
||||
<label>Slug
|
||||
<input type="text" name="slug" value="<%= h(@post.slug) %>"
|
||||
<%= @mode == :edit ? "readonly" : "required" %>>
|
||||
</label>
|
||||
<label>Language <input type="text" name="lang" value="<%= h(@post.lang) %>"></label>
|
||||
<label>Author <input type="text" name="author" value="<%= h(@post.author) %>"></label>
|
||||
<label>Date <input type="date" name="date" value="<%= h(@post.date_string) %>"></label>
|
||||
<label>Tags
|
||||
<input type="text" name="tags" value="<%= h(@post.tags.join(", ")) %>"
|
||||
placeholder="comma, separated">
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="draft" <%= @post.draft? ? "checked" : "" %>> Draft
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="all_langs" <%= @post.all_langs? ? "checked" : "" %>> All languages
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="cover-field">
|
||||
<label for="cover-input">Cover image</label>
|
||||
<div class="cover-row">
|
||||
<input type="text" id="cover-input" name="cover" value="<%= h(@post.cover) %>"
|
||||
placeholder="/media/… or https://…">
|
||||
<button type="button" id="cover-upload">Upload</button>
|
||||
<button type="button" id="cover-clear">Clear</button>
|
||||
</div>
|
||||
<img id="cover-preview" class="cover-preview" alt="" hidden>
|
||||
<input type="file" id="cover-file" accept="image/*" hidden>
|
||||
</div>
|
||||
|
||||
<div class="editor-tabs">
|
||||
<button type="button" data-tab="markdown" class="active">Markdown</button>
|
||||
<button type="button" data-tab="visual">Visual</button>
|
||||
</div>
|
||||
|
||||
<div id="visual-view" hidden>
|
||||
<div class="pane">
|
||||
<div class="ed-toolbar">
|
||||
<button type="button" data-rich="bold" title="Bold (Ctrl+B)"><b>B</b></button>
|
||||
<button type="button" data-rich="italic" title="Italic (Ctrl+I)"><i>I</i></button>
|
||||
<button type="button" data-rich="h2" title="Heading 2">H2</button>
|
||||
<button type="button" data-rich="h3" title="Heading 3">H3</button>
|
||||
<button type="button" data-rich="link" title="Link (Ctrl+K)">Link</button>
|
||||
<button type="button" data-rich="ul" title="Bullet list">• List</button>
|
||||
<button type="button" data-rich="ol" title="Numbered list">1. List</button>
|
||||
<button type="button" data-rich="quote" title="Quote">“</button>
|
||||
<button type="button" data-rich="code" title="Inline code"></></button>
|
||||
<button type="button" data-rich="image" title="Image">Img</button>
|
||||
<button type="button" data-rich="clear" title="Paragraph">P</button>
|
||||
</div>
|
||||
<div id="wysiwyg" class="markdown" contenteditable="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="markdown-view">
|
||||
<div class="editor">
|
||||
<div class="pane">
|
||||
<div class="ed-toolbar">
|
||||
<button type="button" data-md="bold" title="Bold (Ctrl+B)"><b>B</b></button>
|
||||
<button type="button" data-md="italic" title="Italic (Ctrl+I)"><i>I</i></button>
|
||||
<button type="button" data-md="h2" title="Heading">H2</button>
|
||||
<button type="button" data-md="link" title="Link (Ctrl+K)">Link</button>
|
||||
<button type="button" data-md="ul" title="List">List</button>
|
||||
<button type="button" data-md="quote" title="Quote">“</button>
|
||||
<button type="button" data-md="code" title="Code"></></button>
|
||||
<button type="button" data-md="image" title="Image">Img</button>
|
||||
<button type="button" id="toggle-preview" class="toggle" title="Toggle preview">Preview</button>
|
||||
</div>
|
||||
<textarea id="body" name="body" rows="22"><%= h(@post.body) %></textarea>
|
||||
</div>
|
||||
<div class="pane preview-pane">
|
||||
<div class="ed-toolbar"><span>Preview</span></div>
|
||||
<div id="preview" class="markdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="file" id="image-input" accept="image/*" hidden>
|
||||
<button type="submit" class="primary">Save</button>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var form = document.getElementById("post-form");
|
||||
var textarea = document.getElementById("body");
|
||||
var preview = document.getElementById("preview");
|
||||
var wysiwyg = document.getElementById("wysiwyg");
|
||||
var visualView = document.getElementById("visual-view");
|
||||
var markdownView = document.getElementById("markdown-view");
|
||||
var imageInput = document.getElementById("image-input");
|
||||
var coverInput = document.getElementById("cover-input");
|
||||
var coverFile = document.getElementById("cover-file");
|
||||
var coverPreview = document.getElementById("cover-preview");
|
||||
var previewUrl = "<%= to("/admin/preview") %>";
|
||||
var uploadUrl = "<%= to("/admin/uploads") %>";
|
||||
var csrf = "<%= csrf_token %>";
|
||||
var timer = null;
|
||||
|
||||
try { document.execCommand("defaultParagraphSeparator", false, "p"); } catch (e) {}
|
||||
|
||||
// --- Markdown <-> HTML -------------------------------------------------
|
||||
|
||||
function mdToHtml(md) {
|
||||
var data = new URLSearchParams();
|
||||
data.set("body", md);
|
||||
data.set("_csrf", csrf);
|
||||
return fetch(previewUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: data.toString()
|
||||
}).then(function (response) { return response.text(); });
|
||||
}
|
||||
|
||||
function inlineToMd(node) {
|
||||
var out = "";
|
||||
node.childNodes.forEach(function (child) { out += inlineNode(child); });
|
||||
return out;
|
||||
}
|
||||
|
||||
function inlineNode(node) {
|
||||
if (node.nodeType === 3) { return node.nodeValue.replace(/\s+/g, " "); }
|
||||
if (node.nodeType !== 1) { return ""; }
|
||||
var tag = node.tagName.toLowerCase();
|
||||
var inner = inlineToMd(node);
|
||||
if (tag === "strong" || tag === "b") { return "**" + inner.trim() + "**"; }
|
||||
if (tag === "em" || tag === "i") { return "*" + inner.trim() + "*"; }
|
||||
if (tag === "code") { return "`" + node.textContent + "`"; }
|
||||
if (tag === "a") { return "[" + inner + "](" + (node.getAttribute("href") || "") + ")"; }
|
||||
if (tag === "img") {
|
||||
return " || "") + ")";
|
||||
}
|
||||
if (tag === "br") { return "\n"; }
|
||||
return inner;
|
||||
}
|
||||
|
||||
function listToMd(list, ordered) {
|
||||
var index = 1;
|
||||
var items = [];
|
||||
list.childNodes.forEach(function (li) {
|
||||
if (li.nodeType === 1 && li.tagName.toLowerCase() === "li") {
|
||||
var marker = ordered ? (index++ + ". ") : "- ";
|
||||
items.push(marker + inlineToMd(li).trim());
|
||||
}
|
||||
});
|
||||
return items.join("\n");
|
||||
}
|
||||
|
||||
function htmlToMd(root) {
|
||||
var blocks = [];
|
||||
root.childNodes.forEach(function (node) {
|
||||
if (node.nodeType === 3) {
|
||||
var text = node.nodeValue.trim();
|
||||
if (text) { blocks.push(text); }
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== 1) { return; }
|
||||
var tag = node.tagName.toLowerCase();
|
||||
if (/^h[1-6]$/.test(tag)) {
|
||||
blocks.push("#".repeat(Number(tag.charAt(1))) + " " + inlineToMd(node).trim());
|
||||
} else if (tag === "ul" || tag === "ol") {
|
||||
blocks.push(listToMd(node, tag === "ol"));
|
||||
} else if (tag === "blockquote") {
|
||||
blocks.push(inlineToMd(node).trim().split("\n").map(function (line) {
|
||||
return "> " + line;
|
||||
}).join("\n"));
|
||||
} else if (tag === "pre") {
|
||||
blocks.push("```\n" + node.textContent.replace(/\n$/, "") + "\n```");
|
||||
} else if (tag === "hr") {
|
||||
blocks.push("---");
|
||||
} else {
|
||||
var content = inlineToMd(node).trim();
|
||||
if (content) { blocks.push(content); }
|
||||
}
|
||||
});
|
||||
return blocks.join("\n\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
|
||||
}
|
||||
|
||||
// --- Live preview (markdown mode) -------------------------------------
|
||||
|
||||
function renderPreview() {
|
||||
mdToHtml(textarea.value).then(function (html) { preview.innerHTML = html; });
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(renderPreview, 250);
|
||||
}
|
||||
|
||||
// --- Image upload ------------------------------------------------------
|
||||
|
||||
function uploadImage(file, onDone) {
|
||||
var data = new FormData();
|
||||
data.append("file", file);
|
||||
data.append("_csrf", csrf);
|
||||
fetch(uploadUrl, { method: "POST", body: data })
|
||||
.then(function (response) { return response.json(); })
|
||||
.then(function (json) { if (json.url) { onDone(json.url); } });
|
||||
}
|
||||
|
||||
imageInput.addEventListener("change", function () {
|
||||
var file = imageInput.files[0];
|
||||
if (!file) { return; }
|
||||
uploadImage(file, function (url) {
|
||||
if (visualView.hidden) {
|
||||
insertIntoTextarea("");
|
||||
} else {
|
||||
exec("insertImage", url);
|
||||
}
|
||||
});
|
||||
imageInput.value = "";
|
||||
});
|
||||
|
||||
// --- Cover image -------------------------------------------------------
|
||||
|
||||
function refreshCoverPreview() {
|
||||
if (coverInput.value) {
|
||||
coverPreview.src = coverInput.value;
|
||||
coverPreview.hidden = false;
|
||||
} else {
|
||||
coverPreview.hidden = true;
|
||||
coverPreview.removeAttribute("src");
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("cover-upload").addEventListener("click", function () {
|
||||
coverFile.click();
|
||||
});
|
||||
document.getElementById("cover-clear").addEventListener("click", function () {
|
||||
coverInput.value = "";
|
||||
refreshCoverPreview();
|
||||
});
|
||||
coverFile.addEventListener("change", function () {
|
||||
var file = coverFile.files[0];
|
||||
if (!file) { return; }
|
||||
uploadImage(file, function (url) {
|
||||
coverInput.value = url;
|
||||
refreshCoverPreview();
|
||||
});
|
||||
coverFile.value = "";
|
||||
});
|
||||
coverInput.addEventListener("input", refreshCoverPreview);
|
||||
refreshCoverPreview();
|
||||
|
||||
// --- Markdown toolbar --------------------------------------------------
|
||||
|
||||
function insertIntoTextarea(text) {
|
||||
var start = textarea.selectionStart;
|
||||
textarea.value = textarea.value.slice(0, start) + text + textarea.value.slice(textarea.selectionEnd);
|
||||
textarea.focus();
|
||||
textarea.selectionStart = textarea.selectionEnd = start + text.length;
|
||||
schedule();
|
||||
}
|
||||
|
||||
function wrap(before, after) {
|
||||
var start = textarea.selectionStart;
|
||||
var end = textarea.selectionEnd;
|
||||
var selected = textarea.value.slice(start, end);
|
||||
textarea.value = textarea.value.slice(0, start) + before + selected + after +
|
||||
textarea.value.slice(end);
|
||||
textarea.focus();
|
||||
textarea.selectionStart = start + before.length;
|
||||
textarea.selectionEnd = end + before.length;
|
||||
schedule();
|
||||
}
|
||||
|
||||
function linePrefix(prefix) {
|
||||
var start = textarea.selectionStart;
|
||||
var lineStart = textarea.value.lastIndexOf("\n", start - 1) + 1;
|
||||
textarea.value = textarea.value.slice(0, lineStart) + prefix + textarea.value.slice(lineStart);
|
||||
textarea.focus();
|
||||
schedule();
|
||||
}
|
||||
|
||||
var mdActions = {
|
||||
bold: function () { wrap("**", "**"); },
|
||||
italic: function () { wrap("*", "*"); },
|
||||
code: function () { wrap("`", "`"); },
|
||||
link: function () { wrap("[", "](https://)"); },
|
||||
h2: function () { linePrefix("## "); },
|
||||
ul: function () { linePrefix("- "); },
|
||||
quote: function () { linePrefix("> "); },
|
||||
image: function () { imageInput.click(); }
|
||||
};
|
||||
|
||||
// --- Visual toolbar ----------------------------------------------------
|
||||
|
||||
function exec(command, value) {
|
||||
document.execCommand(command, false, value || null);
|
||||
wysiwyg.focus();
|
||||
updateToolbarState();
|
||||
}
|
||||
|
||||
function wrapInline(tagName) {
|
||||
var selection = window.getSelection();
|
||||
if (!selection.rangeCount) { return; }
|
||||
var element = document.createElement(tagName);
|
||||
try { selection.getRangeAt(0).surroundContents(element); } catch (e) { /* spans blocks */ }
|
||||
wysiwyg.focus();
|
||||
updateToolbarState();
|
||||
}
|
||||
|
||||
var richActions = {
|
||||
bold: function () { exec("bold"); },
|
||||
italic: function () { exec("italic"); },
|
||||
h2: function () { exec("formatBlock", "H2"); },
|
||||
h3: function () { exec("formatBlock", "H3"); },
|
||||
quote: function () { exec("formatBlock", "BLOCKQUOTE"); },
|
||||
clear: function () { exec("formatBlock", "P"); },
|
||||
ul: function () { exec("insertUnorderedList"); },
|
||||
ol: function () { exec("insertOrderedList"); },
|
||||
code: function () { wrapInline("code"); },
|
||||
image: function () { imageInput.click(); },
|
||||
link: function () { var url = prompt("Link URL", "https://"); if (url) { exec("createLink", url); } }
|
||||
};
|
||||
|
||||
// --- Toolbar active state ----------------------------------------------
|
||||
|
||||
function queryState(command) {
|
||||
try { return document.queryCommandState(command); } catch (e) { return false; }
|
||||
}
|
||||
|
||||
function currentBlock() {
|
||||
try { return (document.queryCommandValue("formatBlock") || "").toLowerCase().replace(/[<>]/g, ""); }
|
||||
catch (e) { return ""; }
|
||||
}
|
||||
|
||||
function selectionInEditor() {
|
||||
var node = window.getSelection().anchorNode;
|
||||
while (node) { if (node === wysiwyg) { return true; } node = node.parentNode; }
|
||||
return false;
|
||||
}
|
||||
|
||||
function isInside(tagName) {
|
||||
var node = window.getSelection().anchorNode;
|
||||
while (node && node !== wysiwyg) {
|
||||
if (node.nodeType === 1 && node.tagName.toLowerCase() === tagName) { return true; }
|
||||
node = node.parentNode;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function updateToolbarState() {
|
||||
if (visualView.hidden) { return; }
|
||||
var active = {};
|
||||
if (selectionInEditor()) {
|
||||
var block = currentBlock();
|
||||
active = {
|
||||
bold: queryState("bold"),
|
||||
italic: queryState("italic"),
|
||||
ul: queryState("insertUnorderedList"),
|
||||
ol: queryState("insertOrderedList"),
|
||||
h2: block === "h2",
|
||||
h3: block === "h3",
|
||||
quote: block === "blockquote",
|
||||
link: isInside("a"),
|
||||
code: isInside("code")
|
||||
};
|
||||
}
|
||||
document.querySelectorAll("[data-rich]").forEach(function (button) {
|
||||
button.classList.toggle("active", !!active[button.dataset.rich]);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Mode switching ----------------------------------------------------
|
||||
|
||||
function setActiveTab(name) {
|
||||
document.querySelectorAll(".editor-tabs button").forEach(function (button) {
|
||||
button.classList.toggle("active", button.dataset.tab === name);
|
||||
});
|
||||
}
|
||||
|
||||
function showVisual() {
|
||||
mdToHtml(textarea.value).then(function (html) { wysiwyg.innerHTML = html; });
|
||||
visualView.hidden = false;
|
||||
markdownView.hidden = true;
|
||||
setActiveTab("visual");
|
||||
}
|
||||
|
||||
function showMarkdown() {
|
||||
textarea.value = htmlToMd(wysiwyg);
|
||||
renderPreview();
|
||||
visualView.hidden = true;
|
||||
markdownView.hidden = false;
|
||||
setActiveTab("markdown");
|
||||
}
|
||||
|
||||
// --- Keyboard shortcuts ------------------------------------------------
|
||||
|
||||
function shortcut(actions) {
|
||||
return function (event) {
|
||||
if (!(event.ctrlKey || event.metaKey)) { return; }
|
||||
var key = event.key.toLowerCase();
|
||||
if (key === "b") { event.preventDefault(); actions.bold(); }
|
||||
else if (key === "i") { event.preventDefault(); actions.italic(); }
|
||||
else if (key === "k") { event.preventDefault(); actions.link(); }
|
||||
};
|
||||
}
|
||||
|
||||
// --- Wiring ------------------------------------------------------------
|
||||
|
||||
document.querySelectorAll("[data-md]").forEach(function (button) {
|
||||
button.addEventListener("click", function () { mdActions[button.dataset.md](); });
|
||||
});
|
||||
document.querySelectorAll("[data-rich]").forEach(function (button) {
|
||||
button.addEventListener("click", function () { richActions[button.dataset.rich](); });
|
||||
});
|
||||
document.querySelectorAll(".editor-tabs button").forEach(function (button) {
|
||||
button.addEventListener("click", function () {
|
||||
if (button.dataset.tab === "visual") { showVisual(); } else { showMarkdown(); }
|
||||
});
|
||||
});
|
||||
|
||||
wysiwyg.addEventListener("paste", function (event) {
|
||||
event.preventDefault();
|
||||
var text = (event.clipboardData || window.clipboardData).getData("text/plain");
|
||||
document.execCommand("insertText", false, text);
|
||||
});
|
||||
wysiwyg.addEventListener("keydown", shortcut(richActions));
|
||||
wysiwyg.addEventListener("keyup", updateToolbarState);
|
||||
wysiwyg.addEventListener("mouseup", updateToolbarState);
|
||||
document.addEventListener("selectionchange", updateToolbarState);
|
||||
|
||||
textarea.addEventListener("input", schedule);
|
||||
textarea.addEventListener("keydown", shortcut(mdActions));
|
||||
|
||||
form.addEventListener("submit", function () {
|
||||
if (!visualView.hidden) { textarea.value = htmlToMd(wysiwyg); }
|
||||
});
|
||||
|
||||
// --- Preview toggle (persisted) ---------------------------------------
|
||||
|
||||
var editor = markdownView.querySelector(".editor");
|
||||
var togglePreviewBtn = document.getElementById("toggle-preview");
|
||||
|
||||
function applyPreviewPreference() {
|
||||
var off = false;
|
||||
try { off = localStorage.getItem("volumen.preview") === "off"; } catch (e) {}
|
||||
editor.classList.toggle("no-preview", off);
|
||||
togglePreviewBtn.classList.toggle("active", !off);
|
||||
if (!off) { renderPreview(); }
|
||||
}
|
||||
|
||||
togglePreviewBtn.addEventListener("click", function () {
|
||||
var turningOff = !editor.classList.contains("no-preview");
|
||||
try { localStorage.setItem("volumen.preview", turningOff ? "off" : "on"); } catch (e) {}
|
||||
applyPreviewPreference();
|
||||
});
|
||||
|
||||
// Start in Markdown mode (primary).
|
||||
applyPreviewPreference();
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,254 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="<%= h(config.language) %>">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>volumen admin</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--fg: #1b1b1f; --muted: #6b7280; --bg: #f6f7f9; --card: #ffffff;
|
||||
--border: #e4e4ec; --accent: #4f46e5; --accent-strong: #4338ca;
|
||||
--accent-weak: #eef2ff; --on-accent: #ffffff;
|
||||
--danger: #dc2626; --danger-weak: #fef2f2;
|
||||
--ok: #16a34a; --ok-weak: #f0fdf4;
|
||||
--ring: 0 0 0 3px rgba(79, 70, 229, 0.30);
|
||||
--shadow-sm: 0 1px 2px rgba(17, 17, 26, 0.06);
|
||||
--shadow: 0 1px 3px rgba(17, 17, 26, 0.06), 0 10px 30px -16px rgba(17, 17, 26, 0.25);
|
||||
--radius: 12px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--fg: #e7e7ea; --muted: #9aa0aa; --bg: #121217; --card: #1c1c22;
|
||||
--border: #2c2c35; --accent: #818cf8; --accent-strong: #a5b4fc;
|
||||
--accent-weak: rgba(129, 140, 248, 0.16); --on-accent: #14141a;
|
||||
--danger: #f87171; --danger-weak: rgba(248, 113, 113, 0.14);
|
||||
--ok: #4ade80; --ok-weak: rgba(74, 222, 128, 0.14);
|
||||
--ring: 0 0 0 3px rgba(129, 140, 248, 0.40);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 10px 30px -16px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
.brand-logo { background: #f4f4f6; border-radius: 8px; padding: 4px 8px; }
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; display: flex; min-height: 100vh; background: var(--bg); }
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 230px; flex-shrink: 0; position: sticky; top: 0; height: 100vh;
|
||||
overflow-y: auto; background: var(--card); border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column; padding: 1.25rem 0;
|
||||
}
|
||||
.sidebar .brand { display: flex; padding: 0 1.25rem; margin-bottom: 1rem; text-decoration: none; }
|
||||
.brand-logo { height: 28px; display: block; }
|
||||
.sidebar .who {
|
||||
margin: 0 1.25rem; padding: 0.3rem 0.6rem; border: 1px solid var(--border);
|
||||
border-radius: 999px; font-size: 0.78rem; color: var(--muted); align-self: flex-start;
|
||||
}
|
||||
.sidenav { display: flex; flex-direction: column; gap: 1px; padding: 0.75rem 0.75rem; flex: 1; }
|
||||
.sidenav a, .sidenav button {
|
||||
display: flex; align-items: center; text-decoration: none; color: var(--fg);
|
||||
font: inherit; font-size: 0.9rem; font-weight: 500; padding: 0.55rem 0.8rem;
|
||||
border-radius: 9px; border: none; background: none; cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.sidenav a:hover, .sidenav button:hover { background: var(--accent-weak); color: var(--accent-strong); }
|
||||
.sidenav a.active { background: var(--accent); color: var(--on-accent); }
|
||||
.sidebar-footer { padding: 0.75rem 1rem; border-top: 1px solid var(--border); }
|
||||
|
||||
/* Main */
|
||||
main {
|
||||
flex: 1; overflow: auto; padding: 2rem 2rem 4rem;
|
||||
color: var(--fg); font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.main-inner { max-width: 1060px; margin: 0 auto; }
|
||||
|
||||
::selection { background: var(--accent-weak); }
|
||||
a { color: var(--accent); }
|
||||
h1 { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.01em; margin: 0 0 1.25rem; }
|
||||
h2 { font-size: 1.15rem; font-weight: 650; margin: 0 0 0.75rem; }
|
||||
h3 { font-size: 0.95rem; font-weight: 650; }
|
||||
.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; }
|
||||
.toolbar h1 { margin: 0; }
|
||||
|
||||
/* Buttons */
|
||||
button, .button {
|
||||
font: inherit; font-weight: 550; cursor: pointer; display: inline-flex;
|
||||
align-items: center; gap: 0.4rem; border: 1px solid var(--border);
|
||||
background: var(--card); color: var(--fg); border-radius: 9px;
|
||||
padding: 0.45rem 0.9rem; text-decoration: none;
|
||||
transition: background 0.15s, border-color 0.15s, transform 0.05s, box-shadow 0.15s;
|
||||
}
|
||||
button:hover, .button:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }
|
||||
button:active, .button:active { transform: translateY(1px); }
|
||||
button:focus-visible, .button:focus-visible,
|
||||
input:focus-visible, select:focus-visible, textarea:focus-visible,
|
||||
[contenteditable]:focus-visible, .sidenav a:focus-visible, .sidenav button:focus-visible {
|
||||
outline: none; box-shadow: var(--ring);
|
||||
}
|
||||
button.primary, .button.primary {
|
||||
background: var(--accent); border-color: var(--accent); color: var(--on-accent);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
button.primary:hover, .button.primary:hover {
|
||||
background: var(--accent-strong); border-color: var(--accent-strong);
|
||||
}
|
||||
button.danger {
|
||||
color: var(--danger); border-color: color-mix(in srgb, var(--danger) 35%, var(--border));
|
||||
background: transparent;
|
||||
}
|
||||
button.danger:hover { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||
form.inline { display: inline; margin: 0; }
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 1.5rem; margin-bottom: 1.5rem; box-shadow: var(--shadow);
|
||||
}
|
||||
.card h2 { margin-top: 0; }
|
||||
.card h3 { margin: 1.5rem 0 0.6rem; }
|
||||
.card form { margin-bottom: 1.25rem; }
|
||||
.card label { max-width: 380px; margin: 0.5rem 0; }
|
||||
|
||||
/* Forms */
|
||||
.fields { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.9rem; margin-bottom: 1.25rem; }
|
||||
label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.8rem;
|
||||
font-weight: 550; color: var(--muted); }
|
||||
label.check { flex-direction: row; align-items: center; gap: 0.5rem; }
|
||||
.cover-field { margin-bottom: 1rem; }
|
||||
.cover-field > label { display: block; font-size: 0.8rem; font-weight: 550;
|
||||
color: var(--muted); margin-bottom: 0.3rem; }
|
||||
.cover-row { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.cover-row input { flex: 1; }
|
||||
.cover-preview { margin-top: 0.6rem; max-height: 120px; border-radius: 8px;
|
||||
border: 1px solid var(--border); display: block; }
|
||||
input[type=text], input[type=password], input[type=date], input:not([type]), select, textarea {
|
||||
font: inherit; color: var(--fg); background: var(--bg);
|
||||
border: 1px solid var(--border); border-radius: 9px; padding: 0.5rem 0.7rem;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%; border-collapse: collapse; background: var(--card);
|
||||
border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
|
||||
box-shadow: var(--shadow); margin-bottom: 1.5rem;
|
||||
}
|
||||
th, td { text-align: left; padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||
th { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted);
|
||||
background: color-mix(in srgb, var(--bg) 60%, var(--card)); }
|
||||
tbody tr:hover td { background: color-mix(in srgb, var(--accent-weak) 50%, transparent); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
td.actions { display: flex; gap: 0.5rem; align-items: center; }
|
||||
|
||||
/* Alerts */
|
||||
.error, .notice {
|
||||
border: 1px solid; border-radius: 10px; padding: 0.7rem 0.95rem; margin: 0 0 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.error { color: var(--danger); background: var(--danger-weak);
|
||||
border-color: color-mix(in srgb, var(--danger) 30%, transparent); }
|
||||
.notice { color: var(--ok); background: var(--ok-weak);
|
||||
border-color: color-mix(in srgb, var(--ok) 30%, transparent); }
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
/* Login */
|
||||
.login {
|
||||
max-width: 380px; margin: 4rem auto; background: var(--card);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 2rem; box-shadow: var(--shadow);
|
||||
}
|
||||
.login h1 { text-align: center; }
|
||||
.login label, .login button { width: 100%; }
|
||||
.login button { margin-top: 1.25rem; justify-content: center; }
|
||||
|
||||
/* Editor */
|
||||
.editor-tabs {
|
||||
display: inline-flex; gap: 2px; background: var(--card); padding: 3px;
|
||||
border: 1px solid var(--border); border-radius: 10px; margin-bottom: 0.9rem;
|
||||
}
|
||||
.editor-tabs button { border: none; background: transparent; color: var(--muted);
|
||||
border-radius: 7px; padding: 0.35rem 0.95rem; font-size: 0.85rem; }
|
||||
.editor-tabs button.active { background: var(--accent); color: var(--on-accent); }
|
||||
.editor { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.25rem; }
|
||||
.editor.no-preview { grid-template-columns: 1fr; }
|
||||
.editor.no-preview .preview-pane { display: none; }
|
||||
.pane { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
|
||||
background: var(--card); box-shadow: var(--shadow-sm); }
|
||||
.ed-toolbar { display: flex; gap: 0.3rem; padding: 0.45rem; align-items: center;
|
||||
border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--bg) 50%, var(--card)); }
|
||||
.ed-toolbar button { padding: 0.28rem 0.55rem; font-size: 0.82rem; border-radius: 7px;
|
||||
background: transparent; }
|
||||
.ed-toolbar button:hover { background: var(--accent-weak); }
|
||||
.ed-toolbar button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||
.ed-toolbar span { color: var(--muted); font-size: 0.78rem; font-weight: 550; }
|
||||
.ed-toolbar .toggle { margin-left: auto; }
|
||||
textarea { width: 100%; border: none; border-radius: 0; padding: 0.85rem 1rem;
|
||||
font: 13.5px/1.65 ui-monospace, SFMono-Regular, Menlo, monospace; resize: vertical;
|
||||
background: var(--card); color: var(--fg); }
|
||||
textarea:focus { box-shadow: none; }
|
||||
#preview, #wysiwyg { padding: 0.85rem 1.1rem; min-height: 22rem; }
|
||||
#wysiwyg { outline: none; }
|
||||
#wysiwyg:focus { box-shadow: inset 0 0 0 2px var(--accent); }
|
||||
#preview img, #wysiwyg img { max-width: 100%; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
/* Rendered Markdown */
|
||||
.markdown { line-height: 1.7; }
|
||||
.markdown > :first-child { margin-top: 0; }
|
||||
.markdown h1, .markdown h2, .markdown h3, .markdown h4 { line-height: 1.25; margin: 1.4em 0 0.5em; }
|
||||
.markdown h1 { font-size: 1.6rem; }
|
||||
.markdown h2 { font-size: 1.3rem; }
|
||||
.markdown h3 { font-size: 1.1rem; }
|
||||
.markdown p { margin: 0.7em 0; }
|
||||
.markdown a { color: var(--accent); }
|
||||
.markdown ul, .markdown ol { padding-left: 1.5rem; margin: 0.7em 0; }
|
||||
.markdown li { margin: 0.2em 0; }
|
||||
.markdown blockquote { margin: 0.9em 0; padding: 0.3em 1rem; border-left: 3px solid var(--accent);
|
||||
color: var(--muted); background: var(--accent-weak); border-radius: 0 8px 8px 0; }
|
||||
.markdown code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em;
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 5px; padding: 0.1em 0.35em; }
|
||||
.markdown pre { background: var(--bg); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 0.9rem 1rem; overflow-x: auto; margin: 0.9em 0; }
|
||||
.markdown pre code { background: none; border: none; padding: 0; font-size: 0.88em; }
|
||||
.markdown table { border-collapse: collapse; width: 100%; margin: 0.9em 0; font-size: 0.95em;
|
||||
box-shadow: none; }
|
||||
.markdown th, .markdown td { border: 1px solid var(--border); padding: 0.45rem 0.7rem; text-align: left; }
|
||||
.markdown thead th { background: var(--accent-weak); color: var(--fg); }
|
||||
.markdown tbody tr:hover td { background: transparent; }
|
||||
.markdown hr { border: none; border-top: 1px solid var(--border); margin: 1.4em 0; }
|
||||
.markdown img { max-width: 100%; border-radius: 8px; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { transition: none !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="<%= to("/admin/") %>"><img class="brand-logo" src="<%= to("/admin/logo.svg") %>" alt="volumen"></a>
|
||||
<% if authenticated? %>
|
||||
<span class="who"><%= h(current_user) %> · <%= h(current_role) %></span>
|
||||
<% settings_active = request.path_info.start_with?("/admin/settings") %>
|
||||
<nav class="sidenav">
|
||||
<a href="<%= to("/admin/") %>" class="<%= settings_active ? "" : "active" %>">Posts</a>
|
||||
<a href="<%= to("/admin/settings") %>" class="<%= settings_active ? "active" : "" %>">Settings</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<form method="post" action="<%= to("/admin/logout") %>">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<button type="submit">Log out</button>
|
||||
</form>
|
||||
</div>
|
||||
<% end %>
|
||||
</aside>
|
||||
<main>
|
||||
<div class="main-inner">
|
||||
<%= yield %>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,34 @@
|
||||
<div class="toolbar">
|
||||
<h1>Posts</h1>
|
||||
<a class="button primary" href="<%= to("/admin/posts/new") %>">New post</a>
|
||||
</div>
|
||||
|
||||
<% if @posts.empty? %>
|
||||
<p>No posts yet. Create your first one.</p>
|
||||
<% else %>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Title</th><th>Slug</th><th>Lang</th><th>Date</th><th>Draft</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @posts.each do |post| %>
|
||||
<tr>
|
||||
<td><%= h(post.title) %></td>
|
||||
<td><%= h(post.slug) %></td>
|
||||
<td><%= h(post.lang) %></td>
|
||||
<td><%= h(post.date_string) %></td>
|
||||
<td><%= post.draft? ? "yes" : "" %></td>
|
||||
<td class="actions">
|
||||
<a href="<%= to("/admin/posts/#{post.slug}/edit") %>">Edit</a>
|
||||
<form class="inline" method="post"
|
||||
action="<%= to("/admin/posts/#{post.slug}/delete") %>"
|
||||
onsubmit="return confirm('Delete this post?');">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<button type="submit" class="danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% end %>
|
||||
@@ -0,0 +1,23 @@
|
||||
<div class="login">
|
||||
<h1>Sign in</h1>
|
||||
<% if @error %>
|
||||
<p class="error"><%= h(@error) %></p>
|
||||
<% end %>
|
||||
<% unless users.any? %>
|
||||
<p class="error">
|
||||
No users are configured. Set <code>admin.password_hash</code> in your
|
||||
config (you will sign in as <code>admin</code>) or generate one with
|
||||
<code>volumen hash-password</code>.
|
||||
</p>
|
||||
<% end %>
|
||||
<form method="post" action="<%= to("/admin/login") %>">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<label>Username
|
||||
<input type="text" name="username" autofocus autocomplete="username">
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" name="password" autocomplete="current-password">
|
||||
</label>
|
||||
<button type="submit" class="primary">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,86 @@
|
||||
<h1>Settings</h1>
|
||||
|
||||
<% if @notice %><p class="notice"><%= h(@notice) %></p><% end %>
|
||||
<% if @error %><p class="error"><%= h(@error) %></p><% end %>
|
||||
|
||||
<section class="card">
|
||||
<h2>Your account</h2>
|
||||
<p class="muted">Signed in as <strong><%= h(current_user) %></strong> (<%= h(current_role) %>).</p>
|
||||
|
||||
<form method="post" action="<%= to("/admin/settings/password") %>">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<h3>Change password</h3>
|
||||
<label>Current password
|
||||
<input type="password" name="current_password" autocomplete="current-password">
|
||||
</label>
|
||||
<label>New password
|
||||
<input type="password" name="new_password" autocomplete="new-password">
|
||||
</label>
|
||||
<button type="submit" class="primary">Update password</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="<%= to("/admin/settings/username") %>">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<h3>Change username</h3>
|
||||
<label>New username <input type="text" name="username" value="<%= h(current_user) %>"></label>
|
||||
<button type="submit">Update username</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<% if admin? %>
|
||||
<section class="card">
|
||||
<h2>Users</h2>
|
||||
<table>
|
||||
<thead><tr><th>Username</th><th>Role</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<% @users.each do |user| %>
|
||||
<tr>
|
||||
<td><%= h(user.username) %><%= user.username == current_user ? " (you)" : "" %></td>
|
||||
<td>
|
||||
<% if user.username == current_user %>
|
||||
<%= h(user.role) %>
|
||||
<% else %>
|
||||
<form class="inline" method="post"
|
||||
action="<%= to("/admin/settings/users/#{user.username}/role") %>">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<select name="role" onchange="this.form.submit()">
|
||||
<% Volumen::Users::ROLES.each do |role| %>
|
||||
<option value="<%= role %>" <%= user.role == role ? "selected" : "" %>><%= role %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</form>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<% unless user.username == current_user %>
|
||||
<form class="inline" method="post"
|
||||
action="<%= to("/admin/settings/users/#{user.username}/delete") %>"
|
||||
onsubmit="return confirm('Remove this user?');">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<button type="submit" class="danger">Remove</button>
|
||||
</form>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<form method="post" action="<%= to("/admin/settings/users") %>">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<h3>Add user</h3>
|
||||
<div class="fields">
|
||||
<label>Username <input type="text" name="username"></label>
|
||||
<label>Password <input type="password" name="password" autocomplete="new-password"></label>
|
||||
<label>Role
|
||||
<select name="role">
|
||||
<% Volumen::Users::ROLES.each do |role| %>
|
||||
<option value="<%= role %>" <%= role == Volumen::Users::DEFAULT_ROLE ? "selected" : "" %>><%= role %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="primary">Add user</button>
|
||||
</form>
|
||||
</section>
|
||||
<% end %>
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class MarkdownTest < Minitest::Test
|
||||
def test_renders_bold
|
||||
assert_includes Volumen::Markdown.render("**bold**"), "<strong>bold</strong>"
|
||||
end
|
||||
|
||||
def test_renders_heading
|
||||
assert_includes Volumen::Markdown.render("# Title"), "<h1"
|
||||
end
|
||||
|
||||
def test_renders_gfm_table
|
||||
table = <<~MD
|
||||
| A | B |
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
MD
|
||||
assert_includes Volumen::Markdown.render(table), "<table"
|
||||
end
|
||||
|
||||
def test_renders_fenced_code_block
|
||||
code = <<~MD
|
||||
```ruby
|
||||
puts 1
|
||||
```
|
||||
MD
|
||||
html = Volumen::Markdown.render(code)
|
||||
assert_includes html, "<pre"
|
||||
assert_includes html, "<code"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class PasswordTest < Minitest::Test
|
||||
def test_hash_and_verify_round_trip
|
||||
encoded = Volumen::Password.hash("s3cret")
|
||||
assert Volumen::Password.verify("s3cret", encoded)
|
||||
end
|
||||
|
||||
def test_verify_rejects_wrong_password
|
||||
encoded = Volumen::Password.hash("s3cret")
|
||||
refute Volumen::Password.verify("nope", encoded)
|
||||
end
|
||||
|
||||
def test_verify_rejects_malformed_input
|
||||
refute Volumen::Password.verify("x", "not-a-hash")
|
||||
refute Volumen::Password.verify("x", "")
|
||||
end
|
||||
|
||||
def test_encoded_format
|
||||
assert Volumen::Password.hash("pw").start_with?("scrypt$16384$8$1$")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,59 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class PostTest < Minitest::Test
|
||||
def test_parse_builds_post
|
||||
content = <<~POST
|
||||
+++
|
||||
title = "Hello"
|
||||
slug = "hello"
|
||||
lang = "en"
|
||||
tags = ["intro", "ruby"]
|
||||
date = 2026-01-15
|
||||
draft = true
|
||||
+++
|
||||
|
||||
First paragraph.
|
||||
POST
|
||||
post = Volumen::Post.parse(content)
|
||||
assert_equal "hello", post.slug
|
||||
assert_equal "Hello", post.title
|
||||
assert_equal "en", post.lang
|
||||
assert_equal %w[intro ruby], post.tags
|
||||
assert_predicate post, :draft?
|
||||
assert_equal "2026-01-15", post.date_string
|
||||
end
|
||||
|
||||
def test_excerpt_derived_from_body
|
||||
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "# Heading\n\nThe body paragraph.")
|
||||
assert_equal "The body paragraph.", post.excerpt
|
||||
end
|
||||
|
||||
def test_summary_shape
|
||||
post = Volumen::Post.new(metadata: { "slug" => "x", "title" => "X" }, body: "Body")
|
||||
summary = post.summary
|
||||
assert_equal "/api/volumen/posts/x", summary["url"]
|
||||
assert_equal "X", summary["title"]
|
||||
refute_predicate post, :draft?
|
||||
end
|
||||
|
||||
def test_detail_includes_rendered_html
|
||||
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "**hi**")
|
||||
assert_includes post.detail["html"], "<strong>hi</strong>"
|
||||
end
|
||||
|
||||
def test_summary_includes_cover_and_reading_time
|
||||
post = Volumen::Post.new(
|
||||
metadata: { "slug" => "x", "cover" => "/media/c.png" },
|
||||
body: ("word " * 250)
|
||||
)
|
||||
assert_equal "/media/c.png", post.summary["cover"]
|
||||
assert_equal 2, post.summary["reading_time"]
|
||||
end
|
||||
|
||||
def test_reading_time_is_at_least_one
|
||||
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "short")
|
||||
assert_equal 1, post.reading_time
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,95 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
require "volumen/server"
|
||||
require "rack/test"
|
||||
require "tmpdir"
|
||||
require "fileutils"
|
||||
require "json"
|
||||
|
||||
class ServerTest < Minitest::Test
|
||||
include Rack::Test::Methods
|
||||
|
||||
def app
|
||||
Volumen::Server.configured(config: @config, store: @store)
|
||||
end
|
||||
|
||||
def setup
|
||||
@dir = Dir.mktmpdir
|
||||
File.write(File.join(@dir, "hello.md"), <<~POST)
|
||||
+++
|
||||
title = "Hello"
|
||||
tags = ["intro"]
|
||||
+++
|
||||
|
||||
Hello **world**.
|
||||
POST
|
||||
File.write(File.join(@dir, "draft.md"), <<~POST)
|
||||
+++
|
||||
title = "Draft"
|
||||
draft = true
|
||||
+++
|
||||
|
||||
Secret.
|
||||
POST
|
||||
@config = Volumen::Config.load(path: "/nonexistent", overrides: { content: @dir })
|
||||
@store = Volumen::Store.new(@dir, default_lang: "en")
|
||||
end
|
||||
|
||||
def teardown
|
||||
FileUtils.remove_entry(@dir)
|
||||
end
|
||||
|
||||
def test_site_endpoint
|
||||
get "/api/volumen/site"
|
||||
assert_predicate last_response, :ok?
|
||||
assert_equal "My Blog", JSON.parse(last_response.body)["title"]
|
||||
end
|
||||
|
||||
def test_posts_excludes_drafts
|
||||
get "/api/volumen/posts"
|
||||
data = JSON.parse(last_response.body)
|
||||
assert_equal 1, data["total"]
|
||||
assert_equal "hello", data["posts"].first["slug"]
|
||||
end
|
||||
|
||||
def test_post_detail_renders_html
|
||||
get "/api/volumen/posts/hello"
|
||||
assert_predicate last_response, :ok?
|
||||
assert_includes JSON.parse(last_response.body)["html"], "<strong>world</strong>"
|
||||
end
|
||||
|
||||
def test_post_not_found
|
||||
get "/api/volumen/posts/missing"
|
||||
assert_equal 404, last_response.status
|
||||
assert_equal "not_found", JSON.parse(last_response.body)["error"]
|
||||
end
|
||||
|
||||
def test_tags_endpoint
|
||||
get "/api/volumen/tags"
|
||||
assert_equal [{ "name" => "intro", "count" => 1 }], JSON.parse(last_response.body)["tags"]
|
||||
end
|
||||
|
||||
def test_cors_header_present
|
||||
get "/api/volumen/site"
|
||||
header = last_response.headers["Access-Control-Allow-Origin"] ||
|
||||
last_response.headers["access-control-allow-origin"]
|
||||
assert_equal "*", header
|
||||
end
|
||||
|
||||
def test_all_langs_post_appears_in_other_languages
|
||||
File.write(File.join(@dir, "global.md"), <<~POST)
|
||||
+++
|
||||
title = "Global"
|
||||
lang = "en"
|
||||
all_langs = true
|
||||
+++
|
||||
|
||||
Everywhere.
|
||||
POST
|
||||
get "/api/volumen/posts?lang=cs"
|
||||
slugs = JSON.parse(last_response.body)["posts"].map { |post| post["slug"] }
|
||||
assert_includes slugs, "global"
|
||||
refute_includes slugs, "hello"
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "minitest/autorun"
|
||||
require "volumen"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user