Metadata-Version: 2.4
Name: volumen
Version: 0.5.0
Summary: A small, file-based Markdown blog engine with a built-in admin.
Author-email: Petr Balvín <opensource@petrbalvin.org>
License: MIT
Project-URL: Homepage, https://sourcedock.dev/petrbalvin/volumen
Project-URL: Repository, https://sourcedock.dev/petrbalvin/volumen
Project-URL: Issues, https://sourcedock.dev/petrbalvin/volumen/issues
Project-URL: Changelog, https://sourcedock.dev/petrbalvin/volumen/blob/main/CHANGELOG.md
Project-URL: Documentation, https://sourcedock.dev/petrbalvin/volumen/blob/main/docs
Keywords: blog,markdown,fastapi,cms,static-blog,toml,rss,json-feed,engine
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Web Environment
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: End Users/Desktop
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: POSIX :: BSD
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Text Processing :: Markup :: Markdown
Classifier: Typing :: Typed
Requires-Python: >=3.14
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: fastapi[standard]>=0.115
Requires-Dist: itsdangerous>=2.2
Requires-Dist: jinja2>=3.1
Requires-Dist: markdown>=3.7
Requires-Dist: nh3>=0.2
Requires-Dist: pymdown-extensions>=10.14
Requires-Dist: tomli-w>=1.2
Requires-Dist: python-multipart>=0.0.20
Dynamic: license-file

# volumen — file-based Markdown blog engine

<p align="center">
  <img src="assets/volumen-logo.svg" alt="volumen — blog engine" width="480">
</p>

**volumen** is a small, dependency-light blog engine written in **Python**. 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 Python process (Uvicorn); 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.
- **IPv6-first** — the default bind address is `::` (IPv6 with automatic IPv4
  fallback); `volumen init` binds to `::1` behind nginx so the admin port is
  not exposed to the network.

---

## Stack

