Compare commits
50
Commits
v0.1.0
...
6f62d3e3f3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f62d3e3f3 | ||
|
|
1dfbf67921 | ||
|
|
0e8f013bbb | ||
|
|
2f90c6da4d | ||
|
|
f40ace64a8 | ||
|
|
ff0452bcc3 | ||
|
|
34dc67ce99 | ||
|
|
e028db6447 | ||
|
|
cbae03014d | ||
|
|
10ef258e84 | ||
|
|
5fdf8e5ee8 | ||
|
|
6ee8e066aa | ||
|
|
1dbeb66be0 | ||
|
|
d1295c6633 | ||
|
|
5cc3a1aabe | ||
|
|
781707a3fd | ||
|
|
2da9ec9bba | ||
|
|
0ba6e379d7 | ||
|
|
db6da86c93 | ||
|
|
a2f42c6a12 | ||
|
|
223405dcb9 | ||
|
|
95452477d8 | ||
|
|
bc2e056b68 | ||
|
|
e11c7d24a4 | ||
|
|
25ebf691f7 | ||
|
|
4abb5585df | ||
|
|
5437cef1cd | ||
|
|
1440e9a1fd | ||
|
|
b24a8cc377 | ||
|
|
599c63e5bc | ||
|
|
73be893bf8 | ||
|
|
52be2bb996 | ||
|
|
5b8236c0a4 | ||
|
|
51e425e6dd | ||
|
|
ce57410044 | ||
|
|
afd8ab4bfb | ||
|
|
ad00872895 | ||
|
|
d7f9813484 | ||
|
|
263e794669 | ||
|
|
1ca0212197 | ||
|
|
d26d3b5a5b | ||
|
|
4597c685da | ||
|
|
2181538691 | ||
|
|
9dd55adc30 | ||
|
|
7a31b50ca7 | ||
|
|
b2b8156648 | ||
|
|
140b16b55a | ||
|
|
2348352fab | ||
|
|
184aa9b3d8 | ||
|
|
0a70b74b55 |
@@ -13,6 +13,9 @@
|
|||||||
# Local development sandbox (posts, generated config)
|
# Local development sandbox (posts, generated config)
|
||||||
/.volumen/
|
/.volumen/
|
||||||
|
|
||||||
|
# Local dev config (contains password hash)
|
||||||
|
/config.toml
|
||||||
|
|
||||||
# Uploaded media in the dev content directory
|
# Uploaded media in the dev content directory
|
||||||
/posts/media/
|
/posts/media/
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -19,12 +19,22 @@ Layout/LineLength:
|
|||||||
|
|
||||||
Metrics/AbcSize:
|
Metrics/AbcSize:
|
||||||
Max: 30
|
Max: 30
|
||||||
|
Exclude:
|
||||||
|
- "lib/volumen/admin_routes.rb"
|
||||||
|
- "lib/volumen/api_routes.rb"
|
||||||
|
|
||||||
Metrics/MethodLength:
|
Metrics/MethodLength:
|
||||||
Max: 25
|
Max: 25
|
||||||
|
Exclude:
|
||||||
|
- "lib/volumen/admin_routes.rb"
|
||||||
|
- "lib/volumen/api_routes.rb"
|
||||||
|
|
||||||
Metrics/ClassLength:
|
Metrics/ClassLength:
|
||||||
Max: 400
|
Max: 440
|
||||||
|
|
||||||
|
Metrics/ModuleLength:
|
||||||
|
Exclude:
|
||||||
|
- "lib/volumen/admin_routes.rb"
|
||||||
|
|
||||||
Metrics/BlockLength:
|
Metrics/BlockLength:
|
||||||
Exclude:
|
Exclude:
|
||||||
|
|||||||
+149
@@ -5,6 +5,153 @@ All notable changes to **volumen** are documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [development]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## [0.3.0] — 2026-07-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
**Content & authoring**
|
||||||
|
|
||||||
|
- **Fediverse attribution:** a `fediverse_creator` frontmatter field (e.g.
|
||||||
|
`@user@mastodon.social`) plus a site-wide `[site].fediverse_creator` default
|
||||||
|
in `config.toml`. The value is exposed as `fediverse_creator` on the site
|
||||||
|
and post payloads, rendered as `<dc:creator>` in the RSS feed, as
|
||||||
|
`authors[].name` in the JSON feed, and can be consumed by a front-end to
|
||||||
|
emit `<meta name="fediverse:creator">` on rendered pages. Per-post values
|
||||||
|
override the site default.
|
||||||
|
- **Cover caption:** a `cover_caption` post field rendered next to the cover
|
||||||
|
image so authors can credit photos or add a short blurb.
|
||||||
|
- **Titled images as `<figure>`:** Markdown images with a title
|
||||||
|
(``) are wrapped in a `<figure>` with a `<figcaption>`
|
||||||
|
and a small `i` info icon. Images without a title render exactly as before.
|
||||||
|
|
||||||
|
**Public API**
|
||||||
|
|
||||||
|
- `has_next` and `has_prev` boolean flags in the paginated
|
||||||
|
`/api/volumen/posts` response so clients can navigate pages without
|
||||||
|
computing boundaries themselves.
|
||||||
|
- Distinct `"draft"` error code in `/api/volumen/posts/:slug` when the post
|
||||||
|
exists but is a draft, replacing the generic `"not_found"`.
|
||||||
|
|
||||||
|
**Admin**
|
||||||
|
|
||||||
|
- **Modern admin UI:** redesigned layout with a sticky top bar, breadcrumbs
|
||||||
|
on every page, and a unified design language across login, post list,
|
||||||
|
post form, and settings.
|
||||||
|
- **Volumen icon:** a dedicated SVG icon shown in the sidebar and on the
|
||||||
|
login page (served at `/admin/icon.svg`).
|
||||||
|
- **Version in sidebar footer** so admins always see which release is
|
||||||
|
running.
|
||||||
|
- **Per-user display name** on the user record and admin header.
|
||||||
|
- **Per-user fediverse handle** editable from the settings page (independent
|
||||||
|
of the post-level `fediverse_creator`).
|
||||||
|
- **Profile photo upload:** each admin user can upload a WebP/AVIF avatar
|
||||||
|
that is stored alongside posts in the content directory and shown in the
|
||||||
|
settings page.
|
||||||
|
- **Tabbed settings page** separating account, profile, and admin sections.
|
||||||
|
- **Restricted uploads:** only WebP (`image/webp`) and AVIF (`image/avif`)
|
||||||
|
are accepted, with a 415 error message that points users to `cwebp` /
|
||||||
|
`avifenc` for conversion. Reflects the engine's WebP/AVIF-only posture
|
||||||
|
end-to-end.
|
||||||
|
- **Polished native inputs** for `<input type="date|time|file">` so they
|
||||||
|
match the rest of the admin's design language.
|
||||||
|
|
||||||
|
**Tooling**
|
||||||
|
|
||||||
|
- `just lint` recipe for standalone RuboCop checks and `just fmt` for
|
||||||
|
auto-fixing lint issues.
|
||||||
|
- `config.toml.example` template for local development; `config.toml` is
|
||||||
|
now gitignored.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Memoize `published_posts` within a single request so repeated calls
|
||||||
|
(posts list, tags, feeds, sitemap) reuse the cached result.
|
||||||
|
- `Store#all` cache now invalidates on individual file mtime changes, not
|
||||||
|
just on the content directory mtime. Manual edits and `git pull` are
|
||||||
|
detected without a restart.
|
||||||
|
- `Cache-Control: public, max-age=60` header on all public API responses
|
||||||
|
for browser and CDN caching.
|
||||||
|
- Extract feed and sitemap generation into a dedicated `FeedHelpers` module,
|
||||||
|
reducing `Server` from ~500 to ~420 lines.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Admin post save/delete now correctly clears the in-memory posts cache, so
|
||||||
|
the post list reflects edits without a manual reload.
|
||||||
|
- The post form's date field defaults to today when the underlying post has
|
||||||
|
no date, preventing the picker from showing an invalid empty value.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **Content-Security-Policy** header on all `/admin/*` responses:
|
||||||
|
`default-src 'self'`, `script-src 'self' 'unsafe-inline'`,
|
||||||
|
`style-src 'self' 'unsafe-inline'`, `img-src 'self' data:`,
|
||||||
|
`font-src 'self'`, `connect-src 'self'`, `form-action 'self'`,
|
||||||
|
`frame-ancestors 'none'`.
|
||||||
|
- File uploads are rejected (HTTP 413) when exceeding **10 MB**
|
||||||
|
(`MAX_UPLOAD_BYTES`).
|
||||||
|
- File uploads are restricted to `image/webp` and `image/avif`
|
||||||
|
(`ALLOWED_UPLOAD_TYPES`). Anything else is rejected with a 415 and a
|
||||||
|
conversion hint.
|
||||||
|
|
||||||
|
## [0.2.0] — 2026-06-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `<pubDate>` in RSS feed items, derived from the post date.
|
||||||
|
- `<lastmod>` in sitemap entries for SEO.
|
||||||
|
- In-memory rate limiting on the login endpoint: 10 attempts per 60 seconds per IP.
|
||||||
|
- `<language>` element in the RSS feed channel.
|
||||||
|
- Periodic cleanup of stale `login_attempts` entries to prevent unbounded memory growth.
|
||||||
|
- Slug format validation (`[a-z0-9._-]`) preventing path traversal and XSS in templates.
|
||||||
|
- Username validation in `change_username` matching the create-user regex.
|
||||||
|
- Escape-on-output for slug and username in admin template attributes.
|
||||||
|
- Memoization of `Store#all` with mtime-based invalidation, eliminating N×read+parse
|
||||||
|
per request.
|
||||||
|
- Atomic file writes (tmp + rename) for posts and `users.toml`.
|
||||||
|
- Post language directory routing: non-default-language posts now land in
|
||||||
|
`content_dir/<lang>/` instead of overwriting root-level files.
|
||||||
|
- CLI test coverage (version, help, unknown command, option parsing, config bootstrap).
|
||||||
|
- Test coverage for RSS feed and sitemap endpoints.
|
||||||
|
- CORS preflight (`OPTIONS /api/volumen/*`) so browsers can reach the JSON API.
|
||||||
|
- `Users#all` memoization with mtime-based invalidation, matching Store#all.
|
||||||
|
- Session invalidation: deleted users can no longer use existing session cookies.
|
||||||
|
- CSRF protection on `POST /admin/preview`.
|
||||||
|
- Symlink-safe containment in `Store#media_file` via `File.realpath`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Scheduled publishing:** a `publish_at` frontmatter field hides posts from the
|
||||||
|
public API and feeds until their publication date arrives. The admin form
|
||||||
|
includes a Publish at date picker.
|
||||||
|
- **JSON Feed:** `GET /api/volumen/feed.json` serves a jsonfeed.org v1.1 feed
|
||||||
|
with `content_html` and `date_published` per item.
|
||||||
|
- **Fulltext search:** `GET /api/volumen/posts?q=…` filters posts by
|
||||||
|
case-insensitive match in title and body.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Split `lib/volumen/server.rb` into `api_routes.rb` and `admin_routes.rb` modules,
|
||||||
|
keeping the server class focused on configuration and shared helpers.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `admin.session_ttl` was defined but never read; it now controls
|
||||||
|
`Rack::Session::Cookie` expiration via `expire_after`.
|
||||||
|
- Derived post excerpts now strip HTML tags before stripping markdown markup,
|
||||||
|
avoiding raw HTML leaks in auto-generated excerpts.
|
||||||
|
- Draft posts no longer leak through the public detail endpoint (`/api/volumen/posts/:slug`).
|
||||||
|
- `Config::DEFAULTS` inner hashes are now frozen and `deep_merge` builds fresh
|
||||||
|
copies, preventing mutation from silently poisoning later `Config.load` calls.
|
||||||
|
- A warning is emitted when `admin.session_key` is configured but shorter than
|
||||||
|
64 bytes, instead of silently falling back to a random secret.
|
||||||
|
|
||||||
## [0.1.0] — 2026-06-20
|
## [0.1.0] — 2026-06-20
|
||||||
|
|
||||||
First public release: a small, file-based Markdown blog engine in CRuby. Posts
|
First public release: a small, file-based Markdown blog engine in CRuby. Posts
|
||||||
@@ -94,3 +241,5 @@ admin — no database, no external services.
|
|||||||
- Generic login error message to avoid username enumeration.
|
- Generic login error message to avoid username enumeration.
|
||||||
|
|
||||||
[0.1.0]: https://codeberg.org/petrbalvin/volumen/releases/tag/v0.1.0
|
[0.1.0]: https://codeberg.org/petrbalvin/volumen/releases/tag/v0.1.0
|
||||||
|
[0.2.0]: https://codeberg.org/petrbalvin/volumen/releases/tag/v0.2.0
|
||||||
|
[0.3.0]: https://codeberg.org/petrbalvin/volumen/releases/tag/v0.3.0
|
||||||
|
|||||||
+18
-11
@@ -116,15 +116,18 @@ chore: pin gem versions to latest
|
|||||||
|
|
||||||
1. Create a feature branch from `development`.
|
1. Create a feature branch from `development`.
|
||||||
2. Make your changes with Conventional-Commits messages.
|
2. Make your changes with Conventional-Commits messages.
|
||||||
3. Ensure `bundle exec rubocop` reports zero offenses and `just test` passes.
|
3. Record your changes under the `## [development]` section at the top of
|
||||||
4. Add or update tests for your change.
|
`CHANGELOG.md`, using the Keep a Changelog categories (`Added`, `Changed`,
|
||||||
5. Update documentation if the public API, configuration, or behaviour changes:
|
`Fixed`, etc.).
|
||||||
|
4. Ensure `bundle exec rubocop` reports zero offenses and `just test` passes.
|
||||||
|
5. Add or update tests for your change.
|
||||||
|
6. Update documentation if the public API, configuration, or behaviour changes:
|
||||||
- `README.md` — high-level overview.
|
- `README.md` — high-level overview.
|
||||||
- `docs/configuration.md` — config keys.
|
- `docs/configuration.md` — config keys.
|
||||||
- `docs/api-reference.md` — JSON API behaviour.
|
- `docs/api-reference.md` — JSON API behaviour.
|
||||||
- `docs/architecture.md` — structural changes.
|
- `docs/architecture.md` — structural changes.
|
||||||
- `docs/security.md` — anything touching auth, uploads, or rendering.
|
- `docs/security.md` — anything touching auth, uploads, or rendering.
|
||||||
6. Open a pull request against `development`. Releases are cut by merging
|
7. Open a pull request against `development`. Releases are cut by merging
|
||||||
`development` into `main` and pushing a `vX.Y.Z` tag — see
|
`development` into `main` and pushing a `vX.Y.Z` tag — see
|
||||||
[Cutting a release](#cutting-a-release).
|
[Cutting a release](#cutting-a-release).
|
||||||
|
|
||||||
@@ -139,21 +142,24 @@ volumen/
|
|||||||
│ └── volumen/
|
│ └── volumen/
|
||||||
│ ├── version.rb
|
│ ├── version.rb
|
||||||
│ ├── frontmatter.rb # parse/serialise the +++ TOML frontmatter block
|
│ ├── frontmatter.rb # parse/serialise the +++ TOML frontmatter block
|
||||||
│ ├── markdown.rb # kramdown (GFM) → HTML
|
│ ├── markdown.rb # kramdown (GFM) → HTML; <figure> for titled images
|
||||||
│ ├── post.rb # Post model (metadata + body + rendered HTML)
|
│ ├── post.rb # Post model (metadata + body + rendered HTML)
|
||||||
│ ├── store.rb # read/write posts and media on disk
|
│ ├── store.rb # read/write posts and media on disk (mtime-cached)
|
||||||
│ ├── config.rb # load/merge config.toml, generate template
|
│ ├── config.rb # load/merge config.toml, generate template
|
||||||
│ ├── password.rb # scrypt hashing/verification (OpenSSL::KDF)
|
│ ├── password.rb # scrypt hashing/verification (OpenSSL::KDF)
|
||||||
│ ├── users.rb # users.toml, admin/author roles
|
│ ├── users.rb # users.toml, admin/author roles, per-user profile
|
||||||
│ ├── server.rb # Sinatra app: public API + admin
|
│ ├── feed_helpers.rb # RSS / JSON Feed / sitemap generation
|
||||||
|
│ ├── api_routes.rb # /api/volumen/* (Sinatra routes)
|
||||||
|
│ ├── admin_routes.rb # /admin/* (Sinatra routes)
|
||||||
|
│ ├── server.rb # Sinatra app: composes the two route modules
|
||||||
│ ├── cli.rb # command dispatcher
|
│ ├── cli.rb # command dispatcher
|
||||||
│ └── web/
|
│ └── web/
|
||||||
│ ├── views/ # ERB templates (layout, login, list, form, settings)
|
│ ├── views/ # ERB templates (layout, login, list, form, settings)
|
||||||
│ └── public/ # static assets (volumen-logo.svg)
|
│ └── public/ # static assets (volumen-logo.svg, volumen-icon.svg)
|
||||||
├── test/ # minitest suite
|
├── test/ # minitest suite
|
||||||
├── docs/ # architecture, configuration, api-reference, deployment, security
|
├── docs/ # architecture, configuration, api-reference, deployment, security
|
||||||
├── .forgejo/workflows/ # test.yml, release.yml (Forgejo Actions)
|
├── .forgejo/workflows/ # test.yml, release.yml (Forgejo Actions)
|
||||||
├── justfile # install, run, dev, build, test, uninstall
|
├── justfile # install, run, dev, lint, fmt, build, test, uninstall
|
||||||
├── volumen.gemspec
|
├── volumen.gemspec
|
||||||
├── Gemfile
|
├── Gemfile
|
||||||
├── CHANGELOG.md
|
├── CHANGELOG.md
|
||||||
@@ -232,7 +238,8 @@ Requires a `FORGEJO_TOKEN` secret with permission to create releases.
|
|||||||
### Cutting a release
|
### Cutting a release
|
||||||
|
|
||||||
1. Bump `VERSION` in `lib/volumen/version.rb`.
|
1. Bump `VERSION` in `lib/volumen/version.rb`.
|
||||||
2. Add a `## [X.Y.Z] — YYYY-MM-DD` section at the top of `CHANGELOG.md`.
|
2. Rename `## [development]` to `## [X.Y.Z] — YYYY-MM-DD` at the top of
|
||||||
|
`CHANGELOG.md`, and add a fresh empty `## [development]` section above it.
|
||||||
3. Ensure CI is green on `development`.
|
3. Ensure CI is green on `development`.
|
||||||
4. Merge `development` into `main` (the only time `main` changes outside a tag).
|
4. Merge `development` into `main` (the only time `main` changes outside a tag).
|
||||||
5. Tag and push:
|
5. Tag and push:
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
PATH
|
PATH
|
||||||
remote: .
|
remote: .
|
||||||
specs:
|
specs:
|
||||||
volumen (0.1.0)
|
volumen (0.3.0)
|
||||||
kramdown (~> 2.5)
|
kramdown (~> 2.5)
|
||||||
kramdown-parser-gfm (~> 1.1)
|
kramdown-parser-gfm (~> 1.1)
|
||||||
puma (~> 8.0)
|
puma (~> 8.0)
|
||||||
@@ -130,7 +130,7 @@ CHECKSUMS
|
|||||||
toml-rb (4.2.0) sha256=10a48c91613e20cf63483a7a776767dfb3cd7d70e9327c0237443da601e13776
|
toml-rb (4.2.0) sha256=10a48c91613e20cf63483a7a776767dfb3cd7d70e9327c0237443da601e13776
|
||||||
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
|
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
|
||||||
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
|
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
|
||||||
volumen (0.1.0)
|
volumen (0.3.0)
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
4.0.10
|
4.0.10
|
||||||
|
|||||||
@@ -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** (``) is rendered as a
|
||||||
|
`<figure>` with a `<figcaption>` and a small `i` info icon. Images without a
|
||||||
|
title render exactly as before:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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…"
|
||||||
|
|||||||
@@ -18,4 +18,24 @@ require_relative "volumen/users"
|
|||||||
# The Sinatra app (`Volumen::Server`) and the CLI (`Volumen::CLI`) are loaded
|
# The Sinatra app (`Volumen::Server`) and the CLI (`Volumen::CLI`) are loaded
|
||||||
# on demand to keep the core library light.
|
# on demand to keep the core library light.
|
||||||
module Volumen
|
module Volumen
|
||||||
|
CLEANUP_INTERVAL = 300 # seconds between full sweeps of login_attempts
|
||||||
|
|
||||||
|
def self.login_attempts
|
||||||
|
@login_attempts ||= {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.reset_login_attempts!
|
||||||
|
@login_attempts = {}
|
||||||
|
@last_cleanup = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.sweep_login_attempts!
|
||||||
|
now = Time.now.to_f
|
||||||
|
return if @last_cleanup && (now - @last_cleanup) < CLEANUP_INTERVAL
|
||||||
|
|
||||||
|
cutoff = now - Volumen::Server::LOGIN_WINDOW
|
||||||
|
login_attempts.each_value { |timestamps| timestamps.select! { |t| t > cutoff } }
|
||||||
|
login_attempts.delete_if { |_ip, timestamps| timestamps.empty? }
|
||||||
|
@last_cleanup = now
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Volumen
|
||||||
|
# Server-rendered admin routes, registered into Volumen::Server.
|
||||||
|
module AdminRoutes
|
||||||
|
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
||||||
|
def self.registered(app)
|
||||||
|
app.get "/admin" do
|
||||||
|
redirect to("/admin/")
|
||||||
|
end
|
||||||
|
|
||||||
|
app.get "/admin/" do
|
||||||
|
require_login!
|
||||||
|
@posts = sorted_posts
|
||||||
|
@crumbs = [["Posts", nil]]
|
||||||
|
erb :list
|
||||||
|
end
|
||||||
|
|
||||||
|
app.get "/admin/login" do
|
||||||
|
redirect to("/admin/") if authenticated?
|
||||||
|
|
||||||
|
erb :login
|
||||||
|
end
|
||||||
|
|
||||||
|
app.post "/admin/login" do
|
||||||
|
check_csrf!
|
||||||
|
check_login_rate_limit!
|
||||||
|
user = users.authenticate(params["username"], params["password"])
|
||||||
|
if user
|
||||||
|
session[:user] = user.username
|
||||||
|
redirect to("/admin/")
|
||||||
|
else
|
||||||
|
@error = "Invalid username or password."
|
||||||
|
erb :login
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
app.post "/admin/logout" do
|
||||||
|
check_csrf!
|
||||||
|
session.clear
|
||||||
|
redirect to("/admin/login")
|
||||||
|
end
|
||||||
|
|
||||||
|
app.get "/admin/posts/new" do
|
||||||
|
require_login!
|
||||||
|
@mode = :new
|
||||||
|
@post = Post.new(metadata: { "lang" => config.language })
|
||||||
|
@crumbs = [["Posts", to("/admin/")], ["New post", nil]]
|
||||||
|
erb :form
|
||||||
|
end
|
||||||
|
|
||||||
|
app.post "/admin/posts" do
|
||||||
|
require_login!
|
||||||
|
check_csrf!
|
||||||
|
create_post
|
||||||
|
end
|
||||||
|
|
||||||
|
app.get "/admin/posts/:slug/edit" do
|
||||||
|
require_login!
|
||||||
|
@mode = :edit
|
||||||
|
@post = store.find(params["slug"])
|
||||||
|
halt(404, "Not found") if @post.nil?
|
||||||
|
title = @post.title.to_s.empty? ? params["slug"] : @post.title
|
||||||
|
@crumbs = [["Posts", to("/admin/")], [title, nil]]
|
||||||
|
|
||||||
|
erb :form
|
||||||
|
end
|
||||||
|
|
||||||
|
app.post "/admin/posts/:slug" do
|
||||||
|
require_login!
|
||||||
|
check_csrf!
|
||||||
|
update_post
|
||||||
|
end
|
||||||
|
|
||||||
|
app.post "/admin/posts/:slug/delete" do
|
||||||
|
require_login!
|
||||||
|
check_csrf!
|
||||||
|
store.delete(params["slug"])
|
||||||
|
clear_posts_cache!
|
||||||
|
redirect to("/admin/")
|
||||||
|
end
|
||||||
|
|
||||||
|
app.post "/admin/preview" do
|
||||||
|
require_login!
|
||||||
|
check_csrf!
|
||||||
|
content_type :html
|
||||||
|
Markdown.render(params["body"].to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
app.post "/admin/uploads" do
|
||||||
|
require_login!
|
||||||
|
check_csrf!
|
||||||
|
content_type :json
|
||||||
|
upload = params["file"]
|
||||||
|
halt(400, json("error" => "no_file")) unless upload.is_a?(Hash) && upload[:tempfile]
|
||||||
|
|
||||||
|
validate_upload!(upload)
|
||||||
|
json("url" => store.store_upload(upload[:filename], upload[:tempfile]))
|
||||||
|
end
|
||||||
|
|
||||||
|
app.get "/media/*" do
|
||||||
|
path = store.media_file(params["splat"].first)
|
||||||
|
halt(404) if path.nil?
|
||||||
|
|
||||||
|
send_file(path)
|
||||||
|
end
|
||||||
|
|
||||||
|
app.get "/admin/logo.svg" do
|
||||||
|
send_file(File.expand_path("web/public/volumen-logo.svg", __dir__))
|
||||||
|
end
|
||||||
|
|
||||||
|
app.get "/admin/icon.svg" do
|
||||||
|
send_file(File.expand_path("web/public/volumen-icon.svg", __dir__))
|
||||||
|
end
|
||||||
|
|
||||||
|
app.get "/admin/settings" do
|
||||||
|
require_login!
|
||||||
|
@crumbs = [["Settings", nil]]
|
||||||
|
render_settings
|
||||||
|
end
|
||||||
|
|
||||||
|
app.post "/admin/settings/password" do
|
||||||
|
require_login!
|
||||||
|
check_csrf!
|
||||||
|
change_password
|
||||||
|
end
|
||||||
|
|
||||||
|
app.post "/admin/settings/username" do
|
||||||
|
require_login!
|
||||||
|
check_csrf!
|
||||||
|
change_username
|
||||||
|
end
|
||||||
|
|
||||||
|
app.post "/admin/settings/name" do
|
||||||
|
require_login!
|
||||||
|
check_csrf!
|
||||||
|
change_name
|
||||||
|
end
|
||||||
|
|
||||||
|
app.post "/admin/settings/fediverse" do
|
||||||
|
require_login!
|
||||||
|
check_csrf!
|
||||||
|
change_fediverse_creator
|
||||||
|
end
|
||||||
|
|
||||||
|
app.post "/admin/settings/photo" do
|
||||||
|
require_login!
|
||||||
|
check_csrf!
|
||||||
|
change_photo
|
||||||
|
end
|
||||||
|
|
||||||
|
app.post "/admin/settings/photo/remove" do
|
||||||
|
require_login!
|
||||||
|
check_csrf!
|
||||||
|
remove_photo
|
||||||
|
end
|
||||||
|
|
||||||
|
app.post "/admin/settings/users" do
|
||||||
|
require_login!
|
||||||
|
require_admin!
|
||||||
|
check_csrf!
|
||||||
|
create_user
|
||||||
|
end
|
||||||
|
|
||||||
|
app.post "/admin/settings/users/:username/role" do
|
||||||
|
require_login!
|
||||||
|
require_admin!
|
||||||
|
check_csrf!
|
||||||
|
change_role(params["username"])
|
||||||
|
end
|
||||||
|
|
||||||
|
app.post "/admin/settings/users/:username/delete" do
|
||||||
|
require_login!
|
||||||
|
require_admin!
|
||||||
|
check_csrf!
|
||||||
|
remove_user(params["username"])
|
||||||
|
end
|
||||||
|
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Volumen
|
||||||
|
# Public JSON API routes, registered into Volumen::Server.
|
||||||
|
module ApiRoutes
|
||||||
|
def self.registered(app)
|
||||||
|
app.before "/api/volumen/*" do
|
||||||
|
headers "Access-Control-Allow-Origin" => "*",
|
||||||
|
"Cache-Control" => "public, max-age=60"
|
||||||
|
content_type :json
|
||||||
|
end
|
||||||
|
|
||||||
|
app.options "/api/volumen/*" do
|
||||||
|
headers "Access-Control-Allow-Origin" => "*"
|
||||||
|
headers "Access-Control-Allow-Methods" => "GET, OPTIONS"
|
||||||
|
headers "Access-Control-Allow-Headers" => "Content-Type"
|
||||||
|
content_type :json
|
||||||
|
""
|
||||||
|
end
|
||||||
|
|
||||||
|
app.get "/api/volumen/site" do
|
||||||
|
json(site_payload)
|
||||||
|
end
|
||||||
|
|
||||||
|
app.get "/api/volumen/posts" do
|
||||||
|
json(posts_payload)
|
||||||
|
end
|
||||||
|
|
||||||
|
app.get "/api/volumen/posts/:slug" do
|
||||||
|
post = store.find(params["slug"], lang: params["lang"])
|
||||||
|
halt(404, json("error" => "not_found")) if post.nil?
|
||||||
|
halt(404, json("error" => "draft")) if post.draft?
|
||||||
|
|
||||||
|
json(post.detail)
|
||||||
|
end
|
||||||
|
|
||||||
|
app.get "/api/volumen/tags" do
|
||||||
|
json("tags" => tag_counts)
|
||||||
|
end
|
||||||
|
|
||||||
|
app.get "/api/volumen/feed.xml" do
|
||||||
|
content_type "application/rss+xml"
|
||||||
|
feed_xml
|
||||||
|
end
|
||||||
|
|
||||||
|
app.get "/api/volumen/feed.json" do
|
||||||
|
json(feed_json)
|
||||||
|
end
|
||||||
|
|
||||||
|
app.get "/api/volumen/sitemap.xml" do
|
||||||
|
content_type "application/xml"
|
||||||
|
sitemap_xml
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+25
-6
@@ -10,7 +10,7 @@ module Volumen
|
|||||||
DEFAULT_PATH = "/etc/volumen/config.toml"
|
DEFAULT_PATH = "/etc/volumen/config.toml"
|
||||||
|
|
||||||
DEFAULTS = {
|
DEFAULTS = {
|
||||||
"server" => { "host" => "0.0.0.0", "port" => 9090 },
|
"server" => { "host" => "0.0.0.0", "port" => 9090 }.freeze,
|
||||||
"content_dir" => "/var/lib/volumen/posts",
|
"content_dir" => "/var/lib/volumen/posts",
|
||||||
"users_file" => "/var/lib/volumen/users.toml",
|
"users_file" => "/var/lib/volumen/users.toml",
|
||||||
"site" => {
|
"site" => {
|
||||||
@@ -18,13 +18,14 @@ module Volumen
|
|||||||
"description" => "A blog powered by volumen.",
|
"description" => "A blog powered by volumen.",
|
||||||
"base_url" => "https://example.com",
|
"base_url" => "https://example.com",
|
||||||
"language" => "en",
|
"language" => "en",
|
||||||
"author" => "Anonymous"
|
"author" => "Anonymous",
|
||||||
},
|
"fediverse_creator" => nil
|
||||||
|
}.freeze,
|
||||||
"admin" => {
|
"admin" => {
|
||||||
"password_hash" => "",
|
"password_hash" => "",
|
||||||
"session_key" => "",
|
"session_key" => "",
|
||||||
"session_ttl" => 86_400
|
"session_ttl" => 86_400
|
||||||
}
|
}.freeze
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
TEMPLATE = <<~TOML
|
TEMPLATE = <<~TOML
|
||||||
@@ -46,6 +47,9 @@ module Volumen
|
|||||||
base_url = "https://example.com"
|
base_url = "https://example.com"
|
||||||
language = "en"
|
language = "en"
|
||||||
author = "Anonymous"
|
author = "Anonymous"
|
||||||
|
# Default fediverse handle surfaced in <meta name="fediverse:creator">
|
||||||
|
# by frontends consuming the API. Leave empty to disable.
|
||||||
|
fediverse_creator = ""
|
||||||
|
|
||||||
[admin]
|
[admin]
|
||||||
# Bootstrap admin login: paste a hash from `volumen hash-password` to sign
|
# Bootstrap admin login: paste a hash from `volumen hash-password` to sign
|
||||||
@@ -77,14 +81,29 @@ module Volumen
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.deep_merge(base, override)
|
def self.deep_merge(base, override)
|
||||||
base.merge(override) do |_key, base_val, override_val|
|
result = {}
|
||||||
|
base.each do |key, base_val|
|
||||||
|
result[key] = if override.key?(key)
|
||||||
|
merge_leaf(base_val, override[key])
|
||||||
|
elsif base_val.is_a?(Hash)
|
||||||
|
{}.merge(base_val)
|
||||||
|
else
|
||||||
|
base_val
|
||||||
|
end
|
||||||
|
end
|
||||||
|
override.each do |key, val|
|
||||||
|
result[key] = val unless result.key?(key)
|
||||||
|
end
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.merge_leaf(base_val, override_val)
|
||||||
if base_val.is_a?(Hash) && override_val.is_a?(Hash)
|
if base_val.is_a?(Hash) && override_val.is_a?(Hash)
|
||||||
deep_merge(base_val, override_val)
|
deep_merge(base_val, override_val)
|
||||||
else
|
else
|
||||||
override_val
|
override_val
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
def self.apply_overrides(data, overrides)
|
def self.apply_overrides(data, overrides)
|
||||||
server = data["server"].dup
|
server = data["server"].dup
|
||||||
|
|||||||
@@ -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
@@ -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 (``) are wrapped in a `<figure>`
|
||||||
|
# element with a `<figcaption>` so the frontend can style captions and
|
||||||
|
# overlay an info icon.
|
||||||
module Markdown
|
module Markdown
|
||||||
OPTIONS = {
|
OPTIONS = {
|
||||||
input: "GFM",
|
input: "GFM",
|
||||||
@@ -17,7 +22,41 @@ module Volumen
|
|||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
def self.render(text)
|
def self.render(text)
|
||||||
::Kramdown::Document.new(text.to_s, OPTIONS).to_html
|
html = ::Kramdown::Document.new(text.to_s, OPTIONS).to_html
|
||||||
|
wrap_figures(html)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Wrap every `<img title="...">` inside a `<figure>` with `<figcaption>`.
|
||||||
|
# Images without a title are left untouched.
|
||||||
|
def self.wrap_figures(html)
|
||||||
|
doc = REXML::Document.new("<root>#{html}</root>")
|
||||||
|
|
||||||
|
REXML::XPath.each(doc, "//img[@title]") do |img|
|
||||||
|
caption = img.attributes["title"]
|
||||||
|
img.attributes.delete("title")
|
||||||
|
|
||||||
|
figure = REXML::Element.new("figure")
|
||||||
|
figure.add_element(img.deep_clone)
|
||||||
|
|
||||||
|
info = REXML::Element.new("div")
|
||||||
|
info.add_attribute("class", "fig-info")
|
||||||
|
info.add_attribute("aria-hidden", "true")
|
||||||
|
info.text = "i"
|
||||||
|
figure.add_element(info)
|
||||||
|
|
||||||
|
figcaption = REXML::Element.new("figcaption")
|
||||||
|
figcaption.text = caption
|
||||||
|
figure.add_element(figcaption)
|
||||||
|
|
||||||
|
img.parent.replace_child(img, figure)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Strip the wrapping <root> we added.
|
||||||
|
result = +""
|
||||||
|
doc.root.children.each { |child| result << child.to_s }
|
||||||
|
result
|
||||||
|
rescue REXML::ParseException
|
||||||
|
html
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+44
-1
@@ -7,6 +7,8 @@ require_relative "markdown"
|
|||||||
module Volumen
|
module Volumen
|
||||||
# A single blog post: TOML frontmatter metadata plus a Markdown body.
|
# A single blog post: TOML frontmatter metadata plus a Markdown body.
|
||||||
class Post
|
class Post
|
||||||
|
FEDIVERSE_CREATOR_REGEX = /\A@[^@\s]+@[^@\s]+\z/
|
||||||
|
|
||||||
attr_reader :metadata, :body
|
attr_reader :metadata, :body
|
||||||
attr_accessor :path
|
attr_accessor :path
|
||||||
|
|
||||||
@@ -56,6 +58,44 @@ module Volumen
|
|||||||
metadata["cover"]
|
metadata["cover"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def cover_alt
|
||||||
|
metadata["cover_alt"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def cover_caption
|
||||||
|
metadata["cover_caption"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def fediverse_creator
|
||||||
|
value = metadata["fediverse_creator"]
|
||||||
|
return nil if value.nil?
|
||||||
|
|
||||||
|
text = value.to_s.strip
|
||||||
|
text.empty? ? nil : text
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_fediverse_creator?
|
||||||
|
fediverse_creator&.match?(FEDIVERSE_CREATOR_REGEX)
|
||||||
|
end
|
||||||
|
|
||||||
|
def publish_at
|
||||||
|
metadata["publish_at"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def scheduled?
|
||||||
|
value = publish_at
|
||||||
|
return false if value.nil?
|
||||||
|
|
||||||
|
date = case value
|
||||||
|
when Date then value
|
||||||
|
when Time then value.to_date
|
||||||
|
else Date.iso8601(value.to_s)
|
||||||
|
end
|
||||||
|
date > Date.today
|
||||||
|
rescue ArgumentError
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
def reading_time
|
def reading_time
|
||||||
words = body.split(/\s+/).count { |word| !word.empty? }
|
words = body.split(/\s+/).count { |word| !word.empty? }
|
||||||
[(words / 200.0).ceil, 1].max
|
[(words / 200.0).ceil, 1].max
|
||||||
@@ -94,7 +134,10 @@ module Volumen
|
|||||||
"lang" => lang,
|
"lang" => lang,
|
||||||
"tags" => tags,
|
"tags" => tags,
|
||||||
"author" => author,
|
"author" => author,
|
||||||
|
"fediverse_creator" => fediverse_creator,
|
||||||
"cover" => cover,
|
"cover" => cover,
|
||||||
|
"cover_alt" => cover_alt,
|
||||||
|
"cover_caption" => cover_caption,
|
||||||
"reading_time" => reading_time,
|
"reading_time" => reading_time,
|
||||||
"translations" => translations,
|
"translations" => translations,
|
||||||
"url" => "/api/volumen/posts/#{slug}"
|
"url" => "/api/volumen/posts/#{slug}"
|
||||||
@@ -112,7 +155,7 @@ module Volumen
|
|||||||
.find { |para| !para.empty? && !para.start_with?("#") }
|
.find { |para| !para.empty? && !para.start_with?("#") }
|
||||||
return "" if paragraph.nil?
|
return "" if paragraph.nil?
|
||||||
|
|
||||||
stripped = paragraph.gsub(/[*_`>#]/, "").strip
|
stripped = paragraph.gsub(/<[^>]+>/, "").gsub(/[*_`>#]/, "").strip
|
||||||
stripped.length > limit ? "#{stripped[0, limit].rstrip}…" : stripped
|
stripped.length > limit ? "#{stripped[0, limit].rstrip}…" : stripped
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+204
-229
@@ -8,32 +8,54 @@ require_relative "config"
|
|||||||
require_relative "store"
|
require_relative "store"
|
||||||
require_relative "markdown"
|
require_relative "markdown"
|
||||||
require_relative "password"
|
require_relative "password"
|
||||||
|
require_relative "api_routes"
|
||||||
|
require_relative "admin_routes"
|
||||||
|
require_relative "feed_helpers"
|
||||||
|
|
||||||
module Volumen
|
module Volumen
|
||||||
# Sinatra application: the public JSON API at /api/volumen and the
|
# Sinatra application: the public JSON API at /api/volumen and the
|
||||||
# server-rendered admin at /admin.
|
# server-rendered admin at /admin.
|
||||||
class Server < Sinatra::Base
|
class Server < Sinatra::Base
|
||||||
|
MAX_LOGIN_ATTEMPTS = 10
|
||||||
|
LOGIN_WINDOW = 60 # seconds
|
||||||
|
MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||||
|
ALLOWED_UPLOAD_TYPES = %w[image/webp image/avif].freeze
|
||||||
|
SLUG_REGEX = /\A[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\z/
|
||||||
|
|
||||||
|
register ApiRoutes
|
||||||
|
register AdminRoutes
|
||||||
|
helpers FeedHelpers
|
||||||
|
|
||||||
configure do
|
configure do
|
||||||
set :show_exceptions, false
|
set :show_exceptions, false
|
||||||
set :raise_errors, false
|
set :raise_errors, false
|
||||||
set :host_authorization, { permitted_hosts: [] }
|
set :host_authorization, { permitted_hosts: [] } # empty = allow all hosts
|
||||||
set :views, File.expand_path("web/views", __dir__)
|
set :views, File.expand_path("web/views", __dir__)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Returns a configured subclass bound to a specific config + store.
|
# Returns a configured subclass bound to a specific config + store.
|
||||||
def self.configured(config:, store:)
|
def self.configured(config:, store:)
|
||||||
secret = session_secret(config)
|
secret = session_secret(config)
|
||||||
|
ttl = config.admin["session_ttl"].to_i
|
||||||
|
ttl = nil if ttl <= 0
|
||||||
Class.new(self) do
|
Class.new(self) do
|
||||||
set :volumen_config, config
|
set :volumen_config, config
|
||||||
set :volumen_store, store
|
set :volumen_store, store
|
||||||
set :sessions, httponly: true, same_site: :strict
|
set :sessions, httponly: true, same_site: :strict, expire_after: ttl
|
||||||
set :session_secret, secret
|
set :session_secret, secret
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.session_secret(config)
|
def self.session_secret(config)
|
||||||
key = config.admin["session_key"].to_s
|
key = config.admin["session_key"].to_s
|
||||||
key.bytesize >= 64 ? key : SecureRandom.hex(64)
|
return SecureRandom.hex(64) if key.empty?
|
||||||
|
|
||||||
|
if key.bytesize < 64
|
||||||
|
warn "volumen: session_key is shorter than 64 bytes, falling back to random secret"
|
||||||
|
return SecureRandom.hex(64)
|
||||||
|
end
|
||||||
|
|
||||||
|
key
|
||||||
end
|
end
|
||||||
|
|
||||||
# Mark the session cookie Secure only when the request is HTTPS (directly or
|
# Mark the session cookie Secure only when the request is HTTPS (directly or
|
||||||
@@ -42,178 +64,19 @@ module Volumen
|
|||||||
before do
|
before do
|
||||||
options = request.env["rack.session.options"]
|
options = request.env["rack.session.options"]
|
||||||
options[:secure] = request.ssl? if options
|
options[:secure] = request.ssl? if options
|
||||||
|
|
||||||
|
if request.path_info.start_with?("/admin")
|
||||||
|
headers "Content-Security-Policy" => [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self' 'unsafe-inline'",
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"img-src 'self' data:",
|
||||||
|
"font-src 'self'",
|
||||||
|
"connect-src 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
"frame-ancestors 'none'"
|
||||||
|
].join("; ")
|
||||||
end
|
end
|
||||||
|
|
||||||
# --- Public JSON API -------------------------------------------------
|
|
||||||
|
|
||||||
before "/api/volumen/*" do
|
|
||||||
headers "Access-Control-Allow-Origin" => "*"
|
|
||||||
content_type :json
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/api/volumen/site" do
|
|
||||||
json(site_payload)
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/api/volumen/posts" do
|
|
||||||
json(posts_payload)
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/api/volumen/posts/:slug" do
|
|
||||||
post = store.find(params["slug"], lang: params["lang"])
|
|
||||||
halt(404, json("error" => "not_found")) if post.nil?
|
|
||||||
|
|
||||||
json(post.detail)
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/api/volumen/tags" do
|
|
||||||
json("tags" => tag_counts)
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/api/volumen/feed.xml" do
|
|
||||||
content_type "application/rss+xml"
|
|
||||||
feed_xml
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/api/volumen/sitemap.xml" do
|
|
||||||
content_type "application/xml"
|
|
||||||
sitemap_xml
|
|
||||||
end
|
|
||||||
|
|
||||||
# --- Admin -----------------------------------------------------------
|
|
||||||
|
|
||||||
get "/admin" do
|
|
||||||
redirect to("/admin/")
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/admin/" do
|
|
||||||
require_login!
|
|
||||||
@posts = sorted_posts
|
|
||||||
erb :list
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/admin/login" do
|
|
||||||
redirect to("/admin/") if authenticated?
|
|
||||||
|
|
||||||
erb :login
|
|
||||||
end
|
|
||||||
|
|
||||||
post "/admin/login" do
|
|
||||||
check_csrf!
|
|
||||||
user = users.authenticate(params["username"], params["password"])
|
|
||||||
if user
|
|
||||||
session[:user] = user.username
|
|
||||||
redirect to("/admin/")
|
|
||||||
else
|
|
||||||
@error = "Invalid username or password."
|
|
||||||
erb :login
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
post "/admin/logout" do
|
|
||||||
check_csrf!
|
|
||||||
session.clear
|
|
||||||
redirect to("/admin/login")
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/admin/posts/new" do
|
|
||||||
require_login!
|
|
||||||
@mode = :new
|
|
||||||
@post = Post.new(metadata: { "lang" => config.language })
|
|
||||||
erb :form
|
|
||||||
end
|
|
||||||
|
|
||||||
post "/admin/posts" do
|
|
||||||
require_login!
|
|
||||||
check_csrf!
|
|
||||||
create_post
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/admin/posts/:slug/edit" do
|
|
||||||
require_login!
|
|
||||||
@mode = :edit
|
|
||||||
@post = store.find(params["slug"])
|
|
||||||
halt(404, "Not found") if @post.nil?
|
|
||||||
|
|
||||||
erb :form
|
|
||||||
end
|
|
||||||
|
|
||||||
post "/admin/posts/:slug" do
|
|
||||||
require_login!
|
|
||||||
check_csrf!
|
|
||||||
update_post
|
|
||||||
end
|
|
||||||
|
|
||||||
post "/admin/posts/:slug/delete" do
|
|
||||||
require_login!
|
|
||||||
check_csrf!
|
|
||||||
store.delete(params["slug"])
|
|
||||||
redirect to("/admin/")
|
|
||||||
end
|
|
||||||
|
|
||||||
post "/admin/preview" do
|
|
||||||
require_login!
|
|
||||||
content_type :html
|
|
||||||
Markdown.render(params["body"].to_s)
|
|
||||||
end
|
|
||||||
|
|
||||||
post "/admin/uploads" do
|
|
||||||
require_login!
|
|
||||||
check_csrf!
|
|
||||||
content_type :json
|
|
||||||
upload = params["file"]
|
|
||||||
halt(400, json("error" => "no_file")) unless upload.is_a?(Hash) && upload[:tempfile]
|
|
||||||
|
|
||||||
json("url" => store.store_upload(upload[:filename], upload[:tempfile]))
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/media/*" do
|
|
||||||
path = store.media_file(params["splat"].first)
|
|
||||||
halt(404) if path.nil?
|
|
||||||
|
|
||||||
send_file(path)
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/admin/logo.svg" do
|
|
||||||
send_file(File.expand_path("web/public/volumen-logo.svg", __dir__))
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/admin/settings" do
|
|
||||||
require_login!
|
|
||||||
render_settings
|
|
||||||
end
|
|
||||||
|
|
||||||
post "/admin/settings/password" do
|
|
||||||
require_login!
|
|
||||||
check_csrf!
|
|
||||||
change_password
|
|
||||||
end
|
|
||||||
|
|
||||||
post "/admin/settings/username" do
|
|
||||||
require_login!
|
|
||||||
check_csrf!
|
|
||||||
change_username
|
|
||||||
end
|
|
||||||
|
|
||||||
post "/admin/settings/users" do
|
|
||||||
require_login!
|
|
||||||
require_admin!
|
|
||||||
check_csrf!
|
|
||||||
create_user
|
|
||||||
end
|
|
||||||
|
|
||||||
post "/admin/settings/users/:username/role" do
|
|
||||||
require_login!
|
|
||||||
require_admin!
|
|
||||||
check_csrf!
|
|
||||||
change_role(params["username"])
|
|
||||||
end
|
|
||||||
|
|
||||||
post "/admin/settings/users/:username/delete" do
|
|
||||||
require_login!
|
|
||||||
require_admin!
|
|
||||||
check_csrf!
|
|
||||||
remove_user(params["username"])
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -233,11 +96,22 @@ module Volumen
|
|||||||
end
|
end
|
||||||
|
|
||||||
def published_posts
|
def published_posts
|
||||||
store.all.reject(&:draft?).sort_by { |post| post.date_string || "" }.reverse
|
@published_posts ||= store.all
|
||||||
|
.reject { |post| post.draft? || post.scheduled? }
|
||||||
|
.sort_by { |post| post.date_string || "" }.reverse
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_posts_cache!
|
||||||
|
@published_posts = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def site_payload
|
def site_payload
|
||||||
config.site.slice("title", "description", "base_url", "language", "author")
|
config.site.slice("title", "description", "base_url", "language", "author",
|
||||||
|
"fediverse_creator")
|
||||||
|
end
|
||||||
|
|
||||||
|
def site_fediverse_creator
|
||||||
|
config.site["fediverse_creator"]
|
||||||
end
|
end
|
||||||
|
|
||||||
def posts_payload
|
def posts_payload
|
||||||
@@ -245,24 +119,42 @@ module Volumen
|
|||||||
page = positive_int(params["page"], 1)
|
page = positive_int(params["page"], 1)
|
||||||
limit = positive_int(params["limit"], 20).clamp(1, 100)
|
limit = positive_int(params["limit"], 20).clamp(1, 100)
|
||||||
offset = (page - 1) * limit
|
offset = (page - 1) * limit
|
||||||
|
total = posts.length
|
||||||
{
|
{
|
||||||
"page" => page,
|
"page" => page,
|
||||||
"page_size" => limit,
|
"page_size" => limit,
|
||||||
"total" => posts.length,
|
"total" => total,
|
||||||
|
"has_next" => offset + limit < total,
|
||||||
|
"has_prev" => page > 1,
|
||||||
"posts" => posts[offset, limit].to_a.map(&:summary)
|
"posts" => posts[offset, limit].to_a.map(&:summary)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def filtered_posts
|
def filtered_posts
|
||||||
posts = published_posts
|
posts = published_posts
|
||||||
if params["lang"]
|
posts = filter_by_lang(posts)
|
||||||
lang = params["lang"]
|
|
||||||
posts = posts.select { |post| post.lang == lang || post.all_langs? }
|
|
||||||
end
|
|
||||||
posts = posts.select { |post| post.tags.include?(params["tag"]) } if params["tag"]
|
posts = posts.select { |post| post.tags.include?(params["tag"]) } if params["tag"]
|
||||||
|
posts = search_posts(posts) if params["q"]
|
||||||
posts
|
posts
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def filter_by_lang(posts)
|
||||||
|
return posts unless params["lang"]
|
||||||
|
|
||||||
|
lang = params["lang"]
|
||||||
|
posts.select { |post| post.lang == lang || post.all_langs? }
|
||||||
|
end
|
||||||
|
|
||||||
|
def search_posts(posts)
|
||||||
|
query = params["q"].to_s.strip.downcase
|
||||||
|
return posts if query.empty?
|
||||||
|
|
||||||
|
posts.select do |post|
|
||||||
|
post.title.to_s.downcase.include?(query) ||
|
||||||
|
post.body.downcase.include?(query)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def tag_counts
|
def tag_counts
|
||||||
counts = Hash.new(0)
|
counts = Hash.new(0)
|
||||||
published_posts.each { |post| post.tags.each { |tag| counts[tag] += 1 } }
|
published_posts.each { |post| post.tags.each { |tag| counts[tag] += 1 } }
|
||||||
@@ -279,46 +171,6 @@ module Volumen
|
|||||||
config.site["base_url"].to_s.chomp("/")
|
config.site["base_url"].to_s.chomp("/")
|
||||||
end
|
end
|
||||||
|
|
||||||
def feed_xml
|
|
||||||
site = config.site
|
|
||||||
items = published_posts.first(20).map { |post| feed_item(post) }.join("\n")
|
|
||||||
<<~XML
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<rss version="2.0">
|
|
||||||
<channel>
|
|
||||||
<title>#{escape(site["title"])}</title>
|
|
||||||
<link>#{escape(base_url)}</link>
|
|
||||||
<description>#{escape(site["description"])}</description>
|
|
||||||
#{items}
|
|
||||||
</channel>
|
|
||||||
</rss>
|
|
||||||
XML
|
|
||||||
end
|
|
||||||
|
|
||||||
def feed_item(post)
|
|
||||||
link = "#{base_url}/#{post.slug}"
|
|
||||||
<<~XML.chomp
|
|
||||||
<item>
|
|
||||||
<title>#{escape(post.title)}</title>
|
|
||||||
<link>#{escape(link)}</link>
|
|
||||||
<guid>#{escape(link)}</guid>
|
|
||||||
<description>#{escape(post.excerpt)}</description>
|
|
||||||
</item>
|
|
||||||
XML
|
|
||||||
end
|
|
||||||
|
|
||||||
def sitemap_xml
|
|
||||||
urls = published_posts.map do |post|
|
|
||||||
" <url><loc>#{escape("#{base_url}/#{post.slug}")}</loc></url>"
|
|
||||||
end.join("\n")
|
|
||||||
<<~XML
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
||||||
#{urls}
|
|
||||||
</urlset>
|
|
||||||
XML
|
|
||||||
end
|
|
||||||
|
|
||||||
def escape(text)
|
def escape(text)
|
||||||
Rack::Utils.escape_html(text.to_s)
|
Rack::Utils.escape_html(text.to_s)
|
||||||
end
|
end
|
||||||
@@ -347,12 +199,32 @@ module Volumen
|
|||||||
current_user_record&.role
|
current_user_record&.role
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def current_user_name
|
||||||
|
current_user_record&.name
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_user_fediverse
|
||||||
|
current_user_record&.fediverse_creator
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_user_photo
|
||||||
|
current_user_record&.photo
|
||||||
|
end
|
||||||
|
|
||||||
def admin?
|
def admin?
|
||||||
current_role == "admin"
|
current_role == "admin"
|
||||||
end
|
end
|
||||||
|
|
||||||
def require_login!
|
def require_login!
|
||||||
redirect to("/admin/login") unless authenticated?
|
unless authenticated?
|
||||||
|
redirect to("/admin/login")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
return unless current_user_record.nil?
|
||||||
|
|
||||||
|
session.clear
|
||||||
|
redirect to("/admin/login")
|
||||||
end
|
end
|
||||||
|
|
||||||
def require_admin!
|
def require_admin!
|
||||||
@@ -365,11 +237,40 @@ module Volumen
|
|||||||
|
|
||||||
def check_csrf!
|
def check_csrf!
|
||||||
token = params["_csrf"]
|
token = params["_csrf"]
|
||||||
return if token && session[:csrf] && Rack::Utils.secure_compare(session[:csrf], token)
|
return if token && session["csrf"] && Rack::Utils.secure_compare(session["csrf"], token)
|
||||||
|
|
||||||
halt(403, "Invalid CSRF token")
|
halt(403, "Invalid CSRF token")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_upload!(upload)
|
||||||
|
if upload[:tempfile].size > MAX_UPLOAD_BYTES
|
||||||
|
halt(413, json("error" => "file_too_large", "max_bytes" => MAX_UPLOAD_BYTES))
|
||||||
|
end
|
||||||
|
|
||||||
|
content_type = upload[:type].to_s.split(";").first.to_s.strip.downcase
|
||||||
|
return if ALLOWED_UPLOAD_TYPES.include?(content_type)
|
||||||
|
|
||||||
|
halt(415, json("error" => "unsupported_format",
|
||||||
|
"message" => "Only WebP (.webp) and AVIF (.avif) images are supported. " \
|
||||||
|
"Please convert your image before uploading — for example with " \
|
||||||
|
"`cwebp` (JPEG/PNG → WebP) or `avifenc` (PNG/JPEG → AVIF).",
|
||||||
|
"allowed" => ALLOWED_UPLOAD_TYPES))
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_login_rate_limit!
|
||||||
|
Volumen.sweep_login_attempts!
|
||||||
|
ip = request.ip.to_s
|
||||||
|
now = Time.now.to_f
|
||||||
|
cutoff = now - LOGIN_WINDOW
|
||||||
|
attempts = (Volumen.login_attempts[ip] ||= []).select { |t| t > cutoff }
|
||||||
|
if attempts.length >= MAX_LOGIN_ATTEMPTS
|
||||||
|
halt(429, "Too many login attempts. Please wait and try again.")
|
||||||
|
end
|
||||||
|
|
||||||
|
attempts << now
|
||||||
|
Volumen.login_attempts[ip] = attempts
|
||||||
|
end
|
||||||
|
|
||||||
def render_settings(error: nil, notice: nil)
|
def render_settings(error: nil, notice: nil)
|
||||||
@error = error
|
@error = error
|
||||||
@notice = notice
|
@notice = notice
|
||||||
@@ -392,6 +293,11 @@ module Volumen
|
|||||||
def change_username
|
def change_username
|
||||||
new_name = presence(params["username"])
|
new_name = presence(params["username"])
|
||||||
return render_settings(error: "Username cannot be empty.") if new_name.nil?
|
return render_settings(error: "Username cannot be empty.") if new_name.nil?
|
||||||
|
unless new_name.match?(/\A[a-zA-Z0-9._-]+\z/)
|
||||||
|
return render_settings(error: "Username may use letters, numbers, dot, dash, underscore.")
|
||||||
|
end
|
||||||
|
|
||||||
|
return render_settings(notice: "Username unchanged.") if new_name == current_user
|
||||||
|
|
||||||
if users.rename(current_user, new_name)
|
if users.rename(current_user, new_name)
|
||||||
session[:user] = new_name
|
session[:user] = new_name
|
||||||
@@ -401,6 +307,45 @@ module Volumen
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def change_name
|
||||||
|
name = params["name"].to_s.strip
|
||||||
|
name = nil if name.empty?
|
||||||
|
users.update_name(current_user, name)
|
||||||
|
render_settings(notice: name ? "Display name updated." : "Display name cleared.")
|
||||||
|
end
|
||||||
|
|
||||||
|
def change_fediverse_creator
|
||||||
|
value = params["fediverse_creator"].to_s.strip
|
||||||
|
if value.empty?
|
||||||
|
users.update_fediverse_creator(current_user, nil)
|
||||||
|
return render_settings(notice: "Fediverse handle cleared.")
|
||||||
|
end
|
||||||
|
|
||||||
|
unless value.match?(Post::FEDIVERSE_CREATOR_REGEX)
|
||||||
|
return render_settings(error: "Fediverse handle must look like @user@host.")
|
||||||
|
end
|
||||||
|
|
||||||
|
users.update_fediverse_creator(current_user, value)
|
||||||
|
render_settings(notice: "Fediverse handle updated.")
|
||||||
|
end
|
||||||
|
|
||||||
|
def change_photo
|
||||||
|
upload = params["photo"]
|
||||||
|
unless upload.is_a?(Hash) && upload[:tempfile]
|
||||||
|
return render_settings(error: "No file selected.")
|
||||||
|
end
|
||||||
|
|
||||||
|
validate_upload!(upload)
|
||||||
|
url = store.store_user_photo(current_user, upload[:filename], upload[:tempfile])
|
||||||
|
users.update_photo(current_user, url)
|
||||||
|
render_settings(notice: "Profile photo updated.")
|
||||||
|
end
|
||||||
|
|
||||||
|
def remove_photo
|
||||||
|
users.update_photo(current_user, nil)
|
||||||
|
render_settings(notice: "Profile photo removed.")
|
||||||
|
end
|
||||||
|
|
||||||
def create_user
|
def create_user
|
||||||
name = presence(params["username"])
|
name = presence(params["username"])
|
||||||
password = presence(params["password"])
|
password = presence(params["password"])
|
||||||
@@ -454,6 +399,7 @@ module Volumen
|
|||||||
return erb(:form)
|
return erb(:form)
|
||||||
end
|
end
|
||||||
store.save(post)
|
store.save(post)
|
||||||
|
clear_posts_cache!
|
||||||
redirect to("/admin/")
|
redirect to("/admin/")
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -461,33 +407,62 @@ module Volumen
|
|||||||
existing = store.find(params["slug"])
|
existing = store.find(params["slug"])
|
||||||
halt(404, "Not found") if existing.nil?
|
halt(404, "Not found") if existing.nil?
|
||||||
|
|
||||||
store.save(post_from_params(params, existing: existing))
|
post = post_from_params(params, existing: existing)
|
||||||
|
error = fediverse_creator_error(post)
|
||||||
|
if error
|
||||||
|
@mode = :edit
|
||||||
|
@post = post
|
||||||
|
@error = error
|
||||||
|
return erb(:form)
|
||||||
|
end
|
||||||
|
store.save(post)
|
||||||
|
clear_posts_cache!
|
||||||
redirect to("/admin/")
|
redirect to("/admin/")
|
||||||
end
|
end
|
||||||
|
|
||||||
def creation_error(post)
|
def creation_error(post)
|
||||||
return "Slug is required." if presence(post.slug).nil?
|
return "Slug is required." if presence(post.slug).nil?
|
||||||
|
return "Invalid slug." unless post.slug.match?(SLUG_REGEX)
|
||||||
return "A post with that slug already exists." if store.find(post.slug)
|
return "A post with that slug already exists." if store.find(post.slug)
|
||||||
|
|
||||||
nil
|
fediverse_creator_error(post)
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_from_params(http_params, existing: nil)
|
def post_from_params(http_params, existing: nil)
|
||||||
metadata = {
|
metadata = base_metadata(http_params, existing: existing)
|
||||||
|
post = Post.new(metadata: clean_metadata(metadata), body: http_params["body"].to_s)
|
||||||
|
post.path = existing.path if existing
|
||||||
|
post
|
||||||
|
end
|
||||||
|
|
||||||
|
def base_metadata(http_params, existing:)
|
||||||
|
{
|
||||||
"title" => presence(http_params["title"]),
|
"title" => presence(http_params["title"]),
|
||||||
"slug" => presence(http_params["slug"]) || existing&.slug,
|
"slug" => presence(http_params["slug"]) || existing&.slug,
|
||||||
"lang" => presence(http_params["lang"]),
|
"lang" => presence(http_params["lang"]),
|
||||||
"author" => presence(http_params["author"]),
|
"author" => presence(http_params["author"]),
|
||||||
|
"fediverse_creator" => presence(http_params["fediverse_creator"]),
|
||||||
"date" => parse_date(http_params["date"]),
|
"date" => parse_date(http_params["date"]),
|
||||||
|
"publish_at" => parse_date(http_params["publish_at"]),
|
||||||
"tags" => parse_tags(http_params["tags"]),
|
"tags" => parse_tags(http_params["tags"]),
|
||||||
"excerpt" => presence(http_params["excerpt"]),
|
"excerpt" => presence(http_params["excerpt"]),
|
||||||
"cover" => presence(http_params["cover"]),
|
"cover" => presence(http_params["cover"]),
|
||||||
|
"cover_alt" => presence(http_params["cover_alt"]),
|
||||||
|
"cover_caption" => presence(http_params["cover_caption"]),
|
||||||
"draft" => http_params["draft"] == "on",
|
"draft" => http_params["draft"] == "on",
|
||||||
"all_langs" => http_params["all_langs"] == "on"
|
"all_langs" => http_params["all_langs"] == "on"
|
||||||
}
|
}
|
||||||
post = Post.new(metadata: clean_metadata(metadata), body: http_params["body"].to_s)
|
end
|
||||||
post.path = existing.path if existing
|
|
||||||
post
|
def fediverse_creator_error(post)
|
||||||
|
value = post.metadata["fediverse_creator"]
|
||||||
|
return nil if value.nil?
|
||||||
|
|
||||||
|
if Post::FEDIVERSE_CREATOR_REGEX.match?(value.to_s)
|
||||||
|
nil
|
||||||
|
else
|
||||||
|
"Fediverse creator must look like @user@host."
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def clean_metadata(metadata)
|
def clean_metadata(metadata)
|
||||||
|
|||||||
+46
-6
@@ -16,11 +16,19 @@ module Volumen
|
|||||||
def initialize(content_dir, default_lang: nil)
|
def initialize(content_dir, default_lang: nil)
|
||||||
@content_dir = File.expand_path(content_dir.to_s)
|
@content_dir = File.expand_path(content_dir.to_s)
|
||||||
@default_lang = default_lang
|
@default_lang = default_lang
|
||||||
|
@all = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def all
|
def all
|
||||||
|
current_dir_mtime = Dir.exist?(@content_dir) ? File.mtime(@content_dir) : nil
|
||||||
|
current_files_mtime = newest_file_mtime
|
||||||
|
@all = nil if current_dir_mtime != @all_dir_mtime || current_files_mtime != @all_files_mtime
|
||||||
|
@all ||= begin
|
||||||
|
@all_dir_mtime = current_dir_mtime
|
||||||
|
@all_files_mtime = current_files_mtime
|
||||||
markdown_files.filter_map { |path| load_post(path) }
|
markdown_files.filter_map { |path| load_post(path) }
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def find(slug, lang: nil)
|
def find(slug, lang: nil)
|
||||||
all.find do |post|
|
all.find do |post|
|
||||||
@@ -31,14 +39,20 @@ module Volumen
|
|||||||
def save(post)
|
def save(post)
|
||||||
target = post.path || default_path_for(post)
|
target = post.path || default_path_for(post)
|
||||||
FileUtils.mkdir_p(File.dirname(target))
|
FileUtils.mkdir_p(File.dirname(target))
|
||||||
File.write(target, post.to_file)
|
tmp = "#{target}.tmp"
|
||||||
|
File.write(tmp, post.to_file)
|
||||||
|
File.rename(tmp, target)
|
||||||
post.path = target
|
post.path = target
|
||||||
|
@all = nil
|
||||||
post
|
post
|
||||||
end
|
end
|
||||||
|
|
||||||
def delete(slug, lang: nil)
|
def delete(slug, lang: nil)
|
||||||
post = find(slug, lang: lang)
|
post = find(slug, lang: lang)
|
||||||
File.delete(post.path) if post&.path
|
if post&.path
|
||||||
|
File.delete(post.path)
|
||||||
|
@all = nil
|
||||||
|
end
|
||||||
post
|
post
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -47,17 +61,27 @@ module Volumen
|
|||||||
end
|
end
|
||||||
|
|
||||||
def store_upload(original_name, source)
|
def store_upload(original_name, source)
|
||||||
FileUtils.mkdir_p(media_dir)
|
|
||||||
name = safe_media_name(original_name)
|
name = safe_media_name(original_name)
|
||||||
|
FileUtils.mkdir_p(media_dir)
|
||||||
|
File.open(File.join(media_dir, name), "wb") { |file| IO.copy_stream(source, file) }
|
||||||
|
"/media/#{name}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def store_user_photo(username, original_name, source)
|
||||||
|
ext = File.extname(original_name.to_s).gsub(/[^a-zA-Z0-9.]/, "").downcase
|
||||||
|
ext = ".webp" if ext.empty?
|
||||||
|
name = "#{username}-#{SecureRandom.hex(4)}#{ext}"
|
||||||
|
FileUtils.mkdir_p(media_dir)
|
||||||
File.open(File.join(media_dir, name), "wb") { |file| IO.copy_stream(source, file) }
|
File.open(File.join(media_dir, name), "wb") { |file| IO.copy_stream(source, file) }
|
||||||
"/media/#{name}"
|
"/media/#{name}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def media_file(name)
|
def media_file(name)
|
||||||
path = File.join(media_dir, File.basename(name.to_s))
|
path = File.join(media_dir, File.basename(name.to_s))
|
||||||
return nil unless File.file?(path) && within?(media_dir, path)
|
return nil unless File.file?(path)
|
||||||
|
|
||||||
path
|
real = File.realpath(path)
|
||||||
|
within?(media_dir, real) ? path : nil
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -66,6 +90,15 @@ module Volumen
|
|||||||
Dir.glob(File.join(@content_dir, "**", "*.md"))
|
Dir.glob(File.join(@content_dir, "**", "*.md"))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def newest_file_mtime
|
||||||
|
files = markdown_files
|
||||||
|
return nil if files.empty?
|
||||||
|
|
||||||
|
files.map { |path| File.mtime(path) }.max
|
||||||
|
rescue SystemCallError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
def load_post(path)
|
def load_post(path)
|
||||||
post = Post.parse(File.read(path))
|
post = Post.parse(File.read(path))
|
||||||
post.metadata["slug"] ||= File.basename(path, ".md")
|
post.metadata["slug"] ||= File.basename(path, ".md")
|
||||||
@@ -85,7 +118,14 @@ module Volumen
|
|||||||
end
|
end
|
||||||
|
|
||||||
def default_path_for(post)
|
def default_path_for(post)
|
||||||
File.join(@content_dir, "#{post.slug}.md")
|
dir = @content_dir
|
||||||
|
dir = File.join(dir, post.lang) if post.lang && post.lang != @default_lang
|
||||||
|
target = File.join(dir, "#{post.slug}.md")
|
||||||
|
unless File.expand_path(target).start_with?(File.expand_path(@content_dir) + File::SEPARATOR)
|
||||||
|
raise ArgumentError, "slug escapes content directory"
|
||||||
|
end
|
||||||
|
|
||||||
|
target
|
||||||
end
|
end
|
||||||
|
|
||||||
def safe_media_name(original)
|
def safe_media_name(original)
|
||||||
|
|||||||
+43
-9
@@ -15,18 +15,27 @@ module Volumen
|
|||||||
ROLES = %w[admin author].freeze
|
ROLES = %w[admin author].freeze
|
||||||
DEFAULT_ROLE = "author"
|
DEFAULT_ROLE = "author"
|
||||||
|
|
||||||
User = Struct.new(:username, :password_hash, :role)
|
User = Struct.new(:username, :password_hash, :role, :name, :fediverse_creator, :photo)
|
||||||
|
|
||||||
def initialize(path:, bootstrap_hash: nil)
|
def initialize(path:, bootstrap_hash: nil)
|
||||||
@path = path
|
@path = path
|
||||||
@bootstrap_hash = bootstrap_hash.to_s
|
@bootstrap_hash = bootstrap_hash.to_s
|
||||||
|
@all = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def all
|
def all
|
||||||
return read_file if File.exist?(@path)
|
mtime = File.exist?(@path) ? File.mtime(@path) : nil
|
||||||
return [] if @bootstrap_hash.empty?
|
@all = nil if mtime != @all_mtime
|
||||||
|
@all ||= begin
|
||||||
[User.new("admin", @bootstrap_hash, "admin")]
|
@all_mtime = mtime
|
||||||
|
if File.exist?(@path)
|
||||||
|
read_file
|
||||||
|
elsif @bootstrap_hash.empty?
|
||||||
|
[]
|
||||||
|
else
|
||||||
|
[User.new("admin", @bootstrap_hash, "admin", nil, nil, nil)]
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def any?
|
def any?
|
||||||
@@ -45,11 +54,25 @@ module Volumen
|
|||||||
def add(username, password, role = DEFAULT_ROLE)
|
def add(username, password, role = DEFAULT_ROLE)
|
||||||
return nil if username.to_s.empty? || find(username)
|
return nil if username.to_s.empty? || find(username)
|
||||||
|
|
||||||
user = User.new(username, Password.hash(password), normalize_role(role))
|
user = User.new(username, Password.hash(password), normalize_role(role), nil, nil, nil)
|
||||||
persist(all + [user])
|
persist(all + [user])
|
||||||
user
|
user
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def update_name(username, name)
|
||||||
|
mutate(username) { |user| user.name = name.to_s.empty? ? nil : name.to_s }
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_fediverse_creator(username, value)
|
||||||
|
mutate(username) do |user|
|
||||||
|
user.fediverse_creator = value.to_s.empty? ? nil : value.to_s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_photo(username, path)
|
||||||
|
mutate(username) { |user| user.photo = path }
|
||||||
|
end
|
||||||
|
|
||||||
def update_password(username, password)
|
def update_password(username, password)
|
||||||
mutate(username) { |user| user.password_hash = Password.hash(password) }
|
mutate(username) { |user| user.password_hash = Password.hash(password) }
|
||||||
end
|
end
|
||||||
@@ -112,17 +135,28 @@ module Volumen
|
|||||||
def read_file
|
def read_file
|
||||||
data = TomlRB.load_file(@path)
|
data = TomlRB.load_file(@path)
|
||||||
Array(data["users"]).map do |entry|
|
Array(data["users"]).map do |entry|
|
||||||
User.new(entry["username"], entry["password_hash"], entry["role"] || "admin")
|
User.new(entry["username"], entry["password_hash"], entry["role"] || "admin",
|
||||||
|
entry["name"], entry["fediverse_creator"], entry["photo"])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def persist(users)
|
def persist(users)
|
||||||
FileUtils.mkdir_p(File.dirname(@path))
|
FileUtils.mkdir_p(File.dirname(@path))
|
||||||
File.write(@path, TomlRB.dump("users" => users.map { |user| user_hash(user) }))
|
tmp = "#{@path}.tmp"
|
||||||
|
File.write(tmp, TomlRB.dump("users" => users.map { |user| user_hash(user) }))
|
||||||
|
File.rename(tmp, @path)
|
||||||
end
|
end
|
||||||
|
|
||||||
def user_hash(user)
|
def user_hash(user)
|
||||||
{ "username" => user.username, "password_hash" => user.password_hash, "role" => user.role }
|
hash = {
|
||||||
|
"username" => user.username,
|
||||||
|
"password_hash" => user.password_hash,
|
||||||
|
"role" => user.role
|
||||||
|
}
|
||||||
|
hash["name"] = user.name if user.name
|
||||||
|
hash["fediverse_creator"] = user.fediverse_creator if user.fediverse_creator
|
||||||
|
hash["photo"] = user.photo if user.photo
|
||||||
|
hash
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module Volumen
|
module Volumen
|
||||||
VERSION = "0.1.0"
|
VERSION = "0.3.0"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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 |
+196
-78
@@ -1,95 +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") : to("/admin/posts/#{@post.slug}") %>">
|
action="<%= @mode == :new ? to("/admin/posts") : h(to("/admin/posts/#{@post.slug}")) %>">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||||
|
|
||||||
<div class="fields">
|
<div class="form-grid">
|
||||||
<label>Title <input type="text" name="title" value="<%= h(@post.title) %>"></label>
|
<div class="surface full">
|
||||||
<label>Slug
|
<div class="surface-head">
|
||||||
<input type="text" name="slug" value="<%= h(@post.slug) %>"
|
<div>
|
||||||
<%= @mode == :edit ? "readonly" : "required" %>>
|
<h2>Details</h2>
|
||||||
</label>
|
<p class="lede">Title, slug, and publishing options</p>
|
||||||
<label>Language <input type="text" name="lang" value="<%= h(@post.lang) %>"></label>
|
</div>
|
||||||
<label>Author <input type="text" name="author" value="<%= h(@post.author) %>"></label>
|
|
||||||
<label>Date <input type="date" name="date" value="<%= h(@post.date_string) %>"></label>
|
|
||||||
<label>Tags
|
|
||||||
<input type="text" name="tags" value="<%= h(@post.tags.join(", ")) %>"
|
|
||||||
placeholder="comma, separated">
|
|
||||||
</label>
|
|
||||||
<label class="check">
|
|
||||||
<input type="checkbox" name="draft" <%= @post.draft? ? "checked" : "" %>> Draft
|
|
||||||
</label>
|
|
||||||
<label class="check">
|
|
||||||
<input type="checkbox" name="all_langs" <%= @post.all_langs? ? "checked" : "" %>> All languages
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cover-field">
|
<div class="field full">
|
||||||
<label for="cover-input">Cover image</label>
|
<label for="title">Title</label>
|
||||||
<div class="cover-row">
|
<input id="title" class="input" type="text" name="title" value="<%= h(@post.title) %>" placeholder="A clear, descriptive title" required>
|
||||||
<input type="text" id="cover-input" name="cover" value="<%= h(@post.cover) %>"
|
|
||||||
placeholder="/media/… or https://…">
|
|
||||||
<button type="button" id="cover-upload">Upload</button>
|
|
||||||
<button type="button" id="cover-clear">Clear</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label for="slug">Slug</label>
|
||||||
|
<input id="slug" class="input" type="text" name="slug" value="<%= h(@post.slug) %>"
|
||||||
|
<%= @mode == :edit ? "readonly" : "required" %> placeholder="my-post-slug">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="lang">Language</label>
|
||||||
|
<input id="lang" class="input" type="text" name="lang" value="<%= h(@post.lang) %>" placeholder="en">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label for="author">Author</label>
|
||||||
|
<input id="author" class="input" type="text" name="author" value="<%= h(@post.author.to_s.empty? ? current_user_name : @post.author) %>" placeholder="<%= h(current_user_name || 'Petr Balvín') %>">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="fediverse_creator">Fediverse creator</label>
|
||||||
|
<input id="fediverse_creator" class="input" type="text" name="fediverse_creator"
|
||||||
|
value="<%= h(@post.fediverse_creator.to_s.empty? ? (current_user_fediverse || config.site['fediverse_creator']) : @post.fediverse_creator) %>" placeholder="@user@instance.tld">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row-3">
|
||||||
|
<div class="field">
|
||||||
|
<label for="date">Publish date</label>
|
||||||
|
<input id="date" class="input" type="date" name="date" value="<%= h(@post.date_string || Date.today.iso8601) %>">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="publish_at">Schedule for</label>
|
||||||
|
<input id="publish_at" class="input" type="date" name="publish_at"
|
||||||
|
value="<%= @post.publish_at.is_a?(Date) ? @post.publish_at.strftime("%Y-%m-%d") : "" %>">
|
||||||
|
<p class="help">Leave empty to publish immediately</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="tags">Tags</label>
|
||||||
|
<input id="tags" class="input" type="text" name="tags" value="<%= h(@post.tags.join(", ")) %>" placeholder="comma, separated">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field full">
|
||||||
|
<label for="excerpt">Excerpt</label>
|
||||||
|
<input id="excerpt" class="input" type="text" name="excerpt" value="<%= h(@post.excerpt) %>" placeholder="Short summary for listings and previews">
|
||||||
|
<p class="help">Auto-generated from the first paragraph if left empty</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 1.5rem; flex-wrap: wrap; margin-top: 0.5rem;">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="draft" <%= @post.draft? ? "checked" : "" %>> Draft
|
||||||
|
</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="all_langs" <%= @post.all_langs? ? "checked" : "" %>> Available in all languages
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="surface full">
|
||||||
|
<div class="surface-head">
|
||||||
|
<div>
|
||||||
|
<h2>Cover image</h2>
|
||||||
|
<p class="lede">Header image shown on the article page</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cover-field">
|
||||||
|
<div class="cover-input-row">
|
||||||
|
<input id="cover-input" class="input" type="text" name="cover" value="<%= h(@post.cover) %>"
|
||||||
|
placeholder="/media/… or https://…">
|
||||||
|
<button type="button" id="cover-upload" class="btn">Upload</button>
|
||||||
|
<button type="button" id="cover-clear" class="btn btn-ghost">Clear</button>
|
||||||
|
</div>
|
||||||
|
<input id="cover-alt-input" class="input" type="text" name="cover_alt" value="<%= h(@post.cover_alt) %>"
|
||||||
|
placeholder="ALT text (for accessibility)">
|
||||||
|
<input id="cover-caption-input" class="input" type="text" name="cover_caption" value="<%= h(@post.cover_caption) %>"
|
||||||
|
placeholder="Caption (e.g. 'Generated by AI')">
|
||||||
<img id="cover-preview" class="cover-preview" alt="" hidden>
|
<img id="cover-preview" class="cover-preview" alt="" hidden>
|
||||||
<input type="file" id="cover-file" accept="image/*" hidden>
|
<input type="file" id="cover-file" accept="image/*" hidden>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="editor-tabs">
|
<div class="surface full">
|
||||||
|
<div class="editor-tabs" style="margin-bottom: 0.75rem;">
|
||||||
<button type="button" data-tab="markdown" class="active">Markdown</button>
|
<button type="button" data-tab="markdown" class="active">Markdown</button>
|
||||||
<button type="button" data-tab="visual">Visual</button>
|
<button type="button" data-tab="visual">Visual</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="visual-view" hidden>
|
<div id="visual-view" hidden>
|
||||||
<div class="pane">
|
<div class="editor-grid">
|
||||||
<div class="ed-toolbar">
|
<div class="editor-pane">
|
||||||
<button type="button" data-rich="bold" title="Bold (Ctrl+B)"><b>B</b></button>
|
<div class="editor-head">
|
||||||
<button type="button" data-rich="italic" title="Italic (Ctrl+I)"><i>I</i></button>
|
<span class="label">Editor</span>
|
||||||
<button type="button" data-rich="h2" title="Heading 2">H2</button>
|
<span class="spacer"></span>
|
||||||
<button type="button" data-rich="h3" title="Heading 3">H3</button>
|
<button type="button" class="editor-toolbar-btn bold" data-rich="bold" title="Bold (Ctrl+B)">B</button>
|
||||||
<button type="button" data-rich="link" title="Link (Ctrl+K)">Link</button>
|
<button type="button" class="editor-toolbar-btn italic" data-rich="italic" title="Italic (Ctrl+I)">I</button>
|
||||||
<button type="button" data-rich="ul" title="Bullet list">• 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">“</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"></></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">/></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">“</button>
|
<button type="button" class="editor-toolbar-btn" data-md="link" title="Link (Ctrl+K)">↗</button>
|
||||||
<button type="button" data-md="code" title="Code"></></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"></></button>
|
||||||
|
<button type="button" class="editor-toolbar-btn" data-md="image" title="Image">IMG</button>
|
||||||
|
<button type="button" id="toggle-preview" class="editor-toolbar-btn" title="Toggle preview">PREVIEW</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="body" name="body" class="editor-textarea" rows="22" placeholder="Write your post in Markdown…"><%= h(@post.body) %></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="editor-pane" id="preview-pane">
|
||||||
|
<div class="editor-head">
|
||||||
|
<span class="label">Preview</span>
|
||||||
|
</div>
|
||||||
|
<div id="preview" class="markdown editor-preview"></div>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="body" name="body" rows="22"><%= h(@post.body) %></textarea>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="pane preview-pane">
|
|
||||||
<div class="ed-toolbar"><span>Preview</span></div>
|
|
||||||
<div id="preview" class="markdown"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="file" id="image-input" accept="image/*" hidden>
|
<input type="file" id="image-input" accept="image/*" hidden>
|
||||||
<button type="submit" class="primary">Save</button>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -140,7 +235,10 @@
|
|||||||
if (tag === "code") { return "`" + node.textContent + "`"; }
|
if (tag === "code") { return "`" + node.textContent + "`"; }
|
||||||
if (tag === "a") { return "[" + inner + "](" + (node.getAttribute("href") || "") + ")"; }
|
if (tag === "a") { return "[" + inner + "](" + (node.getAttribute("href") || "") + ")"; }
|
||||||
if (tag === "img") {
|
if (tag === "img") {
|
||||||
return " || "") + ")";
|
var title = node.getAttribute("title") || "";
|
||||||
|
var imgMd = " || "") + ")";
|
||||||
|
if (title) { imgMd = imgMd.replace(")", ' "' + title + '")'); }
|
||||||
|
return imgMd;
|
||||||
}
|
}
|
||||||
if (tag === "br") { return "\n"; }
|
if (tag === "br") { return "\n"; }
|
||||||
return inner;
|
return inner;
|
||||||
@@ -188,7 +286,7 @@
|
|||||||
return blocks.join("\n\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
|
return blocks.join("\n\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Live preview (markdown mode) -------------------------------------
|
// --- Live preview (markdown mode) — disabled by default ----------------
|
||||||
|
|
||||||
function renderPreview() {
|
function renderPreview() {
|
||||||
mdToHtml(textarea.value).then(function (html) { preview.innerHTML = html; });
|
mdToHtml(textarea.value).then(function (html) { preview.innerHTML = html; });
|
||||||
@@ -201,13 +299,25 @@
|
|||||||
|
|
||||||
// --- Image upload ------------------------------------------------------
|
// --- Image upload ------------------------------------------------------
|
||||||
|
|
||||||
function uploadImage(file, onDone) {
|
function uploadImage(file, onSuccess, onError) {
|
||||||
var data = new FormData();
|
var data = new FormData();
|
||||||
data.append("file", file);
|
data.append("file", file);
|
||||||
data.append("_csrf", csrf);
|
data.append("_csrf", csrf);
|
||||||
fetch(uploadUrl, { method: "POST", body: data })
|
fetch(uploadUrl, { method: "POST", body: data })
|
||||||
.then(function (response) { return response.json(); })
|
.then(function (response) {
|
||||||
.then(function (json) { if (json.url) { onDone(json.url); } });
|
return response.json().then(function (json) { return { ok: response.ok, json: json }; });
|
||||||
|
})
|
||||||
|
.then(function (result) {
|
||||||
|
if (result.ok && result.json.url) {
|
||||||
|
onSuccess(result.json.url);
|
||||||
|
} else {
|
||||||
|
onError(result.json.message || ("Upload failed (HTTP " + (result.json.error || "error") + ")"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUploadError(message) {
|
||||||
|
alert("Upload failed:\n\n" + message);
|
||||||
}
|
}
|
||||||
|
|
||||||
imageInput.addEventListener("change", function () {
|
imageInput.addEventListener("change", function () {
|
||||||
@@ -219,7 +329,7 @@
|
|||||||
} else {
|
} else {
|
||||||
exec("insertImage", url);
|
exec("insertImage", url);
|
||||||
}
|
}
|
||||||
});
|
}, showUploadError);
|
||||||
imageInput.value = "";
|
imageInput.value = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -248,7 +358,7 @@
|
|||||||
uploadImage(file, function (url) {
|
uploadImage(file, function (url) {
|
||||||
coverInput.value = url;
|
coverInput.value = url;
|
||||||
refreshCoverPreview();
|
refreshCoverPreview();
|
||||||
});
|
}, showUploadError);
|
||||||
coverFile.value = "";
|
coverFile.value = "";
|
||||||
});
|
});
|
||||||
coverInput.addEventListener("input", refreshCoverPreview);
|
coverInput.addEventListener("input", refreshCoverPreview);
|
||||||
@@ -440,26 +550,34 @@
|
|||||||
if (!visualView.hidden) { textarea.value = htmlToMd(wysiwyg); }
|
if (!visualView.hidden) { textarea.value = htmlToMd(wysiwyg); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Preview toggle (persisted) ---------------------------------------
|
// --- Preview toggle (disabled by default) ------------------------------
|
||||||
|
|
||||||
var editor = markdownView.querySelector(".editor");
|
var editorGrid = document.getElementById("editor-grid");
|
||||||
|
var previewPane = document.getElementById("preview-pane");
|
||||||
var togglePreviewBtn = document.getElementById("toggle-preview");
|
var togglePreviewBtn = document.getElementById("toggle-preview");
|
||||||
|
|
||||||
function applyPreviewPreference() {
|
function applyPreviewPreference() {
|
||||||
var off = false;
|
var on = false;
|
||||||
try { off = localStorage.getItem("volumen.preview") === "off"; } catch (e) {}
|
try { on = localStorage.getItem("volumen.preview") === "on"; } catch (e) {}
|
||||||
editor.classList.toggle("no-preview", off);
|
if (on) {
|
||||||
togglePreviewBtn.classList.toggle("active", !off);
|
editorGrid.classList.add("with-preview");
|
||||||
if (!off) { renderPreview(); }
|
previewPane.hidden = false;
|
||||||
|
togglePreviewBtn.classList.add("active");
|
||||||
|
renderPreview();
|
||||||
|
} else {
|
||||||
|
editorGrid.classList.remove("with-preview");
|
||||||
|
previewPane.hidden = true;
|
||||||
|
togglePreviewBtn.classList.remove("active");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
togglePreviewBtn.addEventListener("click", function () {
|
togglePreviewBtn.addEventListener("click", function () {
|
||||||
var turningOff = !editor.classList.contains("no-preview");
|
var turningOn = !editorGrid.classList.contains("with-preview");
|
||||||
try { localStorage.setItem("volumen.preview", turningOff ? "off" : "on"); } catch (e) {}
|
try { localStorage.setItem("volumen.preview", turningOn ? "on" : "off"); } catch (e) {}
|
||||||
applyPreviewPreference();
|
applyPreviewPreference();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start in Markdown mode (primary).
|
// Start in Markdown mode (primary), preview OFF by default.
|
||||||
applyPreviewPreference();
|
applyPreviewPreference();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+1205
-202
File diff suppressed because it is too large
Load Diff
+105
-26
@@ -1,34 +1,113 @@
|
|||||||
<div class="toolbar">
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
<h1>Posts</h1>
|
<h1>Posts</h1>
|
||||||
<a class="button primary" href="<%= to("/admin/posts/new") %>">New post</a>
|
<p class="lede"><%= @posts.length %> post<%= @posts.length == 1 ? "" : "s" %> in total</p>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-primary" href="<%= to("/admin/posts/new") %>">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
New post
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="search">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
|
<input id="post-search" class="input" type="text" placeholder="Search posts...">
|
||||||
|
</div>
|
||||||
|
<div class="filters" id="post-filters">
|
||||||
|
<button type="button" class="filter-chip active" data-filter="all">All</button>
|
||||||
|
<button type="button" class="filter-chip" data-filter="published">Published</button>
|
||||||
|
<button type="button" class="filter-chip" data-filter="draft">Drafts</button>
|
||||||
|
<button type="button" class="filter-chip" data-filter="scheduled">Scheduled</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if @posts.empty? %>
|
<% if @posts.empty? %>
|
||||||
<p>No posts yet. Create your first one.</p>
|
<div class="empty">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||||
|
<h3>No posts yet</h3>
|
||||||
|
<p>Create your first post to get started.</p>
|
||||||
|
<a class="btn btn-primary" href="<%= to("/admin/posts/new") %>" style="margin-top: 0.75rem;">Create post</a>
|
||||||
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<table>
|
<div class="post-grid" id="post-grid">
|
||||||
<thead>
|
|
||||||
<tr><th>Title</th><th>Slug</th><th>Lang</th><th>Date</th><th>Draft</th><th></th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<% @posts.each do |post| %>
|
<% @posts.each do |post| %>
|
||||||
<tr>
|
<%
|
||||||
<td><%= h(post.title) %></td>
|
status = if post.draft?
|
||||||
<td><%= h(post.slug) %></td>
|
"draft"
|
||||||
<td><%= h(post.lang) %></td>
|
elsif post.scheduled?
|
||||||
<td><%= h(post.date_string) %></td>
|
"scheduled"
|
||||||
<td><%= post.draft? ? "yes" : "" %></td>
|
else
|
||||||
<td class="actions">
|
"published"
|
||||||
<a href="<%= to("/admin/posts/#{post.slug}/edit") %>">Edit</a>
|
end
|
||||||
<form class="inline" method="post"
|
%>
|
||||||
action="<%= to("/admin/posts/#{post.slug}/delete") %>"
|
<a href="<%= h(to("/admin/posts/#{post.slug}/edit")) %>"
|
||||||
onsubmit="return confirm('Delete this post?');">
|
class="post-card"
|
||||||
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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="<%= 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="<%= to("/admin/settings/users/#{user.username}/delete") %>"
|
|
||||||
onsubmit="return confirm('Remove this user?');">
|
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||||
<button type="submit" class="danger">Remove</button>
|
<button type="submit" class="btn btn-sm btn-ghost btn-danger">Remove</button>
|
||||||
</form>
|
</form>
|
||||||
<% end %>
|
<% end %>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
|
|
||||||
|
<div class="add-user-form">
|
||||||
|
<h3 style="margin: 0 0 0.75rem;">Add user</h3>
|
||||||
<form method="post" action="<%= to("/admin/settings/users") %>">
|
<form method="post" action="<%= to("/admin/settings/users") %>">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||||
<h3>Add user</h3>
|
<div class="field-row-3">
|
||||||
<div class="fields">
|
<div class="field">
|
||||||
<label>Username <input type="text" name="username"></label>
|
<label for="new-username">Username</label>
|
||||||
<label>Password <input type="password" name="password" autocomplete="new-password"></label>
|
<input id="new-username" class="input" type="text" name="username" placeholder="jane-doe">
|
||||||
<label>Role
|
</div>
|
||||||
<select name="role">
|
<div class="field">
|
||||||
|
<label for="new-password">Password</label>
|
||||||
|
<input id="new-password" class="input" type="password" name="password" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="new-role">Role</label>
|
||||||
|
<select id="new-role" class="select" name="role">
|
||||||
<% Volumen::Users::ROLES.each do |role| %>
|
<% Volumen::Users::ROLES.each do |role| %>
|
||||||
<option value="<%= role %>" <%= role == Volumen::Users::DEFAULT_ROLE ? "selected" : "" %>><%= role %></option>
|
<option value="<%= role %>" <%= role == Volumen::Users::DEFAULT_ROLE ? "selected" : "" %>><%= role %></option>
|
||||||
<% end %>
|
<% end %>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="primary">Add user</button>
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Add user</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var sections = document.querySelectorAll(".settings-panels .surface[id]");
|
||||||
|
var navLinks = document.querySelectorAll(".settings-nav a[href^='#']");
|
||||||
|
if (!sections.length || !navLinks.length) return;
|
||||||
|
|
||||||
|
function showSection(id) {
|
||||||
|
sections.forEach(function (s) {
|
||||||
|
s.hidden = s.id !== id;
|
||||||
|
});
|
||||||
|
navLinks.forEach(function (a) {
|
||||||
|
a.classList.toggle("active", a.getAttribute("href") === "#" + id);
|
||||||
|
});
|
||||||
|
try { history.replaceState(null, "", "#" + id); } catch (e) {}
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
navLinks.forEach(function (link) {
|
||||||
|
link.addEventListener("click", function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
var id = link.getAttribute("href").slice(1);
|
||||||
|
showSection(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var initial = (window.location.hash || "").slice(1);
|
||||||
|
var hasInitial = Array.from(sections).some(function (s) { return s.id === initial; });
|
||||||
|
showSection(hasInitial ? initial : sections[0].id);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|||||||
+92
-4
@@ -30,6 +30,7 @@ class AdminTest < Minitest::Test
|
|||||||
TOML
|
TOML
|
||||||
@config = Volumen::Config.load(path: config_path)
|
@config = Volumen::Config.load(path: config_path)
|
||||||
@store = Volumen::Store.new(@posts_dir, default_lang: "en")
|
@store = Volumen::Store.new(@posts_dir, default_lang: "en")
|
||||||
|
Volumen.reset_login_attempts!
|
||||||
end
|
end
|
||||||
|
|
||||||
def teardown
|
def teardown
|
||||||
@@ -123,22 +124,48 @@ class AdminTest < Minitest::Test
|
|||||||
assert_equal 403, last_response.status
|
assert_equal 403, last_response.status
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_upload_rejects_unsupported_media_type
|
||||||
|
login
|
||||||
|
get "/admin/posts/new"
|
||||||
|
token = csrf(last_response.body)
|
||||||
|
txt_path = File.join(@dir, "readme.txt")
|
||||||
|
File.binwrite(txt_path, "hello")
|
||||||
|
post "/admin/uploads",
|
||||||
|
"_csrf" => token,
|
||||||
|
"file" => Rack::Test::UploadedFile.new(txt_path, "text/plain")
|
||||||
|
assert_equal 415, last_response.status
|
||||||
|
assert_equal "unsupported_format", JSON.parse(last_response.body)["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_upload_rejects_file_too_large
|
||||||
|
login
|
||||||
|
get "/admin/posts/new"
|
||||||
|
token = csrf(last_response.body)
|
||||||
|
big_path = File.join(@dir, "big.png")
|
||||||
|
File.binwrite(big_path, "X" * (Volumen::Server::MAX_UPLOAD_BYTES + 1))
|
||||||
|
post "/admin/uploads",
|
||||||
|
"_csrf" => token,
|
||||||
|
"file" => Rack::Test::UploadedFile.new(big_path, "image/png")
|
||||||
|
assert_equal 413, last_response.status
|
||||||
|
assert_equal "file_too_large", JSON.parse(last_response.body)["error"]
|
||||||
|
end
|
||||||
|
|
||||||
def test_image_upload_and_serving
|
def test_image_upload_and_serving
|
||||||
login
|
login
|
||||||
get "/admin/posts/new"
|
get "/admin/posts/new"
|
||||||
token = csrf(last_response.body)
|
token = csrf(last_response.body)
|
||||||
image_path = File.join(@dir, "pic.png")
|
image_path = File.join(@dir, "pic.webp")
|
||||||
File.binwrite(image_path, "PNGDATA")
|
File.binwrite(image_path, "WEBDATA")
|
||||||
post "/admin/uploads",
|
post "/admin/uploads",
|
||||||
"_csrf" => token,
|
"_csrf" => token,
|
||||||
"file" => Rack::Test::UploadedFile.new(image_path, "image/png")
|
"file" => Rack::Test::UploadedFile.new(image_path, "image/webp")
|
||||||
assert_predicate last_response, :ok?
|
assert_predicate last_response, :ok?
|
||||||
url = JSON.parse(last_response.body)["url"]
|
url = JSON.parse(last_response.body)["url"]
|
||||||
assert_match(%r{\A/media/}, url)
|
assert_match(%r{\A/media/}, url)
|
||||||
|
|
||||||
get url
|
get url
|
||||||
assert_predicate last_response, :ok?
|
assert_predicate last_response, :ok?
|
||||||
assert_equal "PNGDATA", last_response.body
|
assert_equal "WEBDATA", last_response.body
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_settings_requires_login
|
def test_settings_requires_login
|
||||||
@@ -220,6 +247,67 @@ class AdminTest < Minitest::Test
|
|||||||
assert File.exist?(File.join(@posts_dir, "by-author.md"))
|
assert File.exist?(File.join(@posts_dir, "by-author.md"))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_login_rate_limit_sweep_removes_stale_entries
|
||||||
|
Volumen.login_attempts["1.2.3.4"] = [Time.now.to_f - 120]
|
||||||
|
Volumen.instance_variable_set(:@last_cleanup, Time.now.to_f - 600)
|
||||||
|
Volumen.sweep_login_attempts!
|
||||||
|
refute Volumen.login_attempts.key?("1.2.3.4"),
|
||||||
|
"sweep should remove IPs with only stale entries"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_create_post_rejects_invalid_slug
|
||||||
|
login
|
||||||
|
get "/admin/posts/new"
|
||||||
|
post "/admin/posts",
|
||||||
|
"_csrf" => csrf(last_response.body), "title" => "X", "slug" => "../../etc/passwd",
|
||||||
|
"lang" => "en", "body" => "y"
|
||||||
|
assert_includes last_response.body, "Invalid slug"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_create_post_rejects_xss_in_slug
|
||||||
|
login
|
||||||
|
get "/admin/posts/new"
|
||||||
|
post "/admin/posts",
|
||||||
|
"_csrf" => csrf(last_response.body), "title" => "X", "slug" => "foo\" onclick",
|
||||||
|
"lang" => "en", "body" => "y"
|
||||||
|
assert_includes last_response.body, "Invalid slug"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_change_username_rejects_special_chars
|
||||||
|
login
|
||||||
|
get "/admin/settings"
|
||||||
|
post "/admin/settings/username",
|
||||||
|
"_csrf" => csrf(last_response.body), "username" => "foo\" bar"
|
||||||
|
assert_includes last_response.body, "Username may use letters"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_change_username_accepts_same_name
|
||||||
|
login
|
||||||
|
get "/admin/settings"
|
||||||
|
post "/admin/settings/username",
|
||||||
|
"_csrf" => csrf(last_response.body), "username" => "admin"
|
||||||
|
assert_includes last_response.body, "Username unchanged"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_preview_requires_csrf
|
||||||
|
login
|
||||||
|
get "/admin/posts/new"
|
||||||
|
post "/admin/preview", "body" => "**hi**"
|
||||||
|
assert_equal 403, last_response.status
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_orphaned_session_is_rejected
|
||||||
|
login
|
||||||
|
session_for = -> { rack_mock_session.cookie_jar["rack.session"].to_s }
|
||||||
|
refute_empty session_for.call, "expected a session cookie after login"
|
||||||
|
|
||||||
|
# Simulate a deleted user by clearing users.toml while keeping the cookie.
|
||||||
|
File.write(@users_path, "")
|
||||||
|
get "/admin/"
|
||||||
|
assert_equal 302, last_response.status
|
||||||
|
assert_equal "http://example.org/admin/login", last_response.location
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def csrf(body)
|
def csrf(body)
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
require "tmpdir"
|
||||||
|
require "volumen/cli"
|
||||||
|
|
||||||
|
class CLITest < Minitest::Test
|
||||||
|
def test_version
|
||||||
|
assert_output("#{Volumen::VERSION}\n") do
|
||||||
|
Volumen::CLI.run(["version"])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_help
|
||||||
|
output, = capture_io { Volumen::CLI.run(["help"]) }
|
||||||
|
assert_includes output, "volumen #{Volumen::VERSION}"
|
||||||
|
assert_includes output, "Usage:"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_unknown_command
|
||||||
|
_output, err = capture_io do
|
||||||
|
assert_raises(SystemExit) { Volumen::CLI.run(["bogus"]) }
|
||||||
|
end
|
||||||
|
assert_includes err, "unknown command"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_parse_serve_defaults
|
||||||
|
cli = Volumen::CLI.new(["serve"])
|
||||||
|
options = cli.__send__(:parse_serve_options)
|
||||||
|
assert_equal Volumen::Config::DEFAULT_PATH, options[:config]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_parse_serve_overrides
|
||||||
|
cli = Volumen::CLI.new([
|
||||||
|
"serve", "--config", "/t.toml", "--host", "127.0.0.1", "--port", "9000"
|
||||||
|
])
|
||||||
|
options = cli.__send__(:parse_serve_options)
|
||||||
|
assert_equal "/t.toml", options[:config]
|
||||||
|
assert_equal "127.0.0.1", options[:host]
|
||||||
|
assert_equal 9000, options[:port]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_prepare_config_writes_template
|
||||||
|
Dir.mktmpdir do |dir|
|
||||||
|
path = File.join(dir, "config.toml")
|
||||||
|
cli = Volumen::CLI.new([])
|
||||||
|
_output, err = capture_io { cli.__send__(:prepare_config, path) }
|
||||||
|
assert_includes err, "wrote a default config"
|
||||||
|
assert_path_exists path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_prepare_config_noops_when_present
|
||||||
|
Dir.mktmpdir do |dir|
|
||||||
|
path = File.join(dir, "config.toml")
|
||||||
|
File.write(path, "[server]\nport = 1\n")
|
||||||
|
cli = Volumen::CLI.new([])
|
||||||
|
out, err = capture_io { cli.__send__(:prepare_config, path) }
|
||||||
|
assert_empty err
|
||||||
|
assert_empty out
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -27,6 +27,14 @@ class ConfigTest < Minitest::Test
|
|||||||
assert_equal "0.0.0.0", Volumen::Config.load(path: MISSING).host
|
assert_equal "0.0.0.0", Volumen::Config.load(path: MISSING).host
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_does_not_mutate_default_inner_hashes
|
||||||
|
config = Volumen::Config.load(path: MISSING)
|
||||||
|
config.site["title"] = "MUTATED"
|
||||||
|
fresh = Volumen::Config.load(path: MISSING)
|
||||||
|
assert_equal "My Blog", fresh.site["title"],
|
||||||
|
"mutating a loaded config must not poison DEFAULTS for later loads"
|
||||||
|
end
|
||||||
|
|
||||||
def test_loads_and_merges_file
|
def test_loads_and_merges_file
|
||||||
Dir.mktmpdir do |dir|
|
Dir.mktmpdir do |dir|
|
||||||
path = File.join(dir, "config.toml")
|
path = File.join(dir, "config.toml")
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ class PostTest < Minitest::Test
|
|||||||
assert_equal "The body paragraph.", post.excerpt
|
assert_equal "The body paragraph.", post.excerpt
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_derived_excerpt_strips_html_tags
|
||||||
|
body = "<strong>Bold</strong> and <em>italic</em>."
|
||||||
|
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: body)
|
||||||
|
assert_equal "Bold and italic.", post.excerpt
|
||||||
|
end
|
||||||
|
|
||||||
def test_summary_shape
|
def test_summary_shape
|
||||||
post = Volumen::Post.new(metadata: { "slug" => "x", "title" => "X" }, body: "Body")
|
post = Volumen::Post.new(metadata: { "slug" => "x", "title" => "X" }, body: "Body")
|
||||||
summary = post.summary
|
summary = post.summary
|
||||||
@@ -56,4 +62,76 @@ class PostTest < Minitest::Test
|
|||||||
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "short")
|
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "short")
|
||||||
assert_equal 1, post.reading_time
|
assert_equal 1, post.reading_time
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_scheduled_post_is_not_scheduled_when_publish_at_is_past
|
||||||
|
post = Volumen::Post.new(metadata: { "publish_at" => Date.today - 1 })
|
||||||
|
refute_predicate post, :scheduled?
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_scheduled_post_is_scheduled_when_publish_at_is_future
|
||||||
|
post = Volumen::Post.new(metadata: { "publish_at" => Date.today + 7 })
|
||||||
|
assert_predicate post, :scheduled?
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_post_without_publish_at_is_not_scheduled
|
||||||
|
post = Volumen::Post.new(metadata: {})
|
||||||
|
refute_predicate post, :scheduled?
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_fediverse_creator_returns_metadata_value
|
||||||
|
post = Volumen::Post.new(
|
||||||
|
metadata: { "slug" => "x", "fediverse_creator" => "@user@example.social" }
|
||||||
|
)
|
||||||
|
assert_equal "@user@example.social", post.fediverse_creator
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_fediverse_creator_is_nil_when_missing
|
||||||
|
post = Volumen::Post.new(metadata: { "slug" => "x" })
|
||||||
|
assert_nil post.fediverse_creator
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_fediverse_creator_is_nil_when_blank
|
||||||
|
post = Volumen::Post.new(metadata: { "slug" => "x", "fediverse_creator" => " " })
|
||||||
|
assert_nil post.fediverse_creator
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_fediverse_creator_validation_accepts_well_formed_handles
|
||||||
|
post = Volumen::Post.new(
|
||||||
|
metadata: { "slug" => "x", "fediverse_creator" => "@ada@lovelace.org" }
|
||||||
|
)
|
||||||
|
assert_predicate post, :valid_fediverse_creator?
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_fediverse_creator_validation_rejects_malformed_handles
|
||||||
|
[
|
||||||
|
"ada@example.com",
|
||||||
|
"@only-one-at",
|
||||||
|
"@user@example.com extra",
|
||||||
|
"@user@",
|
||||||
|
"@@example.com"
|
||||||
|
].each do |value|
|
||||||
|
post = Volumen::Post.new(metadata: { "slug" => "x", "fediverse_creator" => value })
|
||||||
|
refute_predicate post, :valid_fediverse_creator?, "expected #{value.inspect} to be invalid"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_fediverse_creator_validation_accepts_padded_value
|
||||||
|
post = Volumen::Post.new(
|
||||||
|
metadata: { "slug" => "x", "fediverse_creator" => " @user@example.com " }
|
||||||
|
)
|
||||||
|
assert_predicate post, :valid_fediverse_creator?
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_summary_includes_fediverse_creator
|
||||||
|
post = Volumen::Post.new(
|
||||||
|
metadata: { "slug" => "x", "fediverse_creator" => "@user@example.social" },
|
||||||
|
body: "body"
|
||||||
|
)
|
||||||
|
assert_equal "@user@example.social", post.summary["fediverse_creator"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_summary_omits_fediverse_creator_when_absent
|
||||||
|
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "body")
|
||||||
|
assert_nil post.summary["fediverse_creator"]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -92,4 +92,213 @@ class ServerTest < Minitest::Test
|
|||||||
assert_includes slugs, "global"
|
assert_includes slugs, "global"
|
||||||
refute_includes slugs, "hello"
|
refute_includes slugs, "hello"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_feed_endpoint
|
||||||
|
get "/api/volumen/feed.xml"
|
||||||
|
assert_predicate last_response, :ok?
|
||||||
|
assert_equal "application/rss+xml", last_response["Content-Type"]&.split(";")&.first
|
||||||
|
body = last_response.body
|
||||||
|
assert_includes body, "<rss version=\"2.0\""
|
||||||
|
assert_includes body, "<title>Hello</title>"
|
||||||
|
refute_includes body, "Draft"
|
||||||
|
assert_includes body, "<language>en</language>"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_draft_post_detail_returns_draft_error
|
||||||
|
get "/api/volumen/posts/draft"
|
||||||
|
assert_equal 404, last_response.status
|
||||||
|
assert_equal "draft", JSON.parse(last_response.body)["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_sitemap_endpoint
|
||||||
|
get "/api/volumen/sitemap.xml"
|
||||||
|
assert_predicate last_response, :ok?
|
||||||
|
assert_equal "application/xml", last_response["Content-Type"]&.split(";")&.first
|
||||||
|
body = last_response.body
|
||||||
|
assert_includes body, "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">"
|
||||||
|
assert_includes body, "<loc>https://example.com/hello</loc>"
|
||||||
|
refute_includes body, "draft"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_cors_preflight
|
||||||
|
options "/api/volumen/site"
|
||||||
|
assert_predicate last_response, :ok?
|
||||||
|
assert_equal "*", last_response.headers["Access-Control-Allow-Origin"]
|
||||||
|
assert_equal "GET, OPTIONS", last_response.headers["Access-Control-Allow-Methods"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_scheduled_post_is_hidden
|
||||||
|
future = (Date.today + 30).strftime("%Y-%m-%d")
|
||||||
|
File.write(File.join(@dir, "later.md"), <<~POST)
|
||||||
|
+++
|
||||||
|
title = "Later"
|
||||||
|
publish_at = #{future}
|
||||||
|
+++
|
||||||
|
|
||||||
|
Not yet.
|
||||||
|
POST
|
||||||
|
get "/api/volumen/posts"
|
||||||
|
slugs = JSON.parse(last_response.body)["posts"].map { |post| post["slug"] }
|
||||||
|
refute_includes slugs, "later"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_json_feed_endpoint
|
||||||
|
get "/api/volumen/feed.json"
|
||||||
|
assert_predicate last_response, :ok?
|
||||||
|
data = JSON.parse(last_response.body)
|
||||||
|
assert_equal "https://jsonfeed.org/version/1.1", data["version"]
|
||||||
|
assert_equal "My Blog", data["title"]
|
||||||
|
assert_equal 1, data["items"].length
|
||||||
|
assert_equal "Hello", data["items"].first["title"]
|
||||||
|
assert_includes data["items"].first["content_html"], "<strong>world</strong>"
|
||||||
|
refute_includes data["items"].map { |i| i["title"] }, "Draft"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_post_summary_exposes_fediverse_creator
|
||||||
|
File.write(File.join(@dir, "attributed.md"), <<~POST)
|
||||||
|
+++
|
||||||
|
title = "Attributed"
|
||||||
|
slug = "attributed"
|
||||||
|
fediverse_creator = "@ada@lovelace.org"
|
||||||
|
+++
|
||||||
|
|
||||||
|
Body.
|
||||||
|
POST
|
||||||
|
get "/api/volumen/posts/attributed"
|
||||||
|
assert_predicate last_response, :ok?
|
||||||
|
data = JSON.parse(last_response.body)
|
||||||
|
assert_equal "@ada@lovelace.org", data["fediverse_creator"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_post_summary_fediverse_creator_is_null_when_not_set
|
||||||
|
get "/api/volumen/posts/hello"
|
||||||
|
assert_predicate last_response, :ok?
|
||||||
|
assert_nil JSON.parse(last_response.body)["fediverse_creator"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_posts_list_summary_exposes_fediverse_creator
|
||||||
|
File.write(File.join(@dir, "fed.md"), <<~POST)
|
||||||
|
+++
|
||||||
|
title = "Fed"
|
||||||
|
slug = "fed"
|
||||||
|
fediverse_creator = "@user@example.social"
|
||||||
|
+++
|
||||||
|
|
||||||
|
Body.
|
||||||
|
POST
|
||||||
|
get "/api/volumen/posts"
|
||||||
|
data = JSON.parse(last_response.body)
|
||||||
|
fed = data["posts"].find { |post| post["slug"] == "fed" }
|
||||||
|
assert_equal "@user@example.social", fed["fediverse_creator"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_site_endpoint_exposes_fediverse_creator_default
|
||||||
|
get "/api/volumen/site"
|
||||||
|
assert_predicate last_response, :ok?
|
||||||
|
assert JSON.parse(last_response.body).key?("fediverse_creator")
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_rss_feed_includes_dc_creator_when_set
|
||||||
|
File.write(File.join(@dir, "rss.md"), <<~POST)
|
||||||
|
+++
|
||||||
|
title = "Rss"
|
||||||
|
slug = "rss"
|
||||||
|
fediverse_creator = "@rss@example.com"
|
||||||
|
+++
|
||||||
|
|
||||||
|
Body.
|
||||||
|
POST
|
||||||
|
get "/api/volumen/feed.xml"
|
||||||
|
assert_includes last_response.body, "<dc:creator>@rss@example.com</dc:creator>"
|
||||||
|
assert_includes last_response.body, "xmlns:dc=\"http://purl.org/dc/elements/1.1/\""
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_rss_feed_omits_dc_creator_when_not_set
|
||||||
|
get "/api/volumen/feed.xml"
|
||||||
|
refute_includes last_response.body, "<dc:creator>"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_json_feed_includes_authors_from_post
|
||||||
|
File.write(File.join(@dir, "jsonfed.md"), <<~POST)
|
||||||
|
+++
|
||||||
|
title = "Jsonfed"
|
||||||
|
slug = "jsonfed"
|
||||||
|
fediverse_creator = "@jf@example.io"
|
||||||
|
+++
|
||||||
|
|
||||||
|
Body.
|
||||||
|
POST
|
||||||
|
get "/api/volumen/feed.json"
|
||||||
|
item = JSON.parse(last_response.body)["items"].find { |i| i["id"] == "https://example.com/jsonfed" }
|
||||||
|
assert_equal [{ "name" => "@jf@example.io" }], item["authors"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_json_feed_falls_back_to_site_default_fediverse_creator
|
||||||
|
Dir.chdir(@dir) do
|
||||||
|
File.write("config.toml", <<~TOML)
|
||||||
|
[site]
|
||||||
|
fediverse_creator = "@owner@example.com"
|
||||||
|
TOML
|
||||||
|
end
|
||||||
|
@config = Volumen::Config.load(
|
||||||
|
path: File.join(@dir, "config.toml"),
|
||||||
|
overrides: { content: @dir }
|
||||||
|
)
|
||||||
|
get "/api/volumen/feed.json"
|
||||||
|
items = JSON.parse(last_response.body)["items"]
|
||||||
|
expected = [{ "name" => "@owner@example.com" }]
|
||||||
|
assert(items.all? { |i| i["authors"] == expected })
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_search_by_title
|
||||||
|
get "/api/volumen/posts?q=Hello"
|
||||||
|
data = JSON.parse(last_response.body)
|
||||||
|
assert_equal 1, data["total"]
|
||||||
|
assert_equal "hello", data["posts"].first["slug"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_search_by_body
|
||||||
|
get "/api/volumen/posts?q=world"
|
||||||
|
data = JSON.parse(last_response.body)
|
||||||
|
assert_equal 1, data["total"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_search_no_match
|
||||||
|
get "/api/volumen/posts?q=nonexistent"
|
||||||
|
data = JSON.parse(last_response.body)
|
||||||
|
assert_equal 0, data["total"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_search_is_case_insensitive
|
||||||
|
get "/api/volumen/posts?q=HELLO"
|
||||||
|
data = JSON.parse(last_response.body)
|
||||||
|
assert_equal 1, data["total"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_pagination_has_next_and_prev
|
||||||
|
# Create enough posts to paginate
|
||||||
|
5.times do |i|
|
||||||
|
File.write(File.join(@dir, "post-#{i}.md"), <<~POST)
|
||||||
|
+++
|
||||||
|
title = "Post #{i}"
|
||||||
|
+++
|
||||||
|
|
||||||
|
Body #{i}.
|
||||||
|
POST
|
||||||
|
end
|
||||||
|
get "/api/volumen/posts?page=1&limit=2"
|
||||||
|
data = JSON.parse(last_response.body)
|
||||||
|
assert_equal true, data["has_next"]
|
||||||
|
assert_equal false, data["has_prev"]
|
||||||
|
|
||||||
|
get "/api/volumen/posts?page=2&limit=2"
|
||||||
|
data = JSON.parse(last_response.body)
|
||||||
|
assert_equal true, data["has_next"]
|
||||||
|
assert_equal true, data["has_prev"]
|
||||||
|
|
||||||
|
get "/api/volumen/posts?page=3&limit=2"
|
||||||
|
data = JSON.parse(last_response.body)
|
||||||
|
assert_equal false, data["has_next"]
|
||||||
|
assert_equal true, data["has_prev"]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -49,4 +49,25 @@ class StoreTest < Minitest::Test
|
|||||||
assert store.find("global", lang: "cs")
|
assert store.find("global", lang: "cs")
|
||||||
assert store.find("global", lang: "en")
|
assert store.find("global", lang: "en")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_save_rejects_path_traversal_slug
|
||||||
|
store = Volumen::Store.new(@dir)
|
||||||
|
post = Volumen::Post.new(metadata: { "slug" => "../../etc/passwd" }, body: "x")
|
||||||
|
assert_raises(ArgumentError, "slug escapes content directory") { store.save(post) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_save_uses_lang_subdirectory_for_non_default_language
|
||||||
|
store = Volumen::Store.new(@dir, default_lang: "en")
|
||||||
|
post = Volumen::Post.new(metadata: { "slug" => "bonjour", "lang" => "fr" }, body: "x")
|
||||||
|
store.save(post)
|
||||||
|
assert File.exist?(File.join(@dir, "fr", "bonjour.md"))
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_save_uses_root_for_default_language
|
||||||
|
store = Volumen::Store.new(@dir, default_lang: "en")
|
||||||
|
post = Volumen::Post.new(metadata: { "slug" => "hello-new", "lang" => "en" }, body: "x")
|
||||||
|
store.save(post)
|
||||||
|
assert File.exist?(File.join(@dir, "hello-new.md"))
|
||||||
|
refute File.exist?(File.join(@dir, "en", "hello-new.md"))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user