27 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
31 changed files with 2798 additions and 590 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/
+1 -1
View File
@@ -30,7 +30,7 @@ Metrics/MethodLength:
- "lib/volumen/api_routes.rb" - "lib/volumen/api_routes.rb"
Metrics/ClassLength: Metrics/ClassLength:
Max: 400 Max: 440
Metrics/ModuleLength: Metrics/ModuleLength:
Exclude: Exclude:
+95
View File
@@ -7,6 +7,99 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [development] ## [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 ## [0.2.0] — 2026-06-25
### Added ### Added
@@ -148,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
+9 -6
View File
@@ -142,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
+2 -2
View File
@@ -1,7 +1,7 @@
PATH PATH
remote: . remote: .
specs: specs:
volumen (0.2.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.2.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…"
+37
View File
@@ -3,6 +3,7 @@
module Volumen module Volumen
# Server-rendered admin routes, registered into Volumen::Server. # Server-rendered admin routes, registered into Volumen::Server.
module AdminRoutes module AdminRoutes
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
def self.registered(app) def self.registered(app)
app.get "/admin" do app.get "/admin" do
redirect to("/admin/") redirect to("/admin/")
@@ -11,6 +12,7 @@ module Volumen
app.get "/admin/" do app.get "/admin/" do
require_login! require_login!
@posts = sorted_posts @posts = sorted_posts
@crumbs = [["Posts", nil]]
erb :list erb :list
end end
@@ -43,6 +45,7 @@ module Volumen
require_login! require_login!
@mode = :new @mode = :new
@post = Post.new(metadata: { "lang" => config.language }) @post = Post.new(metadata: { "lang" => config.language })
@crumbs = [["Posts", to("/admin/")], ["New post", nil]]
erb :form erb :form
end end
@@ -57,6 +60,8 @@ module Volumen
@mode = :edit @mode = :edit
@post = store.find(params["slug"]) @post = store.find(params["slug"])
halt(404, "Not found") if @post.nil? 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 erb :form
end end
@@ -71,6 +76,7 @@ module Volumen
require_login! require_login!
check_csrf! check_csrf!
store.delete(params["slug"]) store.delete(params["slug"])
clear_posts_cache!
redirect to("/admin/") redirect to("/admin/")
end end
@@ -88,6 +94,7 @@ module Volumen
upload = params["file"] upload = params["file"]
halt(400, json("error" => "no_file")) unless upload.is_a?(Hash) && upload[:tempfile] 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])) json("url" => store.store_upload(upload[:filename], upload[:tempfile]))
end end
@@ -102,8 +109,13 @@ module Volumen
send_file(File.expand_path("web/public/volumen-logo.svg", __dir__)) send_file(File.expand_path("web/public/volumen-logo.svg", __dir__))
end end
app.get "/admin/icon.svg" do
send_file(File.expand_path("web/public/volumen-icon.svg", __dir__))
end
app.get "/admin/settings" do app.get "/admin/settings" do
require_login! require_login!
@crumbs = [["Settings", nil]]
render_settings render_settings
end end
@@ -119,6 +131,30 @@ module Volumen
change_username change_username
end 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 app.post "/admin/settings/users" do
require_login! require_login!
require_admin! require_admin!
@@ -139,6 +175,7 @@ module Volumen
check_csrf! check_csrf!
remove_user(params["username"]) remove_user(params["username"])
end end
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
end end
end end
end end
+4 -2
View File
@@ -5,7 +5,8 @@ module Volumen
module ApiRoutes module ApiRoutes
def self.registered(app) def self.registered(app)
app.before "/api/volumen/*" do app.before "/api/volumen/*" do
headers "Access-Control-Allow-Origin" => "*" headers "Access-Control-Allow-Origin" => "*",
"Cache-Control" => "public, max-age=60"
content_type :json content_type :json
end end
@@ -27,7 +28,8 @@ module Volumen
app.get "/api/volumen/posts/:slug" do app.get "/api/volumen/posts/:slug" do
post = store.find(params["slug"], lang: params["lang"]) post = store.find(params["slug"], lang: params["lang"])
halt(404, json("error" => "not_found")) if post.nil? || post.draft? halt(404, json("error" => "not_found")) if post.nil?
halt(404, json("error" => "draft")) if post.draft?
json(post.detail) json(post.detail)
end end
+5 -1
View File
@@ -18,7 +18,8 @@ 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, }.freeze,
"admin" => { "admin" => {
"password_hash" => "", "password_hash" => "",
@@ -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
+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
+25
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,26 @@ 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 def publish_at
metadata["publish_at"] metadata["publish_at"]
end end
@@ -112,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}"
+134 -101
View File
@@ -10,6 +10,7 @@ require_relative "markdown"
require_relative "password" require_relative "password"
require_relative "api_routes" require_relative "api_routes"
require_relative "admin_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
@@ -17,10 +18,13 @@ module Volumen
class Server < Sinatra::Base class Server < Sinatra::Base
MAX_LOGIN_ATTEMPTS = 10 MAX_LOGIN_ATTEMPTS = 10
LOGIN_WINDOW = 60 # seconds 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/ SLUG_REGEX = /\A[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\z/
register ApiRoutes register ApiRoutes
register AdminRoutes register AdminRoutes
helpers FeedHelpers
configure do configure do
set :show_exceptions, false set :show_exceptions, false
@@ -60,6 +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 end
private private
@@ -79,12 +96,22 @@ module Volumen
end end
def published_posts def published_posts
store.all.reject { |post| post.draft? || post.scheduled? } @published_posts ||= store.all
.reject { |post| post.draft? || post.scheduled? }
.sort_by { |post| post.date_string || "" }.reverse .sort_by { |post| post.date_string || "" }.reverse
end end
def clear_posts_cache!
@published_posts = nil
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
@@ -92,10 +119,13 @@ 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
@@ -141,97 +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>
<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
<<~XML.chomp
<item>
<title>#{escape(post.title)}</title>
<link>#{escape(link)}</link>
<guid>#{escape(link)}</guid>
<description>#{escape(post.excerpt)}</description>#{pub}
</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
}
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
def escape(text) def escape(text)
Rack::Utils.escape_html(text.to_s) Rack::Utils.escape_html(text.to_s)
end end
@@ -260,6 +199,18 @@ 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
@@ -286,11 +237,26 @@ 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! def check_login_rate_limit!
Volumen.sweep_login_attempts! Volumen.sweep_login_attempts!
ip = request.ip.to_s ip = request.ip.to_s
@@ -341,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"])
@@ -394,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
@@ -401,7 +407,16 @@ 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
@@ -410,26 +425,44 @@ module Volumen
return "Invalid slug." unless post.slug.match?(SLUG_REGEX) 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"]), "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)
+24 -4
View File
@@ -20,10 +20,12 @@ module Volumen
end end
def all def all
mtime = Dir.exist?(@content_dir) ? File.mtime(@content_dir) : nil current_dir_mtime = Dir.exist?(@content_dir) ? File.mtime(@content_dir) : nil
@all = nil if mtime != @all_mtime current_files_mtime = newest_file_mtime
@all = nil if current_dir_mtime != @all_dir_mtime || current_files_mtime != @all_files_mtime
@all ||= begin @all ||= begin
@all_mtime = mtime @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 end
@@ -59,8 +61,17 @@ 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
@@ -79,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")
+28 -5
View File
@@ -15,7 +15,7 @@ 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
@@ -33,7 +33,7 @@ module Volumen
elsif @bootstrap_hash.empty? elsif @bootstrap_hash.empty?
[] []
else else
[User.new("admin", @bootstrap_hash, "admin")] [User.new("admin", @bootstrap_hash, "admin", nil, nil, nil)]
end end
end end
end end
@@ -54,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
@@ -121,7 +135,8 @@ 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
@@ -133,7 +148,15 @@ module Volumen
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.2.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 -82
View File
@@ -1,100 +1,190 @@
<h1><%= @mode == :new ? "New post" : "Edit post" %></h1> <%
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>
<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") : h(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>Publish at
<input type="date" name="publish_at"
value="<%= @post.publish_at.is_a?(Date) ? @post.publish_at.strftime("%Y-%m-%d") : "" %>"
placeholder="leave empty for immediate">
</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>
@@ -145,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;
@@ -193,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; });
@@ -206,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 () {
@@ -224,7 +329,7 @@
} else { } else {
exec("insertImage", url); exec("insertImage", url);
} }
}); }, showUploadError);
imageInput.value = ""; imageInput.value = "";
}); });
@@ -253,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);
@@ -445,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
+105 -26
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="<%= h(to("/admin/posts/#{post.slug}/edit")) %>">Edit</a> end
<form class="inline" method="post" %>
action="<%= h(to("/admin/posts/#{post.slug}/delete")) %>" <a href="<%= h(to("/admin/posts/#{post.slug}/edit")) %>"
onsubmit="return confirm('Delete this post?');"> class="post-card"
<input type="hidden" name="_csrf" value="<%= csrf_token %>"> data-title="<%= h(post.title.to_s.downcase) %>"
<button type="submit" class="danger">Delete</button> data-slug="<%= h(post.slug) %>"
</form> data-status="<%= status %>">
</td> <% if post.cover %>
</tr> <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 %> <% end %>
</tbody> <div class="post-body">
</table> <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 %>">
<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>
</div>
</a>
<% end %>
</div>
<% 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>
+28 -22
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 %>
<p class="error"><%= h(@error) %></p> <% if @error %>
<% end %> <div class="alert alert-error">
<% unless users.any? %> <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>
<p class="error"> <div><%= h(@error) %></div>
No users are configured. Set <code>admin.password_hash</code> in your </div>
config (you will sign in as <code>admin</code>) or generate one with <% end %>
<code>volumen hash-password</code>.
</p> <% unless users.any? %>
<% end %> <div class="alert alert-warn">
<form method="post" action="<%= to("/admin/login") %>"> <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>
<div>No users configured. Set <code>admin.password_hash</code> in your config or run <code>volumen hash-password</code>.</div>
</div>
<% end %>
<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 </div>
<input type="password" name="password" autocomplete="current-password"> <div class="field">
</label> <label for="password">Password</label>
<button type="submit" class="primary">Sign in</button> <input id="password" class="input" type="password" name="password" autocomplete="current-password" required>
</form> </div>
</div> <button type="submit" class="btn btn-primary">Sign in</button>
</form>
+217 -59
View File
@@ -1,86 +1,244 @@
<h1>Settings</h1> <div class="page-head">
<div>
<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>
<label>New password
<input type="password" name="new_password" autocomplete="new-password">
</label>
<button type="submit" class="primary">Update password</button>
</form>
<form method="post" action="<%= to("/admin/settings/username") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<h3>Change username</h3>
<label>New username <input type="text" name="username" value="<%= h(current_user) %>"></label>
<button type="submit">Update username</button>
</form>
</section>
<% if admin? %>
<section class="card">
<h2>Users</h2>
<table>
<thead><tr><th>Username</th><th>Role</th><th></th></tr></thead>
<tbody>
<% @users.each do |user| %>
<tr>
<td><%= h(user.username) %><%= user.username == current_user ? " (you)" : "" %></td>
<td>
<% if user.username == current_user %>
<%= h(user.role) %>
<% else %> <% else %>
<form class="inline" method="post" <div class="photo-preview photo-preview-placeholder"><%= h((current_user_record&.name || current_user).to_s[0].upcase) %></div>
action="<%= h(to("/admin/settings/users/#{user.username}/role")) %>"> <% end %>
<div class="photo-input-fields">
<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>
<% if current_user_record&.photo %>
<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 %>">
<select name="role" onchange="this.form.submit()"> </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>
<% if admin? %>
<section id="users" class="surface">
<div class="surface-head">
<div>
<h2>Users</h2>
<p class="lede">Add or remove admin and editor accounts</p>
</div>
</div>
<div class="users-list">
<% @users.each do |user| %>
<div class="user-row">
<% if user.photo %>
<img src="<%= h(user.photo) %>" alt="" class="user-avatar">
<% else %>
<div class="user-avatar user-avatar-placeholder"><%= h((user.name || user.username).to_s[0].upcase) %></div>
<% 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 %>">
<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="<%= h(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>
+30 -4
View File
@@ -124,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
+57
View File
@@ -77,4 +77,61 @@ class PostTest < Minitest::Test
post = Volumen::Post.new(metadata: {}) post = Volumen::Post.new(metadata: {})
refute_predicate post, :scheduled? refute_predicate post, :scheduled?
end 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
+126 -3
View File
@@ -98,16 +98,16 @@ class ServerTest < Minitest::Test
assert_predicate last_response, :ok? assert_predicate last_response, :ok?
assert_equal "application/rss+xml", last_response["Content-Type"]&.split(";")&.first assert_equal "application/rss+xml", last_response["Content-Type"]&.split(";")&.first
body = last_response.body body = last_response.body
assert_includes body, "<rss version=\"2.0\">" assert_includes body, "<rss version=\"2.0\""
assert_includes body, "<title>Hello</title>" assert_includes body, "<title>Hello</title>"
refute_includes body, "Draft" refute_includes body, "Draft"
assert_includes body, "<language>en</language>" assert_includes body, "<language>en</language>"
end end
def test_draft_post_detail_is_not_found def test_draft_post_detail_returns_draft_error
get "/api/volumen/posts/draft" get "/api/volumen/posts/draft"
assert_equal 404, last_response.status assert_equal 404, last_response.status
assert_equal "not_found", JSON.parse(last_response.body)["error"] assert_equal "draft", JSON.parse(last_response.body)["error"]
end end
def test_sitemap_endpoint def test_sitemap_endpoint
@@ -154,6 +154,102 @@ class ServerTest < Minitest::Test
refute_includes data["items"].map { |i| i["title"] }, "Draft" refute_includes data["items"].map { |i| i["title"] }, "Draft"
end 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 def test_search_by_title
get "/api/volumen/posts?q=Hello" get "/api/volumen/posts?q=Hello"
data = JSON.parse(last_response.body) data = JSON.parse(last_response.body)
@@ -178,4 +274,31 @@ class ServerTest < Minitest::Test
data = JSON.parse(last_response.body) data = JSON.parse(last_response.body)
assert_equal 1, data["total"] assert_equal 1, data["total"]
end 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