Compare commits
27
Commits
v0.2.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 |
@@ -13,6 +13,9 @@
|
||||
# Local development sandbox (posts, generated config)
|
||||
/.volumen/
|
||||
|
||||
# Local dev config (contains password hash)
|
||||
/config.toml
|
||||
|
||||
# Uploaded media in the dev content directory
|
||||
/posts/media/
|
||||
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ Metrics/MethodLength:
|
||||
- "lib/volumen/api_routes.rb"
|
||||
|
||||
Metrics/ClassLength:
|
||||
Max: 400
|
||||
Max: 440
|
||||
|
||||
Metrics/ModuleLength:
|
||||
Exclude:
|
||||
|
||||
@@ -7,6 +7,99 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [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
|
||||
@@ -148,3 +241,5 @@ admin — no database, no external services.
|
||||
- Generic login error message to avoid username enumeration.
|
||||
|
||||
[0.1.0]: https://codeberg.org/petrbalvin/volumen/releases/tag/v0.1.0
|
||||
[0.2.0]: https://codeberg.org/petrbalvin/volumen/releases/tag/v0.2.0
|
||||
[0.3.0]: https://codeberg.org/petrbalvin/volumen/releases/tag/v0.3.0
|
||||
|
||||
+9
-6
@@ -142,21 +142,24 @@ volumen/
|
||||
│ └── volumen/
|
||||
│ ├── version.rb
|
||||
│ ├── 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)
|
||||
│ ├── 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
|
||||
│ ├── password.rb # scrypt hashing/verification (OpenSSL::KDF)
|
||||
│ ├── users.rb # users.toml, admin/author roles
|
||||
│ ├── server.rb # Sinatra app: public API + admin
|
||||
│ ├── users.rb # users.toml, admin/author roles, per-user profile
|
||||
│ ├── 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
|
||||
│ └── web/
|
||||
│ ├── 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
|
||||
├── docs/ # architecture, configuration, api-reference, deployment, security
|
||||
├── .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
|
||||
├── Gemfile
|
||||
├── CHANGELOG.md
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
PATH
|
||||
remote: .
|
||||
specs:
|
||||
volumen (0.2.0)
|
||||
volumen (0.3.0)
|
||||
kramdown (~> 2.5)
|
||||
kramdown-parser-gfm (~> 1.1)
|
||||
puma (~> 8.0)
|
||||
@@ -130,7 +130,7 @@ CHECKSUMS
|
||||
toml-rb (4.2.0) sha256=10a48c91613e20cf63483a7a776767dfb3cd7d70e9327c0237443da601e13776
|
||||
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
|
||||
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
|
||||
volumen (0.2.0)
|
||||
volumen (0.3.0)
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.10
|
||||
|
||||
@@ -88,6 +88,7 @@ slug = "my-post" # optional, defaults to the filename
|
||||
date = 2026-01-15 # first-class TOML date
|
||||
lang = "en" # language code
|
||||
author = "Petr Balvín" # optional
|
||||
fediverse_creator = "@petrbalvin@mastodon.social" # optional, for Mastodon attribution
|
||||
tags = ["ruby", "web"] # optional
|
||||
draft = false # optional
|
||||
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
|
||||
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
|
||||
|
||||
- **First-class dates** — `date = 2026-01-15` is a real date, not a guess.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 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]
|
||||
host = "127.0.0.1"
|
||||
@@ -16,8 +16,7 @@ language = "cs"
|
||||
author = "Petr Balvín"
|
||||
|
||||
[admin]
|
||||
# Dev credentials: password is "admin". Replace before any real use
|
||||
# (generate with `volumen hash-password`).
|
||||
password_hash = "scrypt$16384$8$1$I7XEC8EpyfaptaPxmBQSUg==$gK7TRZAJnYmOSfKJ7xg+cOaYeBV4r5jlqcoL5+jNlSs="
|
||||
# Generate a hash with: bundle exec exe/volumen hash-password
|
||||
password_hash = ""
|
||||
session_key = ""
|
||||
session_ttl = 86400
|
||||
@@ -16,10 +16,14 @@ Returns site metadata from the configuration.
|
||||
"description": "A blog powered by volumen.",
|
||||
"base_url": "https://example.com",
|
||||
"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`
|
||||
|
||||
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",
|
||||
"tags": ["intro"],
|
||||
"author": null,
|
||||
"fediverse_creator": null,
|
||||
"cover": null,
|
||||
"reading_time": 1,
|
||||
"translations": {},
|
||||
@@ -70,6 +75,7 @@ no match.
|
||||
"lang": "en",
|
||||
"tags": ["intro"],
|
||||
"author": null,
|
||||
"fediverse_creator": "@petrbalvin@mastodon.social",
|
||||
"cover": null,
|
||||
"reading_time": 1,
|
||||
"translations": {},
|
||||
|
||||
+21
-10
@@ -20,7 +20,7 @@ server-rendered admin for editing them.
|
||||
- **Markdown-first** — the visual editor round-trips through the same renderer
|
||||
that powers the public API, so stored content is always Markdown.
|
||||
|
||||
## Components (planned layout)
|
||||
## Components
|
||||
|
||||
```
|
||||
lib/volumen/
|
||||
@@ -28,27 +28,38 @@ lib/volumen/
|
||||
config.rb # loads /etc/volumen/config.toml, applies CLI overrides
|
||||
frontmatter.rb # parse/serialise the `+++ … +++` TOML block
|
||||
post.rb # Post model: metadata + raw body + rendered HTML
|
||||
store.rb # reads the content directory into Post objects
|
||||
markdown.rb # kramdown wrapper (render + sanitise)
|
||||
server.rb # Sinatra app: JSON API + admin routes
|
||||
store.rb # reads the content directory into Post objects (mtime-cached)
|
||||
markdown.rb # kramdown wrapper (render + <figure>/<figcaption> for titled images)
|
||||
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/
|
||||
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
|
||||
```
|
||||
|
||||
## Request flow (planned)
|
||||
## Request flow
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[HTTP request] --> B{Route}
|
||||
B -->|/api/volumen/*| C[JSON API]
|
||||
B -->|/admin/*| D[Admin: session + CSRF]
|
||||
B -->|/api/volumen/*| C[api_routes.rb]
|
||||
B -->|/admin/*| D[admin_routes.rb + CSRF + CSP]
|
||||
C --> E[Store]
|
||||
D --> E
|
||||
E --> F[(content_dir/*.md)]
|
||||
D --> U[Users]
|
||||
C --> F[FeedHelpers]
|
||||
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
|
||||
|
||||
@@ -22,6 +22,11 @@ description = "A blog powered by volumen."
|
||||
base_url = "https://example.com"
|
||||
language = "en"
|
||||
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]
|
||||
# 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`.
|
||||
- `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
|
||||
|
||||
- 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
|
||||
containment check, so `../` traversal cannot read files outside the media
|
||||
directory.
|
||||
- Upload content-type is **not** validated (the `accept="image/*"` attribute is
|
||||
only a UI hint); this is acceptable under the trusted-author model.
|
||||
- Uploads are **rejected (HTTP 413)** when the file exceeds **10 MB**
|
||||
(`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
|
||||
|
||||
@@ -91,7 +118,9 @@ A small, audited set: `sinatra`, `kramdown` (+ `kramdown-parser-gfm`),
|
||||
|
||||
## 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.
|
||||
- **No 2FA** and **no audit log**.
|
||||
- **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 …"
|
||||
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)
|
||||
build:
|
||||
@echo "→ Linting…"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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/")
|
||||
@@ -11,6 +12,7 @@ module Volumen
|
||||
app.get "/admin/" do
|
||||
require_login!
|
||||
@posts = sorted_posts
|
||||
@crumbs = [["Posts", nil]]
|
||||
erb :list
|
||||
end
|
||||
|
||||
@@ -43,6 +45,7 @@ module Volumen
|
||||
require_login!
|
||||
@mode = :new
|
||||
@post = Post.new(metadata: { "lang" => config.language })
|
||||
@crumbs = [["Posts", to("/admin/")], ["New post", nil]]
|
||||
erb :form
|
||||
end
|
||||
|
||||
@@ -57,6 +60,8 @@ module Volumen
|
||||
@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
|
||||
@@ -71,6 +76,7 @@ module Volumen
|
||||
require_login!
|
||||
check_csrf!
|
||||
store.delete(params["slug"])
|
||||
clear_posts_cache!
|
||||
redirect to("/admin/")
|
||||
end
|
||||
|
||||
@@ -88,6 +94,7 @@ module Volumen
|
||||
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
|
||||
|
||||
@@ -102,8 +109,13 @@ module Volumen
|
||||
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
|
||||
|
||||
@@ -119,6 +131,30 @@ module Volumen
|
||||
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!
|
||||
@@ -139,6 +175,7 @@ module Volumen
|
||||
check_csrf!
|
||||
remove_user(params["username"])
|
||||
end
|
||||
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,7 +5,8 @@ module Volumen
|
||||
module ApiRoutes
|
||||
def self.registered(app)
|
||||
app.before "/api/volumen/*" do
|
||||
headers "Access-Control-Allow-Origin" => "*"
|
||||
headers "Access-Control-Allow-Origin" => "*",
|
||||
"Cache-Control" => "public, max-age=60"
|
||||
content_type :json
|
||||
end
|
||||
|
||||
@@ -27,7 +28,8 @@ module Volumen
|
||||
|
||||
app.get "/api/volumen/posts/:slug" do
|
||||
post = store.find(params["slug"], lang: params["lang"])
|
||||
halt(404, json("error" => "not_found")) if post.nil? || post.draft?
|
||||
halt(404, json("error" => "not_found")) if post.nil?
|
||||
halt(404, json("error" => "draft")) if post.draft?
|
||||
|
||||
json(post.detail)
|
||||
end
|
||||
|
||||
@@ -18,7 +18,8 @@ module Volumen
|
||||
"description" => "A blog powered by volumen.",
|
||||
"base_url" => "https://example.com",
|
||||
"language" => "en",
|
||||
"author" => "Anonymous"
|
||||
"author" => "Anonymous",
|
||||
"fediverse_creator" => nil
|
||||
}.freeze,
|
||||
"admin" => {
|
||||
"password_hash" => "",
|
||||
@@ -46,6 +47,9 @@ module Volumen
|
||||
base_url = "https://example.com"
|
||||
language = "en"
|
||||
author = "Anonymous"
|
||||
# Default fediverse handle surfaced in <meta name="fediverse:creator">
|
||||
# by frontends consuming the API. Leave empty to disable.
|
||||
fediverse_creator = ""
|
||||
|
||||
[admin]
|
||||
# Bootstrap admin login: paste a hash from `volumen hash-password` to sign
|
||||
|
||||
@@ -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
|
||||
|
||||
require "kramdown"
|
||||
require "rexml/document"
|
||||
|
||||
module Volumen
|
||||
# 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
|
||||
# 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
|
||||
OPTIONS = {
|
||||
input: "GFM",
|
||||
@@ -17,7 +22,41 @@ module Volumen
|
||||
}.freeze
|
||||
|
||||
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
|
||||
|
||||
@@ -7,6 +7,8 @@ require_relative "markdown"
|
||||
module Volumen
|
||||
# A single blog post: TOML frontmatter metadata plus a Markdown body.
|
||||
class Post
|
||||
FEDIVERSE_CREATOR_REGEX = /\A@[^@\s]+@[^@\s]+\z/
|
||||
|
||||
attr_reader :metadata, :body
|
||||
attr_accessor :path
|
||||
|
||||
@@ -56,6 +58,26 @@ module Volumen
|
||||
metadata["cover"]
|
||||
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
|
||||
@@ -112,7 +134,10 @@ module Volumen
|
||||
"lang" => lang,
|
||||
"tags" => tags,
|
||||
"author" => author,
|
||||
"fediverse_creator" => fediverse_creator,
|
||||
"cover" => cover,
|
||||
"cover_alt" => cover_alt,
|
||||
"cover_caption" => cover_caption,
|
||||
"reading_time" => reading_time,
|
||||
"translations" => translations,
|
||||
"url" => "/api/volumen/posts/#{slug}"
|
||||
|
||||
+134
-101
@@ -10,6 +10,7 @@ require_relative "markdown"
|
||||
require_relative "password"
|
||||
require_relative "api_routes"
|
||||
require_relative "admin_routes"
|
||||
require_relative "feed_helpers"
|
||||
|
||||
module Volumen
|
||||
# Sinatra application: the public JSON API at /api/volumen and the
|
||||
@@ -17,10 +18,13 @@ module Volumen
|
||||
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
|
||||
set :show_exceptions, false
|
||||
@@ -60,6 +64,19 @@ module Volumen
|
||||
before do
|
||||
options = request.env["rack.session.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
|
||||
|
||||
private
|
||||
@@ -79,12 +96,22 @@ module Volumen
|
||||
end
|
||||
|
||||
def published_posts
|
||||
store.all.reject { |post| post.draft? || post.scheduled? }
|
||||
@published_posts ||= store.all
|
||||
.reject { |post| post.draft? || post.scheduled? }
|
||||
.sort_by { |post| post.date_string || "" }.reverse
|
||||
end
|
||||
|
||||
def clear_posts_cache!
|
||||
@published_posts = nil
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def posts_payload
|
||||
@@ -92,10 +119,13 @@ module Volumen
|
||||
page = positive_int(params["page"], 1)
|
||||
limit = positive_int(params["limit"], 20).clamp(1, 100)
|
||||
offset = (page - 1) * limit
|
||||
total = posts.length
|
||||
{
|
||||
"page" => page,
|
||||
"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)
|
||||
}
|
||||
end
|
||||
@@ -141,97 +171,6 @@ module Volumen
|
||||
config.site["base_url"].to_s.chomp("/")
|
||||
end
|
||||
|
||||
def feed_xml
|
||||
site = config.site
|
||||
items = published_posts.first(20).map { |post| feed_item(post) }.join("\n")
|
||||
<<~XML
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>#{escape(site["title"])}</title>
|
||||
<link>#{escape(base_url)}</link>
|
||||
<description>#{escape(site["description"])}</description>
|
||||
<language>#{escape(site["language"])}</language>
|
||||
#{items}
|
||||
</channel>
|
||||
</rss>
|
||||
XML
|
||||
end
|
||||
|
||||
def feed_item(post)
|
||||
link = "#{base_url}/#{post.slug}"
|
||||
pub = if post.metadata["date"]
|
||||
"\n <pubDate>#{escape(rfc822_date(post))}</pubDate>"
|
||||
else
|
||||
""
|
||||
end
|
||||
<<~XML.chomp
|
||||
<item>
|
||||
<title>#{escape(post.title)}</title>
|
||||
<link>#{escape(link)}</link>
|
||||
<guid>#{escape(link)}</guid>
|
||||
<description>#{escape(post.excerpt)}</description>#{pub}
|
||||
</item>
|
||||
XML
|
||||
end
|
||||
|
||||
def rfc822_date(post)
|
||||
value = post.metadata["date"]
|
||||
case value
|
||||
when Date then value.to_time.rfc822
|
||||
when Time then value.rfc822
|
||||
end
|
||||
end
|
||||
|
||||
def feed_json
|
||||
site = config.site
|
||||
feed_url = "#{base_url}/api/volumen/feed.json"
|
||||
{
|
||||
"version" => "https://jsonfeed.org/version/1.1",
|
||||
"title" => site["title"],
|
||||
"home_page_url" => base_url,
|
||||
"feed_url" => feed_url,
|
||||
"description" => site["description"],
|
||||
"language" => site["language"],
|
||||
"items" => published_posts.first(20).map { |post| json_feed_item(post) }
|
||||
}
|
||||
end
|
||||
|
||||
def json_feed_item(post)
|
||||
link = "#{base_url}/#{post.slug}"
|
||||
item = {
|
||||
"id" => link,
|
||||
"url" => link,
|
||||
"title" => post.title,
|
||||
"content_html" => post.html,
|
||||
"summary" => post.excerpt,
|
||||
"date_published" => iso_date(post.metadata["date"]),
|
||||
"tags" => post.tags
|
||||
}
|
||||
item.reject { |_k, v| v.nil? || v == "" || v == [] }
|
||||
end
|
||||
|
||||
def iso_date(value)
|
||||
case value
|
||||
when Date then value.iso8601
|
||||
when Time then value.to_date.iso8601
|
||||
end
|
||||
end
|
||||
|
||||
def sitemap_xml
|
||||
urls = published_posts.map do |post|
|
||||
url = " <url><loc>#{escape("#{base_url}/#{post.slug}")}</loc>"
|
||||
url << "<lastmod>#{escape(post.date_string)}</lastmod>" if post.date_string
|
||||
url << "</url>"
|
||||
end.join("\n")
|
||||
<<~XML
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
#{urls}
|
||||
</urlset>
|
||||
XML
|
||||
end
|
||||
|
||||
def escape(text)
|
||||
Rack::Utils.escape_html(text.to_s)
|
||||
end
|
||||
@@ -260,6 +199,18 @@ module Volumen
|
||||
current_user_record&.role
|
||||
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?
|
||||
current_role == "admin"
|
||||
end
|
||||
@@ -286,11 +237,26 @@ module Volumen
|
||||
|
||||
def check_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")
|
||||
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
|
||||
@@ -341,6 +307,45 @@ module Volumen
|
||||
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
|
||||
name = presence(params["username"])
|
||||
password = presence(params["password"])
|
||||
@@ -394,6 +399,7 @@ module Volumen
|
||||
return erb(:form)
|
||||
end
|
||||
store.save(post)
|
||||
clear_posts_cache!
|
||||
redirect to("/admin/")
|
||||
end
|
||||
|
||||
@@ -401,7 +407,16 @@ module Volumen
|
||||
existing = store.find(params["slug"])
|
||||
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/")
|
||||
end
|
||||
|
||||
@@ -410,26 +425,44 @@ module Volumen
|
||||
return "Invalid slug." unless post.slug.match?(SLUG_REGEX)
|
||||
return "A post with that slug already exists." if store.find(post.slug)
|
||||
|
||||
nil
|
||||
fediverse_creator_error(post)
|
||||
end
|
||||
|
||||
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"]),
|
||||
"slug" => presence(http_params["slug"]) || existing&.slug,
|
||||
"lang" => presence(http_params["lang"]),
|
||||
"author" => presence(http_params["author"]),
|
||||
"fediverse_creator" => presence(http_params["fediverse_creator"]),
|
||||
"date" => parse_date(http_params["date"]),
|
||||
"publish_at" => parse_date(http_params["publish_at"]),
|
||||
"tags" => parse_tags(http_params["tags"]),
|
||||
"excerpt" => presence(http_params["excerpt"]),
|
||||
"cover" => presence(http_params["cover"]),
|
||||
"cover_alt" => presence(http_params["cover_alt"]),
|
||||
"cover_caption" => presence(http_params["cover_caption"]),
|
||||
"draft" => http_params["draft"] == "on",
|
||||
"all_langs" => http_params["all_langs"] == "on"
|
||||
}
|
||||
post = Post.new(metadata: clean_metadata(metadata), body: http_params["body"].to_s)
|
||||
post.path = existing.path if existing
|
||||
post
|
||||
end
|
||||
|
||||
def 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
|
||||
|
||||
def clean_metadata(metadata)
|
||||
|
||||
+24
-4
@@ -20,10 +20,12 @@ module Volumen
|
||||
end
|
||||
|
||||
def all
|
||||
mtime = Dir.exist?(@content_dir) ? File.mtime(@content_dir) : nil
|
||||
@all = nil if mtime != @all_mtime
|
||||
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_mtime = mtime
|
||||
@all_dir_mtime = current_dir_mtime
|
||||
@all_files_mtime = current_files_mtime
|
||||
markdown_files.filter_map { |path| load_post(path) }
|
||||
end
|
||||
end
|
||||
@@ -59,8 +61,17 @@ module Volumen
|
||||
end
|
||||
|
||||
def store_upload(original_name, source)
|
||||
FileUtils.mkdir_p(media_dir)
|
||||
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) }
|
||||
"/media/#{name}"
|
||||
end
|
||||
@@ -79,6 +90,15 @@ module Volumen
|
||||
Dir.glob(File.join(@content_dir, "**", "*.md"))
|
||||
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)
|
||||
post = Post.parse(File.read(path))
|
||||
post.metadata["slug"] ||= File.basename(path, ".md")
|
||||
|
||||
+28
-5
@@ -15,7 +15,7 @@ module Volumen
|
||||
ROLES = %w[admin author].freeze
|
||||
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)
|
||||
@path = path
|
||||
@@ -33,7 +33,7 @@ module Volumen
|
||||
elsif @bootstrap_hash.empty?
|
||||
[]
|
||||
else
|
||||
[User.new("admin", @bootstrap_hash, "admin")]
|
||||
[User.new("admin", @bootstrap_hash, "admin", nil, nil, nil)]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -54,11 +54,25 @@ module Volumen
|
||||
def add(username, password, role = DEFAULT_ROLE)
|
||||
return nil if username.to_s.empty? || find(username)
|
||||
|
||||
user = User.new(username, Password.hash(password), normalize_role(role))
|
||||
user = User.new(username, Password.hash(password), normalize_role(role), nil, nil, nil)
|
||||
persist(all + [user])
|
||||
user
|
||||
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)
|
||||
mutate(username) { |user| user.password_hash = Password.hash(password) }
|
||||
end
|
||||
@@ -121,7 +135,8 @@ module Volumen
|
||||
def read_file
|
||||
data = TomlRB.load_file(@path)
|
||||
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
|
||||
|
||||
@@ -133,7 +148,15 @@ module Volumen
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Volumen
|
||||
VERSION = "0.2.0"
|
||||
VERSION = "0.3.0"
|
||||
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 |
+195
-82
@@ -1,100 +1,190 @@
|
||||
<h1><%= @mode == :new ? "New post" : "Edit post" %></h1>
|
||||
<%
|
||||
crumbs = @mode == :new ? [["Posts", to("/admin/")], ["New post", nil]] : [["Posts", to("/admin/")], [@post.title.to_s.empty? ? "Untitled" : @post.title, nil]]
|
||||
%>
|
||||
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1><%= @mode == :new ? "New post" : "Edit post" %></h1>
|
||||
<p class="lede"><%= @mode == :new ? "Draft a new article in Markdown." : "Update and publish this post." %></p>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<% if @mode == :edit %>
|
||||
<a class="btn btn-ghost" href="<%= to("/api/volumen/posts/#{@post.slug}") %>" target="_blank">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
Preview
|
||||
</a>
|
||||
<% end %>
|
||||
<a class="btn btn-ghost" href="<%= to("/admin/") %>">Cancel</a>
|
||||
<button type="submit" form="post-form" class="btn btn-primary">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @error %>
|
||||
<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 %>
|
||||
|
||||
<form id="post-form" method="post"
|
||||
action="<%= @mode == :new ? to("/admin/posts") : h(to("/admin/posts/#{@post.slug}")) %>">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
|
||||
<div class="fields">
|
||||
<label>Title <input type="text" name="title" value="<%= h(@post.title) %>"></label>
|
||||
<label>Slug
|
||||
<input type="text" name="slug" value="<%= h(@post.slug) %>"
|
||||
<%= @mode == :edit ? "readonly" : "required" %>>
|
||||
</label>
|
||||
<label>Language <input type="text" name="lang" value="<%= h(@post.lang) %>"></label>
|
||||
<label>Author <input type="text" name="author" value="<%= h(@post.author) %>"></label>
|
||||
<label>Date <input type="date" name="date" value="<%= h(@post.date_string) %>"></label>
|
||||
<label>Publish at
|
||||
<input type="date" name="publish_at"
|
||||
value="<%= @post.publish_at.is_a?(Date) ? @post.publish_at.strftime("%Y-%m-%d") : "" %>"
|
||||
placeholder="leave empty for immediate">
|
||||
</label>
|
||||
<label>Tags
|
||||
<input type="text" name="tags" value="<%= h(@post.tags.join(", ")) %>"
|
||||
placeholder="comma, separated">
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="draft" <%= @post.draft? ? "checked" : "" %>> Draft
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="all_langs" <%= @post.all_langs? ? "checked" : "" %>> All languages
|
||||
</label>
|
||||
<div class="form-grid">
|
||||
<div class="surface full">
|
||||
<div class="surface-head">
|
||||
<div>
|
||||
<h2>Details</h2>
|
||||
<p class="lede">Title, slug, and publishing options</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cover-field">
|
||||
<label for="cover-input">Cover image</label>
|
||||
<div class="cover-row">
|
||||
<input type="text" id="cover-input" name="cover" value="<%= h(@post.cover) %>"
|
||||
placeholder="/media/… or https://…">
|
||||
<button type="button" id="cover-upload">Upload</button>
|
||||
<button type="button" id="cover-clear">Clear</button>
|
||||
<div class="field full">
|
||||
<label for="title">Title</label>
|
||||
<input id="title" class="input" type="text" name="title" value="<%= h(@post.title) %>" placeholder="A clear, descriptive title" required>
|
||||
</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>
|
||||
<input type="file" id="cover-file" accept="image/*" hidden>
|
||||
</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="visual">Visual</button>
|
||||
</div>
|
||||
|
||||
<div id="visual-view" hidden>
|
||||
<div class="pane">
|
||||
<div class="ed-toolbar">
|
||||
<button type="button" data-rich="bold" title="Bold (Ctrl+B)"><b>B</b></button>
|
||||
<button type="button" data-rich="italic" title="Italic (Ctrl+I)"><i>I</i></button>
|
||||
<button type="button" data-rich="h2" title="Heading 2">H2</button>
|
||||
<button type="button" data-rich="h3" title="Heading 3">H3</button>
|
||||
<button type="button" data-rich="link" title="Link (Ctrl+K)">Link</button>
|
||||
<button type="button" data-rich="ul" title="Bullet list">• List</button>
|
||||
<button type="button" data-rich="ol" title="Numbered list">1. List</button>
|
||||
<button type="button" data-rich="quote" title="Quote">“</button>
|
||||
<button type="button" data-rich="code" title="Inline code"></></button>
|
||||
<button type="button" data-rich="image" title="Image">Img</button>
|
||||
<button type="button" data-rich="clear" title="Paragraph">P</button>
|
||||
<div class="editor-grid">
|
||||
<div class="editor-pane">
|
||||
<div class="editor-head">
|
||||
<span class="label">Editor</span>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="editor-toolbar-btn bold" data-rich="bold" title="Bold (Ctrl+B)">B</button>
|
||||
<button type="button" class="editor-toolbar-btn italic" data-rich="italic" title="Italic (Ctrl+I)">I</button>
|
||||
<button type="button" class="editor-toolbar-btn" data-rich="h2" title="Heading 2">H2</button>
|
||||
<button type="button" class="editor-toolbar-btn" data-rich="h3" title="Heading 3">H3</button>
|
||||
<button type="button" class="editor-toolbar-btn" data-rich="link" title="Link (Ctrl+K)">⌘</button>
|
||||
<button type="button" class="editor-toolbar-btn" data-rich="ul" title="Bullet list">•</button>
|
||||
<button type="button" class="editor-toolbar-btn" data-rich="ol" title="Numbered list">1.</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 id="wysiwyg" class="markdown" contenteditable="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="markdown-view">
|
||||
<div class="editor">
|
||||
<div class="pane">
|
||||
<div class="ed-toolbar">
|
||||
<button type="button" data-md="bold" title="Bold (Ctrl+B)"><b>B</b></button>
|
||||
<button type="button" data-md="italic" title="Italic (Ctrl+I)"><i>I</i></button>
|
||||
<button type="button" data-md="h2" title="Heading">H2</button>
|
||||
<button type="button" data-md="link" title="Link (Ctrl+K)">Link</button>
|
||||
<button type="button" data-md="ul" title="List">List</button>
|
||||
<button type="button" data-md="quote" title="Quote">“</button>
|
||||
<button type="button" data-md="code" title="Code"></></button>
|
||||
<button type="button" data-md="image" title="Image">Img</button>
|
||||
<button type="button" id="toggle-preview" class="toggle" title="Toggle preview">Preview</button>
|
||||
<div class="editor-grid" id="editor-grid">
|
||||
<div class="editor-pane">
|
||||
<div class="editor-head">
|
||||
<span class="label">Markdown</span>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="editor-toolbar-btn bold" data-md="bold" title="Bold (Ctrl+B)">B</button>
|
||||
<button type="button" class="editor-toolbar-btn italic" data-md="italic" title="Italic (Ctrl+I)">I</button>
|
||||
<button type="button" class="editor-toolbar-btn" data-md="h2" title="Heading">H2</button>
|
||||
<button type="button" class="editor-toolbar-btn" data-md="link" title="Link (Ctrl+K)">↗</button>
|
||||
<button type="button" class="editor-toolbar-btn" data-md="ul" title="List">•</button>
|
||||
<button type="button" class="editor-toolbar-btn" data-md="quote" title="Quote">"</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>
|
||||
<textarea id="body" name="body" rows="22"><%= h(@post.body) %></textarea>
|
||||
</div>
|
||||
<div class="pane preview-pane">
|
||||
<div class="ed-toolbar"><span>Preview</span></div>
|
||||
<div id="preview" class="markdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="file" id="image-input" accept="image/*" hidden>
|
||||
<button type="submit" class="primary">Save</button>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
@@ -145,7 +235,10 @@
|
||||
if (tag === "code") { return "`" + node.textContent + "`"; }
|
||||
if (tag === "a") { return "[" + inner + "](" + (node.getAttribute("href") || "") + ")"; }
|
||||
if (tag === "img") {
|
||||
return " || "") + ")";
|
||||
var title = node.getAttribute("title") || "";
|
||||
var imgMd = " || "") + ")";
|
||||
if (title) { imgMd = imgMd.replace(")", ' "' + title + '")'); }
|
||||
return imgMd;
|
||||
}
|
||||
if (tag === "br") { return "\n"; }
|
||||
return inner;
|
||||
@@ -193,7 +286,7 @@
|
||||
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() {
|
||||
mdToHtml(textarea.value).then(function (html) { preview.innerHTML = html; });
|
||||
@@ -206,13 +299,25 @@
|
||||
|
||||
// --- Image upload ------------------------------------------------------
|
||||
|
||||
function uploadImage(file, onDone) {
|
||||
function uploadImage(file, onSuccess, onError) {
|
||||
var data = new FormData();
|
||||
data.append("file", file);
|
||||
data.append("_csrf", csrf);
|
||||
fetch(uploadUrl, { method: "POST", body: data })
|
||||
.then(function (response) { return response.json(); })
|
||||
.then(function (json) { if (json.url) { onDone(json.url); } });
|
||||
.then(function (response) {
|
||||
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 () {
|
||||
@@ -224,7 +329,7 @@
|
||||
} else {
|
||||
exec("insertImage", url);
|
||||
}
|
||||
});
|
||||
}, showUploadError);
|
||||
imageInput.value = "";
|
||||
});
|
||||
|
||||
@@ -253,7 +358,7 @@
|
||||
uploadImage(file, function (url) {
|
||||
coverInput.value = url;
|
||||
refreshCoverPreview();
|
||||
});
|
||||
}, showUploadError);
|
||||
coverFile.value = "";
|
||||
});
|
||||
coverInput.addEventListener("input", refreshCoverPreview);
|
||||
@@ -445,26 +550,34 @@
|
||||
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");
|
||||
|
||||
function applyPreviewPreference() {
|
||||
var off = false;
|
||||
try { off = localStorage.getItem("volumen.preview") === "off"; } catch (e) {}
|
||||
editor.classList.toggle("no-preview", off);
|
||||
togglePreviewBtn.classList.toggle("active", !off);
|
||||
if (!off) { renderPreview(); }
|
||||
var on = false;
|
||||
try { on = localStorage.getItem("volumen.preview") === "on"; } catch (e) {}
|
||||
if (on) {
|
||||
editorGrid.classList.add("with-preview");
|
||||
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 () {
|
||||
var turningOff = !editor.classList.contains("no-preview");
|
||||
try { localStorage.setItem("volumen.preview", turningOff ? "off" : "on"); } catch (e) {}
|
||||
var turningOn = !editorGrid.classList.contains("with-preview");
|
||||
try { localStorage.setItem("volumen.preview", turningOn ? "on" : "off"); } catch (e) {}
|
||||
applyPreviewPreference();
|
||||
});
|
||||
|
||||
// Start in Markdown mode (primary).
|
||||
// Start in Markdown mode (primary), preview OFF by default.
|
||||
applyPreviewPreference();
|
||||
})();
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<% 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 %>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Title</th><th>Slug</th><th>Lang</th><th>Date</th><th>Draft</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<div class="post-grid" id="post-grid">
|
||||
<% @posts.each do |post| %>
|
||||
<tr>
|
||||
<td><%= h(post.title) %></td>
|
||||
<td><%= h(post.slug) %></td>
|
||||
<td><%= h(post.lang) %></td>
|
||||
<td><%= h(post.date_string) %></td>
|
||||
<td><%= post.draft? ? "yes" : "" %></td>
|
||||
<td class="actions">
|
||||
<a href="<%= h(to("/admin/posts/#{post.slug}/edit")) %>">Edit</a>
|
||||
<form class="inline" method="post"
|
||||
action="<%= h(to("/admin/posts/#{post.slug}/delete")) %>"
|
||||
onsubmit="return confirm('Delete this post?');">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<button type="submit" class="danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<%
|
||||
status = if post.draft?
|
||||
"draft"
|
||||
elsif post.scheduled?
|
||||
"scheduled"
|
||||
else
|
||||
"published"
|
||||
end
|
||||
%>
|
||||
<a href="<%= h(to("/admin/posts/#{post.slug}/edit")) %>"
|
||||
class="post-card"
|
||||
data-title="<%= h(post.title.to_s.downcase) %>"
|
||||
data-slug="<%= h(post.slug) %>"
|
||||
data-status="<%= status %>">
|
||||
<% if post.cover %>
|
||||
<div class="post-cover" style="background-image: url('<%= h(post.cover) %>');"></div>
|
||||
<% else %>
|
||||
<div class="post-cover placeholder">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||
</div>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="post-body">
|
||||
<h3 class="post-title"><%= h(post.title) %></h3>
|
||||
<% if post.excerpt && !post.excerpt.to_s.empty? %>
|
||||
<p class="post-excerpt"><%= h(post.excerpt) %></p>
|
||||
<% end %>
|
||||
<div class="post-meta">
|
||||
<span class="badge badge-lang"><%= h(post.lang) %></span>
|
||||
<% if post.draft? %><span class="badge badge-warn">Draft</span><% end %>
|
||||
<% if post.scheduled? %><span class="badge badge-accent">Scheduled</span><% end %>
|
||||
<% if post.date_string %><span><%= h(post.date_string) %></span><% end %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="post-actions">
|
||||
<span class="btn btn-sm btn-ghost" style="cursor: default;">Edit</span>
|
||||
<span class="spacer" style="flex: 1;"></span>
|
||||
<form method="post" action="<%= h(to("/admin/posts/#{post.slug}/delete")) %>"
|
||||
onsubmit="return confirm('Delete this post?');" style="margin: 0;">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<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 %>
|
||||
|
||||
<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">
|
||||
<h1>Sign in</h1>
|
||||
<% if @error %>
|
||||
<p class="error"><%= h(@error) %></p>
|
||||
<% end %>
|
||||
<% unless users.any? %>
|
||||
<p class="error">
|
||||
No users are configured. Set <code>admin.password_hash</code> in your
|
||||
config (you will sign in as <code>admin</code>) or generate one with
|
||||
<code>volumen hash-password</code>.
|
||||
</p>
|
||||
<% end %>
|
||||
<form method="post" action="<%= to("/admin/login") %>">
|
||||
<h2>Sign in</h2>
|
||||
<p class="lede">Welcome back. Sign in to manage your posts.</p>
|
||||
|
||||
<% 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 %>
|
||||
|
||||
<% unless users.any? %>
|
||||
<div class="alert alert-warn">
|
||||
<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 %>">
|
||||
<label>Username
|
||||
<input type="text" name="username" autofocus autocomplete="username">
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" name="password" autocomplete="current-password">
|
||||
</label>
|
||||
<button type="submit" class="primary">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" class="input" type="text" name="username" autofocus autocomplete="username" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" class="input" type="password" name="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Sign in</button>
|
||||
</form>
|
||||
|
||||
@@ -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 @error %><p class="error"><%= h(@error) %></p><% end %>
|
||||
<% if @notice %>
|
||||
<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">
|
||||
<h2>Your account</h2>
|
||||
<p class="muted">Signed in as <strong><%= h(current_user) %></strong> (<%= h(current_role) %>).</p>
|
||||
<div class="settings-layout">
|
||||
<nav class="settings-nav" aria-label="Settings sections">
|
||||
<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 %>">
|
||||
<h3>Change password</h3>
|
||||
<label>Current password
|
||||
<input type="password" name="current_password" autocomplete="current-password">
|
||||
</label>
|
||||
<label>New password
|
||||
<input type="password" name="new_password" autocomplete="new-password">
|
||||
</label>
|
||||
<button type="submit" class="primary">Update password</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="<%= to("/admin/settings/username") %>">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<h3>Change username</h3>
|
||||
<label>New username <input type="text" name="username" value="<%= h(current_user) %>"></label>
|
||||
<button type="submit">Update username</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<% if admin? %>
|
||||
<section class="card">
|
||||
<h2>Users</h2>
|
||||
<table>
|
||||
<thead><tr><th>Username</th><th>Role</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<% @users.each do |user| %>
|
||||
<tr>
|
||||
<td><%= h(user.username) %><%= user.username == current_user ? " (you)" : "" %></td>
|
||||
<td>
|
||||
<% if user.username == current_user %>
|
||||
<%= h(user.role) %>
|
||||
<div class="photo-input-row">
|
||||
<% if current_user_record&.photo %>
|
||||
<img src="<%= h(current_user_record.photo) %>" alt="" class="photo-preview">
|
||||
<% else %>
|
||||
<form class="inline" method="post"
|
||||
action="<%= h(to("/admin/settings/users/#{user.username}/role")) %>">
|
||||
<div class="photo-preview photo-preview-placeholder"><%= h((current_user_record&.name || current_user).to_s[0].upcase) %></div>
|
||||
<% 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 %>">
|
||||
<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| %>
|
||||
<option value="<%= role %>" <%= user.role == role ? "selected" : "" %>><%= role %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</form>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="actions">
|
||||
</div>
|
||||
<% unless user.username == current_user %>
|
||||
<form class="inline" method="post"
|
||||
action="<%= h(to("/admin/settings/users/#{user.username}/delete")) %>"
|
||||
onsubmit="return confirm('Remove this user?');">
|
||||
<form method="post" action="<%= h(to("/admin/settings/users/#{user.username}/delete")) %>" onsubmit="return confirm('Remove this user?');" style="margin: 0;">
|
||||
<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>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="add-user-form">
|
||||
<h3 style="margin: 0 0 0.75rem;">Add user</h3>
|
||||
<form method="post" action="<%= to("/admin/settings/users") %>">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<h3>Add user</h3>
|
||||
<div class="fields">
|
||||
<label>Username <input type="text" name="username"></label>
|
||||
<label>Password <input type="password" name="password" autocomplete="new-password"></label>
|
||||
<label>Role
|
||||
<select name="role">
|
||||
<div class="field-row-3">
|
||||
<div class="field">
|
||||
<label for="new-username">Username</label>
|
||||
<input id="new-username" class="input" type="text" name="username" placeholder="jane-doe">
|
||||
</div>
|
||||
<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| %>
|
||||
<option value="<%= role %>" <%= role == Volumen::Users::DEFAULT_ROLE ? "selected" : "" %>><%= role %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="primary">Add user</button>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add user</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var sections = document.querySelectorAll(".settings-panels .surface[id]");
|
||||
var navLinks = document.querySelectorAll(".settings-nav a[href^='#']");
|
||||
if (!sections.length || !navLinks.length) return;
|
||||
|
||||
function showSection(id) {
|
||||
sections.forEach(function (s) {
|
||||
s.hidden = s.id !== id;
|
||||
});
|
||||
navLinks.forEach(function (a) {
|
||||
a.classList.toggle("active", a.getAttribute("href") === "#" + id);
|
||||
});
|
||||
try { history.replaceState(null, "", "#" + id); } catch (e) {}
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
navLinks.forEach(function (link) {
|
||||
link.addEventListener("click", function (event) {
|
||||
event.preventDefault();
|
||||
var id = link.getAttribute("href").slice(1);
|
||||
showSection(id);
|
||||
});
|
||||
});
|
||||
|
||||
var initial = (window.location.hash || "").slice(1);
|
||||
var hasInitial = Array.from(sections).some(function (s) { return s.id === initial; });
|
||||
showSection(hasInitial ? initial : sections[0].id);
|
||||
})();
|
||||
</script>
|
||||
|
||||
+30
-4
@@ -124,22 +124,48 @@ class AdminTest < Minitest::Test
|
||||
assert_equal 403, last_response.status
|
||||
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
|
||||
login
|
||||
get "/admin/posts/new"
|
||||
token = csrf(last_response.body)
|
||||
image_path = File.join(@dir, "pic.png")
|
||||
File.binwrite(image_path, "PNGDATA")
|
||||
image_path = File.join(@dir, "pic.webp")
|
||||
File.binwrite(image_path, "WEBDATA")
|
||||
post "/admin/uploads",
|
||||
"_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?
|
||||
url = JSON.parse(last_response.body)["url"]
|
||||
assert_match(%r{\A/media/}, url)
|
||||
|
||||
get url
|
||||
assert_predicate last_response, :ok?
|
||||
assert_equal "PNGDATA", last_response.body
|
||||
assert_equal "WEBDATA", last_response.body
|
||||
end
|
||||
|
||||
def test_settings_requires_login
|
||||
|
||||
@@ -77,4 +77,61 @@ class PostTest < Minitest::Test
|
||||
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
|
||||
|
||||
+126
-3
@@ -98,16 +98,16 @@ class ServerTest < Minitest::Test
|
||||
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, "<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_is_not_found
|
||||
def test_draft_post_detail_returns_draft_error
|
||||
get "/api/volumen/posts/draft"
|
||||
assert_equal 404, last_response.status
|
||||
assert_equal "not_found", JSON.parse(last_response.body)["error"]
|
||||
assert_equal "draft", JSON.parse(last_response.body)["error"]
|
||||
end
|
||||
|
||||
def test_sitemap_endpoint
|
||||
@@ -154,6 +154,102 @@ class ServerTest < Minitest::Test
|
||||
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)
|
||||
@@ -178,4 +274,31 @@ class ServerTest < Minitest::Test
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user