50 Commits
Author SHA1 Message Date
petrbalvin 6f62d3e3f3 chore: prepare release v0.3.0 2026-07-12 14:03:14 +02:00
petrbalvin 1dfbf67921 style(web): polish native date, time and file inputs 2026-07-12 12:55:29 +02:00
petrbalvin 0e8f013bbb feat(web): show version in sidebar footer 2026-07-12 11:25:22 +02:00
petrbalvin 2f90c6da4d feat(admin): add volumen icon and use it in sidebar and login 2026-07-12 11:18:59 +02:00
petrbalvin f40ace64a8 feat(settings): add user profile photo upload and tabbed layout 2026-07-12 11:12:37 +02:00
petrbalvin ff0452bcc3 feat(uploads): restrict to webp/avif with conversion hint 2026-07-12 11:03:49 +02:00
petrbalvin 34dc67ce99 feat(admin): add user fediverse handle setting 2026-07-12 11:00:01 +02:00
petrbalvin e028db6447 feat(users): add display name field for users 2026-07-12 10:57:14 +02:00
petrbalvin cbae03014d fix(form): default to today when date is missing 2026-07-12 10:52:17 +02:00
petrbalvin 10ef258e84 feat(admin): redesign admin UI with modern layout 2026-07-12 10:50:49 +02:00
petrbalvin 5fdf8e5ee8 feat(post): add cover caption metadata 2026-07-12 10:31:21 +02:00
petrbalvin 6ee8e066aa feat(markdown): wrap titled images in figures with caption 2026-07-12 10:24:45 +02:00
petrbalvin 1dbeb66be0 fix(admin): clear posts cache after save/delete 2026-07-12 09:33:30 +02:00
petrbalvin d1295c6633 docs: document all development changes in changelog
Add entries for pagination flags, draft error code, just lint/fmt,
config.toml.example, published_posts memoization, Store mtime
invalidation, Cache-Control, FeedHelpers extraction, CSP header,
upload size limit, and MIME whitelist.
2026-06-26 20:49:48 +02:00
petrbalvin 5cc3a1aabe feat: return distinct 'draft' error for draft post detail requests
Previously the API returned 'not_found' for both missing and draft
posts. Now draft posts return a specific 'draft' error code so API
consumers can distinguish between the two cases and show an
appropriate message.
2026-06-26 20:44:42 +02:00
petrbalvin 781707a3fd chore: bump ClassLength limit to 440
Server sits at 423 lines after extracting FeedHelpers; allow a small
headroom for upcoming changes.
2026-06-26 20:44:03 +02:00
petrbalvin 2da9ec9bba refactor: extract feed and sitemap helpers into FeedHelpers module
Move feed_xml, feed_item, feed_json, json_feed_item, rfc822_date,
iso_date, and sitemap_xml out of Server into a dedicated FeedHelpers
module included via helpers. Reduces Server from ~500 to ~400 lines
and separates feed generation concerns from routing.
2026-06-26 20:43:27 +02:00
petrbalvin 0ba6e379d7 chore: gitignore config.toml, add config.toml.example
The dev config.toml contains a password hash and should not be
committed. Add a config.toml.example template instead and document
the copy step.
2026-06-26 20:41:49 +02:00
petrbalvin db6da86c93 perf: add Cache-Control header to public API responses
Set Cache-Control: public, max-age=60 on all /api/volumen/* endpoints
so browsers and CDNs can cache responses for 60 seconds, reducing
unnecessary re-fetches.
2026-06-26 20:41:30 +02:00
petrbalvin a2f42c6a12 security: add Content-Security-Policy header for admin routes
Set a strict CSP on all /admin/* responses: default-src 'self',
script/style 'unsafe-inline' (needed for the inline editor JS and
layout CSS), img-src 'self' data:, frame-ancestors 'none'. Public
API routes are unaffected. Bump ClassLength limit to 420 to
accommodate the new helper.
2026-06-26 20:41:10 +02:00
petrbalvin 223405dcb9 perf: invalidate Store cache on individual file mtime changes
Previously the store only checked the content directory's own mtime,
which does not change when an existing file is modified in place. Now
track the newest mtime across all .md files as well, so edits picked
up from disk (manual changes, git pull) are detected without a
restart.
2026-06-26 20:40:21 +02:00
petrbalvin 95452477d8 chore: add just lint and just fmt recipes
Add standalone lint and fmt (auto-fix) recipes to the justfile so
developers can check style without running the full build. Also
extract upload validation into a private Server helper to keep
the admin routes method under the complexity threshold.
2026-06-26 20:37:21 +02:00
petrbalvin bc2e056b68 feat: add has_next and has_prev to paginated posts response
Clients no longer need to compute page boundaries themselves; the API
now returns boolean flags indicating whether more pages exist in each
direction. Includes tests for first, middle, and last page.
2026-06-26 20:36:16 +02:00
petrbalvin e11c7d24a4 security: whitelist allowed image MIME types for uploads
Only accept common image formats (JPEG, PNG, GIF, WebP, AVIF, SVG)
on the upload endpoint. Non-image uploads now return HTTP 415 with a
structured JSON error listing the allowed types. Includes a test.
2026-06-26 20:35:46 +02:00
petrbalvin 25ebf691f7 security: reject file uploads exceeding 10 MB
Add a MAX_UPLOAD_BYTES constant (10 MB) to Server and check the
uploaded tempfile size before writing it to disk. Exceeding the limit
returns HTTP 413 with a structured JSON error. Includes a test.
2026-06-26 20:35:23 +02:00
petrbalvin 4abb5585df perf: memoize published_posts within a single request
Store the filtered and sorted list of published posts in an instance
variable so repeated calls during the same request (posts list, tags,
feeds, sitemap) reuse the cached result instead of re-filtering and
re-sorting from scratch each time. Sinatra resets instance variables
between requests, so the cache stays fresh.
2026-06-26 20:34:37 +02:00
petrbalvinandQwen-Coder 5437cef1cd feat: add fediverse_creator handle for Mastodon attribution
Expose a per-post fediverse_creator frontmatter field plus a site-wide
default in config.toml ([site].fediverse_creator). The handle is returned
in the post and site payloads, rendered as <dc:creator> in the RSS feed
and as authors[].name in the JSON feed, so a front-end can drop it into
<meta name="fediverse:creator"> on rendered pages. Per-post value
overrides the site default. Validated against a simple @user@host regex.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-06-26 17:40:48 +02:00
petrbalvin 1440e9a1fd release: bump version to 0.2.0 2026-06-25 22:12:55 +02:00
petrbalvin b24a8cc377 feat: add fulltext search with ?q= parameter
Filter posts by case-insensitive match in title and body. Extracted
filter_by_lang and search_posts helpers to keep complexity in check.
2026-06-25 22:06:49 +02:00
petrbalvin 599c63e5bc feat: add JSON Feed endpoint at /api/volumen/feed.json
Implements jsonfeed.org version 1.1 with title, home_page_url, feed_url,
description, language, and items with content_html and date_published.
2026-06-25 22:06:49 +02:00
petrbalvin 73be893bf8 feat: add scheduled publishing with publish_at frontmatter field
Posts with a future publish_at date are hidden from the public API
and feeds, same as drafts. The admin form exposes a Publish at field.
2026-06-25 22:06:49 +02:00
petrbalvin 52be2bb996 security: CORS preflight, session invalidation, CSRF on preview, symlink containment, and hardening
- Add OPTIONS /api/volumen/* for CORS preflight (S-22).
- Invalidate session in require_login! when user no longer exists (S-8).
- Add check_csrf! to POST /admin/preview (S-1).
- Use File.realpath for symlink-safe containment in media_file (S-2).
- Memoize Users#all with mtime-based invalidation (S-12).
- Document that empty permitted_hosts means allow-all (S-5).
- Warn and fall back to random when session_key is too short (S-20).
2026-06-25 22:06:49 +02:00
petrbalvin 5b8236c0a4 test: add sitemap.xml endpoint test
Verify content type, urlset structure, post URL presence, and draft exclusion.
2026-06-25 22:06:49 +02:00
petrbalvin 51e425e6dd test: add CLI test coverage
Cover version, help, unknown command, parse_serve_options defaults
and overrides, prepare_config template generation and no-op.
2026-06-25 22:06:49 +02:00
petrbalvin ce57410044 fix: Store#save writes non-default language posts into lang subdirectory
default_path_for now routes posts whose lang differs from default_lang
into content_dir/<lang>/<slug>.md, matching the documented multi-language
layout and preventing same-slug posts in different languages from
overwriting each other.
2026-06-25 22:06:49 +02:00
petrbalvin afd8ab4bfb fix: atomic writes for posts and users.toml
Write to a .tmp file and then File.rename into place, so a concurrent
read or a mid-write crash never sees a torn file.
2026-06-25 22:06:49 +02:00
petrbalvin ad00872895 perf: memoize Store#all with mtime-based invalidation
Cache the parsed post list per Store instance, invalidated on save,
delete, or content_dir mtime change. Eliminates re-parsing every .md
file on every API and admin request.
2026-06-25 22:06:49 +02:00
petrbalvin d7f9813484 fix: prevent Config DEFAULTS mutation through deep_merge
Freeze inner hashes in DEFAULTS and rewrite deep_merge to build fresh
hashes with {}.merge for uncopied inner maps. Previously, inner hashes
shared objects with the frozen-looking DEFAULTS, so mutating a loaded
config silently poisoned all later loads.
2026-06-25 22:06:49 +02:00
petrbalvin 263e794669 security: hide draft posts from public detail endpoint
Add post.draft? check to GET /api/volumen/posts/:slug so guessing a
draft slug returns 404 like the list endpoint already does.
2026-06-25 22:06:49 +02:00
petrbalvin 1ca0212197 security: add slug validation, username validation, and template escaping
- Enforce SLUG_REGEX (alphanumeric + dot/dash/underscore only) in creation_error
  to prevent path traversal (C-1) and stored XSS (C-3).
- Add File.expand_path containment check in default_path_for as defense-in-depth.
- Escape slug in form action attributes (list, form views).
- Validate username in change_username using the same regex as create_user (C-4).
- Escape username in settings form action attributes.
- Treat submitting the current username as a no-op success instead of 'taken'.
2026-06-25 22:06:49 +02:00
petrbalvin d26d3b5a5b fix: strip HTML tags from derived excerpt
gsub(/<[^>]+>/, '') before stripping markdown markup, so auto-generated
excerpts don't leak raw HTML from post bodies.
2026-06-25 22:06:49 +02:00
petrbalvin 4597c685da test: add RSS feed endpoint tests
Verify content type, RSS structure, draft exclusion, and language
element.
2026-06-25 21:41:19 +02:00
petrbalvin 2181538691 feat: add language element to RSS feed channel 2026-06-25 21:40:57 +02:00
petrbalvin 9dd55adc30 fix: add periodic cleanup of login_attempts hash
Sweep stale entries every 5 minutes to prevent unbounded memory growth
from IPs that touch the login endpoint once and never return.
2026-06-25 21:40:37 +02:00
petrbalvin 7a31b50ca7 docs: add [development] section to CHANGELOG, document in CONTRIBUTING
Record ongoing changes under [development] in CHANGELOG.md. Update the
release workflow to rename [development] to the versioned section.
2026-06-25 21:37:27 +02:00
petrbalvin b2b8156648 refactor: split Server into api_routes and admin_routes modules
Keep server.rb focused on configuration, session setup, and shared
helpers. Extract route definitions into registered Sinatra extension
modules. Exclude the registered methods from AbcSize/MethodLength
metrics — large registered methods are the idiomatic Sinatra pattern.
2026-06-25 21:35:07 +02:00
petrbalvin 140b16b55a feat: add in-memory rate limiting for login endpoint
Rate-limit POST /admin/login at 10 attempts per 60 seconds per IP.
Stored in Volumen.login_attempts so tests can reset the counter.
2026-06-25 21:33:16 +02:00
petrbalvin 2348352fab feat: add lastmod to sitemap entries 2026-06-25 21:31:57 +02:00
petrbalvin 184aa9b3d8 feat: add pubDate to RSS feed items 2026-06-25 21:31:02 +02:00
petrbalvin 0a70b74b55 fix: wire session_ttl config into Rack session cookie expiration
The admin.session_ttl value was defined in defaults, template, and
documentation but never read — sessions had no expiration. Pass it as
expire_after to Rack::Session::Cookie. A value <= 0 means persistent
(until browser close), matching the documented behaviour.
2026-06-25 21:30:20 +02:00
35 changed files with 3495 additions and 729 deletions
+3
View File
@@ -13,6 +13,9 @@
# Local development sandbox (posts, generated config) # Local development sandbox (posts, generated config)
/.volumen/ /.volumen/
# Local dev config (contains password hash)
/config.toml
# Uploaded media in the dev content directory # Uploaded media in the dev content directory
/posts/media/ /posts/media/
+11 -1
View File
@@ -19,12 +19,22 @@ Layout/LineLength:
Metrics/AbcSize: Metrics/AbcSize:
Max: 30 Max: 30
Exclude:
- "lib/volumen/admin_routes.rb"
- "lib/volumen/api_routes.rb"
Metrics/MethodLength: Metrics/MethodLength:
Max: 25 Max: 25
Exclude:
- "lib/volumen/admin_routes.rb"
- "lib/volumen/api_routes.rb"
Metrics/ClassLength: Metrics/ClassLength:
Max: 400 Max: 440
Metrics/ModuleLength:
Exclude:
- "lib/volumen/admin_routes.rb"
Metrics/BlockLength: Metrics/BlockLength:
Exclude: Exclude:
+149
View File
@@ -5,6 +5,153 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [development]
### Added
-
## [0.3.0] — 2026-07-12
### Added
**Content & authoring**
- **Fediverse attribution:** a `fediverse_creator` frontmatter field (e.g.
`@user@mastodon.social`) plus a site-wide `[site].fediverse_creator` default
in `config.toml`. The value is exposed as `fediverse_creator` on the site
and post payloads, rendered as `<dc:creator>` in the RSS feed, as
`authors[].name` in the JSON feed, and can be consumed by a front-end to
emit `<meta name="fediverse:creator">` on rendered pages. Per-post values
override the site default.
- **Cover caption:** a `cover_caption` post field rendered next to the cover
image so authors can credit photos or add a short blurb.
- **Titled images as `<figure>`:** Markdown images with a title
(`![alt](url "caption")`) are wrapped in a `<figure>` with a `<figcaption>`
and a small `i` info icon. Images without a title render exactly as before.
**Public API**
- `has_next` and `has_prev` boolean flags in the paginated
`/api/volumen/posts` response so clients can navigate pages without
computing boundaries themselves.
- Distinct `"draft"` error code in `/api/volumen/posts/:slug` when the post
exists but is a draft, replacing the generic `"not_found"`.
**Admin**
- **Modern admin UI:** redesigned layout with a sticky top bar, breadcrumbs
on every page, and a unified design language across login, post list,
post form, and settings.
- **Volumen icon:** a dedicated SVG icon shown in the sidebar and on the
login page (served at `/admin/icon.svg`).
- **Version in sidebar footer** so admins always see which release is
running.
- **Per-user display name** on the user record and admin header.
- **Per-user fediverse handle** editable from the settings page (independent
of the post-level `fediverse_creator`).
- **Profile photo upload:** each admin user can upload a WebP/AVIF avatar
that is stored alongside posts in the content directory and shown in the
settings page.
- **Tabbed settings page** separating account, profile, and admin sections.
- **Restricted uploads:** only WebP (`image/webp`) and AVIF (`image/avif`)
are accepted, with a 415 error message that points users to `cwebp` /
`avifenc` for conversion. Reflects the engine's WebP/AVIF-only posture
end-to-end.
- **Polished native inputs** for `<input type="date|time|file">` so they
match the rest of the admin's design language.
**Tooling**
- `just lint` recipe for standalone RuboCop checks and `just fmt` for
auto-fixing lint issues.
- `config.toml.example` template for local development; `config.toml` is
now gitignored.
### Changed
- Memoize `published_posts` within a single request so repeated calls
(posts list, tags, feeds, sitemap) reuse the cached result.
- `Store#all` cache now invalidates on individual file mtime changes, not
just on the content directory mtime. Manual edits and `git pull` are
detected without a restart.
- `Cache-Control: public, max-age=60` header on all public API responses
for browser and CDN caching.
- Extract feed and sitemap generation into a dedicated `FeedHelpers` module,
reducing `Server` from ~500 to ~420 lines.
### Fixed
- Admin post save/delete now correctly clears the in-memory posts cache, so
the post list reflects edits without a manual reload.
- The post form's date field defaults to today when the underlying post has
no date, preventing the picker from showing an invalid empty value.
### Security
- **Content-Security-Policy** header on all `/admin/*` responses:
`default-src 'self'`, `script-src 'self' 'unsafe-inline'`,
`style-src 'self' 'unsafe-inline'`, `img-src 'self' data:`,
`font-src 'self'`, `connect-src 'self'`, `form-action 'self'`,
`frame-ancestors 'none'`.
- File uploads are rejected (HTTP 413) when exceeding **10 MB**
(`MAX_UPLOAD_BYTES`).
- File uploads are restricted to `image/webp` and `image/avif`
(`ALLOWED_UPLOAD_TYPES`). Anything else is rejected with a 415 and a
conversion hint.
## [0.2.0] — 2026-06-25
### Added
- `<pubDate>` in RSS feed items, derived from the post date.
- `<lastmod>` in sitemap entries for SEO.
- In-memory rate limiting on the login endpoint: 10 attempts per 60 seconds per IP.
- `<language>` element in the RSS feed channel.
- Periodic cleanup of stale `login_attempts` entries to prevent unbounded memory growth.
- Slug format validation (`[a-z0-9._-]`) preventing path traversal and XSS in templates.
- Username validation in `change_username` matching the create-user regex.
- Escape-on-output for slug and username in admin template attributes.
- Memoization of `Store#all` with mtime-based invalidation, eliminating N×read+parse
per request.
- Atomic file writes (tmp + rename) for posts and `users.toml`.
- Post language directory routing: non-default-language posts now land in
`content_dir/<lang>/` instead of overwriting root-level files.
- CLI test coverage (version, help, unknown command, option parsing, config bootstrap).
- Test coverage for RSS feed and sitemap endpoints.
- CORS preflight (`OPTIONS /api/volumen/*`) so browsers can reach the JSON API.
- `Users#all` memoization with mtime-based invalidation, matching Store#all.
- Session invalidation: deleted users can no longer use existing session cookies.
- CSRF protection on `POST /admin/preview`.
- Symlink-safe containment in `Store#media_file` via `File.realpath`.
### Added
- **Scheduled publishing:** a `publish_at` frontmatter field hides posts from the
public API and feeds until their publication date arrives. The admin form
includes a Publish at date picker.
- **JSON Feed:** `GET /api/volumen/feed.json` serves a jsonfeed.org v1.1 feed
with `content_html` and `date_published` per item.
- **Fulltext search:** `GET /api/volumen/posts?q=…` filters posts by
case-insensitive match in title and body.
### Changed
- Split `lib/volumen/server.rb` into `api_routes.rb` and `admin_routes.rb` modules,
keeping the server class focused on configuration and shared helpers.
### Fixed
- `admin.session_ttl` was defined but never read; it now controls
`Rack::Session::Cookie` expiration via `expire_after`.
- Derived post excerpts now strip HTML tags before stripping markdown markup,
avoiding raw HTML leaks in auto-generated excerpts.
- Draft posts no longer leak through the public detail endpoint (`/api/volumen/posts/:slug`).
- `Config::DEFAULTS` inner hashes are now frozen and `deep_merge` builds fresh
copies, preventing mutation from silently poisoning later `Config.load` calls.
- A warning is emitted when `admin.session_key` is configured but shorter than
64 bytes, instead of silently falling back to a random secret.
## [0.1.0] — 2026-06-20 ## [0.1.0] — 2026-06-20
First public release: a small, file-based Markdown blog engine in CRuby. Posts First public release: a small, file-based Markdown blog engine in CRuby. Posts
@@ -94,3 +241,5 @@ admin — no database, no external services.
- Generic login error message to avoid username enumeration. - Generic login error message to avoid username enumeration.
[0.1.0]: https://codeberg.org/petrbalvin/volumen/releases/tag/v0.1.0 [0.1.0]: https://codeberg.org/petrbalvin/volumen/releases/tag/v0.1.0
[0.2.0]: https://codeberg.org/petrbalvin/volumen/releases/tag/v0.2.0
[0.3.0]: https://codeberg.org/petrbalvin/volumen/releases/tag/v0.3.0
+18 -11
View File
@@ -116,15 +116,18 @@ chore: pin gem versions to latest
1. Create a feature branch from `development`. 1. Create a feature branch from `development`.
2. Make your changes with Conventional-Commits messages. 2. Make your changes with Conventional-Commits messages.
3. Ensure `bundle exec rubocop` reports zero offenses and `just test` passes. 3. Record your changes under the `## [development]` section at the top of
4. Add or update tests for your change. `CHANGELOG.md`, using the Keep a Changelog categories (`Added`, `Changed`,
5. Update documentation if the public API, configuration, or behaviour changes: `Fixed`, etc.).
4. Ensure `bundle exec rubocop` reports zero offenses and `just test` passes.
5. Add or update tests for your change.
6. Update documentation if the public API, configuration, or behaviour changes:
- `README.md` — high-level overview. - `README.md` — high-level overview.
- `docs/configuration.md` — config keys. - `docs/configuration.md` — config keys.
- `docs/api-reference.md` — JSON API behaviour. - `docs/api-reference.md` — JSON API behaviour.
- `docs/architecture.md` — structural changes. - `docs/architecture.md` — structural changes.
- `docs/security.md` — anything touching auth, uploads, or rendering. - `docs/security.md` — anything touching auth, uploads, or rendering.
6. Open a pull request against `development`. Releases are cut by merging 7. Open a pull request against `development`. Releases are cut by merging
`development` into `main` and pushing a `vX.Y.Z` tag — see `development` into `main` and pushing a `vX.Y.Z` tag — see
[Cutting a release](#cutting-a-release). [Cutting a release](#cutting-a-release).
@@ -139,21 +142,24 @@ volumen/
│ └── volumen/ │ └── volumen/
│ ├── version.rb │ ├── version.rb
│ ├── frontmatter.rb # parse/serialise the +++ TOML frontmatter block │ ├── frontmatter.rb # parse/serialise the +++ TOML frontmatter block
│ ├── markdown.rb # kramdown (GFM) → HTML │ ├── markdown.rb # kramdown (GFM) → HTML; <figure> for titled images
│ ├── post.rb # Post model (metadata + body + rendered HTML) │ ├── post.rb # Post model (metadata + body + rendered HTML)
│ ├── store.rb # read/write posts and media on disk │ ├── store.rb # read/write posts and media on disk (mtime-cached)
│ ├── config.rb # load/merge config.toml, generate template │ ├── config.rb # load/merge config.toml, generate template
│ ├── password.rb # scrypt hashing/verification (OpenSSL::KDF) │ ├── password.rb # scrypt hashing/verification (OpenSSL::KDF)
│ ├── users.rb # users.toml, admin/author roles │ ├── users.rb # users.toml, admin/author roles, per-user profile
│ ├── server.rb # Sinatra app: public API + admin │ ├── feed_helpers.rb # RSS / JSON Feed / sitemap generation
│ ├── api_routes.rb # /api/volumen/* (Sinatra routes)
│ ├── admin_routes.rb # /admin/* (Sinatra routes)
│ ├── server.rb # Sinatra app: composes the two route modules
│ ├── cli.rb # command dispatcher │ ├── cli.rb # command dispatcher
│ └── web/ │ └── web/
│ ├── views/ # ERB templates (layout, login, list, form, settings) │ ├── views/ # ERB templates (layout, login, list, form, settings)
│ └── public/ # static assets (volumen-logo.svg) │ └── public/ # static assets (volumen-logo.svg, volumen-icon.svg)
├── test/ # minitest suite ├── test/ # minitest suite
├── docs/ # architecture, configuration, api-reference, deployment, security ├── docs/ # architecture, configuration, api-reference, deployment, security
├── .forgejo/workflows/ # test.yml, release.yml (Forgejo Actions) ├── .forgejo/workflows/ # test.yml, release.yml (Forgejo Actions)
├── justfile # install, run, dev, build, test, uninstall ├── justfile # install, run, dev, lint, fmt, build, test, uninstall
├── volumen.gemspec ├── volumen.gemspec
├── Gemfile ├── Gemfile
├── CHANGELOG.md ├── CHANGELOG.md
@@ -232,7 +238,8 @@ Requires a `FORGEJO_TOKEN` secret with permission to create releases.
### Cutting a release ### Cutting a release
1. Bump `VERSION` in `lib/volumen/version.rb`. 1. Bump `VERSION` in `lib/volumen/version.rb`.
2. Add a `## [X.Y.Z] — YYYY-MM-DD` section at the top of `CHANGELOG.md`. 2. Rename `## [development]` to `## [X.Y.Z] — YYYY-MM-DD` at the top of
`CHANGELOG.md`, and add a fresh empty `## [development]` section above it.
3. Ensure CI is green on `development`. 3. Ensure CI is green on `development`.
4. Merge `development` into `main` (the only time `main` changes outside a tag). 4. Merge `development` into `main` (the only time `main` changes outside a tag).
5. Tag and push: 5. Tag and push:
+2 -2
View File
@@ -1,7 +1,7 @@
PATH PATH
remote: . remote: .
specs: specs:
volumen (0.1.0) volumen (0.3.0)
kramdown (~> 2.5) kramdown (~> 2.5)
kramdown-parser-gfm (~> 1.1) kramdown-parser-gfm (~> 1.1)
puma (~> 8.0) puma (~> 8.0)
@@ -130,7 +130,7 @@ CHECKSUMS
toml-rb (4.2.0) sha256=10a48c91613e20cf63483a7a776767dfb3cd7d70e9327c0237443da601e13776 toml-rb (4.2.0) sha256=10a48c91613e20cf63483a7a776767dfb3cd7d70e9327c0237443da601e13776
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
volumen (0.1.0) volumen (0.3.0)
BUNDLED WITH BUNDLED WITH
4.0.10 4.0.10
+36
View File
@@ -88,6 +88,7 @@ slug = "my-post" # optional, defaults to the filename
date = 2026-01-15 # first-class TOML date date = 2026-01-15 # first-class TOML date
lang = "en" # language code lang = "en" # language code
author = "Petr Balvín" # optional author = "Petr Balvín" # optional
fediverse_creator = "@petrbalvin@mastodon.social" # optional, for Mastodon attribution
tags = ["ruby", "web"] # optional tags = ["ruby", "web"] # optional
draft = false # optional draft = false # optional
excerpt = "Short summary" # optional, auto-derived from body if missing excerpt = "Short summary" # optional, auto-derived from body if missing
@@ -113,6 +114,41 @@ front-end can offer a language switcher.
Set `all_langs = true` on a post to show it in **every** language (useful for Set `all_langs = true` on a post to show it in **every** language (useful for
posts that have no per-language translations). posts that have no per-language translations).
### Cover caption
A `cover_caption` frontmatter field renders a short caption next to the cover
image — useful for photo credits or a one-line blurb:
```toml
cover = "media/cover.webp"
cover_caption = "Photo by Petr Balvín"
```
### Titled images as figures
A Markdown image with a **title** (`![alt](url "caption")`) is rendered as a
`<figure>` with a `<figcaption>` and a small `i` info icon. Images without a
title render exactly as before:
```markdown
![A sunset over the hills](media/sunset.webp "Sunset from the ridge above the village")
```
### 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
(`/api/volumen/posts/{slug}` and `/api/volumen/posts`), and as `<dc:creator>` in
the RSS feed and `authors[].name` in the JSON feed. A front-end consuming the
API can drop it straight into `<head>`:
```html
<meta name="fediverse:creator" content="@petrbalvin@mastodon.social">
```
A site-wide default can be set in `config.toml` (`[site].fediverse_creator`); a
per-post value takes precedence.
### Why TOML instead of YAML ### Why TOML instead of YAML
- **First-class dates** — `date = 2026-01-15` is a real date, not a guess. - **First-class dates** — `date = 2026-01-15` is a real date, not a guess.
+3 -4
View File
@@ -1,5 +1,5 @@
# Local development configuration for `just dev`. # Local development configuration for `just dev`.
# Not shipped with the gem; safe to edit. Keep real admin hashes out of Git. # Copy to config.toml and adjust. Keep real admin hashes out of Git.
[server] [server]
host = "127.0.0.1" host = "127.0.0.1"
@@ -16,8 +16,7 @@ language = "cs"
author = "Petr Balvín" author = "Petr Balvín"
[admin] [admin]
# Dev credentials: password is "admin". Replace before any real use # Generate a hash with: bundle exec exe/volumen hash-password
# (generate with `volumen hash-password`). password_hash = ""
password_hash = "scrypt$16384$8$1$I7XEC8EpyfaptaPxmBQSUg==$gK7TRZAJnYmOSfKJ7xg+cOaYeBV4r5jlqcoL5+jNlSs="
session_key = "" session_key = ""
session_ttl = 86400 session_ttl = 86400
+7 -1
View File
@@ -16,10 +16,14 @@ Returns site metadata from the configuration.
"description": "A blog powered by volumen.", "description": "A blog powered by volumen.",
"base_url": "https://example.com", "base_url": "https://example.com",
"language": "en", "language": "en",
"author": "Anonymous" "author": "Anonymous",
"fediverse_creator": "@petrbalvin@mastodon.social"
} }
``` ```
`fediverse_creator` is `null` when the site-wide default is not set; a per-post
value (see below) takes precedence when present.
## `GET /api/volumen/posts` ## `GET /api/volumen/posts`
Paginated list of published posts (drafts are excluded), newest first. Paginated list of published posts (drafts are excluded), newest first.
@@ -46,6 +50,7 @@ Posts with `all_langs = true` in their frontmatter match every `lang` filter.
"lang": "en", "lang": "en",
"tags": ["intro"], "tags": ["intro"],
"author": null, "author": null,
"fediverse_creator": null,
"cover": null, "cover": null,
"reading_time": 1, "reading_time": 1,
"translations": {}, "translations": {},
@@ -70,6 +75,7 @@ no match.
"lang": "en", "lang": "en",
"tags": ["intro"], "tags": ["intro"],
"author": null, "author": null,
"fediverse_creator": "@petrbalvin@mastodon.social",
"cover": null, "cover": null,
"reading_time": 1, "reading_time": 1,
"translations": {}, "translations": {},
+21 -10
View File
@@ -20,7 +20,7 @@ server-rendered admin for editing them.
- **Markdown-first** — the visual editor round-trips through the same renderer - **Markdown-first** — the visual editor round-trips through the same renderer
that powers the public API, so stored content is always Markdown. that powers the public API, so stored content is always Markdown.
## Components (planned layout) ## Components
``` ```
lib/volumen/ lib/volumen/
@@ -28,27 +28,38 @@ lib/volumen/
config.rb # loads /etc/volumen/config.toml, applies CLI overrides config.rb # loads /etc/volumen/config.toml, applies CLI overrides
frontmatter.rb # parse/serialise the `+++ … +++` TOML block frontmatter.rb # parse/serialise the `+++ … +++` TOML block
post.rb # Post model: metadata + raw body + rendered HTML post.rb # Post model: metadata + raw body + rendered HTML
store.rb # reads the content directory into Post objects store.rb # reads the content directory into Post objects (mtime-cached)
markdown.rb # kramdown wrapper (render + sanitise) markdown.rb # kramdown wrapper (render + <figure>/<figcaption> for titled images)
server.rb # Sinatra app: JSON API + admin routes feed_helpers.rb # RSS / JSON Feed / sitemap generation
users.rb # users.toml + admin/author roles
password.rb # scrypt hashing/verification (OpenSSL::KDF)
api_routes.rb # /api/volumen/* (Sinatra routes)
admin_routes.rb # /admin/* (Sinatra routes)
server.rb # Sinatra app: composes the two route modules, shared helpers
cli.rb # command dispatcher
web/ web/
views/ # ERB templates for the admin views/ # ERB templates for the admin
public/ # admin CSS + the visual-editor JS island public/ # static assets (volumen-logo.svg, volumen-icon.svg)
exe/volumen # CLI: serve, hash-password, version, help exe/volumen # CLI: serve, hash-password, version, help
``` ```
## Request flow (planned) ## Request flow
```mermaid ```mermaid
graph TD graph TD
A[HTTP request] --> B{Route} A[HTTP request] --> B{Route}
B -->|/api/volumen/*| C[JSON API] B -->|/api/volumen/*| C[api_routes.rb]
B -->|/admin/*| D[Admin: session + CSRF] B -->|/admin/*| D[admin_routes.rb + CSRF + CSP]
C --> E[Store] C --> E[Store]
D --> E D --> E
E --> F[(content_dir/*.md)] D --> U[Users]
C --> F[FeedHelpers]
C --> G[Markdown renderer] C --> G[Markdown renderer]
D --> G E --> H[(content_dir/*.md)]
E --> I[(content_dir/media/*)]
U --> J[(users.toml)]
F --> E
G --> K[<figure> for titled images]
``` ```
## Public site integration ## Public site integration
+5
View File
@@ -22,6 +22,11 @@ description = "A blog powered by volumen."
base_url = "https://example.com" base_url = "https://example.com"
language = "en" language = "en"
author = "Anonymous" author = "Anonymous"
# Site-wide fediverse handle (e.g. Mastodon). Exposed as `fediverse_creator`
# on /api/volumen/site and used as the fallback in the RSS / JSON feeds when
# a post does not set its own `fediverse_creator` frontmatter field. Leave
# empty if you do not publish to the fediverse.
fediverse_creator = "@you@example.social"
[admin] [admin]
# scrypt hash produced by `volumen hash-password`. # scrypt hash produced by `volumen hash-password`.
+32 -3
View File
@@ -51,6 +51,26 @@ than opening a public issue.
Requests without a valid token get `403`. Requests without a valid token get `403`.
- `SameSite=Strict` cookies provide a second, independent layer. - `SameSite=Strict` cookies provide a second, independent layer.
## Content Security Policy (admin)
All `/admin/*` responses send a strict **Content-Security-Policy** header:
```
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
form-action 'self';
frame-ancestors 'none'
```
The inline allowances are required by the server-rendered admin (ERB templates
emit small inline `<script>` blocks and inline `style=""` attributes);
`frame-ancestors 'none'` prevents clickjacking via iframe embedding. The public
JSON API does not set a CSP — it returns no HTML, so none is needed.
## CORS and Host handling ## CORS and Host handling
- The public read API sends `Access-Control-Allow-Origin: *` (read-only, no - The public read API sends `Access-Control-Allow-Origin: *` (read-only, no
@@ -68,8 +88,15 @@ than opening a public issue.
- `GET /media/*` resolves names with `File.basename` and an explicit - `GET /media/*` resolves names with `File.basename` and an explicit
containment check, so `../` traversal cannot read files outside the media containment check, so `../` traversal cannot read files outside the media
directory. directory.
- Upload content-type is **not** validated (the `accept="image/*"` attribute is - Uploads are **rejected (HTTP 413)** when the file exceeds **10 MB**
only a UI hint); this is acceptable under the trusted-author model. (`MAX_UPLOAD_BYTES`).
- Upload MIME type is **whitelisted** to `image/webp` and `image/avif`
(`ALLOWED_UPLOAD_TYPES`); anything else returns **HTTP 415** with a
conversion hint pointing at `cwebp` / `avifenc`. This matches the
WebP/AVIF-only posture the engine serves content in and rejects binaries
(PHP, executable, …) outright.
- Per-user profile photos go through the same upload validation and storage
pipeline as post media.
## Secrets and files ## Secrets and files
@@ -91,7 +118,9 @@ A small, audited set: `sinatra`, `kramdown` (+ `kramdown-parser-gfm`),
## Known limitations / non-goals ## Known limitations / non-goals
- **No rate limiting or lockout** on `/admin/login`. Put nginx `limit_req` in - **No rate limiting or lockout** on `/admin/login` at the application layer
(the engine does have an in-memory rate limiter, but nginx is the durable
line of defence — see `docs/deployment.md`). Put nginx `limit_req` in
front of it, or use fail2ban, to slow brute-force attempts. front of it, or use fail2ban, to slow brute-force attempts.
- **No 2FA** and **no audit log**. - **No 2FA** and **no audit log**.
- **Raw HTML in posts is trusted** (see the trust model). There is no built-in - **Raw HTML in posts is trusted** (see the trust model). There is no built-in
+10
View File
@@ -19,6 +19,16 @@ dev:
@echo "→ Starting volumen (dev) on http://localhost:9090 …" @echo "→ Starting volumen (dev) on http://localhost:9090 …"
bundle exec exe/volumen serve --config ./config.toml --content ./posts bundle exec exe/volumen serve --config ./config.toml --content ./posts
# Lint the codebase (RuboCop, zero offenses)
lint:
@echo "→ Linting…"
bundle exec rubocop
# Auto-fix lint issues
fmt:
@echo "→ Auto-fixing…"
bundle exec rubocop -A
# Lint and package the gem (must pass with zero warnings) # Lint and package the gem (must pass with zero warnings)
build: build:
@echo "→ Linting…" @echo "→ Linting…"
+20
View File
@@ -18,4 +18,24 @@ require_relative "volumen/users"
# The Sinatra app (`Volumen::Server`) and the CLI (`Volumen::CLI`) are loaded # The Sinatra app (`Volumen::Server`) and the CLI (`Volumen::CLI`) are loaded
# on demand to keep the core library light. # on demand to keep the core library light.
module Volumen module Volumen
CLEANUP_INTERVAL = 300 # seconds between full sweeps of login_attempts
def self.login_attempts
@login_attempts ||= {}
end
def self.reset_login_attempts!
@login_attempts = {}
@last_cleanup = nil
end
def self.sweep_login_attempts!
now = Time.now.to_f
return if @last_cleanup && (now - @last_cleanup) < CLEANUP_INTERVAL
cutoff = now - Volumen::Server::LOGIN_WINDOW
login_attempts.each_value { |timestamps| timestamps.select! { |t| t > cutoff } }
login_attempts.delete_if { |_ip, timestamps| timestamps.empty? }
@last_cleanup = now
end
end end
+181
View File
@@ -0,0 +1,181 @@
# frozen_string_literal: true
module Volumen
# Server-rendered admin routes, registered into Volumen::Server.
module AdminRoutes
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
def self.registered(app)
app.get "/admin" do
redirect to("/admin/")
end
app.get "/admin/" do
require_login!
@posts = sorted_posts
@crumbs = [["Posts", nil]]
erb :list
end
app.get "/admin/login" do
redirect to("/admin/") if authenticated?
erb :login
end
app.post "/admin/login" do
check_csrf!
check_login_rate_limit!
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
app.post "/admin/logout" do
check_csrf!
session.clear
redirect to("/admin/login")
end
app.get "/admin/posts/new" do
require_login!
@mode = :new
@post = Post.new(metadata: { "lang" => config.language })
@crumbs = [["Posts", to("/admin/")], ["New post", nil]]
erb :form
end
app.post "/admin/posts" do
require_login!
check_csrf!
create_post
end
app.get "/admin/posts/:slug/edit" do
require_login!
@mode = :edit
@post = store.find(params["slug"])
halt(404, "Not found") if @post.nil?
title = @post.title.to_s.empty? ? params["slug"] : @post.title
@crumbs = [["Posts", to("/admin/")], [title, nil]]
erb :form
end
app.post "/admin/posts/:slug" do
require_login!
check_csrf!
update_post
end
app.post "/admin/posts/:slug/delete" do
require_login!
check_csrf!
store.delete(params["slug"])
clear_posts_cache!
redirect to("/admin/")
end
app.post "/admin/preview" do
require_login!
check_csrf!
content_type :html
Markdown.render(params["body"].to_s)
end
app.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]
validate_upload!(upload)
json("url" => store.store_upload(upload[:filename], upload[:tempfile]))
end
app.get "/media/*" do
path = store.media_file(params["splat"].first)
halt(404) if path.nil?
send_file(path)
end
app.get "/admin/logo.svg" do
send_file(File.expand_path("web/public/volumen-logo.svg", __dir__))
end
app.get "/admin/icon.svg" do
send_file(File.expand_path("web/public/volumen-icon.svg", __dir__))
end
app.get "/admin/settings" do
require_login!
@crumbs = [["Settings", nil]]
render_settings
end
app.post "/admin/settings/password" do
require_login!
check_csrf!
change_password
end
app.post "/admin/settings/username" do
require_login!
check_csrf!
change_username
end
app.post "/admin/settings/name" do
require_login!
check_csrf!
change_name
end
app.post "/admin/settings/fediverse" do
require_login!
check_csrf!
change_fediverse_creator
end
app.post "/admin/settings/photo" do
require_login!
check_csrf!
change_photo
end
app.post "/admin/settings/photo/remove" do
require_login!
check_csrf!
remove_photo
end
app.post "/admin/settings/users" do
require_login!
require_admin!
check_csrf!
create_user
end
app.post "/admin/settings/users/:username/role" do
require_login!
require_admin!
check_csrf!
change_role(params["username"])
end
app.post "/admin/settings/users/:username/delete" do
require_login!
require_admin!
check_csrf!
remove_user(params["username"])
end
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
end
end
end
+56
View File
@@ -0,0 +1,56 @@
# frozen_string_literal: true
module Volumen
# Public JSON API routes, registered into Volumen::Server.
module ApiRoutes
def self.registered(app)
app.before "/api/volumen/*" do
headers "Access-Control-Allow-Origin" => "*",
"Cache-Control" => "public, max-age=60"
content_type :json
end
app.options "/api/volumen/*" do
headers "Access-Control-Allow-Origin" => "*"
headers "Access-Control-Allow-Methods" => "GET, OPTIONS"
headers "Access-Control-Allow-Headers" => "Content-Type"
content_type :json
""
end
app.get "/api/volumen/site" do
json(site_payload)
end
app.get "/api/volumen/posts" do
json(posts_payload)
end
app.get "/api/volumen/posts/:slug" do
post = store.find(params["slug"], lang: params["lang"])
halt(404, json("error" => "not_found")) if post.nil?
halt(404, json("error" => "draft")) if post.draft?
json(post.detail)
end
app.get "/api/volumen/tags" do
json("tags" => tag_counts)
end
app.get "/api/volumen/feed.xml" do
content_type "application/rss+xml"
feed_xml
end
app.get "/api/volumen/feed.json" do
json(feed_json)
end
app.get "/api/volumen/sitemap.xml" do
content_type "application/xml"
sitemap_xml
end
end
end
end
+25 -6
View File
@@ -10,7 +10,7 @@ module Volumen
DEFAULT_PATH = "/etc/volumen/config.toml" DEFAULT_PATH = "/etc/volumen/config.toml"
DEFAULTS = { DEFAULTS = {
"server" => { "host" => "0.0.0.0", "port" => 9090 }, "server" => { "host" => "0.0.0.0", "port" => 9090 }.freeze,
"content_dir" => "/var/lib/volumen/posts", "content_dir" => "/var/lib/volumen/posts",
"users_file" => "/var/lib/volumen/users.toml", "users_file" => "/var/lib/volumen/users.toml",
"site" => { "site" => {
@@ -18,13 +18,14 @@ module Volumen
"description" => "A blog powered by volumen.", "description" => "A blog powered by volumen.",
"base_url" => "https://example.com", "base_url" => "https://example.com",
"language" => "en", "language" => "en",
"author" => "Anonymous" "author" => "Anonymous",
}, "fediverse_creator" => nil
}.freeze,
"admin" => { "admin" => {
"password_hash" => "", "password_hash" => "",
"session_key" => "", "session_key" => "",
"session_ttl" => 86_400 "session_ttl" => 86_400
} }.freeze
}.freeze }.freeze
TEMPLATE = <<~TOML TEMPLATE = <<~TOML
@@ -46,6 +47,9 @@ module Volumen
base_url = "https://example.com" base_url = "https://example.com"
language = "en" language = "en"
author = "Anonymous" author = "Anonymous"
# Default fediverse handle surfaced in <meta name="fediverse:creator">
# by frontends consuming the API. Leave empty to disable.
fediverse_creator = ""
[admin] [admin]
# Bootstrap admin login: paste a hash from `volumen hash-password` to sign # Bootstrap admin login: paste a hash from `volumen hash-password` to sign
@@ -77,14 +81,29 @@ module Volumen
end end
def self.deep_merge(base, override) def self.deep_merge(base, override)
base.merge(override) do |_key, base_val, override_val| result = {}
base.each do |key, base_val|
result[key] = if override.key?(key)
merge_leaf(base_val, override[key])
elsif base_val.is_a?(Hash)
{}.merge(base_val)
else
base_val
end
end
override.each do |key, val|
result[key] = val unless result.key?(key)
end
result
end
def self.merge_leaf(base_val, override_val)
if base_val.is_a?(Hash) && override_val.is_a?(Hash) if base_val.is_a?(Hash) && override_val.is_a?(Hash)
deep_merge(base_val, override_val) deep_merge(base_val, override_val)
else else
override_val override_val
end end
end end
end
def self.apply_overrides(data, overrides) def self.apply_overrides(data, overrides)
server = data["server"].dup server = data["server"].dup
+104
View File
@@ -0,0 +1,104 @@
# frozen_string_literal: true
module Volumen
# RSS, JSON Feed, and sitemap helpers extracted from Server to keep the
# main class focused on configuration and routing.
module FeedHelpers
private
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" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>#{escape(site["title"])}</title>
<link>#{escape(base_url)}</link>
<description>#{escape(site["description"])}</description>
<language>#{escape(site["language"])}</language>
#{items}
</channel>
</rss>
XML
end
def feed_item(post)
link = "#{base_url}/#{post.slug}"
pub = if post.metadata["date"]
"\n <pubDate>#{escape(rfc822_date(post))}</pubDate>"
else
""
end
creator = post.fediverse_creator
creator_tag = creator ? "\n <dc:creator>#{escape(creator)}</dc:creator>" : ""
<<~XML.chomp
<item>
<title>#{escape(post.title)}</title>
<link>#{escape(link)}</link>
<guid>#{escape(link)}</guid>
<description>#{escape(post.excerpt)}</description>#{pub}#{creator_tag}
</item>
XML
end
def rfc822_date(post)
value = post.metadata["date"]
case value
when Date then value.to_time.rfc822
when Time then value.rfc822
end
end
def feed_json
site = config.site
feed_url = "#{base_url}/api/volumen/feed.json"
{
"version" => "https://jsonfeed.org/version/1.1",
"title" => site["title"],
"home_page_url" => base_url,
"feed_url" => feed_url,
"description" => site["description"],
"language" => site["language"],
"items" => published_posts.first(20).map { |post| json_feed_item(post) }
}
end
def json_feed_item(post)
link = "#{base_url}/#{post.slug}"
item = {
"id" => link,
"url" => link,
"title" => post.title,
"content_html" => post.html,
"summary" => post.excerpt,
"date_published" => iso_date(post.metadata["date"]),
"tags" => post.tags
}
author = post.fediverse_creator || site_fediverse_creator
item["authors"] = [{ "name" => author }] if author
item.reject { |_k, v| v.nil? || v == "" || v == [] }
end
def iso_date(value)
case value
when Date then value.iso8601
when Time then value.to_date.iso8601
end
end
def sitemap_xml
urls = published_posts.map do |post|
url = " <url><loc>#{escape("#{base_url}/#{post.slug}")}</loc>"
url << "<lastmod>#{escape(post.date_string)}</lastmod>" if post.date_string
url << "</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
end
end
+40 -1
View File
@@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require "kramdown" require "kramdown"
require "rexml/document"
module Volumen module Volumen
# Renders Markdown to HTML using kramdown's GFM parser (tables, fenced code, # Renders Markdown to HTML using kramdown's GFM parser (tables, fenced code,
@@ -8,6 +9,10 @@ module Volumen
# #
# Posts are authored by the single trusted admin, so kramdown's default # Posts are authored by the single trusted admin, so kramdown's default
# behaviour of passing raw HTML through is intentional. # behaviour of passing raw HTML through is intentional.
#
# Images with a title (`![alt](url "caption")`) are wrapped in a `<figure>`
# element with a `<figcaption>` so the frontend can style captions and
# overlay an info icon.
module Markdown module Markdown
OPTIONS = { OPTIONS = {
input: "GFM", input: "GFM",
@@ -17,7 +22,41 @@ module Volumen
}.freeze }.freeze
def self.render(text) def self.render(text)
::Kramdown::Document.new(text.to_s, OPTIONS).to_html html = ::Kramdown::Document.new(text.to_s, OPTIONS).to_html
wrap_figures(html)
end
# Wrap every `<img title="...">` inside a `<figure>` with `<figcaption>`.
# Images without a title are left untouched.
def self.wrap_figures(html)
doc = REXML::Document.new("<root>#{html}</root>")
REXML::XPath.each(doc, "//img[@title]") do |img|
caption = img.attributes["title"]
img.attributes.delete("title")
figure = REXML::Element.new("figure")
figure.add_element(img.deep_clone)
info = REXML::Element.new("div")
info.add_attribute("class", "fig-info")
info.add_attribute("aria-hidden", "true")
info.text = "i"
figure.add_element(info)
figcaption = REXML::Element.new("figcaption")
figcaption.text = caption
figure.add_element(figcaption)
img.parent.replace_child(img, figure)
end
# Strip the wrapping <root> we added.
result = +""
doc.root.children.each { |child| result << child.to_s }
result
rescue REXML::ParseException
html
end end
end end
end end
+44 -1
View File
@@ -7,6 +7,8 @@ require_relative "markdown"
module Volumen module Volumen
# A single blog post: TOML frontmatter metadata plus a Markdown body. # A single blog post: TOML frontmatter metadata plus a Markdown body.
class Post class Post
FEDIVERSE_CREATOR_REGEX = /\A@[^@\s]+@[^@\s]+\z/
attr_reader :metadata, :body attr_reader :metadata, :body
attr_accessor :path attr_accessor :path
@@ -56,6 +58,44 @@ module Volumen
metadata["cover"] metadata["cover"]
end end
def cover_alt
metadata["cover_alt"]
end
def cover_caption
metadata["cover_caption"]
end
def fediverse_creator
value = metadata["fediverse_creator"]
return nil if value.nil?
text = value.to_s.strip
text.empty? ? nil : text
end
def valid_fediverse_creator?
fediverse_creator&.match?(FEDIVERSE_CREATOR_REGEX)
end
def publish_at
metadata["publish_at"]
end
def scheduled?
value = publish_at
return false if value.nil?
date = case value
when Date then value
when Time then value.to_date
else Date.iso8601(value.to_s)
end
date > Date.today
rescue ArgumentError
false
end
def reading_time def reading_time
words = body.split(/\s+/).count { |word| !word.empty? } words = body.split(/\s+/).count { |word| !word.empty? }
[(words / 200.0).ceil, 1].max [(words / 200.0).ceil, 1].max
@@ -94,7 +134,10 @@ module Volumen
"lang" => lang, "lang" => lang,
"tags" => tags, "tags" => tags,
"author" => author, "author" => author,
"fediverse_creator" => fediverse_creator,
"cover" => cover, "cover" => cover,
"cover_alt" => cover_alt,
"cover_caption" => cover_caption,
"reading_time" => reading_time, "reading_time" => reading_time,
"translations" => translations, "translations" => translations,
"url" => "/api/volumen/posts/#{slug}" "url" => "/api/volumen/posts/#{slug}"
@@ -112,7 +155,7 @@ module Volumen
.find { |para| !para.empty? && !para.start_with?("#") } .find { |para| !para.empty? && !para.start_with?("#") }
return "" if paragraph.nil? return "" if paragraph.nil?
stripped = paragraph.gsub(/[*_`>#]/, "").strip stripped = paragraph.gsub(/<[^>]+>/, "").gsub(/[*_`>#]/, "").strip
stripped.length > limit ? "#{stripped[0, limit].rstrip}" : stripped stripped.length > limit ? "#{stripped[0, limit].rstrip}" : stripped
end end
end end
+204 -229
View File
@@ -8,32 +8,54 @@ require_relative "config"
require_relative "store" require_relative "store"
require_relative "markdown" require_relative "markdown"
require_relative "password" require_relative "password"
require_relative "api_routes"
require_relative "admin_routes"
require_relative "feed_helpers"
module Volumen module Volumen
# Sinatra application: the public JSON API at /api/volumen and the # Sinatra application: the public JSON API at /api/volumen and the
# server-rendered admin at /admin. # server-rendered admin at /admin.
class Server < Sinatra::Base class Server < Sinatra::Base
MAX_LOGIN_ATTEMPTS = 10
LOGIN_WINDOW = 60 # seconds
MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB
ALLOWED_UPLOAD_TYPES = %w[image/webp image/avif].freeze
SLUG_REGEX = /\A[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\z/
register ApiRoutes
register AdminRoutes
helpers FeedHelpers
configure do configure do
set :show_exceptions, false set :show_exceptions, false
set :raise_errors, false set :raise_errors, false
set :host_authorization, { permitted_hosts: [] } set :host_authorization, { permitted_hosts: [] } # empty = allow all hosts
set :views, File.expand_path("web/views", __dir__) set :views, File.expand_path("web/views", __dir__)
end end
# Returns a configured subclass bound to a specific config + store. # Returns a configured subclass bound to a specific config + store.
def self.configured(config:, store:) def self.configured(config:, store:)
secret = session_secret(config) secret = session_secret(config)
ttl = config.admin["session_ttl"].to_i
ttl = nil if ttl <= 0
Class.new(self) do Class.new(self) do
set :volumen_config, config set :volumen_config, config
set :volumen_store, store set :volumen_store, store
set :sessions, httponly: true, same_site: :strict set :sessions, httponly: true, same_site: :strict, expire_after: ttl
set :session_secret, secret set :session_secret, secret
end end
end end
def self.session_secret(config) def self.session_secret(config)
key = config.admin["session_key"].to_s key = config.admin["session_key"].to_s
key.bytesize >= 64 ? key : SecureRandom.hex(64) return SecureRandom.hex(64) if key.empty?
if key.bytesize < 64
warn "volumen: session_key is shorter than 64 bytes, falling back to random secret"
return SecureRandom.hex(64)
end
key
end end
# Mark the session cookie Secure only when the request is HTTPS (directly or # Mark the session cookie Secure only when the request is HTTPS (directly or
@@ -42,178 +64,19 @@ module Volumen
before do before do
options = request.env["rack.session.options"] options = request.env["rack.session.options"]
options[:secure] = request.ssl? if options options[:secure] = request.ssl? if options
if request.path_info.start_with?("/admin")
headers "Content-Security-Policy" => [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"form-action 'self'",
"frame-ancestors 'none'"
].join("; ")
end 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 end
private private
@@ -233,11 +96,22 @@ module Volumen
end end
def published_posts def published_posts
store.all.reject(&:draft?).sort_by { |post| post.date_string || "" }.reverse @published_posts ||= store.all
.reject { |post| post.draft? || post.scheduled? }
.sort_by { |post| post.date_string || "" }.reverse
end
def clear_posts_cache!
@published_posts = nil
end end
def site_payload def site_payload
config.site.slice("title", "description", "base_url", "language", "author") config.site.slice("title", "description", "base_url", "language", "author",
"fediverse_creator")
end
def site_fediverse_creator
config.site["fediverse_creator"]
end end
def posts_payload def posts_payload
@@ -245,24 +119,42 @@ module Volumen
page = positive_int(params["page"], 1) page = positive_int(params["page"], 1)
limit = positive_int(params["limit"], 20).clamp(1, 100) limit = positive_int(params["limit"], 20).clamp(1, 100)
offset = (page - 1) * limit offset = (page - 1) * limit
total = posts.length
{ {
"page" => page, "page" => page,
"page_size" => limit, "page_size" => limit,
"total" => posts.length, "total" => total,
"has_next" => offset + limit < total,
"has_prev" => page > 1,
"posts" => posts[offset, limit].to_a.map(&:summary) "posts" => posts[offset, limit].to_a.map(&:summary)
} }
end end
def filtered_posts def filtered_posts
posts = published_posts posts = published_posts
if params["lang"] posts = filter_by_lang(posts)
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 = posts.select { |post| post.tags.include?(params["tag"]) } if params["tag"]
posts = search_posts(posts) if params["q"]
posts posts
end end
def filter_by_lang(posts)
return posts unless params["lang"]
lang = params["lang"]
posts.select { |post| post.lang == lang || post.all_langs? }
end
def search_posts(posts)
query = params["q"].to_s.strip.downcase
return posts if query.empty?
posts.select do |post|
post.title.to_s.downcase.include?(query) ||
post.body.downcase.include?(query)
end
end
def tag_counts def tag_counts
counts = Hash.new(0) counts = Hash.new(0)
published_posts.each { |post| post.tags.each { |tag| counts[tag] += 1 } } published_posts.each { |post| post.tags.each { |tag| counts[tag] += 1 } }
@@ -279,46 +171,6 @@ module Volumen
config.site["base_url"].to_s.chomp("/") config.site["base_url"].to_s.chomp("/")
end 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) def escape(text)
Rack::Utils.escape_html(text.to_s) Rack::Utils.escape_html(text.to_s)
end end
@@ -347,12 +199,32 @@ module Volumen
current_user_record&.role current_user_record&.role
end end
def current_user_name
current_user_record&.name
end
def current_user_fediverse
current_user_record&.fediverse_creator
end
def current_user_photo
current_user_record&.photo
end
def admin? def admin?
current_role == "admin" current_role == "admin"
end end
def require_login! def require_login!
redirect to("/admin/login") unless authenticated? unless authenticated?
redirect to("/admin/login")
return
end
return unless current_user_record.nil?
session.clear
redirect to("/admin/login")
end end
def require_admin! def require_admin!
@@ -365,11 +237,40 @@ module Volumen
def check_csrf! def check_csrf!
token = params["_csrf"] token = params["_csrf"]
return if token && session[:csrf] && Rack::Utils.secure_compare(session[:csrf], token) return if token && session["csrf"] && Rack::Utils.secure_compare(session["csrf"], token)
halt(403, "Invalid CSRF token") halt(403, "Invalid CSRF token")
end end
def validate_upload!(upload)
if upload[:tempfile].size > MAX_UPLOAD_BYTES
halt(413, json("error" => "file_too_large", "max_bytes" => MAX_UPLOAD_BYTES))
end
content_type = upload[:type].to_s.split(";").first.to_s.strip.downcase
return if ALLOWED_UPLOAD_TYPES.include?(content_type)
halt(415, json("error" => "unsupported_format",
"message" => "Only WebP (.webp) and AVIF (.avif) images are supported. " \
"Please convert your image before uploading — for example with " \
"`cwebp` (JPEG/PNG → WebP) or `avifenc` (PNG/JPEG → AVIF).",
"allowed" => ALLOWED_UPLOAD_TYPES))
end
def check_login_rate_limit!
Volumen.sweep_login_attempts!
ip = request.ip.to_s
now = Time.now.to_f
cutoff = now - LOGIN_WINDOW
attempts = (Volumen.login_attempts[ip] ||= []).select { |t| t > cutoff }
if attempts.length >= MAX_LOGIN_ATTEMPTS
halt(429, "Too many login attempts. Please wait and try again.")
end
attempts << now
Volumen.login_attempts[ip] = attempts
end
def render_settings(error: nil, notice: nil) def render_settings(error: nil, notice: nil)
@error = error @error = error
@notice = notice @notice = notice
@@ -392,6 +293,11 @@ module Volumen
def change_username def change_username
new_name = presence(params["username"]) new_name = presence(params["username"])
return render_settings(error: "Username cannot be empty.") if new_name.nil? return render_settings(error: "Username cannot be empty.") if new_name.nil?
unless new_name.match?(/\A[a-zA-Z0-9._-]+\z/)
return render_settings(error: "Username may use letters, numbers, dot, dash, underscore.")
end
return render_settings(notice: "Username unchanged.") if new_name == current_user
if users.rename(current_user, new_name) if users.rename(current_user, new_name)
session[:user] = new_name session[:user] = new_name
@@ -401,6 +307,45 @@ module Volumen
end end
end end
def change_name
name = params["name"].to_s.strip
name = nil if name.empty?
users.update_name(current_user, name)
render_settings(notice: name ? "Display name updated." : "Display name cleared.")
end
def change_fediverse_creator
value = params["fediverse_creator"].to_s.strip
if value.empty?
users.update_fediverse_creator(current_user, nil)
return render_settings(notice: "Fediverse handle cleared.")
end
unless value.match?(Post::FEDIVERSE_CREATOR_REGEX)
return render_settings(error: "Fediverse handle must look like @user@host.")
end
users.update_fediverse_creator(current_user, value)
render_settings(notice: "Fediverse handle updated.")
end
def change_photo
upload = params["photo"]
unless upload.is_a?(Hash) && upload[:tempfile]
return render_settings(error: "No file selected.")
end
validate_upload!(upload)
url = store.store_user_photo(current_user, upload[:filename], upload[:tempfile])
users.update_photo(current_user, url)
render_settings(notice: "Profile photo updated.")
end
def remove_photo
users.update_photo(current_user, nil)
render_settings(notice: "Profile photo removed.")
end
def create_user def create_user
name = presence(params["username"]) name = presence(params["username"])
password = presence(params["password"]) password = presence(params["password"])
@@ -454,6 +399,7 @@ module Volumen
return erb(:form) return erb(:form)
end end
store.save(post) store.save(post)
clear_posts_cache!
redirect to("/admin/") redirect to("/admin/")
end end
@@ -461,33 +407,62 @@ module Volumen
existing = store.find(params["slug"]) existing = store.find(params["slug"])
halt(404, "Not found") if existing.nil? halt(404, "Not found") if existing.nil?
store.save(post_from_params(params, existing: existing)) post = post_from_params(params, existing: existing)
error = fediverse_creator_error(post)
if error
@mode = :edit
@post = post
@error = error
return erb(:form)
end
store.save(post)
clear_posts_cache!
redirect to("/admin/") redirect to("/admin/")
end end
def creation_error(post) def creation_error(post)
return "Slug is required." if presence(post.slug).nil? return "Slug is required." if presence(post.slug).nil?
return "Invalid slug." unless post.slug.match?(SLUG_REGEX)
return "A post with that slug already exists." if store.find(post.slug) return "A post with that slug already exists." if store.find(post.slug)
nil fediverse_creator_error(post)
end end
def post_from_params(http_params, existing: nil) def post_from_params(http_params, existing: nil)
metadata = { metadata = base_metadata(http_params, existing: existing)
post = Post.new(metadata: clean_metadata(metadata), body: http_params["body"].to_s)
post.path = existing.path if existing
post
end
def base_metadata(http_params, existing:)
{
"title" => presence(http_params["title"]), "title" => presence(http_params["title"]),
"slug" => presence(http_params["slug"]) || existing&.slug, "slug" => presence(http_params["slug"]) || existing&.slug,
"lang" => presence(http_params["lang"]), "lang" => presence(http_params["lang"]),
"author" => presence(http_params["author"]), "author" => presence(http_params["author"]),
"fediverse_creator" => presence(http_params["fediverse_creator"]),
"date" => parse_date(http_params["date"]), "date" => parse_date(http_params["date"]),
"publish_at" => parse_date(http_params["publish_at"]),
"tags" => parse_tags(http_params["tags"]), "tags" => parse_tags(http_params["tags"]),
"excerpt" => presence(http_params["excerpt"]), "excerpt" => presence(http_params["excerpt"]),
"cover" => presence(http_params["cover"]), "cover" => presence(http_params["cover"]),
"cover_alt" => presence(http_params["cover_alt"]),
"cover_caption" => presence(http_params["cover_caption"]),
"draft" => http_params["draft"] == "on", "draft" => http_params["draft"] == "on",
"all_langs" => http_params["all_langs"] == "on" "all_langs" => http_params["all_langs"] == "on"
} }
post = Post.new(metadata: clean_metadata(metadata), body: http_params["body"].to_s) end
post.path = existing.path if existing
post def fediverse_creator_error(post)
value = post.metadata["fediverse_creator"]
return nil if value.nil?
if Post::FEDIVERSE_CREATOR_REGEX.match?(value.to_s)
nil
else
"Fediverse creator must look like @user@host."
end
end end
def clean_metadata(metadata) def clean_metadata(metadata)
+46 -6
View File
@@ -16,11 +16,19 @@ module Volumen
def initialize(content_dir, default_lang: nil) def initialize(content_dir, default_lang: nil)
@content_dir = File.expand_path(content_dir.to_s) @content_dir = File.expand_path(content_dir.to_s)
@default_lang = default_lang @default_lang = default_lang
@all = nil
end end
def all def all
current_dir_mtime = Dir.exist?(@content_dir) ? File.mtime(@content_dir) : nil
current_files_mtime = newest_file_mtime
@all = nil if current_dir_mtime != @all_dir_mtime || current_files_mtime != @all_files_mtime
@all ||= begin
@all_dir_mtime = current_dir_mtime
@all_files_mtime = current_files_mtime
markdown_files.filter_map { |path| load_post(path) } markdown_files.filter_map { |path| load_post(path) }
end end
end
def find(slug, lang: nil) def find(slug, lang: nil)
all.find do |post| all.find do |post|
@@ -31,14 +39,20 @@ module Volumen
def save(post) def save(post)
target = post.path || default_path_for(post) target = post.path || default_path_for(post)
FileUtils.mkdir_p(File.dirname(target)) FileUtils.mkdir_p(File.dirname(target))
File.write(target, post.to_file) tmp = "#{target}.tmp"
File.write(tmp, post.to_file)
File.rename(tmp, target)
post.path = target post.path = target
@all = nil
post post
end end
def delete(slug, lang: nil) def delete(slug, lang: nil)
post = find(slug, lang: lang) post = find(slug, lang: lang)
File.delete(post.path) if post&.path if post&.path
File.delete(post.path)
@all = nil
end
post post
end end
@@ -47,17 +61,27 @@ module Volumen
end end
def store_upload(original_name, source) def store_upload(original_name, source)
FileUtils.mkdir_p(media_dir)
name = safe_media_name(original_name) name = safe_media_name(original_name)
FileUtils.mkdir_p(media_dir)
File.open(File.join(media_dir, name), "wb") { |file| IO.copy_stream(source, file) }
"/media/#{name}"
end
def store_user_photo(username, original_name, source)
ext = File.extname(original_name.to_s).gsub(/[^a-zA-Z0-9.]/, "").downcase
ext = ".webp" if ext.empty?
name = "#{username}-#{SecureRandom.hex(4)}#{ext}"
FileUtils.mkdir_p(media_dir)
File.open(File.join(media_dir, name), "wb") { |file| IO.copy_stream(source, file) } File.open(File.join(media_dir, name), "wb") { |file| IO.copy_stream(source, file) }
"/media/#{name}" "/media/#{name}"
end end
def media_file(name) def media_file(name)
path = File.join(media_dir, File.basename(name.to_s)) path = File.join(media_dir, File.basename(name.to_s))
return nil unless File.file?(path) && within?(media_dir, path) return nil unless File.file?(path)
path real = File.realpath(path)
within?(media_dir, real) ? path : nil
end end
private private
@@ -66,6 +90,15 @@ module Volumen
Dir.glob(File.join(@content_dir, "**", "*.md")) Dir.glob(File.join(@content_dir, "**", "*.md"))
end end
def newest_file_mtime
files = markdown_files
return nil if files.empty?
files.map { |path| File.mtime(path) }.max
rescue SystemCallError
nil
end
def load_post(path) def load_post(path)
post = Post.parse(File.read(path)) post = Post.parse(File.read(path))
post.metadata["slug"] ||= File.basename(path, ".md") post.metadata["slug"] ||= File.basename(path, ".md")
@@ -85,7 +118,14 @@ module Volumen
end end
def default_path_for(post) def default_path_for(post)
File.join(@content_dir, "#{post.slug}.md") dir = @content_dir
dir = File.join(dir, post.lang) if post.lang && post.lang != @default_lang
target = File.join(dir, "#{post.slug}.md")
unless File.expand_path(target).start_with?(File.expand_path(@content_dir) + File::SEPARATOR)
raise ArgumentError, "slug escapes content directory"
end
target
end end
def safe_media_name(original) def safe_media_name(original)
+43 -9
View File
@@ -15,18 +15,27 @@ module Volumen
ROLES = %w[admin author].freeze ROLES = %w[admin author].freeze
DEFAULT_ROLE = "author" DEFAULT_ROLE = "author"
User = Struct.new(:username, :password_hash, :role) User = Struct.new(:username, :password_hash, :role, :name, :fediverse_creator, :photo)
def initialize(path:, bootstrap_hash: nil) def initialize(path:, bootstrap_hash: nil)
@path = path @path = path
@bootstrap_hash = bootstrap_hash.to_s @bootstrap_hash = bootstrap_hash.to_s
@all = nil
end end
def all def all
return read_file if File.exist?(@path) mtime = File.exist?(@path) ? File.mtime(@path) : nil
return [] if @bootstrap_hash.empty? @all = nil if mtime != @all_mtime
@all ||= begin
[User.new("admin", @bootstrap_hash, "admin")] @all_mtime = mtime
if File.exist?(@path)
read_file
elsif @bootstrap_hash.empty?
[]
else
[User.new("admin", @bootstrap_hash, "admin", nil, nil, nil)]
end
end
end end
def any? def any?
@@ -45,11 +54,25 @@ module Volumen
def add(username, password, role = DEFAULT_ROLE) def add(username, password, role = DEFAULT_ROLE)
return nil if username.to_s.empty? || find(username) return nil if username.to_s.empty? || find(username)
user = User.new(username, Password.hash(password), normalize_role(role)) user = User.new(username, Password.hash(password), normalize_role(role), nil, nil, nil)
persist(all + [user]) persist(all + [user])
user user
end end
def update_name(username, name)
mutate(username) { |user| user.name = name.to_s.empty? ? nil : name.to_s }
end
def update_fediverse_creator(username, value)
mutate(username) do |user|
user.fediverse_creator = value.to_s.empty? ? nil : value.to_s
end
end
def update_photo(username, path)
mutate(username) { |user| user.photo = path }
end
def update_password(username, password) def update_password(username, password)
mutate(username) { |user| user.password_hash = Password.hash(password) } mutate(username) { |user| user.password_hash = Password.hash(password) }
end end
@@ -112,17 +135,28 @@ module Volumen
def read_file def read_file
data = TomlRB.load_file(@path) data = TomlRB.load_file(@path)
Array(data["users"]).map do |entry| Array(data["users"]).map do |entry|
User.new(entry["username"], entry["password_hash"], entry["role"] || "admin") User.new(entry["username"], entry["password_hash"], entry["role"] || "admin",
entry["name"], entry["fediverse_creator"], entry["photo"])
end end
end end
def persist(users) def persist(users)
FileUtils.mkdir_p(File.dirname(@path)) FileUtils.mkdir_p(File.dirname(@path))
File.write(@path, TomlRB.dump("users" => users.map { |user| user_hash(user) })) tmp = "#{@path}.tmp"
File.write(tmp, TomlRB.dump("users" => users.map { |user| user_hash(user) }))
File.rename(tmp, @path)
end end
def user_hash(user) def user_hash(user)
{ "username" => user.username, "password_hash" => user.password_hash, "role" => user.role } hash = {
"username" => user.username,
"password_hash" => user.password_hash,
"role" => user.role
}
hash["name"] = user.name if user.name
hash["fediverse_creator"] = user.fediverse_creator if user.fediverse_creator
hash["photo"] = user.photo if user.photo
hash
end end
end end
end end
+1 -1
View File
@@ -1,5 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
module Volumen module Volumen
VERSION = "0.1.0" VERSION = "0.3.0"
end end
+159
View File
@@ -0,0 +1,159 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="volumen">
<title>volumen</title>
<defs>
<!-- Background gradient: deep night sky -->
<radialGradient id="bg" cx="50%" cy="35%" r="80%">
<stop offset="0%" stop-color="#4a5bd8"/>
<stop offset="55%" stop-color="#2a2d6e"/>
<stop offset="100%" stop-color="#15163a"/>
</radialGradient>
<!-- Subtle inner ring gradient -->
<linearGradient id="ring" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#8aa0ff" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#3b3f8a" stop-opacity="0.6"/>
</linearGradient>
<!-- Glossy highlight at the top -->
<radialGradient id="gloss" cx="50%" cy="15%" r="55%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.35"/>
<stop offset="60%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
<!-- Wave stroke gradient -->
<linearGradient id="wave" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#7afcff" stop-opacity="0.15"/>
<stop offset="50%" stop-color="#7afcff" stop-opacity="0.95"/>
<stop offset="100%" stop-color="#7afcff" stop-opacity="0.15"/>
</linearGradient>
<linearGradient id="waveDim" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
</linearGradient>
<!-- Central V fill gradient -->
<linearGradient id="vFill" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#fdfdff"/>
<stop offset="100%" stop-color="#b8c4ff"/>
</linearGradient>
<!-- Soft drop shadow -->
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="2" result="off"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.5"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- Outer glow for the disc -->
<filter id="outerGlow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="4"/>
</filter>
<!-- Reusable wave path (sinusoidal, centered around y=128) -->
<path id="wavePath"
d="M 36 128
C 56 96, 76 96, 96 128
S 136 160, 156 128
S 196 96, 216 128"
fill="none"/>
</defs>
<!-- ===== Outer glow halo ===== -->
<circle cx="128" cy="128" r="116" fill="url(#bg)" opacity="0.55" filter="url(#outerGlow)"/>
<!-- ===== Main disc ===== -->
<circle cx="128" cy="128" r="112" fill="url(#bg)"/>
<!-- Inner decorative ring -->
<circle cx="128" cy="128" r="104" fill="none" stroke="url(#ring)" stroke-width="1.2" opacity="0.7"/>
<!-- Dotted ring (tick marks) -->
<g fill="#8aa0ff" opacity="0.55">
<circle cx="128" cy="22" r="1.4"/>
<circle cx="180" cy="32" r="1.2"/>
<circle cx="220" cy="62" r="1.4"/>
<circle cx="234" cy="110" r="1.2"/>
<circle cx="234" cy="146" r="1.4"/>
<circle cx="220" cy="194" r="1.2"/>
<circle cx="180" cy="224" r="1.4"/>
<circle cx="128" cy="234" r="1.2"/>
<circle cx="76" cy="224" r="1.4"/>
<circle cx="36" cy="194" r="1.2"/>
<circle cx="22" cy="146" r="1.4"/>
<circle cx="22" cy="110" r="1.2"/>
<circle cx="36" cy="62" r="1.4"/>
<circle cx="76" cy="32" r="1.2"/>
</g>
<!-- ===== Waves (background layer) ===== -->
<g stroke-linecap="round" fill="none">
<use href="#wavePath" stroke="url(#waveDim)" stroke-width="1.2"
transform="translate(0,-46)" opacity="0.6"/>
<use href="#wavePath" stroke="url(#waveDim)" stroke-width="1.2"
transform="translate(0,46)" opacity="0.6"/>
</g>
<!-- ===== Central stylized "V" ===== -->
<g filter="url(#softShadow)">
<!-- Main V shape, geometric, with a small notch at the bottom for a leaf/quill hint -->
<path d="
M 78 70
L 110 70
L 128 142
L 146 70
L 178 70
L 138 186
L 118 186
Z"
fill="url(#vFill)"/>
<!-- Inner accent line on the V -->
<path d="M 118 186 L 128 142 L 138 186"
fill="none" stroke="#2a2d6e" stroke-width="1.2" stroke-linejoin="round" opacity="0.35"/>
<!-- Subtle highlight stripe on the left arm of V -->
<path d="M 82 74 L 108 74 L 100 100 Z"
fill="#ffffff" opacity="0.25"/>
</g>
<!-- ===== Foreground waves crossing the V ===== -->
<g stroke-linecap="round" fill="none">
<use href="#wavePath" stroke="url(#wave)" stroke-width="3.2" opacity="0.9"/>
<use href="#wavePath" stroke="url(#wave)" stroke-width="1.6"
transform="translate(0,18)" opacity="0.7"/>
<use href="#wavePath" stroke="url(#wave)" stroke-width="1.6"
transform="translate(0,-18)" opacity="0.7"/>
</g>
<!-- ===== Sparkles / dots ===== -->
<g fill="#ffffff">
<circle cx="60" cy="92" r="1.8" opacity="0.95"/>
<circle cx="196" cy="92" r="1.6" opacity="0.85"/>
<circle cx="200" cy="168" r="1.4" opacity="0.8"/>
<circle cx="58" cy="172" r="1.4" opacity="0.8"/>
<circle cx="156" cy="58" r="1.2" opacity="0.7"/>
<circle cx="100" cy="206" r="1.2" opacity="0.7"/>
</g>
<!-- Four-point sparkle (north star) -->
<g transform="translate(70,70)" fill="#ffffff" opacity="0.9">
<path d="M 0 -7 L 1.4 -1.4 L 7 0 L 1.4 1.4 L 0 7 L -1.4 1.4 L -7 0 L -1.4 -1.4 Z"/>
</g>
<g transform="translate(190,190) scale(0.7)" fill="#ffffff" opacity="0.8">
<path d="M 0 -7 L 1.4 -1.4 L 7 0 L 1.4 1.4 L 0 7 L -1.4 1.4 L -7 0 L -1.4 -1.4 Z"/>
</g>
<!-- ===== Glossy top highlight ===== -->
<circle cx="128" cy="128" r="112" fill="url(#gloss)"/>
</svg>

After

Width:  |  Height:  |  Size: 5.8 KiB

+195 -77
View File
@@ -1,95 +1,190 @@
<%
crumbs = @mode == :new ? [["Posts", to("/admin/")], ["New post", nil]] : [["Posts", to("/admin/")], [@post.title.to_s.empty? ? "Untitled" : @post.title, nil]]
%>
<div class="page-head">
<div>
<h1><%= @mode == :new ? "New post" : "Edit post" %></h1> <h1><%= @mode == :new ? "New post" : "Edit post" %></h1>
<p class="lede"><%= @mode == :new ? "Draft a new article in Markdown." : "Update and publish this post." %></p>
</div>
<div style="display: flex; gap: 0.5rem;">
<% if @mode == :edit %>
<a class="btn btn-ghost" href="<%= to("/api/volumen/posts/#{@post.slug}") %>" target="_blank">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Preview
</a>
<% end %>
<a class="btn btn-ghost" href="<%= to("/admin/") %>">Cancel</a>
<button type="submit" form="post-form" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
Save
</button>
</div>
</div>
<% if @error %> <% if @error %>
<p class="error"><%= h(@error) %></p> <div class="alert alert-error">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<div><%= h(@error) %></div>
</div>
<% end %> <% end %>
<form id="post-form" method="post" <form id="post-form" method="post"
action="<%= @mode == :new ? to("/admin/posts") : to("/admin/posts/#{@post.slug}") %>"> action="<%= @mode == :new ? to("/admin/posts") : h(to("/admin/posts/#{@post.slug}")) %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>"> <input type="hidden" name="_csrf" value="<%= csrf_token %>">
<div class="fields"> <div class="form-grid">
<label>Title <input type="text" name="title" value="<%= h(@post.title) %>"></label> <div class="surface full">
<label>Slug <div class="surface-head">
<input type="text" name="slug" value="<%= h(@post.slug) %>" <div>
<%= @mode == :edit ? "readonly" : "required" %>> <h2>Details</h2>
</label> <p class="lede">Title, slug, and publishing options</p>
<label>Language <input type="text" name="lang" value="<%= h(@post.lang) %>"></label> </div>
<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>
<div class="cover-field"> <div class="field full">
<label for="cover-input">Cover image</label> <label for="title">Title</label>
<div class="cover-row"> <input id="title" class="input" type="text" name="title" value="<%= h(@post.title) %>" placeholder="A clear, descriptive title" required>
<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> </div>
<div class="field-row">
<div class="field">
<label for="slug">Slug</label>
<input id="slug" class="input" type="text" name="slug" value="<%= h(@post.slug) %>"
<%= @mode == :edit ? "readonly" : "required" %> placeholder="my-post-slug">
</div>
<div class="field">
<label for="lang">Language</label>
<input id="lang" class="input" type="text" name="lang" value="<%= h(@post.lang) %>" placeholder="en">
</div>
</div>
<div class="field-row">
<div class="field">
<label for="author">Author</label>
<input id="author" class="input" type="text" name="author" value="<%= h(@post.author.to_s.empty? ? current_user_name : @post.author) %>" placeholder="<%= h(current_user_name || 'Petr Balvín') %>">
</div>
<div class="field">
<label for="fediverse_creator">Fediverse creator</label>
<input id="fediverse_creator" class="input" type="text" name="fediverse_creator"
value="<%= h(@post.fediverse_creator.to_s.empty? ? (current_user_fediverse || config.site['fediverse_creator']) : @post.fediverse_creator) %>" placeholder="@user@instance.tld">
</div>
</div>
<div class="field-row-3">
<div class="field">
<label for="date">Publish date</label>
<input id="date" class="input" type="date" name="date" value="<%= h(@post.date_string || Date.today.iso8601) %>">
</div>
<div class="field">
<label for="publish_at">Schedule for</label>
<input id="publish_at" class="input" type="date" name="publish_at"
value="<%= @post.publish_at.is_a?(Date) ? @post.publish_at.strftime("%Y-%m-%d") : "" %>">
<p class="help">Leave empty to publish immediately</p>
</div>
<div class="field">
<label for="tags">Tags</label>
<input id="tags" class="input" type="text" name="tags" value="<%= h(@post.tags.join(", ")) %>" placeholder="comma, separated">
</div>
</div>
<div class="field full">
<label for="excerpt">Excerpt</label>
<input id="excerpt" class="input" type="text" name="excerpt" value="<%= h(@post.excerpt) %>" placeholder="Short summary for listings and previews">
<p class="help">Auto-generated from the first paragraph if left empty</p>
</div>
<div style="display: flex; gap: 1.5rem; flex-wrap: wrap; margin-top: 0.5rem;">
<label class="checkbox">
<input type="checkbox" name="draft" <%= @post.draft? ? "checked" : "" %>> Draft
</label>
<label class="checkbox">
<input type="checkbox" name="all_langs" <%= @post.all_langs? ? "checked" : "" %>> Available in all languages
</label>
</div>
</div>
<div class="surface full">
<div class="surface-head">
<div>
<h2>Cover image</h2>
<p class="lede">Header image shown on the article page</p>
</div>
</div>
<div class="cover-field">
<div class="cover-input-row">
<input id="cover-input" class="input" type="text" name="cover" value="<%= h(@post.cover) %>"
placeholder="/media/… or https://…">
<button type="button" id="cover-upload" class="btn">Upload</button>
<button type="button" id="cover-clear" class="btn btn-ghost">Clear</button>
</div>
<input id="cover-alt-input" class="input" type="text" name="cover_alt" value="<%= h(@post.cover_alt) %>"
placeholder="ALT text (for accessibility)">
<input id="cover-caption-input" class="input" type="text" name="cover_caption" value="<%= h(@post.cover_caption) %>"
placeholder="Caption (e.g. 'Generated by AI')">
<img id="cover-preview" class="cover-preview" alt="" hidden> <img id="cover-preview" class="cover-preview" alt="" hidden>
<input type="file" id="cover-file" accept="image/*" hidden> <input type="file" id="cover-file" accept="image/*" hidden>
</div> </div>
</div>
<div class="editor-tabs"> <div class="surface full">
<div class="editor-tabs" style="margin-bottom: 0.75rem;">
<button type="button" data-tab="markdown" class="active">Markdown</button> <button type="button" data-tab="markdown" class="active">Markdown</button>
<button type="button" data-tab="visual">Visual</button> <button type="button" data-tab="visual">Visual</button>
</div> </div>
<div id="visual-view" hidden> <div id="visual-view" hidden>
<div class="pane"> <div class="editor-grid">
<div class="ed-toolbar"> <div class="editor-pane">
<button type="button" data-rich="bold" title="Bold (Ctrl+B)"><b>B</b></button> <div class="editor-head">
<button type="button" data-rich="italic" title="Italic (Ctrl+I)"><i>I</i></button> <span class="label">Editor</span>
<button type="button" data-rich="h2" title="Heading 2">H2</button> <span class="spacer"></span>
<button type="button" data-rich="h3" title="Heading 3">H3</button> <button type="button" class="editor-toolbar-btn bold" data-rich="bold" title="Bold (Ctrl+B)">B</button>
<button type="button" data-rich="link" title="Link (Ctrl+K)">Link</button> <button type="button" class="editor-toolbar-btn italic" data-rich="italic" title="Italic (Ctrl+I)">I</button>
<button type="button" data-rich="ul" title="Bullet list">&bull; List</button> <button type="button" class="editor-toolbar-btn" data-rich="h2" title="Heading 2">H2</button>
<button type="button" data-rich="ol" title="Numbered list">1. List</button> <button type="button" class="editor-toolbar-btn" data-rich="h3" title="Heading 3">H3</button>
<button type="button" data-rich="quote" title="Quote">&ldquo;</button> <button type="button" class="editor-toolbar-btn" data-rich="link" title="Link (Ctrl+K)"></button>
<button type="button" data-rich="code" title="Inline code">&lt;/&gt;</button> <button type="button" class="editor-toolbar-btn" data-rich="ul" title="Bullet list"></button>
<button type="button" data-rich="image" title="Image">Img</button> <button type="button" class="editor-toolbar-btn" data-rich="ol" title="Numbered list">1.</button>
<button type="button" data-rich="clear" title="Paragraph">P</button> <button type="button" class="editor-toolbar-btn" data-rich="quote" title="Quote">"</button>
<button type="button" class="editor-toolbar-btn" data-rich="code" title="Code">/&gt;</button>
<button type="button" class="editor-toolbar-btn" data-rich="image" title="Image">IMG</button>
</div>
<div id="wysiwyg" class="markdown editor-preview" contenteditable="true"></div>
</div> </div>
<div id="wysiwyg" class="markdown" contenteditable="true"></div>
</div> </div>
</div> </div>
<div id="markdown-view"> <div id="markdown-view">
<div class="editor"> <div class="editor-grid" id="editor-grid">
<div class="pane"> <div class="editor-pane">
<div class="ed-toolbar"> <div class="editor-head">
<button type="button" data-md="bold" title="Bold (Ctrl+B)"><b>B</b></button> <span class="label">Markdown</span>
<button type="button" data-md="italic" title="Italic (Ctrl+I)"><i>I</i></button> <span class="spacer"></span>
<button type="button" data-md="h2" title="Heading">H2</button> <button type="button" class="editor-toolbar-btn bold" data-md="bold" title="Bold (Ctrl+B)">B</button>
<button type="button" data-md="link" title="Link (Ctrl+K)">Link</button> <button type="button" class="editor-toolbar-btn italic" data-md="italic" title="Italic (Ctrl+I)">I</button>
<button type="button" data-md="ul" title="List">List</button> <button type="button" class="editor-toolbar-btn" data-md="h2" title="Heading">H2</button>
<button type="button" data-md="quote" title="Quote">&ldquo;</button> <button type="button" class="editor-toolbar-btn" data-md="link" title="Link (Ctrl+K)"></button>
<button type="button" data-md="code" title="Code">&lt;/&gt;</button> <button type="button" class="editor-toolbar-btn" data-md="ul" title="List"></button>
<button type="button" data-md="image" title="Image">Img</button> <button type="button" class="editor-toolbar-btn" data-md="quote" title="Quote">"</button>
<button type="button" id="toggle-preview" class="toggle" title="Toggle preview">Preview</button> <button type="button" class="editor-toolbar-btn" data-md="code" title="Code">&lt;/&gt;</button>
<button type="button" class="editor-toolbar-btn" data-md="image" title="Image">IMG</button>
<button type="button" id="toggle-preview" class="editor-toolbar-btn" title="Toggle preview">PREVIEW</button>
</div>
<textarea id="body" name="body" class="editor-textarea" rows="22" placeholder="Write your post in Markdown…"><%= h(@post.body) %></textarea>
</div>
<div class="editor-pane" id="preview-pane">
<div class="editor-head">
<span class="label">Preview</span>
</div>
<div id="preview" class="markdown editor-preview"></div>
</div> </div>
<textarea id="body" name="body" rows="22"><%= h(@post.body) %></textarea>
</div> </div>
<div class="pane preview-pane">
<div class="ed-toolbar"><span>Preview</span></div>
<div id="preview" class="markdown"></div>
</div> </div>
</div> </div>
</div> </div>
<input type="file" id="image-input" accept="image/*" hidden> <input type="file" id="image-input" accept="image/*" hidden>
<button type="submit" class="primary">Save</button>
</form> </form>
<script> <script>
@@ -140,7 +235,10 @@
if (tag === "code") { return "`" + node.textContent + "`"; } if (tag === "code") { return "`" + node.textContent + "`"; }
if (tag === "a") { return "[" + inner + "](" + (node.getAttribute("href") || "") + ")"; } if (tag === "a") { return "[" + inner + "](" + (node.getAttribute("href") || "") + ")"; }
if (tag === "img") { if (tag === "img") {
return "![" + (node.getAttribute("alt") || "") + "](" + (node.getAttribute("src") || "") + ")"; var title = node.getAttribute("title") || "";
var imgMd = "![" + (node.getAttribute("alt") || "") + "](" + (node.getAttribute("src") || "") + ")";
if (title) { imgMd = imgMd.replace(")", ' "' + title + '")'); }
return imgMd;
} }
if (tag === "br") { return "\n"; } if (tag === "br") { return "\n"; }
return inner; return inner;
@@ -188,7 +286,7 @@
return blocks.join("\n\n").replace(/\n{3,}/g, "\n\n").trim() + "\n"; return blocks.join("\n\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
} }
// --- Live preview (markdown mode) ------------------------------------- // --- Live preview (markdown mode) — disabled by default ----------------
function renderPreview() { function renderPreview() {
mdToHtml(textarea.value).then(function (html) { preview.innerHTML = html; }); mdToHtml(textarea.value).then(function (html) { preview.innerHTML = html; });
@@ -201,13 +299,25 @@
// --- Image upload ------------------------------------------------------ // --- Image upload ------------------------------------------------------
function uploadImage(file, onDone) { function uploadImage(file, onSuccess, onError) {
var data = new FormData(); var data = new FormData();
data.append("file", file); data.append("file", file);
data.append("_csrf", csrf); data.append("_csrf", csrf);
fetch(uploadUrl, { method: "POST", body: data }) fetch(uploadUrl, { method: "POST", body: data })
.then(function (response) { return response.json(); }) .then(function (response) {
.then(function (json) { if (json.url) { onDone(json.url); } }); return response.json().then(function (json) { return { ok: response.ok, json: json }; });
})
.then(function (result) {
if (result.ok && result.json.url) {
onSuccess(result.json.url);
} else {
onError(result.json.message || ("Upload failed (HTTP " + (result.json.error || "error") + ")"));
}
});
}
function showUploadError(message) {
alert("Upload failed:\n\n" + message);
} }
imageInput.addEventListener("change", function () { imageInput.addEventListener("change", function () {
@@ -219,7 +329,7 @@
} else { } else {
exec("insertImage", url); exec("insertImage", url);
} }
}); }, showUploadError);
imageInput.value = ""; imageInput.value = "";
}); });
@@ -248,7 +358,7 @@
uploadImage(file, function (url) { uploadImage(file, function (url) {
coverInput.value = url; coverInput.value = url;
refreshCoverPreview(); refreshCoverPreview();
}); }, showUploadError);
coverFile.value = ""; coverFile.value = "";
}); });
coverInput.addEventListener("input", refreshCoverPreview); coverInput.addEventListener("input", refreshCoverPreview);
@@ -440,26 +550,34 @@
if (!visualView.hidden) { textarea.value = htmlToMd(wysiwyg); } if (!visualView.hidden) { textarea.value = htmlToMd(wysiwyg); }
}); });
// --- Preview toggle (persisted) --------------------------------------- // --- Preview toggle (disabled by default) ------------------------------
var editor = markdownView.querySelector(".editor"); var editorGrid = document.getElementById("editor-grid");
var previewPane = document.getElementById("preview-pane");
var togglePreviewBtn = document.getElementById("toggle-preview"); var togglePreviewBtn = document.getElementById("toggle-preview");
function applyPreviewPreference() { function applyPreviewPreference() {
var off = false; var on = false;
try { off = localStorage.getItem("volumen.preview") === "off"; } catch (e) {} try { on = localStorage.getItem("volumen.preview") === "on"; } catch (e) {}
editor.classList.toggle("no-preview", off); if (on) {
togglePreviewBtn.classList.toggle("active", !off); editorGrid.classList.add("with-preview");
if (!off) { renderPreview(); } previewPane.hidden = false;
togglePreviewBtn.classList.add("active");
renderPreview();
} else {
editorGrid.classList.remove("with-preview");
previewPane.hidden = true;
togglePreviewBtn.classList.remove("active");
}
} }
togglePreviewBtn.addEventListener("click", function () { togglePreviewBtn.addEventListener("click", function () {
var turningOff = !editor.classList.contains("no-preview"); var turningOn = !editorGrid.classList.contains("with-preview");
try { localStorage.setItem("volumen.preview", turningOff ? "off" : "on"); } catch (e) {} try { localStorage.setItem("volumen.preview", turningOn ? "on" : "off"); } catch (e) {}
applyPreviewPreference(); applyPreviewPreference();
}); });
// Start in Markdown mode (primary). // Start in Markdown mode (primary), preview OFF by default.
applyPreviewPreference(); applyPreviewPreference();
})(); })();
</script> </script>
File diff suppressed because it is too large Load Diff
+103 -24
View File
@@ -1,34 +1,113 @@
<div class="toolbar"> <div class="page-head">
<div>
<h1>Posts</h1> <h1>Posts</h1>
<a class="button primary" href="<%= to("/admin/posts/new") %>">New post</a> <p class="lede"><%= @posts.length %> post<%= @posts.length == 1 ? "" : "s" %> in total</p>
</div>
<a class="btn btn-primary" href="<%= to("/admin/posts/new") %>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New post
</a>
</div>
<div class="toolbar">
<div class="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="post-search" class="input" type="text" placeholder="Search posts...">
</div>
<div class="filters" id="post-filters">
<button type="button" class="filter-chip active" data-filter="all">All</button>
<button type="button" class="filter-chip" data-filter="published">Published</button>
<button type="button" class="filter-chip" data-filter="draft">Drafts</button>
<button type="button" class="filter-chip" data-filter="scheduled">Scheduled</button>
</div>
</div> </div>
<% if @posts.empty? %> <% if @posts.empty? %>
<p>No posts yet. Create your first one.</p> <div class="empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<h3>No posts yet</h3>
<p>Create your first post to get started.</p>
<a class="btn btn-primary" href="<%= to("/admin/posts/new") %>" style="margin-top: 0.75rem;">Create post</a>
</div>
<% else %> <% else %>
<table> <div class="post-grid" id="post-grid">
<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| %> <% @posts.each do |post| %>
<tr> <%
<td><%= h(post.title) %></td> status = if post.draft?
<td><%= h(post.slug) %></td> "draft"
<td><%= h(post.lang) %></td> elsif post.scheduled?
<td><%= h(post.date_string) %></td> "scheduled"
<td><%= post.draft? ? "yes" : "" %></td> else
<td class="actions"> "published"
<a href="<%= to("/admin/posts/#{post.slug}/edit") %>">Edit</a> end
<form class="inline" method="post" %>
action="<%= to("/admin/posts/#{post.slug}/delete") %>" <a href="<%= h(to("/admin/posts/#{post.slug}/edit")) %>"
onsubmit="return confirm('Delete this post?');"> class="post-card"
data-title="<%= h(post.title.to_s.downcase) %>"
data-slug="<%= h(post.slug) %>"
data-status="<%= status %>">
<% if post.cover %>
<div class="post-cover" style="background-image: url('<%= h(post.cover) %>');"></div>
<% else %>
<div class="post-cover placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
</div>
<% end %>
<div class="post-body">
<h3 class="post-title"><%= h(post.title) %></h3>
<% if post.excerpt && !post.excerpt.to_s.empty? %>
<p class="post-excerpt"><%= h(post.excerpt) %></p>
<% end %>
<div class="post-meta">
<span class="badge badge-lang"><%= h(post.lang) %></span>
<% if post.draft? %><span class="badge badge-warn">Draft</span><% end %>
<% if post.scheduled? %><span class="badge badge-accent">Scheduled</span><% end %>
<% if post.date_string %><span><%= h(post.date_string) %></span><% end %>
</div>
</div>
<div class="post-actions">
<span class="btn btn-sm btn-ghost" style="cursor: default;">Edit</span>
<span class="spacer" style="flex: 1;"></span>
<form method="post" action="<%= h(to("/admin/posts/#{post.slug}/delete")) %>"
onsubmit="return confirm('Delete this post?');" style="margin: 0;">
<input type="hidden" name="_csrf" value="<%= csrf_token %>"> <input type="hidden" name="_csrf" value="<%= csrf_token %>">
<button type="submit" class="danger">Delete</button> <button type="submit" class="btn btn-sm btn-ghost" style="color: var(--danger);" onclick="event.preventDefault(); event.stopPropagation(); this.closest('form').submit();">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</form> </form>
</td> </div>
</tr> </a>
<% end %> <% end %>
</tbody> </div>
</table>
<% end %> <% end %>
<script>
(function () {
var search = document.getElementById("post-search");
var filterButtons = document.querySelectorAll("#post-filters .filter-chip");
var cards = document.querySelectorAll("#post-grid .post-card");
var currentFilter = "all";
function applyFilters() {
var q = (search.value || "").toLowerCase().trim();
cards.forEach(function (card) {
var title = card.dataset.title;
var slug = card.dataset.slug;
var status = card.dataset.status;
var matchesSearch = !q || title.indexOf(q) !== -1 || slug.indexOf(q) !== -1;
var matchesFilter = currentFilter === "all" || status === currentFilter;
card.style.display = matchesSearch && matchesFilter ? "" : "none";
});
}
if (search) search.addEventListener("input", applyFilters);
filterButtons.forEach(function (btn) {
btn.addEventListener("click", function () {
filterButtons.forEach(function (b) { b.classList.remove("active"); });
btn.classList.add("active");
currentFilter = btn.dataset.filter;
applyFilters();
});
});
})();
</script>
+22 -16
View File
@@ -1,23 +1,29 @@
<div class="login"> <h2>Sign in</h2>
<h1>Sign in</h1> <p class="lede">Welcome back. Sign in to manage your posts.</p>
<% if @error %> <% if @error %>
<p class="error"><%= h(@error) %></p> <div class="alert alert-error">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<div><%= h(@error) %></div>
</div>
<% end %> <% end %>
<% unless users.any? %> <% unless users.any? %>
<p class="error"> <div class="alert alert-warn">
No users are configured. Set <code>admin.password_hash</code> in your <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
config (you will sign in as <code>admin</code>) or generate one with <div>No users configured. Set <code>admin.password_hash</code> in your config or run <code>volumen hash-password</code>.</div>
<code>volumen hash-password</code>. </div>
</p>
<% end %> <% end %>
<form method="post" action="<%= to("/admin/login") %>"> <form method="post" action="<%= to("/admin/login") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>"> <input type="hidden" name="_csrf" value="<%= csrf_token %>">
<label>Username <div class="field">
<input type="text" name="username" autofocus autocomplete="username"> <label for="username">Username</label>
</label> <input id="username" class="input" type="text" name="username" autofocus autocomplete="username" required>
<label>Password
<input type="password" name="password" autocomplete="current-password">
</label>
<button type="submit" class="primary">Sign in</button>
</form>
</div> </div>
<div class="field">
<label for="password">Password</label>
<input id="password" class="input" type="password" name="password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary">Sign in</button>
</form>
+207 -49
View File
@@ -1,86 +1,244 @@
<div class="page-head">
<div>
<h1>Settings</h1> <h1>Settings</h1>
<p class="lede">Manage your account and users</p>
</div>
</div>
<% if @notice %><p class="notice"><%= h(@notice) %></p><% end %> <% if @notice %>
<% if @error %><p class="error"><%= h(@error) %></p><% end %> <div class="alert alert-success">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<div><%= h(@notice) %></div>
</div>
<% end %>
<% if @error %>
<div class="alert alert-error">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<div><%= h(@error) %></div>
</div>
<% end %>
<section class="card"> <div class="settings-layout">
<h2>Your account</h2> <nav class="settings-nav" aria-label="Settings sections">
<p class="muted">Signed in as <strong><%= h(current_user) %></strong> (<%= h(current_role) %>).</p> <a href="#account" class="active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Account
</a>
<% if admin? %>
<a href="#users">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Users
</a>
<% end %>
</nav>
<form method="post" action="<%= to("/admin/settings/password") %>"> <div class="settings-panels">
<section id="account" class="surface">
<div class="surface-head">
<div>
<h2>Account</h2>
<p class="lede">Signed in as <strong style="color: var(--fg);"><%= h(current_user) %></strong> · <%= h(current_role) %></p>
</div>
</div>
<div class="settings-section">
<div class="settings-section-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/><path d="M12 11v6"/><path d="M9 14h6"/></svg>
</div>
<div class="settings-section-body">
<h3>Profile photo</h3>
<p class="settings-section-desc">WebP or AVIF, max 10 MB. Shown in the sidebar.</p>
<form method="post" action="<%= to("/admin/settings/photo") %>" enctype="multipart/form-data" class="settings-photo-form">
<input type="hidden" name="_csrf" value="<%= csrf_token %>"> <input type="hidden" name="_csrf" value="<%= csrf_token %>">
<h3>Change password</h3> <div class="photo-input-row">
<label>Current password <% if current_user_record&.photo %>
<input type="password" name="current_password" autocomplete="current-password"> <img src="<%= h(current_user_record.photo) %>" alt="" class="photo-preview">
</label> <% else %>
<label>New password <div class="photo-preview photo-preview-placeholder"><%= h((current_user_record&.name || current_user).to_s[0].upcase) %></div>
<input type="password" name="new_password" autocomplete="new-password"> <% end %>
</label> <div class="photo-input-fields">
<button type="submit" class="primary">Update password</button> <input type="file" name="photo" accept="image/webp,image/avif" required class="file-input">
<div class="photo-buttons">
<button type="submit" class="btn btn-primary">Upload</button>
<% if current_user_record&.photo %>
<button type="submit" form="remove-photo-form" class="btn btn-ghost btn-danger">Remove</button>
<% end %>
</div>
</div>
</div>
</form> </form>
<% if current_user_record&.photo %>
<form method="post" action="<%= to("/admin/settings/username") %>"> <form id="remove-photo-form" method="post" action="<%= to("/admin/settings/photo/remove") %>" style="display: none;">
<input type="hidden" name="_csrf" value="<%= csrf_token %>"> <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> </form>
<% end %>
</div>
</div>
<div class="settings-section">
<div class="settings-section-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>
</div>
<div class="settings-section-body">
<h3>Identity</h3>
<p class="settings-section-desc">Display name pre-fills the author field. Fediverse handle pre-fills the creator.</p>
<form method="post" action="<%= to("/admin/settings/name") %>" class="settings-inline-form">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<div class="field">
<label for="name">Display name (real name)</label>
<input id="name" class="input" type="text" name="name" value="<%= h(current_user_record&.name) %>" placeholder="Your real name">
</div>
<button type="submit" class="btn">Save name</button>
</form>
<form method="post" action="<%= to("/admin/settings/fediverse") %>" class="settings-inline-form" style="margin-top: 1rem;">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<div class="field">
<label for="fediverse_creator">Fediverse handle</label>
<input id="fediverse_creator" class="input" type="text" name="fediverse_creator" value="<%= h(current_user_record&.fediverse_creator) %>" placeholder="@user@instance.tld">
</div>
<button type="submit" class="btn">Save handle</button>
</form>
</div>
</div>
<div class="settings-section">
<div class="settings-section-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</div>
<div class="settings-section-body">
<h3>Security</h3>
<p class="settings-section-desc">Change your password and username. You can also lock yourself out.</p>
<form method="post" action="<%= to("/admin/settings/password") %>" class="settings-inline-form">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<div class="field-row">
<div class="field">
<label for="current_password">Current password</label>
<input id="current_password" class="input" type="password" name="current_password" autocomplete="current-password">
</div>
<div class="field">
<label for="new_password">New password</label>
<input id="new_password" class="input" type="password" name="new_password" autocomplete="new-password">
</div>
</div>
<button type="submit" class="btn">Update password</button>
</form>
<form method="post" action="<%= to("/admin/settings/username") %>" class="settings-inline-form" style="margin-top: 1rem;">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<div class="field">
<label for="username">Username</label>
<input id="username" class="input" type="text" name="username" value="<%= h(current_user) %>">
</div>
<button type="submit" class="btn">Update username</button>
</form>
</div>
</div>
</section> </section>
<% if admin? %> <% if admin? %>
<section class="card"> <section id="users" class="surface">
<div class="surface-head">
<div>
<h2>Users</h2> <h2>Users</h2>
<table> <p class="lede">Add or remove admin and editor accounts</p>
<thead><tr><th>Username</th><th>Role</th><th></th></tr></thead> </div>
<tbody> </div>
<div class="users-list">
<% @users.each do |user| %> <% @users.each do |user| %>
<tr> <div class="user-row">
<td><%= h(user.username) %><%= user.username == current_user ? " (you)" : "" %></td> <% if user.photo %>
<td> <img src="<%= h(user.photo) %>" alt="" class="user-avatar">
<% if user.username == current_user %>
<%= h(user.role) %>
<% else %> <% else %>
<form class="inline" method="post" <div class="user-avatar user-avatar-placeholder"><%= h((user.name || user.username).to_s[0].upcase) %></div>
action="<%= to("/admin/settings/users/#{user.username}/role") %>"> <% end %>
<div class="user-info">
<div class="user-name">
<%= h(user.name || user.username) %>
<% if user.username == current_user %><span class="badge badge-accent" style="margin-left: 0.4rem;">you</span><% end %>
</div>
<div class="user-username">@<%= h(user.username) %></div>
</div>
<div class="user-role">
<% if user.username == current_user %>
<span class="badge badge-neutral"><%= h(user.role) %></span>
<% else %>
<form method="post" action="<%= h(to("/admin/settings/users/#{user.username}/role")) %>" style="margin: 0;">
<input type="hidden" name="_csrf" value="<%= csrf_token %>"> <input type="hidden" name="_csrf" value="<%= csrf_token %>">
<select name="role" onchange="this.form.submit()"> <select class="select select-sm" name="role" onchange="this.form.submit()">
<% Volumen::Users::ROLES.each do |role| %> <% Volumen::Users::ROLES.each do |role| %>
<option value="<%= role %>" <%= user.role == role ? "selected" : "" %>><%= role %></option> <option value="<%= role %>" <%= user.role == role ? "selected" : "" %>><%= role %></option>
<% end %> <% end %>
</select> </select>
</form> </form>
<% end %> <% end %>
</td> </div>
<td class="actions">
<% unless user.username == current_user %> <% unless user.username == current_user %>
<form class="inline" method="post" <form method="post" action="<%= h(to("/admin/settings/users/#{user.username}/delete")) %>" onsubmit="return confirm('Remove this user?');" style="margin: 0;">
action="<%= to("/admin/settings/users/#{user.username}/delete") %>"
onsubmit="return confirm('Remove this user?');">
<input type="hidden" name="_csrf" value="<%= csrf_token %>"> <input type="hidden" name="_csrf" value="<%= csrf_token %>">
<button type="submit" class="danger">Remove</button> <button type="submit" class="btn btn-sm btn-ghost btn-danger">Remove</button>
</form> </form>
<% end %> <% end %>
</td> </div>
</tr>
<% end %> <% end %>
</tbody> </div>
</table>
<div class="add-user-form">
<h3 style="margin: 0 0 0.75rem;">Add user</h3>
<form method="post" action="<%= to("/admin/settings/users") %>"> <form method="post" action="<%= to("/admin/settings/users") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>"> <input type="hidden" name="_csrf" value="<%= csrf_token %>">
<h3>Add user</h3> <div class="field-row-3">
<div class="fields"> <div class="field">
<label>Username <input type="text" name="username"></label> <label for="new-username">Username</label>
<label>Password <input type="password" name="password" autocomplete="new-password"></label> <input id="new-username" class="input" type="text" name="username" placeholder="jane-doe">
<label>Role </div>
<select name="role"> <div class="field">
<label for="new-password">Password</label>
<input id="new-password" class="input" type="password" name="password" autocomplete="new-password">
</div>
<div class="field">
<label for="new-role">Role</label>
<select id="new-role" class="select" name="role">
<% Volumen::Users::ROLES.each do |role| %> <% Volumen::Users::ROLES.each do |role| %>
<option value="<%= role %>" <%= role == Volumen::Users::DEFAULT_ROLE ? "selected" : "" %>><%= role %></option> <option value="<%= role %>" <%= role == Volumen::Users::DEFAULT_ROLE ? "selected" : "" %>><%= role %></option>
<% end %> <% end %>
</select> </select>
</label>
</div> </div>
<button type="submit" class="primary">Add user</button> </div>
<button type="submit" class="btn btn-primary">Add user</button>
</form> </form>
</div>
</section> </section>
<% end %> <% end %>
</div>
</div>
<script>
(function () {
var sections = document.querySelectorAll(".settings-panels .surface[id]");
var navLinks = document.querySelectorAll(".settings-nav a[href^='#']");
if (!sections.length || !navLinks.length) return;
function showSection(id) {
sections.forEach(function (s) {
s.hidden = s.id !== id;
});
navLinks.forEach(function (a) {
a.classList.toggle("active", a.getAttribute("href") === "#" + id);
});
try { history.replaceState(null, "", "#" + id); } catch (e) {}
window.scrollTo({ top: 0, behavior: "smooth" });
}
navLinks.forEach(function (link) {
link.addEventListener("click", function (event) {
event.preventDefault();
var id = link.getAttribute("href").slice(1);
showSection(id);
});
});
var initial = (window.location.hash || "").slice(1);
var hasInitial = Array.from(sections).some(function (s) { return s.id === initial; });
showSection(hasInitial ? initial : sections[0].id);
})();
</script>
+92 -4
View File
@@ -30,6 +30,7 @@ class AdminTest < Minitest::Test
TOML TOML
@config = Volumen::Config.load(path: config_path) @config = Volumen::Config.load(path: config_path)
@store = Volumen::Store.new(@posts_dir, default_lang: "en") @store = Volumen::Store.new(@posts_dir, default_lang: "en")
Volumen.reset_login_attempts!
end end
def teardown def teardown
@@ -123,22 +124,48 @@ class AdminTest < Minitest::Test
assert_equal 403, last_response.status assert_equal 403, last_response.status
end end
def test_upload_rejects_unsupported_media_type
login
get "/admin/posts/new"
token = csrf(last_response.body)
txt_path = File.join(@dir, "readme.txt")
File.binwrite(txt_path, "hello")
post "/admin/uploads",
"_csrf" => token,
"file" => Rack::Test::UploadedFile.new(txt_path, "text/plain")
assert_equal 415, last_response.status
assert_equal "unsupported_format", JSON.parse(last_response.body)["error"]
end
def test_upload_rejects_file_too_large
login
get "/admin/posts/new"
token = csrf(last_response.body)
big_path = File.join(@dir, "big.png")
File.binwrite(big_path, "X" * (Volumen::Server::MAX_UPLOAD_BYTES + 1))
post "/admin/uploads",
"_csrf" => token,
"file" => Rack::Test::UploadedFile.new(big_path, "image/png")
assert_equal 413, last_response.status
assert_equal "file_too_large", JSON.parse(last_response.body)["error"]
end
def test_image_upload_and_serving def test_image_upload_and_serving
login login
get "/admin/posts/new" get "/admin/posts/new"
token = csrf(last_response.body) token = csrf(last_response.body)
image_path = File.join(@dir, "pic.png") image_path = File.join(@dir, "pic.webp")
File.binwrite(image_path, "PNGDATA") File.binwrite(image_path, "WEBDATA")
post "/admin/uploads", post "/admin/uploads",
"_csrf" => token, "_csrf" => token,
"file" => Rack::Test::UploadedFile.new(image_path, "image/png") "file" => Rack::Test::UploadedFile.new(image_path, "image/webp")
assert_predicate last_response, :ok? assert_predicate last_response, :ok?
url = JSON.parse(last_response.body)["url"] url = JSON.parse(last_response.body)["url"]
assert_match(%r{\A/media/}, url) assert_match(%r{\A/media/}, url)
get url get url
assert_predicate last_response, :ok? assert_predicate last_response, :ok?
assert_equal "PNGDATA", last_response.body assert_equal "WEBDATA", last_response.body
end end
def test_settings_requires_login def test_settings_requires_login
@@ -220,6 +247,67 @@ class AdminTest < Minitest::Test
assert File.exist?(File.join(@posts_dir, "by-author.md")) assert File.exist?(File.join(@posts_dir, "by-author.md"))
end end
def test_login_rate_limit_sweep_removes_stale_entries
Volumen.login_attempts["1.2.3.4"] = [Time.now.to_f - 120]
Volumen.instance_variable_set(:@last_cleanup, Time.now.to_f - 600)
Volumen.sweep_login_attempts!
refute Volumen.login_attempts.key?("1.2.3.4"),
"sweep should remove IPs with only stale entries"
end
def test_create_post_rejects_invalid_slug
login
get "/admin/posts/new"
post "/admin/posts",
"_csrf" => csrf(last_response.body), "title" => "X", "slug" => "../../etc/passwd",
"lang" => "en", "body" => "y"
assert_includes last_response.body, "Invalid slug"
end
def test_create_post_rejects_xss_in_slug
login
get "/admin/posts/new"
post "/admin/posts",
"_csrf" => csrf(last_response.body), "title" => "X", "slug" => "foo\" onclick",
"lang" => "en", "body" => "y"
assert_includes last_response.body, "Invalid slug"
end
def test_change_username_rejects_special_chars
login
get "/admin/settings"
post "/admin/settings/username",
"_csrf" => csrf(last_response.body), "username" => "foo\" bar"
assert_includes last_response.body, "Username may use letters"
end
def test_change_username_accepts_same_name
login
get "/admin/settings"
post "/admin/settings/username",
"_csrf" => csrf(last_response.body), "username" => "admin"
assert_includes last_response.body, "Username unchanged"
end
def test_preview_requires_csrf
login
get "/admin/posts/new"
post "/admin/preview", "body" => "**hi**"
assert_equal 403, last_response.status
end
def test_orphaned_session_is_rejected
login
session_for = -> { rack_mock_session.cookie_jar["rack.session"].to_s }
refute_empty session_for.call, "expected a session cookie after login"
# Simulate a deleted user by clearing users.toml while keeping the cookie.
File.write(@users_path, "")
get "/admin/"
assert_equal 302, last_response.status
assert_equal "http://example.org/admin/login", last_response.location
end
private private
def csrf(body) def csrf(body)
+63
View File
@@ -0,0 +1,63 @@
# frozen_string_literal: true
require "test_helper"
require "tmpdir"
require "volumen/cli"
class CLITest < Minitest::Test
def test_version
assert_output("#{Volumen::VERSION}\n") do
Volumen::CLI.run(["version"])
end
end
def test_help
output, = capture_io { Volumen::CLI.run(["help"]) }
assert_includes output, "volumen #{Volumen::VERSION}"
assert_includes output, "Usage:"
end
def test_unknown_command
_output, err = capture_io do
assert_raises(SystemExit) { Volumen::CLI.run(["bogus"]) }
end
assert_includes err, "unknown command"
end
def test_parse_serve_defaults
cli = Volumen::CLI.new(["serve"])
options = cli.__send__(:parse_serve_options)
assert_equal Volumen::Config::DEFAULT_PATH, options[:config]
end
def test_parse_serve_overrides
cli = Volumen::CLI.new([
"serve", "--config", "/t.toml", "--host", "127.0.0.1", "--port", "9000"
])
options = cli.__send__(:parse_serve_options)
assert_equal "/t.toml", options[:config]
assert_equal "127.0.0.1", options[:host]
assert_equal 9000, options[:port]
end
def test_prepare_config_writes_template
Dir.mktmpdir do |dir|
path = File.join(dir, "config.toml")
cli = Volumen::CLI.new([])
_output, err = capture_io { cli.__send__(:prepare_config, path) }
assert_includes err, "wrote a default config"
assert_path_exists path
end
end
def test_prepare_config_noops_when_present
Dir.mktmpdir do |dir|
path = File.join(dir, "config.toml")
File.write(path, "[server]\nport = 1\n")
cli = Volumen::CLI.new([])
out, err = capture_io { cli.__send__(:prepare_config, path) }
assert_empty err
assert_empty out
end
end
end
+8
View File
@@ -27,6 +27,14 @@ class ConfigTest < Minitest::Test
assert_equal "0.0.0.0", Volumen::Config.load(path: MISSING).host assert_equal "0.0.0.0", Volumen::Config.load(path: MISSING).host
end end
def test_does_not_mutate_default_inner_hashes
config = Volumen::Config.load(path: MISSING)
config.site["title"] = "MUTATED"
fresh = Volumen::Config.load(path: MISSING)
assert_equal "My Blog", fresh.site["title"],
"mutating a loaded config must not poison DEFAULTS for later loads"
end
def test_loads_and_merges_file def test_loads_and_merges_file
Dir.mktmpdir do |dir| Dir.mktmpdir do |dir|
path = File.join(dir, "config.toml") path = File.join(dir, "config.toml")
+78
View File
@@ -30,6 +30,12 @@ class PostTest < Minitest::Test
assert_equal "The body paragraph.", post.excerpt assert_equal "The body paragraph.", post.excerpt
end end
def test_derived_excerpt_strips_html_tags
body = "<strong>Bold</strong> and <em>italic</em>."
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: body)
assert_equal "Bold and italic.", post.excerpt
end
def test_summary_shape def test_summary_shape
post = Volumen::Post.new(metadata: { "slug" => "x", "title" => "X" }, body: "Body") post = Volumen::Post.new(metadata: { "slug" => "x", "title" => "X" }, body: "Body")
summary = post.summary summary = post.summary
@@ -56,4 +62,76 @@ class PostTest < Minitest::Test
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "short") post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "short")
assert_equal 1, post.reading_time assert_equal 1, post.reading_time
end end
def test_scheduled_post_is_not_scheduled_when_publish_at_is_past
post = Volumen::Post.new(metadata: { "publish_at" => Date.today - 1 })
refute_predicate post, :scheduled?
end
def test_scheduled_post_is_scheduled_when_publish_at_is_future
post = Volumen::Post.new(metadata: { "publish_at" => Date.today + 7 })
assert_predicate post, :scheduled?
end
def test_post_without_publish_at_is_not_scheduled
post = Volumen::Post.new(metadata: {})
refute_predicate post, :scheduled?
end
def test_fediverse_creator_returns_metadata_value
post = Volumen::Post.new(
metadata: { "slug" => "x", "fediverse_creator" => "@user@example.social" }
)
assert_equal "@user@example.social", post.fediverse_creator
end
def test_fediverse_creator_is_nil_when_missing
post = Volumen::Post.new(metadata: { "slug" => "x" })
assert_nil post.fediverse_creator
end
def test_fediverse_creator_is_nil_when_blank
post = Volumen::Post.new(metadata: { "slug" => "x", "fediverse_creator" => " " })
assert_nil post.fediverse_creator
end
def test_fediverse_creator_validation_accepts_well_formed_handles
post = Volumen::Post.new(
metadata: { "slug" => "x", "fediverse_creator" => "@ada@lovelace.org" }
)
assert_predicate post, :valid_fediverse_creator?
end
def test_fediverse_creator_validation_rejects_malformed_handles
[
"ada@example.com",
"@only-one-at",
"@user@example.com extra",
"@user@",
"@@example.com"
].each do |value|
post = Volumen::Post.new(metadata: { "slug" => "x", "fediverse_creator" => value })
refute_predicate post, :valid_fediverse_creator?, "expected #{value.inspect} to be invalid"
end
end
def test_fediverse_creator_validation_accepts_padded_value
post = Volumen::Post.new(
metadata: { "slug" => "x", "fediverse_creator" => " @user@example.com " }
)
assert_predicate post, :valid_fediverse_creator?
end
def test_summary_includes_fediverse_creator
post = Volumen::Post.new(
metadata: { "slug" => "x", "fediverse_creator" => "@user@example.social" },
body: "body"
)
assert_equal "@user@example.social", post.summary["fediverse_creator"]
end
def test_summary_omits_fediverse_creator_when_absent
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "body")
assert_nil post.summary["fediverse_creator"]
end
end end
+209
View File
@@ -92,4 +92,213 @@ class ServerTest < Minitest::Test
assert_includes slugs, "global" assert_includes slugs, "global"
refute_includes slugs, "hello" refute_includes slugs, "hello"
end end
def test_feed_endpoint
get "/api/volumen/feed.xml"
assert_predicate last_response, :ok?
assert_equal "application/rss+xml", last_response["Content-Type"]&.split(";")&.first
body = last_response.body
assert_includes body, "<rss version=\"2.0\""
assert_includes body, "<title>Hello</title>"
refute_includes body, "Draft"
assert_includes body, "<language>en</language>"
end
def test_draft_post_detail_returns_draft_error
get "/api/volumen/posts/draft"
assert_equal 404, last_response.status
assert_equal "draft", JSON.parse(last_response.body)["error"]
end
def test_sitemap_endpoint
get "/api/volumen/sitemap.xml"
assert_predicate last_response, :ok?
assert_equal "application/xml", last_response["Content-Type"]&.split(";")&.first
body = last_response.body
assert_includes body, "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">"
assert_includes body, "<loc>https://example.com/hello</loc>"
refute_includes body, "draft"
end
def test_cors_preflight
options "/api/volumen/site"
assert_predicate last_response, :ok?
assert_equal "*", last_response.headers["Access-Control-Allow-Origin"]
assert_equal "GET, OPTIONS", last_response.headers["Access-Control-Allow-Methods"]
end
def test_scheduled_post_is_hidden
future = (Date.today + 30).strftime("%Y-%m-%d")
File.write(File.join(@dir, "later.md"), <<~POST)
+++
title = "Later"
publish_at = #{future}
+++
Not yet.
POST
get "/api/volumen/posts"
slugs = JSON.parse(last_response.body)["posts"].map { |post| post["slug"] }
refute_includes slugs, "later"
end
def test_json_feed_endpoint
get "/api/volumen/feed.json"
assert_predicate last_response, :ok?
data = JSON.parse(last_response.body)
assert_equal "https://jsonfeed.org/version/1.1", data["version"]
assert_equal "My Blog", data["title"]
assert_equal 1, data["items"].length
assert_equal "Hello", data["items"].first["title"]
assert_includes data["items"].first["content_html"], "<strong>world</strong>"
refute_includes data["items"].map { |i| i["title"] }, "Draft"
end
def test_post_summary_exposes_fediverse_creator
File.write(File.join(@dir, "attributed.md"), <<~POST)
+++
title = "Attributed"
slug = "attributed"
fediverse_creator = "@ada@lovelace.org"
+++
Body.
POST
get "/api/volumen/posts/attributed"
assert_predicate last_response, :ok?
data = JSON.parse(last_response.body)
assert_equal "@ada@lovelace.org", data["fediverse_creator"]
end
def test_post_summary_fediverse_creator_is_null_when_not_set
get "/api/volumen/posts/hello"
assert_predicate last_response, :ok?
assert_nil JSON.parse(last_response.body)["fediverse_creator"]
end
def test_posts_list_summary_exposes_fediverse_creator
File.write(File.join(@dir, "fed.md"), <<~POST)
+++
title = "Fed"
slug = "fed"
fediverse_creator = "@user@example.social"
+++
Body.
POST
get "/api/volumen/posts"
data = JSON.parse(last_response.body)
fed = data["posts"].find { |post| post["slug"] == "fed" }
assert_equal "@user@example.social", fed["fediverse_creator"]
end
def test_site_endpoint_exposes_fediverse_creator_default
get "/api/volumen/site"
assert_predicate last_response, :ok?
assert JSON.parse(last_response.body).key?("fediverse_creator")
end
def test_rss_feed_includes_dc_creator_when_set
File.write(File.join(@dir, "rss.md"), <<~POST)
+++
title = "Rss"
slug = "rss"
fediverse_creator = "@rss@example.com"
+++
Body.
POST
get "/api/volumen/feed.xml"
assert_includes last_response.body, "<dc:creator>@rss@example.com</dc:creator>"
assert_includes last_response.body, "xmlns:dc=\"http://purl.org/dc/elements/1.1/\""
end
def test_rss_feed_omits_dc_creator_when_not_set
get "/api/volumen/feed.xml"
refute_includes last_response.body, "<dc:creator>"
end
def test_json_feed_includes_authors_from_post
File.write(File.join(@dir, "jsonfed.md"), <<~POST)
+++
title = "Jsonfed"
slug = "jsonfed"
fediverse_creator = "@jf@example.io"
+++
Body.
POST
get "/api/volumen/feed.json"
item = JSON.parse(last_response.body)["items"].find { |i| i["id"] == "https://example.com/jsonfed" }
assert_equal [{ "name" => "@jf@example.io" }], item["authors"]
end
def test_json_feed_falls_back_to_site_default_fediverse_creator
Dir.chdir(@dir) do
File.write("config.toml", <<~TOML)
[site]
fediverse_creator = "@owner@example.com"
TOML
end
@config = Volumen::Config.load(
path: File.join(@dir, "config.toml"),
overrides: { content: @dir }
)
get "/api/volumen/feed.json"
items = JSON.parse(last_response.body)["items"]
expected = [{ "name" => "@owner@example.com" }]
assert(items.all? { |i| i["authors"] == expected })
end
def test_search_by_title
get "/api/volumen/posts?q=Hello"
data = JSON.parse(last_response.body)
assert_equal 1, data["total"]
assert_equal "hello", data["posts"].first["slug"]
end
def test_search_by_body
get "/api/volumen/posts?q=world"
data = JSON.parse(last_response.body)
assert_equal 1, data["total"]
end
def test_search_no_match
get "/api/volumen/posts?q=nonexistent"
data = JSON.parse(last_response.body)
assert_equal 0, data["total"]
end
def test_search_is_case_insensitive
get "/api/volumen/posts?q=HELLO"
data = JSON.parse(last_response.body)
assert_equal 1, data["total"]
end
def test_pagination_has_next_and_prev
# Create enough posts to paginate
5.times do |i|
File.write(File.join(@dir, "post-#{i}.md"), <<~POST)
+++
title = "Post #{i}"
+++
Body #{i}.
POST
end
get "/api/volumen/posts?page=1&limit=2"
data = JSON.parse(last_response.body)
assert_equal true, data["has_next"]
assert_equal false, data["has_prev"]
get "/api/volumen/posts?page=2&limit=2"
data = JSON.parse(last_response.body)
assert_equal true, data["has_next"]
assert_equal true, data["has_prev"]
get "/api/volumen/posts?page=3&limit=2"
data = JSON.parse(last_response.body)
assert_equal false, data["has_next"]
assert_equal true, data["has_prev"]
end
end end
+21
View File
@@ -49,4 +49,25 @@ class StoreTest < Minitest::Test
assert store.find("global", lang: "cs") assert store.find("global", lang: "cs")
assert store.find("global", lang: "en") assert store.find("global", lang: "en")
end end
def test_save_rejects_path_traversal_slug
store = Volumen::Store.new(@dir)
post = Volumen::Post.new(metadata: { "slug" => "../../etc/passwd" }, body: "x")
assert_raises(ArgumentError, "slug escapes content directory") { store.save(post) }
end
def test_save_uses_lang_subdirectory_for_non_default_language
store = Volumen::Store.new(@dir, default_lang: "en")
post = Volumen::Post.new(metadata: { "slug" => "bonjour", "lang" => "fr" }, body: "x")
store.save(post)
assert File.exist?(File.join(@dir, "fr", "bonjour.md"))
end
def test_save_uses_root_for_default_language
store = Volumen::Store.new(@dir, default_lang: "en")
post = Volumen::Post.new(metadata: { "slug" => "hello-new", "lang" => "en" }, body: "x")
store.save(post)
assert File.exist?(File.join(@dir, "hello-new.md"))
refute File.exist?(File.join(@dir, "en", "hello-new.md"))
end
end end