| Concern        | Choice                                                                 |
|----------------|------------------------------------------------------------------------|
| Language       | Python 3.14+                                                           |
| Web framework  | [FastAPI](https://fastapi.tiangolo.com/)                               |
| App server     | [Uvicorn](https://www.uvicorn.org/) (via `fastapi[standard]`)          |
| Markdown       | [python-markdown](https://python-markdown.github.io/) + [pymdown-extensions](https://facelessuser.github.io/pymdown-extensions/), sanitised by [nh3](https://github.com/messense/nh3) |
| Frontmatter    | TOML via stdlib [`tomllib`](https://docs.python.org/3/library/tomllib.html) + [`tomli-w`](https://github.com/camillescott/tomli-w) |
| Templating     | [Jinja2](https://jinja.palletsprojects.com/)                            |
| Sessions       | Starlette `SessionMiddleware` (signed cookies via [`itsdangerous`](https://itsdangerous.palletsprojects.com/)) |
| Packaging      | Wheel + sdist via [`setuptools`](https://setuptools.pypa.io/); [`uv`](https://docs.astral.sh/uv/) for dependency management |
| Lint / format  | [Ruff](https://docs.astral.sh/ruff/)                                   |
| Tests          | [pytest](https://docs.pytest.org/) with a ≥ 80 % coverage gate         |

---

## Features

**Content**

- Markdown posts with TOML frontmatter (`title`, `date`, `lang`, `tags`,
  `draft`, `publish_at`, `translations`, …)
- Multi-language posts with a translations map and an `all_langs` flag
- Post series — `series` + `series_order` frontmatter with
  `GET /api/volumen/series` and `GET /api/volumen/series/{name}` endpoints
  returning parts in reading order, for “part N of M” navigation
- Scheduled publishing (`publish_at`) with active publishing via
  `volumen publish-due` (cron / systemd timer)
- Outgoing webhooks — signed JSON notifications on post create / update /
  delete / publish, so a front-end rebuilds its cache or static pages
  automatically
- Post revisions — every save archives the previous version under
  `posts/.revisions/`, with a History view, per-revision download and
  one-click restore
- Post templates managed from the admin (Settings → Templates), pre-filling
  the new-post form
- Post import (drag-and-drop `.md` upload) and download (raw `.md` with
  frontmatter)

**Public API** at `/api/volumen/`

- `GET /site` — site metadata
- `GET /posts` — paginated list with `lang`, `tag`, `q`, `page`, `limit`
  filters
- `GET /posts/{slug}` — single post (raw Markdown + rendered HTML)
- `GET /tags` — tag cloud with counts
- `GET /feed.xml` — RSS 2.0, `GET /feed.json` — JSON Feed 1.1,
  `GET /sitemap.xml` — sitemap
- Sliding-window rate limiting per client IP (`X-RateLimit-*` headers,
  `429` + `Retry-After`), configured in `[api]`
- Token-authenticated write endpoints — `POST/PUT/DELETE /api/volumen/posts`
  with `Authorization: Bearer <token>`, for publishing from scripts and CI;
  tokens managed in the admin (Settings → API tokens)
- Permissive CORS (`*`) on read endpoints

**Admin** at `/admin/`

- Username + password login (scrypt, constant-time verification), per-IP
  login rate limiting, CSRF tokens, strict CSP with per-request nonces
- Dashboard with post statistics (published / drafts / scheduled / total)
- Post CRUD with a Markdown source editor and a visual editor: live preview,
  toolbar and shortcuts (`Ctrl+B/I/K/S`), drag-and-drop and paste image
  upload, smart slug generation, word/reading-time counters, autosave with
  restore
- Media library (WebP / AVIF only — declared MIME must match the file
  signature), profile photos, cover images
- Settings: account (password, username, display name, fediverse handle,
  photo), user and role management (admin only), templates, backup &
  restore (full-blog `.tar.gz` download / upload), version panel with
  in-place self-update
- Session cookies (`HttpOnly`, `SameSite=Strict`, `Secure` behind HTTPS) and
  `Cache-Control: no-store` on all admin responses

**Operations**

- `volumen init` — operational bootstrap: config, data directories, admin
  password, session key, optional hardened systemd unit
- `volumen doctor` — installation health check; `volumen status` — state
  report; `volumen check-update` — PyPI version comparison
- `volumen export` / `volumen import` — full-blog backup and restore
- `/healthz` health-check endpoint, `robots.txt`, `/sitemap.xml` redirect
- Structured JSON logging (`[server].log_format = "json"`) for journald and
  log aggregators; graceful shutdown on `SIGTERM`

![volumen login page](assets/volumen-login.webp)

---

## Quick start

### Production / first install

```sh
# 1. Install the volumen CLI (once, on the machine):
uv tool install volumen                       # or: pipx install volumen

# 2. Bootstrap (once, typically as root — generates config, prompts for
#    an admin password, and installs a hardened systemd unit):
sudo volumen init --systemd
sudo systemctl status volumen

# 3. Reverse-proxy with nginx and you're done — see docs/deployment.md.
```

`uv tool install` (or `pipx install`) drops a self-contained `volumen`
executable on `PATH`; `volumen init` is the operational bootstrap that
renders the config, creates the data dirs, generates a session key, and
(with `--systemd`) installs a hardened unit file. Run it with `--local` for
a per-user install without systemd. See [`docs/cli.md`](docs/cli.md) and
[`docs/deployment.md`](docs/deployment.md) for the full flow.

### Development (this checkout)

```sh
# 1. Install dependencies (Python 3.14+ and uv required)
just install

# 2. Run with hot reload against ./config.toml and ./posts
just dev
```

`just install` runs `uv sync` (creates `.venv/` and installs the locked
dependency set from `uv.lock`). `just dev` launches uvicorn with `--reload`,
so code changes restart the server automatically.

The admin lives at <http://localhost:9090/admin/> and the API at
<http://localhost:9090/api/volumen/posts>. The dev admin password is `admin`
(the hash lives in `./config.toml`, which is git-ignored).

---

## 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
publish_at = 2026-02-01     # optional, schedule publication
lang = "en"                 # language code
author = "Petr Balvín"      # optional
fediverse_creator = "@petrbalvin@mastodon.social"  # optional
tags = ["python", "web"]    # optional
draft = false               # optional
series = "rust-tutorial"    # optional, group multi-part posts
series_order = 1            # optional, part number within the series
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 python-markdown.
```

Posts are organised on disk as either:

- `content_dir/<slug>.md` (default language), or
- `content_dir/<lang>/<slug>.md` (per-language subdirectory)

Every save of an existing post archives the previous version under
`content_dir/.revisions/<slug>/` (hidden from the post list and the API);
the number of kept versions is configurable via `revision_limit`.

### Titled images as figures

A Markdown image with a **title** (`![alt](url "caption")`) is rendered as a
`<figure>` with a `<figcaption>`. Images without a title render as plain
`<img>`:

```markdown
![A sunset over the hills](media/sunset.webp "Sunset from the ridge")
```

### Fediverse attribution

Set `fediverse_creator = "@user@instance.tld"` on a post to attach a Mastodon
handle to it. The value is exposed as `fediverse_creator` on the post
payload and as `<dc:creator>` in the RSS feed / `authors[].name` in the JSON
feed. A site-wide default can be set in `config.toml`
(`[site].fediverse_creator`); a per-post value takes precedence.

### 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.

The engine parses it with the standard library `tomllib`; no extra TOML
dependency is needed for reading.

---

## Configuration

A single TOML file at `/etc/volumen/config.toml` (system install) or
`~/.config/volumen/config.toml` (per-user install), generated by
`volumen init`. See [`docs/configuration.md`](docs/configuration.md) for the
full schema.

```toml
[server]
host = "::"            # IPv6 + IPv4 fallback; volumen init uses "::1" behind nginx
port = 9090
env = "production"     # "development" | "production"
trust_proxy = true     # honour X-Forwarded-* behind a reverse proxy
cookie_secure = true   # Secure flag on session cookies
log_format = "json"    # "text" | "json"

content_dir = "/var/lib/volumen/posts"
users_file = "/var/lib/volumen/users.toml"
revision_limit = 10    # archived versions kept per post, 0 disables

[site]
title = "My Blog"
description = "A blog powered by volumen."
base_url = "https://example.com"
language = "en"
author = "Anonymous"

[admin]
password_hash = ""     # populated by `volumen init`
session_key = ""       # populated by `volumen init`; >= 64 bytes required in production
session_ttl = 86400
min_password_length = 10
max_password_length = 1024
max_upload_bytes = 10485760

[api]
rate_limit = 60        # requests per window per client IP, 0 disables
rate_limit_window = 60 # seconds
```

To rotate the admin password later:

```sh
sudo volumen hash-password   # prints a scrypt$… string for [admin].password_hash
```

Updates are managed from the admin panel (Settings → Version, with an
in-place **Update now** action) or on the command line via
`volumen check-update`.

---

## Documentation

- [`docs/architecture.md`](docs/architecture.md) — components and request flow
- [`docs/configuration.md`](docs/configuration.md) — config schema (TOML)
- [`docs/api.md`](docs/api.md) — public JSON API
- [`docs/cli.md`](docs/cli.md) — full CLI reference
- [`docs/development.md`](docs/development.md) — development setup, recipes, tests, CI
- [`docs/deployment.md`](docs/deployment.md) — systemd, nginx, backups, scheduled publishing
- [`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. AI
assistance policy and repository rules live in [AGENTS.md](AGENTS.md).

## License

MIT — see [LICENSE](LICENSE).
Copyright © 2026 [Petr Balvín](https://petrbalvin.org)
