Compare commits

..
113 Commits
Author SHA1 Message Date
Florian Sylvain efecdee200 docs: release notes for v3.6.0-beta 2026-10-28 00:13:30 +01:00
Florian Sylvain 34dffe222f feat: roles SPA Users, Profile, API keys, Activity 2026-10-28 00:10:15 +01:00
Florian Sylvain 3db490a615 feat: SPA Roles screens 2026-10-27 23:05:04 +01:00
Florian Sylvain 907949aa12 feat: roles SPA foundations 2026-10-27 22:21:12 +01:00
Florian Sylvain 80f7037d3a feat: Users API, ownership transfer and removal of the legacy role string 2026-10-27 03:08:15 +01:00
Florian Sylvain 094df63a7f feat: /v1/role list, get, create, update and delete. Reassign members on delete. Write-time validation (ValidateGrants, duplicates, typed keys against real content types). Name uniqueness (role_name_taken), role_locked, role_has_members, role_conflict on version mismatch. No-escalation: nobody grants a permission they don't hold, and roles.edit_owner_only guards the Owner role. Activity events for role changes. Typed-key exposure in /auth/me. Cleanup of typed rows when a content type is deleted. | Backend only. Will carry the deferred ListGrants decision. 2026-10-27 00:45:24 +01:00
Florian Sylvain a043f7f41a refac: debt on role backend 2026-10-26 23:34:35 +01:00
Florian Sylvain de142f6c5f feat: more role backend and admin/writer roles additions and adjustments 2026-10-26 18:15:26 +01:00
Florian Sylvain 8c6c6456da feat: roles system backend 2026-10-26 05:02:49 +01:00
Florian Sylvain 44cca46d5e chore: roles management groundwork 2026-10-26 02:53:33 +01:00
Florian Sylvain 7c0ee21906 feat: updated integration page with missing API routes 2026-10-26 02:18:35 +01:00
Florian Sylvain 25d9c30b8a feat: integration search bar 2026-10-25 21:57:43 +01:00
Florian Sylvain a8fea8d900 fix: changed "Jump to" so it scrolls only the page's own scroll container 2026-10-25 20:26:52 +01:00
Florian Sylvain a0fdf03be0 docs: release notes for v3.5.3-beta 2026-10-23 01:57:29 +02:00
Florian Sylvain 9ab98fd50c fix: response schema is now inferred from each endpoint's sample JSON 2026-10-23 01:56:35 +02:00
Florian Sylvain d2af39da61 docs: release notes for v3.5.2-beta 2026-10-23 01:31:51 +02:00
Florian Sylvain 4cff43fefb feat: llms.txt and llms-full.txt, openapi.json, MCP server 2026-10-23 01:29:59 +02:00
Florian Sylvain daba3c04c0 fix: relation picker page size refused by entry list 2026-10-23 00:48:10 +02:00
Florian Sylvain f2cdbcd3cb docs: release notes for v3.5.0-beta 2026-10-23 00:38:22 +02:00
Florian Sylvain 37a77c9c22 feat: relation field and picker 2026-10-23 00:13:42 +02:00
Florian Sylvain b45c99be3b fix: reduced SPA build chunk sizes 2026-10-22 10:15:32 +02:00
Florian Sylvain fc51ab8c10 fix: editor's right side panel vertical overflow 2026-10-22 03:04:25 +02:00
Florian Sylvain 07f5c5d1ee docs: release notes for v3.4.2-beta 2026-10-22 00:50:33 +02:00
Florian Sylvain ea98c5ec05 fix: security & privacy (rate limiter fix, csrf session cookie, logout on server, drafts stale token passw, login timing, password over 72b, JWT_SECRET complexity, SMTP TLS, CSV export neutralised, fonts bundled, upload metadata, hourly cleanup job, privacy.md report) 2026-10-22 00:49:19 +02:00
Florian Sylvain e2812ee0b9 docs: release notes for v3.4.1-beta 2026-10-21 23:31:01 +02:00
Florian Sylvain aa5d3582f8 fix: updated go dependencies with latest security fixes 2026-10-21 23:28:41 +02:00
Florian Sylvain c68e9a7858 refac: removed 7 direct and 12 indirect go modules for custom code instead 2026-10-21 23:13:46 +02:00
Florian Sylvain cfcf108488 fix: web dependencies security updates 2026-10-21 22:55:37 +02:00
Florian Sylvain b43cc6861f docs: removed coding standards from version control 2026-10-21 22:48:31 +02:00
Florian Sylvain 074944c99a refac: atomic merge, lost-update race, RunPurge cost, duplicated walks 2026-10-21 22:47:46 +02:00
Florian Sylvain 0fc3511e2b feat(SPA): media library indications for custom medial field type usage 2026-10-21 16:17:09 +02:00
Florian Sylvain 3e7fc9a4a5 feat(SPA): media field type 2026-10-21 15:17:15 +02:00
Florian Sylvain a2d596f4ca feat(API): media field type 2026-10-21 05:07:36 +02:00
Florian Sylvain ad5faf3d19 docs: release notes for v3.4.0-beta 2026-10-21 01:46:15 +02:00
Florian Sylvain 89051b3f1f refac: duplicates, crap, dry etc. on last features commits 2026-10-21 00:52:14 +02:00
Florian Sylvain fba20f83c5 feat: entry editor at /content/:key/new and /content/:key/:id 2026-10-21 00:12:34 +02:00
Florian Sylvain 91d916fb74 feat: entry list at /content/:key 2026-10-20 21:05:03 +02:00
Florian Sylvain b4cbcb0395 refac: split useSchemaBuilder & SchemaDeleteControls 2026-10-20 18:12:29 +02:00
Florian Sylvain 5404291af2 feat: delete a custom type 2026-10-20 17:56:44 +02:00
Florian Sylvain 76e9e1220c fix: accessibility issues for builder's design 2026-10-20 05:11:14 +02:00
Florian Sylvain 8e344b2500 feat: edit an existing custom type 2026-10-20 04:42:50 +02:00
Florian Sylvain 083cc9e23e feat: schema builder's create flow 2026-10-20 00:29:01 +02:00
Florian Sylvain f514146fed feat: built-in type read-only view,s chema list, states, spec files 2026-10-19 22:31:13 +02:00
Florian Sylvain 3c67cd2314 feat(SPA): schema list and the sidebar Content group 2026-10-19 19:34:45 +02:00
Florian Sylvain 73a75d5cb9 feat: custom content type icons, built-in types, user lookup 2026-10-19 18:22:55 +02:00
Florian Sylvain e6e088c963 chore: custom content types design groundwork 2026-10-19 17:38:41 +02:00
Florian Sylvain 58d58f5a04 feat: custom content types backend 2026-10-19 17:01:37 +02:00
Florian Sylvain ed67b7e70d docs: release notes for v3.3.0-beta 2026-10-19 04:10:29 +02:00
Florian Sylvain 23e88190d9 fix: route every toggle switch in the SPA through one new component, for one correct geometry 2026-10-19 04:06:34 +02:00
Florian Sylvain 340a0ad461 feat: pagination in the SPA for the Sites and Authors lists 2026-10-19 04:00:47 +02:00
Florian Sylvain 500ad59458 fix: authors profile pictute all accross the SPA 2026-10-19 00:46:16 +02:00
Florian Sylvain 18600ab49d chore: fix pagination for authors and site 2026-10-19 00:30:19 +02:00
Florian Sylvain daf3594316 fix: technical debts left by the Sites, Authors and SEO slices, plus backend support for the new Sites and Authors pagination 2026-10-19 00:25:31 +02:00
Florian Sylvain 9f4ce426a8 chore: groundwork for SEO fixes and tech&vis debt 2026-10-18 17:30:12 +02:00
Florian Sylvain 02fb8751fd feat: SEO SPA Sites, Articles and Integration (4/4) 2026-10-18 15:51:28 +02:00
Florian Sylvain cb737b7ae3 fix: SEO SPA editor last fixes (3/4) 2026-10-16 16:31:17 +02:00
Florian Sylvain 470584400f feat: SEO SPA editor integration 2026-10-16 03:06:57 +02:00
Florian Sylvain eb9876fde3 feat: SEO backend output (2/4) 2026-10-15 21:48:55 +02:00
Florian Sylvain 0eb46b0d74 feat: SEO backend data (1/4) 2026-10-15 19:30:29 +02:00
Florian Sylvain cb1410f0b0 chore: grounwork for SEO features 2026-10-15 15:48:57 +02:00
Florian Sylvain 6b8990c55e feat: sites and authors integration into existing screens (& formated all frontend files kinda accidentally) 2026-10-15 05:25:44 +02:00
Florian Sylvain 3d81d2c717 feat(SPA): screens for Sites and Authors 2026-10-14 23:00:50 +02:00
Florian Sylvain b34a173d83 feat: Authors feature backend (and upgraded deprecated mods) 2026-10-14 17:45:00 +02:00
Florian Sylvain a11338094e feat: Sites feature backend 2026-10-14 03:56:50 +02:00
Florian Sylvain 45fcbd890d chore: groundwork for sites & authors 2026-10-14 00:33:52 +02:00
Florian Sylvain 0d03a27f31 docs: release notes for v3.2.1-beta 2026-10-13 22:39:42 +02:00
Florian Sylvain 63c1e7076e feat(media): pagination 2026-10-13 19:54:59 +02:00
Florian Sylvain 04dc151ed7 feat(variant): image preview on variant creation with a real sample image 2026-10-13 17:29:56 +02:00
Florian Sylvain a1cdfce529 fix(integration): missing french translations 2026-10-13 16:54:06 +02:00
Florian Sylvain c41e6dbca5 fix(SPA): activity list saves pagination too 2026-10-13 16:36:42 +02:00
Florian Sylvain 9d9453c1b3 fix(SPA): save article list pagination in query url 2026-10-13 16:20:37 +02:00
Florian Sylvain 54f27ac98f docs: release notes for v3.2.0-beta and version references cleanup 2026-10-13 03:26:04 +02:00
Florian Sylvain 0632bf53cb feat(SPA): "<page> · RenewCMS" as pages titles, instead of being stuck on whatever lands on first loading 2026-10-13 03:19:25 +02:00
Florian Sylvain fe254112a2 fix(register): onSubmit now calls await session.retry() before going to /register/pending 2026-10-13 02:49:43 +02:00
Florian Sylvain 160c0a49ce feat(media): align visual checks and variant UI with merged design 2026-10-13 02:35:20 +02:00
Florian Sylvain e6746f2be7 feat(integration): add image variants section and standardize code snippets 2026-10-12 23:27:41 +02:00
Florian Sylvain 6f9ae6207a feat(editor): link insertion full UI and flow 2026-10-12 17:12:36 +02:00
Florian Sylvain 03b39e64f2 feat(activity): update summary cards to toggles and introduce export menu 2026-10-12 05:13:25 +02:00
Florian Sylvain 73f4b539fe feat: articles pagination 2026-10-11 19:14:37 +02:00
Florian Sylvain a2d224240c fix: small design harmonization and overhaul 2026-10-11 17:17:22 +02:00
Florian Sylvain d6566743a7 fix: prevent air from deleting the whole tmp folder, used for visual diff task 2026-10-11 08:49:31 +02:00
Florian Sylvain 10ec74c8cf feat: remove the 10,000-character article body limit and make the New article body optional 2026-10-11 08:49:11 +02:00
Florian Sylvain 5e05b606a7 chore: 2 big design passes to harmonize and fix stuff 2026-10-11 07:45:17 +02:00
Florian Sylvain 67b3ff03d0 fix: linux dev workflow 2026-10-11 06:31:49 +02:00
fsylvain ce9c876db6 refac: removed ugly multi-line method chaining 2026-10-10 20:49:05 +02:00
fsylvain e8dff6bb5f chore: improved mutate script so we don't end up with hunders of GBs of gremlins cache after a few slices 2026-10-10 00:23:39 +02:00
fsylvain c484d5bd12 release: v3.1.0-beta Image variants 2026-10-08 21:15:43 +02:00
fsylvain d768668014 feat: image variants cache panel 2026-10-08 21:13:32 +02:00
fsylvain 951fef18ba feat: image variants per image 2026-10-08 19:57:51 +02:00
fsylvain f567866161 feat: image variants settings screens 2026-10-08 13:57:41 +02:00
fsylvain f1d56ef50e feat: image variants engine 2026-10-08 02:33:32 +02:00
fsylvain f0845de643 chore: media compression feature groundwork 2026-10-08 00:26:31 +02:00
fsylvain f914eae3c5 feat: setup logs retention by max file size 2026-10-07 22:02:13 +02:00
fsylvain 70c8492f4f fix: design harmonization on last features 2026-10-07 18:20:18 +02:00
fsylvain 953be7342d fix: jsdom doesn't implement Range.prototype.getClientRects 2026-10-07 16:51:57 +02:00
fsylvain 28d2d5c826 fix: missing integration page routes 2026-10-07 16:50:44 +02:00
fsylvain 59c72a84ce release: v3.0.0-beta Activity log, more databases and S3 storage 2026-10-07 16:04:23 +02:00
fsylvain 43469ef08d feat: recent activity card on Home and Retention card on Settings 2026-10-07 16:01:28 +02:00
fsylvain 35db442cd3 feat: activity screen in the SPA, route /activity 2026-10-07 01:48:21 +02:00
fsylvain e37a46a027 feat: activity log producers 2026-10-06 22:25:23 +02:00
fsylvain 50b1649965 feat: activity log backend foundation 2026-10-06 20:35:03 +02:00
fsylvain 8907bb49fe chore: groundwork for activity page 2026-10-06 14:23:51 +02:00
fsylvain dc5d12348c feat: PostgreSQL driver support 2026-10-06 13:35:10 +02:00
fsylvain eb9bfabed5 feat: optional S3-compatible storage for uploaded images, instead of the local upload directory 2026-10-06 12:13:24 +02:00
fsylvain d752a61e52 feat: optional MySQL/MariaDB database driver for deployments that cannot use local SQLite 2026-10-06 04:08:33 +02:00
fsylvain 81e299f09a refac: decouple persistence and image storage behind driver registries 2026-10-06 02:13:35 +02:00
fsylvain 421cfafec3 release: v2.9.0-beta Media duplicate purge 2026-10-05 23:58:54 +02:00
fsylvain 2303b12377 feat: media duplicate purge, part 3 of 3: the SPA (Settings "Media cleanup" card, PurgeDuplicatesDialog, Media Duplicates toolbar button) 2026-10-05 23:57:50 +02:00
fsylvain 1a55a39425 feat: media duplicate purge, part 2 of 3: the automatic purge scheduler (Go only) 2026-10-05 19:44:15 +02:00
fsylvain e82d0ce254 feat: media duplicate purge, part 1 of 3: persisted purge settings and the purge API 2026-10-05 18:24:03 +02:00
fsylvain 161ddc0f7d chore: groundwork for media purge system 2026-10-05 17:16:06 +02:00
fsylvain c1e73bda81 release: v2.8.3-beta Security policy fixes for the web app 2026-10-05 16:33:31 +02:00
fsylvain 508a8ab77e fix: CSP policy missing front components like icons, nextui color mode, wasm 2026-10-05 16:32:19 +02:00
1700 changed files with 245689 additions and 9379 deletions

No files matched your search

+4 -4
View File
@@ -1,11 +1,11 @@
root = "." root = "."
tmp_dir = "tmp" tmp_dir = "tmp/air"
[build] [build]
cmd = "go build -tags nofrontend -o ./tmp/renewcms.exe ./cmd/server" cmd = "go build -tags nofrontend -o ./tmp/air/renewcms ./cmd/server"
bin = "tmp/renewcms.exe" bin = "tmp/air/renewcms"
delay = 500 delay = 500
exclude_dir = ["tmp", "bin", "data", "web", "features", "docs", "internal/infrastructure/assets/dist", ".claude"] exclude_dir = ["tmp", "tmp/air", "bin", "data", "web", "features", "docs", "internal/infrastructure/assets/dist", ".claude"]
exclude_regex = ["_test.go"] exclude_regex = ["_test.go"]
include_ext = ["go", "html"] include_ext = ["go", "html"]
log = "build-errors.log" log = "build-errors.log"
+25
View File
@@ -14,5 +14,30 @@ CORS_ALLOWED_ORIGINS=
AUTH_RATE_LIMIT_MAX_ATTEMPTS=5 AUTH_RATE_LIMIT_MAX_ATTEMPTS=5
AUTH_RATE_LIMIT_WINDOW_SECONDS=60 AUTH_RATE_LIMIT_WINDOW_SECONDS=60
TRUSTED_PROXIES= TRUSTED_PROXIES=
SMTP_ALLOW_INSECURE=false
UPDATE_FEED_URL=https://git.floriansylvain.fr UPDATE_FEED_URL=https://git.floriansylvain.fr
RENEWCMS_CONTAINER=false RENEWCMS_CONTAINER=false
IMAGE_VARIANT_MAX_SOURCE_BYTES=10485760
IMAGE_VARIANT_MAX_SOURCE_PIXELS=50000000
IMAGE_VARIANT_MAX_CONCURRENT_RENDERS=4
DB_DRIVER=sqlite
DATA_DIR=
DB_HOST=
DB_PORT=
DB_USER=
DB_PASSWORD=
DB_NAME=
DB_TLS=false
DB_SSLMODE=disable
DB_MAX_OPEN_CONNS=10
DB_MAX_IDLE_CONNS=5
DB_CONN_MAX_LIFETIME_SECONDS=300
STORAGE_DRIVER=local
S3_BUCKET=
S3_REGION=us-east-1
S3_ENDPOINT=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_KEY_PREFIX=
S3_PATH_STYLE=false
S3_TLS=true
+5
View File
@@ -28,3 +28,8 @@ CLAUDE.md
.agents/ .agents/
AGENTS.md AGENTS.md
codex-workflow.md codex-workflow.md
CODING_STANDARDS.md
# Generated LLM docs
internal/infrastructure/llmdocs/generated/*
!internal/infrastructure/llmdocs/generated/.gitkeep
-100
View File
@@ -1,100 +0,0 @@
# Coding Standards
These rules apply to every file in this repo , for humans and agents alike.
## General rules
1. Follow standard conventions.
2. Keep it simple stupid. Simpler is always better. Reduce complexity as much as possible.
3. Boy scout rule. Leave the campground cleaner than you found it.
4. Always find root cause. Always look for the root cause of a problem.
## Design rules
1. Keep configurable data at high levels.
2. Prefer polymorphism to if/else or switch/case.
3. Separate multi-threading code.
4. Prevent over-configurability.
5. Use dependency injection.
6. Follow Law of Demeter. A class should know only its direct dependencies.
## Understandability tips
1. Be consistent. If you do something a certain way, do all similar things in the same way.
2. Use explanatory variables.
3. Encapsulate boundary conditions. Boundary conditions are hard to keep track of. Put the processing for them in one place.
4. Prefer dedicated value objects to primitive types.
5. Avoid logical dependency. Don't write methods which work correctly only depending on something else in the same class.
6. Avoid negative conditionals.
## Names rules
1. Choose descriptive and unambiguous names.
2. Make meaningful distinctions.
3. Use pronounceable names.
4. Use searchable names.
5. Replace magic numbers with named constants.
6. Avoid encodings. Don't append prefixes or type information.
## Functions rules
1. Small.
2. Do one thing.
3. Use descriptive names.
4. Prefer fewer arguments.
5. Have no side effects.
6. Don't use flag arguments. Split the method into several independent methods that can be called from the client without the flag.
## Comments rules
1. No comments in the codebase. This covers Go, TypeScript/Vue, HTML templates and config files (YAML, TOML, `.env.example`, `.gitattributes`, …).
2. Explain yourself in code instead: descriptive names, small functions, named constants, explanatory variables.
3. The only exceptions are tool directives the code needs to work, such as `//go:build` and `//go:embed`.
4. Don't comment out code; just remove it.
5. Prose belongs in `docs/` and `README.md`, not in source files.
## Source code structure
1. Separate concepts vertically.
2. Related code should appear vertically dense.
3. Declare variables close to their usage.
4. Dependent functions should be close.
5. Similar functions should be close.
6. Place functions in the downward direction (caller above callee).
7. Keep lines short.
8. Don't use horizontal alignment.
9. Use white space to associate related things and disassociate weakly related ones.
10. Don't break indentation.
## Objects and data structures
1. Hide internal structure.
2. Prefer data structures when there's no behavior to attach.
3. Avoid hybrid structures (half object, half data).
4. Keep classes small.
5. Each class should do one thing.
6. Keep the number of instance variables small.
7. Base classes should know nothing about their derivatives.
8. Prefer many small functions over one function with a parameter that selects behavior.
9. Prefer non-static methods to static methods.
## Tests
1. One assert per test.
2. Readable.
3. Fast.
4. Independent.
5. Repeatable.
## Code smells to watch for
1. **Rigidity** — the software is difficult to change; a small change causes a cascade of subsequent changes.
2. **Fragility** — the software breaks in many places due to a single change.
3. **Immobility** — you cannot reuse parts of the code in other projects because of the risk and effort involved.
4. **Needless complexity**.
5. **Needless repetition**.
6. **Opacity** — the code is hard to understand.
## Credits
Thanks to [@wojtekluwojteklu/clean_code.md](https://gist.github.com/wojteklu/73c6914cc446146b8b533c0988cf8d29)
+6 -1
View File
@@ -4,8 +4,12 @@ COPY web/package.json web/bun.lock ./
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
COPY web/ ./ COPY web/ ./
RUN bun run build-only RUN bun run build-only
WORKDIR /src
COPY docs/ ./docs/
COPY scripts/generate-llms.ts ./scripts/generate-llms.ts
RUN bun scripts/generate-llms.ts
FROM golang:1.27.1-alpine AS server FROM golang:1.27.3-alpine AS server
ARG VERSION=dev ARG VERSION=dev
ENV CGO_ENABLED=0 ENV CGO_ENABLED=0
WORKDIR /src WORKDIR /src
@@ -13,6 +17,7 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
COPY --from=web /src/internal/infrastructure/assets/dist ./internal/infrastructure/assets/dist COPY --from=web /src/internal/infrastructure/assets/dist ./internal/infrastructure/assets/dist
COPY --from=web /src/internal/infrastructure/llmdocs/generated ./internal/infrastructure/llmdocs/generated
RUN go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/renewcms ./cmd/server RUN go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/renewcms ./cmd/server
FROM alpine:3.22 FROM alpine:3.22
+8 -5
View File
@@ -23,7 +23,7 @@ docker compose pull
docker compose up -d # http://localhost:8080 docker compose up -d # http://localhost:8080
``` ```
Upgrade with `docker compose pull && docker compose up -d`. The image `git.floriansylvain.fr/fsylvain/renewcms` is published by the maintainer with `task docker:publish` (no CI). Tags: `vX.Y.Z`, `X.Y.Z`, `X.Y` and `latest` for a stable release; `vX.Y.Z-<pre>`, `X.Y.Z-<pre>` and the moving `beta` or `rc` for a pre-release (`latest` never moves for those, so the default is `beta` for now). To build from source use the override described in **[docs/docker.md](docs/docker.md)**. Upgrade with `docker compose pull && docker compose up -d`. The image is `git.floriansylvain.fr/fsylvain/renewcms`. Every release is a pre-release for now, so the default tag is the moving `beta`. Pin a version with `RENEWCMS_IMAGE`. Tags, building from source and publishing are in **[docs/docker.md](docs/docker.md)**.
### Updates from the browser ### Updates from the browser
@@ -47,11 +47,14 @@ task dev # API with hot reload + Vite HMR → http://localhost:51
| [docs/deployment.md](docs/deployment.md) | Building release binaries, running as a service, HTTPS, backups, upgrades | | [docs/deployment.md](docs/deployment.md) | Building release binaries, running as a service, HTTPS, backups, upgrades |
| [docs/docker.md](docs/docker.md) | Running RenewCMS in Docker: image, Compose, data volume, backups, reverse proxy, tags, upgrades, publishing | | [docs/docker.md](docs/docker.md) | Running RenewCMS in Docker: image, Compose, data volume, backups, reverse proxy, tags, upgrades, publishing |
| [docs/configuration.md](docs/configuration.md) | Every environment variable | | [docs/configuration.md](docs/configuration.md) | Every environment variable |
| [docs/privacy.md](docs/privacy.md) | Personal data stored, retention, cookies, outbound connections and GDPR requests |
| [docs/architecture.md](docs/architecture.md) | Backend layering and how the SPA is embedded | | [docs/architecture.md](docs/architecture.md) | Backend layering and how the SPA is embedded |
| [docs/performance.md](docs/performance.md) | Measured costs, such as the `?seo=` article filter |
## API Usage | [docs/api.md](docs/api.md) | API reference: authentication, API keys, routes and the activity log |
| [docs/api/articles.md](docs/api/articles.md) | Reading articles: pagination, search, `format` and body schemas |
TODO | [docs/custom-content-types.md](docs/custom-content-types.md) | Custom content types: layering, phase 1 limitations and their fix path |
| [docs/image-variants.md](docs/image-variants.md) | Image variants and their cache |
| [docs/release-notes.md](docs/release-notes.md) | Release notes format, the published releases and how to publish one |
## TODOs ## TODOs
+141 -4
View File
@@ -3,6 +3,7 @@ version: '3'
vars: vars:
BINARY: renewcms BINARY: renewcms
DIST_DIR: internal/infrastructure/assets/dist DIST_DIR: internal/infrastructure/assets/dist
LLMS_DIR: internal/infrastructure/llmdocs/generated
VERSION: VERSION:
sh: git describe --tags --always --dirty sh: git describe --tags --always --dirty
LDFLAGS: -s -w -X main.version={{.VERSION}} LDFLAGS: -s -w -X main.version={{.VERSION}}
@@ -10,6 +11,7 @@ vars:
env: env:
CGO_ENABLED: '0' CGO_ENABLED: '0'
__COMPAT_LAYER: RunAsInvoker
tasks: tasks:
default: default:
@@ -31,6 +33,7 @@ tasks:
dev:api: dev:api:
desc: Run the API with hot reload on :8080 (SPA not embedded) desc: Run the API with hot reload on :8080 (SPA not embedded)
deps: [build:llms]
cmds: cmds:
- air - air
@@ -46,6 +49,127 @@ tasks:
cmds: cmds:
- go test -tags nofrontend ./test/... -count=1 {{.CLI_ARGS}} - go test -tags nofrontend ./test/... -count=1 {{.CLI_ARGS}}
test:mysql:
desc: Run the MySQL tests against real servers (mysql:8.4 then mariadb:11 in Docker, removed afterwards)
cmds:
- cmd: |
PASSWORD=renewcms-test-root
STATUS=0
run_engine() {
NAME="renewcms-test-$1"
IMAGE="$2"
docker rm -f "$NAME" >/dev/null 2>&1
docker run -d --name "$NAME" -e MYSQL_ROOT_PASSWORD="$PASSWORD" -p 127.0.0.1::3306 "$IMAGE" >/dev/null || return 1
MAPPING="$(docker port "$NAME" 3306/tcp)"
PORT="${MAPPING##*:}"
READY=no
TRIES=0
while [ "$TRIES" -lt 90 ]; do
if docker exec "$NAME" sh -c "\$(command -v mariadb || command -v mysql) -h127.0.0.1 -uroot -p$PASSWORD -e 'SELECT 1'" >/dev/null 2>&1; then
READY=yes
break
fi
TRIES=$((TRIES + 1))
sleep 2
done
if [ "$READY" != yes ]; then
echo "$IMAGE did not become ready" >&2
docker logs "$NAME" 2>&1 | tail -n 20 >&2
docker rm -f "$NAME" >/dev/null 2>&1
return 1
fi
echo "== $IMAGE on 127.0.0.1:$PORT"
RENEWCMS_TEST_MYSQL_DSN="root:$PASSWORD@tcp(127.0.0.1:$PORT)/mysql" go test -tags nofrontend ./test/... -run MySQL -count=1 {{.CLI_ARGS}}
RESULT=$?
docker rm -f "$NAME" >/dev/null 2>&1
return $RESULT
}
run_engine mysql mysql:8.4 || STATUS=1
run_engine mariadb mariadb:11 || STATUS=1
exit $STATUS
silent: true
test:postgres:
desc: Run the PostgreSQL tests against real servers (postgres:16 then postgres:17 in Docker, removed afterwards)
cmds:
- cmd: |
PASSWORD=renewcms-test-postgres
STATUS=0
run_engine() {
NAME="renewcms-test-postgres-$1"
IMAGE="postgres:$1"
docker rm -f "$NAME" >/dev/null 2>&1
docker run -d --name "$NAME" -e POSTGRES_PASSWORD="$PASSWORD" -p 127.0.0.1::5432 "$IMAGE" >/dev/null || return 1
MAPPING="$(docker port "$NAME" 5432/tcp | head -n 1)"
PORT="${MAPPING##*:}"
READY=no
TRIES=0
while [ "$TRIES" -lt 90 ]; do
if docker exec "$NAME" pg_isready -h 127.0.0.1 -U postgres >/dev/null 2>&1 && docker exec "$NAME" psql -h 127.0.0.1 -U postgres -c 'SELECT 1' >/dev/null 2>&1; then
READY=yes
break
fi
TRIES=$((TRIES + 1))
sleep 2
done
if [ "$READY" != yes ]; then
echo "$IMAGE did not become ready" >&2
docker logs "$NAME" 2>&1 | tail -n 20 >&2
docker rm -f "$NAME" >/dev/null 2>&1
return 1
fi
echo "== $IMAGE on 127.0.0.1:$PORT"
RENEWCMS_TEST_POSTGRES_DSN="postgres://postgres:$PASSWORD@127.0.0.1:$PORT/postgres?sslmode=disable" go test -tags nofrontend ./test/... -run Postgres -count=1 {{.CLI_ARGS}}
RESULT=$?
docker rm -f "$NAME" >/dev/null 2>&1
return $RESULT
}
run_engine 16 || STATUS=1
run_engine 17 || STATUS=1
exit $STATUS
silent: true
test:s3:
desc: Run the S3 storage integration test against a throwaway SeaweedFS in Docker (skipped when Docker is unavailable)
cmds:
- cmd: |
if ! docker info >/dev/null 2>&1; then
echo "Docker is not available, skipping the S3 integration test"
exit 0
fi
export MSYS_NO_PATHCONV=1
NAME=renewcms-test-seaweedfs
IMAGE=chrislusf/seaweedfs:4.48
BUCKET=renewcms-test
ACCESS_KEY=renewcms-test
SECRET_KEY=renewcms-test-secret
docker rm -f "$NAME" >/dev/null 2>&1
docker run -d --name "$NAME" -e AWS_ACCESS_KEY_ID="$ACCESS_KEY" -e AWS_SECRET_ACCESS_KEY="$SECRET_KEY" -p 127.0.0.1::8333 "$IMAGE" server -dir=/data -s3 -s3.port=8333 >/dev/null || exit 1
MAPPING="$(docker port "$NAME" 8333/tcp | head -n 1)"
PORT="${MAPPING##*:}"
READY=no
TRIES=0
while [ "$TRIES" -lt 60 ]; do
if docker run --rm --network "container:$NAME" --entrypoint sh "$IMAGE" -c "echo 's3.bucket.create -name $BUCKET' | timeout 30 weed shell -master=127.0.0.1:9333 && echo s3.bucket.list | timeout 30 weed shell -master=127.0.0.1:9333 | grep -q $BUCKET" >/dev/null 2>&1; then
READY=yes
break
fi
TRIES=$((TRIES + 1))
sleep 2
done
if [ "$READY" != yes ]; then
echo "SeaweedFS did not become ready" >&2
docker logs "$NAME" 2>&1 | tail -n 20 >&2
docker rm -f "$NAME" >/dev/null 2>&1
exit 1
fi
echo "== SeaweedFS on 127.0.0.1:$PORT"
RENEWCMS_TEST_S3_ENDPOINT="http://127.0.0.1:$PORT" RENEWCMS_TEST_S3_BUCKET="$BUCKET" RENEWCMS_TEST_S3_ACCESS_KEY="$ACCESS_KEY" RENEWCMS_TEST_S3_SECRET_KEY="$SECRET_KEY" go test -tags nofrontend ./test/... -run S3StorageIntegration -count=1 -v {{.CLI_ARGS}}
RESULT=$?
docker rm -f "$NAME" >/dev/null 2>&1
exit $RESULT
silent: true
cover: cover:
desc: Go test coverage of the use cases → tmp/coverage.out desc: Go test coverage of the use cases → tmp/coverage.out
cmds: cmds:
@@ -56,8 +180,7 @@ tasks:
mutate: mutate:
desc: Gremlins mutation testing of the use cases → tmp/gremlins.json desc: Gremlins mutation testing of the use cases → tmp/gremlins.json
cmds: cmds:
- mkdir -p tmp - bash scripts/mutate.sh {{.CLI_ARGS}}
- gremlins unleash --integration --tags nofrontend --coverpkg=./internal/infrastructure/useCases/...,./api/middleware/...,./api/pkg/claims/... --timeout-coefficient 10 --output tmp/gremlins.json ./internal/infrastructure/useCases ./api/middleware ./api/pkg/claims
test:web: test:web:
desc: Run the SPA test suite (Vitest) in run mode desc: Run the SPA test suite (Vitest) in run mode
@@ -110,22 +233,36 @@ tasks:
desc: Type-check and build the SPA into {{.DIST_DIR}} desc: Type-check and build the SPA into {{.DIST_DIR}}
dir: web dir: web
deps: [web:install] deps: [web:install]
sources: [src/**/*, public/**/*, index.html, vite.config.ts, tsconfig*.json, package.json, bun.lock] sources: [src/**/*, public/**/*, vite/**/*, index.html, vite.config.ts, tsconfig*.json, package.json, bun.lock]
generates: ['../{{.DIST_DIR}}/index.html'] generates: ['../{{.DIST_DIR}}/index.html']
cmds: cmds:
- bun run build - bun run build
build:llms:
desc: Generate llms.txt, llms-full.txt and openapi.json from the route catalogue and docs/ → {{.LLMS_DIR}}
deps: [web:install]
sources: [scripts/generate-llms.ts, web/src/data/apiCatalogue.ts, docs/api.md, docs/api/*.md, docs/llm/*.md, docs/configuration.md]
generates: ['{{.LLMS_DIR}}/llms-full.txt']
cmds:
- bun scripts/generate-llms.ts
build: build:
desc: Production binary for this machine → bin/{{.BINARY}} desc: Production binary for this machine → bin/{{.BINARY}}
deps: [build:web] deps: [build:web, build:llms]
cmds: cmds:
- go build -trimpath -ldflags "{{.LDFLAGS}}" -o bin/{{.BINARY}}{{exeExt}} ./cmd/server - go build -trimpath -ldflags "{{.LDFLAGS}}" -o bin/{{.BINARY}}{{exeExt}} ./cmd/server
build:mcp:
desc: MCP server (stdio) for this machine → bin/{{.BINARY}}-mcp
cmds:
- go build -trimpath -ldflags "{{.LDFLAGS}}" -o bin/{{.BINARY}}-mcp{{exeExt}} ./cmd/mcp
release: release:
desc: Cross-compile and sign production binaries for all release targets → bin/ (needs RELEASE_SIGNING_KEY) desc: Cross-compile and sign production binaries for all release targets → bin/ (needs RELEASE_SIGNING_KEY)
cmds: cmds:
- task: release:check - task: release:check
- task: build:web - task: build:web
- task: build:llms
- for: { var: RELEASE_TARGETS } - for: { var: RELEASE_TARGETS }
task: build:target task: build:target
vars: vars:
+350 -70
View File
@@ -1,23 +1,38 @@
package api package api
import ( import (
"RenewCMS/api/pkg/authtoken"
domainAPIKey "RenewCMS/internal/domain/apikey" domainAPIKey "RenewCMS/internal/domain/apikey"
domainArticle "RenewCMS/internal/domain/article" domainArticle "RenewCMS/internal/domain/article"
domainAudit "RenewCMS/internal/domain/audit"
domainAuthor "RenewCMS/internal/domain/author"
domainContentType "RenewCMS/internal/domain/contenttype"
domainEntry "RenewCMS/internal/domain/entry"
domainImage "RenewCMS/internal/domain/image" domainImage "RenewCMS/internal/domain/image"
domainInvite "RenewCMS/internal/domain/invite" domainInvite "RenewCMS/internal/domain/invite"
domainMail "RenewCMS/internal/domain/mail" domainMail "RenewCMS/internal/domain/mail"
domainPurge "RenewCMS/internal/domain/purge"
domainRole "RenewCMS/internal/domain/role"
domainSession "RenewCMS/internal/domain/session"
domainSite "RenewCMS/internal/domain/site"
domainTransaction "RenewCMS/internal/domain/transaction"
domainUpdate "RenewCMS/internal/domain/update" domainUpdate "RenewCMS/internal/domain/update"
domainUser "RenewCMS/internal/domain/user" domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/auditing"
"RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing" "RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/mailer" "RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/markdown" "RenewCMS/internal/infrastructure/markdown"
"RenewCMS/internal/infrastructure/persistence/models" "RenewCMS/internal/infrastructure/persistence/database"
persistence "RenewCMS/internal/infrastructure/persistence/repositories" persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/scheduler"
"RenewCMS/internal/infrastructure/storage"
"RenewCMS/internal/infrastructure/update" "RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/usage"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"context"
"crypto/ed25519" "crypto/ed25519"
"fmt"
"log" "log"
"net/http" "net/http"
"os" "os"
@@ -25,21 +40,56 @@ import (
"runtime" "runtime"
"sync" "sync"
"time" "time"
"github.com/glebarez/sqlite"
"github.com/go-chi/jwtauth/v5"
"gorm.io/gorm"
) )
type UseCases struct { type UseCases struct {
apiKeyAuthOnce sync.Once apiKeyAuthOnce sync.Once
apiKeyAuthMiddleware func(http.Handler) http.Handler apiKeyAuthMiddleware func(http.Handler) http.Handler
Token *jwtauth.JWTAuth Token *authtoken.Auth
PurgeScheduler *scheduler.Runner
ActivityCleanupScheduler *scheduler.Runner
RetentionScheduler *scheduler.Runner
CreateArticleUseCase *useCases.CreateArticleUseCase CreateArticleUseCase *useCases.CreateArticleUseCase
GetArticleUseCase *useCases.GetArticleUseCase GetArticleUseCase *useCases.GetArticleUseCase
ListArticlesUseCase *useCases.ListArticlesUseCase ListArticlesUseCase *useCases.ListArticlesUseCase
UpdateArticleUseCase *useCases.UpdateArticleUseCase UpdateArticleUseCase *useCases.UpdateArticleUseCase
ResolveArticleSEOUseCase *useCases.ResolveArticleSEOUseCase
CheckArticleSEOUseCase *useCases.CheckArticleSEOUseCase
ListSitemapUseCase *useCases.ListSitemapUseCase
DeleteArticleUseCase *useCases.DeleteArticleUseCase DeleteArticleUseCase *useCases.DeleteArticleUseCase
ListAuthorsUseCase *useCases.ListAuthorsUseCase
CreateAuthorUseCase *useCases.CreateAuthorUseCase
CreateMyAuthorUseCase *useCases.CreateMyAuthorUseCase
UpdateAuthorUseCase *useCases.UpdateAuthorUseCase
DeleteAuthorUseCase *useCases.DeleteAuthorUseCase
GetMyAuthorUseCase *useCases.GetMyAuthorUseCase
GetMyAccessUseCase *useCases.GetMyAccessUseCase
CheckPermissionUseCase *useCases.CheckPermissionUseCase
ListPermissionsUseCase *useCases.ListPermissionsUseCase
ListSitesUseCase *useCases.ListSitesUseCase
CreateSiteUseCase *useCases.CreateSiteUseCase
UpdateSiteUseCase *useCases.UpdateSiteUseCase
SetDefaultSiteUseCase *useCases.SetDefaultSiteUseCase
DeleteSiteUseCase *useCases.DeleteSiteUseCase
CreateContentTypeUseCase *useCases.CreateContentTypeUseCase
ListContentTypesUseCase *useCases.ListContentTypesUseCase
GetContentTypeUseCase *useCases.GetContentTypeUseCase
UpdateContentTypeUseCase *useCases.UpdateContentTypeUseCase
DeleteContentTypeUseCase *useCases.DeleteContentTypeUseCase
ListRolesUseCase *useCases.ListRolesUseCase
GetRoleUseCase *useCases.GetRoleUseCase
CreateRoleUseCase *useCases.CreateRoleUseCase
UpdateRoleUseCase *useCases.UpdateRoleUseCase
DeleteRoleUseCase *useCases.DeleteRoleUseCase
ReassignRoleMembersUseCase *useCases.ReassignRoleMembersUseCase
ListBuiltinTypesUseCase *useCases.ListBuiltinTypesUseCase
GetBuiltinTypeUseCase *useCases.GetBuiltinTypeUseCase
LookupUserNamesUseCase *useCases.LookupUserNamesUseCase
CreateEntryUseCase *useCases.CreateEntryUseCase
GetEntryUseCase *useCases.GetEntryUseCase
ListEntriesUseCase *useCases.ListEntriesUseCase
UpdateEntryUseCase *useCases.UpdateEntryUseCase
DeleteEntryUseCase *useCases.DeleteEntryUseCase
GetUserUseCase *useCases.GetUserUseCase GetUserUseCase *useCases.GetUserUseCase
CreateUserUseCase *useCases.CreateUserUseCase CreateUserUseCase *useCases.CreateUserUseCase
AuthenticateUserUseCase *useCases.AuthenticateUserUseCase AuthenticateUserUseCase *useCases.AuthenticateUserUseCase
@@ -53,15 +103,22 @@ type UseCases struct {
CancelInviteUseCase *useCases.CancelInviteUseCase CancelInviteUseCase *useCases.CancelInviteUseCase
RenewInviteUseCase *useCases.RenewInviteUseCase RenewInviteUseCase *useCases.RenewInviteUseCase
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
ChangeUserRoleUseCase *useCases.ChangeUserRoleUseCase
TransferOwnershipUseCase *useCases.TransferOwnershipUseCase
GetInviteUseCase *useCases.GetInviteUseCase GetInviteUseCase *useCases.GetInviteUseCase
AcceptInviteUseCase *useCases.AcceptInviteUseCase AcceptInviteUseCase *useCases.AcceptInviteUseCase
SendMailUseCase *useCases.SendMailUseCase SendMailUseCase *useCases.SendMailUseCase
CreateImageUseCase *useCases.CreateImageUseCase CreateImageUseCase *useCases.CreateImageUseCase
OpenMediaUseCase *useCases.OpenMediaUseCase
ListMediaUseCase *useCases.ListMediaUseCase ListMediaUseCase *useCases.ListMediaUseCase
RenameMediaUseCase *useCases.RenameMediaUseCase RenameMediaUseCase *useCases.RenameMediaUseCase
DeleteMediaUseCase *useCases.DeleteMediaUseCase DeleteMediaUseCase *useCases.DeleteMediaUseCase
MergeMediaUseCase *useCases.MergeMediaUseCase MergeMediaUseCase *useCases.MergeMediaUseCase
GetMediaUsageUseCase *useCases.GetMediaUsageUseCase GetMediaUsageUseCase *useCases.GetMediaUsageUseCase
GetPurgeSettingsUseCase *useCases.GetPurgeSettingsUseCase
UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase
PreviewPurgeUseCase *useCases.PreviewPurgeUseCase
RunPurgeUseCase *useCases.RunPurgeUseCase
GetSettingsUseCase *useCases.GetSettingsUseCase GetSettingsUseCase *useCases.GetSettingsUseCase
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
@@ -72,14 +129,64 @@ type UseCases struct {
ListAPIKeysUseCase *useCases.ListAPIKeysUseCase ListAPIKeysUseCase *useCases.ListAPIKeysUseCase
RevokeAPIKeyUseCase *useCases.RevokeAPIKeyUseCase RevokeAPIKeyUseCase *useCases.RevokeAPIKeyUseCase
AuthenticateAPIKeyUseCase *useCases.AuthenticateAPIKeyUseCase AuthenticateAPIKeyUseCase *useCases.AuthenticateAPIKeyUseCase
ListActivityUseCase *useCases.ListActivityUseCase
GetActivitySummaryUseCase *useCases.GetActivitySummaryUseCase
ExportActivityUseCase *useCases.ExportActivityUseCase
GetActivityRetentionUseCase *useCases.GetActivityRetentionUseCase
UpdateActivityRetentionUseCase *useCases.UpdateActivityRetentionUseCase
ListVariantsUseCase *useCases.ListVariantsUseCase
CreateVariantUseCase *useCases.CreateVariantUseCase
UpdateVariantUseCase *useCases.UpdateVariantUseCase
SetVariantEnabledUseCase *useCases.SetVariantEnabledUseCase
DeleteVariantUseCase *useCases.DeleteVariantUseCase
PurgeVariantUseCase *useCases.PurgeVariantUseCase
ServeVariantUseCase *useCases.ServeVariantUseCase
ListImageVariantsUseCase *useCases.ListImageVariantsUseCase
GenerateImageVariantUseCase *useCases.GenerateImageVariantUseCase
GenerateAllImageVariantsUseCase *useCases.GenerateAllImageVariantsUseCase
PurgeImageVariantUseCase *useCases.PurgeImageVariantUseCase
GenerateVariantsOnUploadUseCase *useCases.GenerateVariantsOnUploadUseCase
GetVariantCacheStatsUseCase *useCases.GetVariantCacheStatsUseCase
PurgeVariantCacheUseCase *useCases.PurgeVariantCacheUseCase
VariantUsageTracker *usage.Tracker
}
func (c *UseCases) RunSchedulers(ctx context.Context) {
var running sync.WaitGroup
for _, runner := range []*scheduler.Runner{c.PurgeScheduler, c.ActivityCleanupScheduler, c.RetentionScheduler} {
if runner == nil {
continue
}
running.Add(1)
go func() {
defer running.Done()
runner.Run(ctx)
}()
}
running.Wait()
} }
type Repositories struct { type Repositories struct {
ArticleRepo domainArticle.Repository ArticleRepo domainArticle.Repository
SiteRepo domainSite.Repository
ContentTypeRepo domainContentType.Repository
EntryRepo domainEntry.Repository
AuthorRepo domainAuthor.Repository
UserRepo domainUser.Repository UserRepo domainUser.Repository
RoleRepo domainRole.Repository
ImageRepo domainImage.Repository ImageRepo domainImage.Repository
Transactor domainTransaction.Transactor
VariantRepo domainImage.VariantRepository
VariantFileRepo domainImage.VariantFileRepository
VariantCacheRepo domainImage.VariantCacheRepository
VariantUsageRepo domainImage.VariantUsageRepository
VariantRenderer domainImage.VariantRenderer
InviteRepo domainInvite.Repository InviteRepo domainInvite.Repository
APIKeyRepo domainAPIKey.Repository APIKeyRepo domainAPIKey.Repository
SessionRepo domainSession.Repository
PurgeSettingsRepo domainPurge.Repository
AuditRepo domainAudit.Repository
AuditSettingsRepo domainAudit.SettingsRepository
MailRepo domainMail.Repository MailRepo domainMail.Repository
ReleaseFeed domainUpdate.ReleaseFeed ReleaseFeed domainUpdate.ReleaseFeed
UpdateStore domainUpdate.StatusStore UpdateStore domainUpdate.StatusStore
@@ -95,7 +202,6 @@ type Repositories struct {
} }
const ( const (
sqliteBusyTimeoutMs = 5000
updateStatusFileName = "update-status.json" updateStatusFileName = "update-status.json"
updateJobFileName = "update-job.json" updateJobFileName = "update-job.json"
updateHelperFileName = "update-helper.json" updateHelperFileName = "update-helper.json"
@@ -103,32 +209,94 @@ const (
backupsDirName = "backups" backupsDirName = "backups"
) )
func SQLiteDSN(dbName string) string { func imageStorageConfig(settings config.Settings) storage.Config {
return fmt.Sprintf("%s?_pragma=busy_timeout(%d)", dbName, sqliteBusyTimeoutMs) if settings.Storage.Driver == config.StorageDriverS3 {
s3 := settings.Storage.S3
return storage.Config{
Driver: storage.DriverS3,
S3: storage.S3Config{
Bucket: s3.Bucket,
Region: s3.Region,
Endpoint: s3.Endpoint,
AccessKeyID: s3.AccessKeyID,
SecretAccessKey: s3.SecretAccessKey,
KeyPrefix: s3.KeyPrefix,
PathStyle: s3.PathStyle,
TLS: s3.TLS,
},
}
}
return storage.Config{Driver: storage.DriverLocal, Location: settings.UploadDir}
} }
func getDb(dbName string, uploadDir string) *gorm.DB { func openImageStorage(settings config.Settings) domainImage.Storage {
if err := os.MkdirAll(filepath.Dir(dbName), os.ModePerm); err != nil { imageStorage, err := storage.Open(imageStorageConfig(settings))
panic("Unable to create necessary subdirectories: " + err.Error()) if err != nil {
panic("Unable to open the image storage: " + err.Error())
} }
db, err := gorm.Open(sqlite.Open(SQLiteDSN(dbName)), &gorm.Config{}) return imageStorage
}
func databaseConfig(settings config.Settings) database.Config {
switch settings.Database.Driver {
case config.DatabaseDriverMySQL:
return mysqlDatabaseConfig(settings.Database)
case config.DatabaseDriverPostgres:
return postgresDatabaseConfig(settings.Database)
case "", config.DatabaseDriverSQLite:
return database.Config{Driver: database.DriverSQLite, Source: settings.DBFile}
default:
return database.Config{Driver: settings.Database.Driver}
}
}
func mysqlDatabaseConfig(settings config.DatabaseSettings) database.Config {
return database.Config{
Driver: database.DriverMySQL,
Source: database.MySQLDSN(database.MySQLConfig{
Host: settings.Host,
Port: settings.Port,
User: settings.User,
Password: settings.Password,
Name: settings.Name,
TLS: settings.TLS,
}),
Pool: &database.PoolSettings{
MaxOpenConns: settings.MaxOpenConns,
MaxIdleConns: settings.MaxIdleConns,
ConnMaxLifetime: settings.ConnMaxLifetime,
},
}
}
func postgresDatabaseConfig(settings config.DatabaseSettings) database.Config {
return database.Config{
Driver: database.DriverPostgres,
Source: database.PostgresDSN(database.PostgresConfig{
Host: settings.Host,
Port: settings.Port,
User: settings.User,
Password: settings.Password,
Name: settings.Name,
SSLMode: settings.SSLMode,
}),
Pool: &database.PoolSettings{
MaxOpenConns: settings.MaxOpenConns,
MaxIdleConns: settings.MaxIdleConns,
ConnMaxLifetime: settings.ConnMaxLifetime,
},
}
}
func openDatabase(settings config.Settings) *database.Connection {
connection, err := database.Connect(databaseConfig(settings))
if err != nil { if err != nil {
panic("Unable to open the database: " + err.Error()) panic("Unable to open the database: " + err.Error())
} }
if err := database.Migrate(connection.DB, settings.PublicURL); err != nil {
if err := db.AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}); err != nil {
panic("Failed to migrate database: " + err.Error()) panic("Failed to migrate database: " + err.Error())
} }
return connection
if err := persistence.BackfillUserRoles(db); err != nil {
panic("Failed to backfill user roles: " + err.Error())
}
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
panic("Failed to backfill image metadata: " + err.Error())
}
return db
} }
func releasePublicKey() ed25519.PublicKey { func releasePublicKey() ed25519.PublicKey {
@@ -139,14 +307,31 @@ func releasePublicKey() ed25519.PublicKey {
return key return key
} }
func getRepositories(db *gorm.DB, settings config.Settings) *Repositories { func getRepositories(connection *database.Connection, settings config.Settings) *Repositories {
dataDir := filepath.Dir(settings.DBFile) db := connection.DB
dataDir := settings.DataDir()
variantFiles := persistence.NewVariantFileRepository(db)
return &Repositories{ return &Repositories{
ArticleRepo: persistence.NewArticleRepository(db), ArticleRepo: persistence.NewArticleRepository(db),
SiteRepo: persistence.NewSiteRepository(db),
ContentTypeRepo: persistence.NewContentTypeRepository(db),
EntryRepo: persistence.NewEntryRepository(db),
AuthorRepo: persistence.NewAuthorRepository(db),
UserRepo: persistence.NewUserRepository(db), UserRepo: persistence.NewUserRepository(db),
ImageRepo: persistence.NewImageRepository(db, settings.UploadDir), RoleRepo: persistence.NewRoleRepository(db),
ImageRepo: persistence.NewImageRepository(db),
Transactor: persistence.NewTransactor(db),
VariantRepo: persistence.NewVariantRepository(db),
VariantFileRepo: variantFiles,
VariantCacheRepo: variantFiles,
VariantUsageRepo: persistence.NewVariantUsageRepository(db),
VariantRenderer: imaging.NewVariantRenderer(),
InviteRepo: persistence.NewInviteRepository(db), InviteRepo: persistence.NewInviteRepository(db),
APIKeyRepo: persistence.NewAPIKeyRepository(db), APIKeyRepo: persistence.NewAPIKeyRepository(db),
SessionRepo: persistence.NewRevokedSessionRepository(db),
PurgeSettingsRepo: persistence.NewPurgeSettingsRepository(db),
AuditRepo: persistence.NewAuditEventRepository(db),
AuditSettingsRepo: persistence.NewAuditSettingsRepository(db),
MailRepo: mailer.NewMailRepository(settings.Smtp), MailRepo: mailer.NewMailRepository(settings.Smtp),
ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL), ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL),
UpdateStore: update.NewJSONStatusStore(filepath.Join(dataDir, updateStatusFileName)), UpdateStore: update.NewJSONStatusStore(filepath.Join(dataDir, updateStatusFileName)),
@@ -155,61 +340,136 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
AssetFetcher: update.NewHTTPAssetFetcher(settings.UpdateFeedURL), AssetFetcher: update.NewHTTPAssetFetcher(settings.UpdateFeedURL),
SignatureVerifier: update.NewEd25519SignatureVerifier(releasePublicKey()), SignatureVerifier: update.NewEd25519SignatureVerifier(releasePublicKey()),
StagedRunner: update.NewExecStagedBinaryRunner(), StagedRunner: update.NewExecStagedBinaryRunner(),
DatabaseBackup: update.NewSQLiteDatabaseBackup(db, filepath.Join(dataDir, backupsDirName), time.Now), DatabaseBackup: connection.NewBackup(filepath.Join(dataDir, backupsDirName), time.Now),
UpdateResults: update.NewJSONResultStore(filepath.Join(dataDir, domainUpdate.ResultFileName)), UpdateResults: update.NewJSONResultStore(filepath.Join(dataDir, domainUpdate.ResultFileName)),
RestartRequests: update.NewJSONRestartRequestStore(filepath.Join(dataDir, domainUpdate.RestartRequestFileName)), RestartRequests: update.NewJSONRestartRequestStore(filepath.Join(dataDir, domainUpdate.RestartRequestFileName)),
RunningInfo: update.NewJSONRunningInfoStore(filepath.Join(dataDir, domainUpdate.RunningInfoFileName)), RunningInfo: update.NewJSONRunningInfoStore(filepath.Join(dataDir, domainUpdate.RunningInfoFileName)),
} }
} }
func getUseCases(repos *Repositories, settings config.Settings) *UseCases { func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings config.Settings) *UseCases {
hasher := hashing.NewBcryptHasher() hasher := hashing.NewBcryptHasher()
renderer := markdown.NewConverter() renderer := markdown.NewConverter()
keyGenerator := hashing.NewSHA256KeyGenerator() keyGenerator := hashing.NewSHA256KeyGenerator()
recorder := auditing.NewRecorder(repos.AuditRepo, time.Now, log.Default())
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage, repos.VariantFileRepo, recorder, repos.ContentTypeRepo, repos.EntryRepo, repos.Transactor, log.Default())
runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default(), recorder, repos.SiteRepo, repos.AuthorRepo)
purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge, recorder)
activityCleanup := useCases.NewCleanupActivityUseCase(repos.AuditRepo, repos.AuditSettingsRepo, recorder)
usageTracker := usage.NewTracker(repos.VariantUsageRepo, time.Now)
serveVariant := useCases.NewServeVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, repos.ImageRepo, imageStorage, repos.VariantRenderer, useCases.ServeVariantLimits{MaxSourceBytes: settings.ImageVariantMaxSourceBytes, MaxSourcePixels: settings.ImageVariantMaxSourcePixels, MaxConcurrentRenders: settings.ImageVariantMaxConcurrentRenders}).WithUsageTracker(usageTracker)
return &UseCases{ return &UseCases{
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil), PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer), ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
RetentionScheduler: scheduler.NewSystemRetentionRunner(useCases.NewEnforceRetentionUseCase(repos.UserRepo, repos.InviteRepo, repos.SessionRepo), log.Default()),
Token: authtoken.New([]byte(settings.JWTSecret), authtoken.WithRevocationStore(useCases.NewSessionRevocationUseCase(repos.SessionRepo, time.Now))),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer), GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer), ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ImageRepo),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, recorder),
ResolveArticleSEOUseCase: useCases.NewResolveArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo),
CheckArticleSEOUseCase: useCases.NewCheckArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo),
ListSitemapUseCase: useCases.NewListSitemapUseCase(repos.ArticleRepo, repos.SiteRepo),
ListAuthorsUseCase: useCases.NewListAuthorsUseCase(repos.AuthorRepo, repos.UserRepo),
CreateAuthorUseCase: useCases.NewCreateAuthorUseCase(repos.AuthorRepo, repos.UserRepo, repos.ImageRepo, recorder),
CreateMyAuthorUseCase: useCases.NewCreateMyAuthorUseCase(repos.AuthorRepo, repos.UserRepo, recorder),
UpdateAuthorUseCase: useCases.NewUpdateAuthorUseCase(repos.AuthorRepo, repos.UserRepo, repos.ImageRepo, recorder),
DeleteAuthorUseCase: useCases.NewDeleteAuthorUseCase(repos.AuthorRepo, recorder),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(repos.AuthorRepo),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(repos.RoleRepo, repos.UserRepo),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(repos.RoleRepo),
ListPermissionsUseCase: useCases.NewListPermissionsUseCase(),
ListSitesUseCase: useCases.NewListSitesUseCase(repos.SiteRepo),
CreateSiteUseCase: useCases.NewCreateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
SetDefaultSiteUseCase: useCases.NewSetDefaultSiteUseCase(repos.SiteRepo, recorder),
DeleteSiteUseCase: useCases.NewDeleteSiteUseCase(repos.SiteRepo, recorder),
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(repos.ContentTypeRepo, repos.RoleRepo, time.Now),
ListContentTypesUseCase: useCases.NewListContentTypesUseCase(repos.ContentTypeRepo),
GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(repos.ContentTypeRepo),
UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo, time.Now),
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.RoleRepo),
ListRolesUseCase: useCases.NewListRolesUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo),
GetRoleUseCase: useCases.NewGetRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo),
CreateRoleUseCase: useCases.NewCreateRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder),
UpdateRoleUseCase: useCases.NewUpdateRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder, time.Now),
DeleteRoleUseCase: useCases.NewDeleteRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder),
ReassignRoleMembersUseCase: useCases.NewReassignRoleMembersUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder),
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
LookupUserNamesUseCase: useCases.NewLookupUserNamesUseCase(repos.UserRepo),
CreateEntryUseCase: useCases.NewCreateEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.ImageRepo, time.Now),
GetEntryUseCase: useCases.NewGetEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.ImageRepo),
ListEntriesUseCase: useCases.NewListEntriesUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.ImageRepo),
UpdateEntryUseCase: useCases.NewUpdateEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.ImageRepo, time.Now),
DeleteEntryUseCase: useCases.NewDeleteEntryUseCase(repos.ContentTypeRepo, repos.EntryRepo),
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo), GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL), CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, repos.RoleRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher, recorder),
VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher), VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher, recorder),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo), CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL), RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now), ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder),
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo), UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, recorder),
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo), ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL), CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, repos.ContentTypeRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo), CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo, recorder),
RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL), RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, repos.ContentTypeRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo), ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo),
ChangeUserRoleUseCase: useCases.NewChangeUserRoleUseCase(repos.UserRepo, repos.RoleRepo, repos.ContentTypeRepo, recorder),
TransferOwnershipUseCase: useCases.NewTransferOwnershipUseCase(repos.UserRepo, repos.RoleRepo, recorder),
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher), GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher), AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, hasher, recorder),
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo), SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo), CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder, imaging.NewMetadataStripper()),
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo), OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo), ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo, repos.VariantFileRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo), RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo), DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ArticleRepo),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo), MergeMediaUseCase: mergeMedia,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo),
RunPurgeUseCase: runPurge,
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version), GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now), GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now), CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now, recorder),
StartUpdateDownloadUseCase: startUpdateDownloadUseCase(repos, settings), StartUpdateDownloadUseCase: startUpdateDownloadUseCase(repos, settings, recorder),
RequestRestartUseCase: useCases.NewRequestRestartUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, time.Now), RequestRestartUseCase: useCases.NewRequestRestartUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, time.Now, recorder),
RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now), RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now), CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now, recorder),
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now), ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now),
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now), RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, time.Now, recorder),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, repos.UserRepo, time.Now), AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, repos.UserRepo, time.Now),
ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now, repos.SiteRepo, repos.AuthorRepo),
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now),
ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, time.Now),
UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, recorder, time.Now),
ListVariantsUseCase: useCases.NewListVariantsUseCase(repos.VariantRepo, repos.VariantFileRepo),
CreateVariantUseCase: useCases.NewCreateVariantUseCase(repos.VariantRepo, repos.VariantRenderer, time.Now, recorder),
UpdateVariantUseCase: useCases.NewUpdateVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, repos.VariantRenderer, time.Now, recorder),
SetVariantEnabledUseCase: useCases.NewSetVariantEnabledUseCase(repos.VariantRepo, repos.VariantFileRepo, time.Now, recorder),
DeleteVariantUseCase: useCases.NewDeleteVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder),
PurgeVariantUseCase: useCases.NewPurgeVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder),
ServeVariantUseCase: serveVariant,
ListImageVariantsUseCase: useCases.NewListImageVariantsUseCase(repos.ImageRepo, repos.VariantRepo, repos.VariantFileRepo, repos.VariantRenderer),
GenerateImageVariantUseCase: useCases.NewGenerateImageVariantUseCase(repos.ImageRepo, repos.VariantRepo, repos.VariantFileRepo, repos.VariantRenderer, serveVariant),
GenerateAllImageVariantsUseCase: useCases.NewGenerateAllImageVariantsUseCase(repos.ImageRepo, repos.VariantRepo, repos.VariantFileRepo, repos.VariantRenderer, serveVariant, recorder),
PurgeImageVariantUseCase: useCases.NewPurgeImageVariantUseCase(repos.ImageRepo, repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder),
GenerateVariantsOnUploadUseCase: useCases.NewGenerateVariantsOnUploadUseCase(repos.VariantRepo, serveVariant, log.Default()),
GetVariantCacheStatsUseCase: useCases.NewGetVariantCacheStatsUseCase(repos.VariantRepo, repos.VariantFileRepo, repos.VariantCacheRepo, repos.ImageRepo, repos.VariantUsageRepo, usageTracker, time.Now),
PurgeVariantCacheUseCase: useCases.NewPurgeVariantCacheUseCase(repos.VariantRepo, repos.ImageRepo, repos.VariantCacheRepo, repos.VariantFileRepo, imageStorage, time.Now, recorder),
VariantUsageTracker: usageTracker,
} }
} }
func startUpdateDownloadUseCase(repos *Repositories, settings config.Settings) *useCases.StartUpdateDownloadUseCase { func startUpdateDownloadUseCase(repos *Repositories, settings config.Settings, recorder domainAudit.Recorder) *useCases.StartUpdateDownloadUseCase {
return useCases.NewStartUpdateDownloadUseCase(useCases.StartUpdateDownloadDependencies{ return useCases.NewStartUpdateDownloadUseCase(useCases.StartUpdateDownloadDependencies{
Store: repos.UpdateStore, Store: repos.UpdateStore,
Jobs: repos.UpdateJobs, Jobs: repos.UpdateJobs,
@@ -220,28 +480,48 @@ func startUpdateDownloadUseCase(repos *Repositories, settings config.Settings) *
Verifier: repos.SignatureVerifier, Verifier: repos.SignatureVerifier,
Runner: repos.StagedRunner, Runner: repos.StagedRunner,
Backup: repos.DatabaseBackup, Backup: repos.DatabaseBackup,
Staging: update.NewOSStagingArea(filepath.Join(filepath.Dir(settings.DBFile), updatesDirName)), Staging: update.NewOSStagingArea(filepath.Join(settings.DataDir(), updatesDirName)),
Logger: log.Default(), Logger: log.Default(),
CurrentVersion: settings.Version, CurrentVersion: settings.Version,
Platform: domainUpdate.Platform{OS: runtime.GOOS, Arch: runtime.GOARCH}, Platform: domainUpdate.Platform{OS: runtime.GOOS, Arch: runtime.GOARCH},
FeedURL: settings.UpdateFeedURL, FeedURL: settings.UpdateFeedURL,
Now: time.Now, Now: time.Now,
Recorder: recorder,
}) })
} }
func InitContainer(settings config.Settings) (*UseCases, func() error) { func backfillImageMetadata(imageRepo domainImage.Repository, imageStorage domainImage.Storage, settings config.Settings) error {
db := getDb(settings.DBFile, settings.UploadDir) run := func(readable domainImage.Storage) error {
repos := getRepositories(db, settings) return useCases.NewBackfillImageMetadataUseCase(imageRepo, readable).Run()
return getUseCases(repos, settings), closeDb(db) }
if settings.Storage.Driver == config.StorageDriverS3 {
return storage.RunBackfillReportingReads(imageStorage, log.Default(), run)
}
return run(imageStorage)
} }
func closeDb(db *gorm.DB) func() error { func InitContainer(settings config.Settings) (*UseCases, func() error) {
return func() error { imageStorage := openImageStorage(settings)
sqlDb, err := db.DB() connection := openDatabase(settings)
if err != nil { repos := getRepositories(connection, settings)
return err if err := backfillImageMetadata(repos.ImageRepo, imageStorage, settings); err != nil {
panic("Failed to backfill image metadata: " + err.Error())
} }
return sqlDb.Close() container := getUseCases(repos, imageStorage, settings)
return container, container.runUsageTracker(connection.Close)
}
func (c *UseCases) runUsageTracker(closeDatabase func() error) func() error {
ctx, stop := context.WithCancel(context.Background())
stopped := make(chan struct{})
go func() {
defer close(stopped)
c.VariantUsageTracker.Run(ctx)
}()
return func() error {
stop()
<-stopped
return closeDatabase()
} }
} }
+249
View File
@@ -0,0 +1,249 @@
package activity
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"time"
)
const (
csvContentType = "text/csv; charset=utf-8"
csvContentDisposition = `attachment; filename="activity.csv"`
)
type Handler struct {
ListUseCase *useCases.ListActivityUseCase
SummaryUseCase *useCases.GetActivitySummaryUseCase
ExportUseCase *useCases.ExportActivityUseCase
}
type filterQuery struct {
Q string `validate:"max=200"`
Actor string `validate:"max=40"`
Category string `validate:"omitempty,oneof=articles media users api_keys auth system sites authors"`
Outcome string `validate:"omitempty,oneof=all success failed"`
Security string `validate:"omitempty,oneof=true"`
Range string `validate:"omitempty,oneof=today 7d 30d all custom"`
From string `validate:"max=10"`
To string `validate:"max=10"`
}
type listQuery struct {
filterQuery
Page int `validate:"gte=1"`
Size int `validate:"oneof=5 25 50 100"`
}
type actorResponse struct {
Type audit.ActorType `json:"type"`
ID *uint32 `json:"id"`
Name string `json:"name"`
}
type targetResponse struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Exists bool `json:"exists"`
}
type itemResponse struct {
ID uint64 `json:"id"`
CreatedAt time.Time `json:"created_at"`
Action string `json:"action"`
Category audit.Category `json:"category"`
Actor actorResponse `json:"actor"`
Target *targetResponse `json:"target"`
Outcome audit.Outcome `json:"outcome"`
FailureReason *string `json:"failure_reason"`
Security bool `json:"security"`
Source audit.Source `json:"source"`
IP *string `json:"ip"`
UserAgent *string `json:"user_agent"`
Params map[string]any `json:"params"`
Changes []audit.Change `json:"changes"`
}
type listResponse struct {
Items []itemResponse `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
func NewHandler(list *useCases.ListActivityUseCase, summary *useCases.GetActivitySummaryUseCase, export *useCases.ExportActivityUseCase) *Handler {
return &Handler{ListUseCase: list, SummaryUseCase: summary, ExportUseCase: export}
}
func readFilterQuery(values url.Values) filterQuery {
return filterQuery{
Q: values.Get("q"),
Actor: values.Get("actor"),
Category: values.Get("category"),
Outcome: values.Get("outcome"),
Security: values.Get("security"),
Range: values.Get("range"),
From: values.Get("from"),
To: values.Get("to"),
}
}
func readListQuery(values url.Values) (listQuery, error) {
page, err := intParam(values, "page", 1)
if err != nil {
return listQuery{}, err
}
size, err := intParam(values, "size", 25)
if err != nil {
return listQuery{}, err
}
return listQuery{filterQuery: readFilterQuery(values), Page: page, Size: size}, nil
}
func intParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
}
func toActivityQuery(query filterQuery) useCases.ActivityQuery {
return useCases.ActivityQuery{
Q: query.Q,
Actor: query.Actor,
Category: query.Category,
Outcome: query.Outcome,
Security: query.Security,
Range: query.Range,
From: query.From,
To: query.To,
}
}
func writeActivityError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, audit.ErrInvalidFilter):
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
case errors.Is(err, audit.ErrActorNotAllowed):
access.WriteMissingPermission(w, role.KeyActivityView)
case access.WriteIfMissingPermission(w, err):
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
query, err := readListQuery(r.URL.Query())
if err != nil || validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
viewerId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
activityQuery := toActivityQuery(query.filterQuery)
activityQuery.Page, activityQuery.Size = query.Page, query.Size
page, err := h.ListUseCase.ListActivity(useCases.ListActivityCommand{ViewerID: viewerId, Grants: access.Grants(r), Query: activityQuery})
if err != nil {
writeActivityError(w, err)
return
}
items := make([]itemResponse, 0, len(page.Items))
for _, entry := range page.Items {
items = append(items, toItemResponse(entry))
}
writeJSON(w, listResponse{Items: items, Total: page.Total, Page: page.Page, Size: page.Size})
}
func toItemResponse(entry useCases.ActivityEntry) itemResponse {
event := entry.Event
item := itemResponse{
ID: event.ID,
CreatedAt: event.CreatedAt,
Action: event.Action,
Category: event.Category,
Actor: actorResponse{Type: event.Actor.Type, ID: event.Actor.ID, Name: event.Actor.Name},
Outcome: event.Outcome,
FailureReason: event.FailureReason,
Security: event.Security,
Source: event.Source,
IP: event.IP,
UserAgent: event.UserAgent,
Params: event.Params,
Changes: event.Changes,
}
if item.Params == nil {
item.Params = map[string]any{}
}
if item.Changes == nil {
item.Changes = []audit.Change{}
}
if event.Target.Type != "" {
item.Target = &targetResponse{Type: event.Target.Type, ID: event.Target.ID, Name: event.Target.Name, Exists: entry.TargetExists}
}
return item
}
func (h *Handler) Summary(w http.ResponseWriter, r *http.Request) {
query := readFilterQuery(r.URL.Query())
if validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
summary, err := h.SummaryUseCase.GetActivitySummary(toActivityQuery(query))
if err != nil {
writeActivityError(w, err)
return
}
writeJSON(w, summary)
}
func (h *Handler) Export(w http.ResponseWriter, r *http.Request) {
query := readFilterQuery(r.URL.Query())
if validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
viewerId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
prepared, err := h.ExportUseCase.Prepare(useCases.ExportActivityCommand{ViewerID: viewerId, Grants: access.Grants(r), Query: toActivityQuery(query)})
if err != nil {
writeActivityError(w, err)
return
}
w.Header().Set("Content-Type", csvContentType)
w.Header().Set("Content-Disposition", csvContentDisposition)
w.Header().Set("Cache-Control", "no-store")
_ = prepared.WriteCSV(w)
}
func writeJSON(w http.ResponseWriter, payload any) {
body, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
+8 -3
View File
@@ -1,6 +1,7 @@
package apikey package apikey
import ( import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
@@ -71,7 +72,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
return return
} }
created, err := h.CreateUseCase.CreateAPIKey(useCases.CreateAPIKeyCommand{ created, err := h.CreateUseCase.CreateAPIKey(r.Context(), useCases.CreateAPIKeyCommand{
UserID: userId, UserID: userId,
Name: request.Name, Name: request.Name,
ExpiresAt: request.ExpiresAt, ExpiresAt: request.ExpiresAt,
@@ -109,8 +110,11 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
return return
} }
entries, err := h.ListUseCase.ListAPIKeys(userId) entries, err := h.ListUseCase.ListAPIKeys(userId, access.Grants(r))
if err != nil { if err != nil {
if access.WriteIfMissingPermission(w, err) {
return
}
if errors.Is(err, user.ErrUserNotFound) { if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return return
@@ -161,8 +165,9 @@ func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := h.RevokeUseCase.RevokeAPIKey(userId, uint32(id)); err != nil { if err := h.RevokeUseCase.RevokeAPIKey(r.Context(), userId, uint32(id), access.Grants(r)); err != nil {
switch { switch {
case access.WriteIfMissingPermission(w, err):
case errors.Is(err, domain.ErrAPIKeyNotFound): case errors.Is(err, domain.ErrAPIKeyNotFound):
http.Error(w, apperr.APIKeyNotFoundErrorMessage, http.StatusNotFound) http.Error(w, apperr.APIKeyNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, user.ErrUserNotFound): case errors.Is(err, user.ErrUserNotFound):
+364 -54
View File
@@ -1,20 +1,28 @@
package article package article
import ( import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/article" domain "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"log" "log"
"net/http" "net/http"
"net/url"
"slices"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
playground "github.com/go-playground/validator/v10"
) )
type Handler struct { type Handler struct {
@@ -23,57 +31,131 @@ type Handler struct {
ListUseCase *useCases.ListArticlesUseCase ListUseCase *useCases.ListArticlesUseCase
DeleteUseCase *useCases.DeleteArticleUseCase DeleteUseCase *useCases.DeleteArticleUseCase
UpdateUseCase *useCases.UpdateArticleUseCase UpdateUseCase *useCases.UpdateArticleUseCase
GetUserUseCase *useCases.GetUserUseCase
} }
type PostArticle struct { type PostArticle struct {
Title string `json:"title" validate:"required,min=3,max=200"` Title string `json:"title" validate:"required,min=3,max=200"`
Body string `json:"body" validate:"required,max=10000"` Body string `json:"body"`
} }
func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase, update *useCases.UpdateArticleUseCase, getUser *useCases.GetUserUseCase) *Handler { func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase, update *useCases.UpdateArticleUseCase) *Handler {
return &Handler{ return &Handler{
CreateUseCase: create, CreateUseCase: create,
GetUseCase: get, GetUseCase: get,
ListUseCase: list, ListUseCase: list,
DeleteUseCase: delete, DeleteUseCase: delete,
UpdateUseCase: update, UpdateUseCase: update,
GetUserUseCase: getUser,
} }
} }
type articleResponse struct { type baseArticleResponse struct {
ID uint32 `json:"id"` ID uint32 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Body json.RawMessage `json:"body"` Body json.RawMessage `json:"body"`
BodyFormat domain.BodyFormat `json:"body_format"` BodyFormat domain.BodyFormat `json:"body_format"`
IsOnline bool `json:"is_online"` IsOnline bool `json:"is_online"`
SiteID uint32 `json:"site_id"`
CreatedBy *uint32 `json:"created_by,omitempty"`
Authors []author.Byline `json:"authors"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
func toArticleResponse(article domain.Article, format domain.BodyFormat) (articleResponse, error) { type articleResponse struct {
baseArticleResponse
Slug string `json:"slug"`
Excerpt string `json:"excerpt"`
SEOTitle string `json:"seo_title"`
SEODescription string `json:"seo_description"`
CoverImageID *uint32 `json:"cover_image_id"`
CoverImageAlt string `json:"cover_image_alt"`
SocialTitle string `json:"social_title"`
SocialDescription string `json:"social_description"`
SocialImageID *uint32 `json:"social_image_id"`
CanonicalURL string `json:"canonical_url"`
Noindex bool `json:"noindex"`
SEODismissed []string `json:"seo_dismissed"`
}
type listItemResponse struct {
baseArticleResponse
Excerpt string `json:"excerpt"`
Slug string `json:"slug"`
SEOStatus string `json:"seo_status"`
SEOSuggestions int `json:"seo_suggestions"`
}
func creatorFor(article domain.Article, viewer access.Caller) *uint32 {
if viewer.UserID == 0 {
return nil
}
createdBy := article.CreatedBy
return &createdBy
}
func toBaseResponse(article domain.Article, format domain.BodyFormat, viewer access.Caller) (baseArticleResponse, error) {
body := json.RawMessage(article.Body) body := json.RawMessage(article.Body)
if format != domain.BodyFormatJSON { if format != domain.BodyFormatJSON {
encoded, err := json.Marshal(article.Body) encoded, err := json.Marshal(article.Body)
if err != nil { if err != nil {
return articleResponse{}, err return baseArticleResponse{}, err
} }
body = encoded body = encoded
} }
return articleResponse{ authors := article.Authors
if authors == nil {
authors = []author.Byline{}
}
return baseArticleResponse{
ID: article.ID, ID: article.ID,
Title: article.Title, Title: article.Title,
Body: body, Body: body,
BodyFormat: format, BodyFormat: format,
IsOnline: article.IsOnline, IsOnline: article.IsOnline,
SiteID: article.SiteID,
CreatedBy: creatorFor(article, viewer),
Authors: authors,
CreatedAt: article.CreatedAt, CreatedAt: article.CreatedAt,
UpdatedAt: article.UpdatedAt, UpdatedAt: article.UpdatedAt,
}, nil }, nil
} }
func encodeArticle(article domain.Article, format domain.BodyFormat) ([]byte, error) { func toArticleResponse(article domain.Article, format domain.BodyFormat, viewer access.Caller) (articleResponse, error) {
response, err := toArticleResponse(article, format) base, err := toBaseResponse(article, format, viewer)
if err != nil {
return articleResponse{}, err
}
dismissed := article.SEODismissed
if dismissed == nil {
dismissed = []string{}
}
return articleResponse{
baseArticleResponse: base,
Slug: article.Slug,
Excerpt: article.Excerpt,
SEOTitle: article.SEOTitle,
SEODescription: article.SEODescription,
CoverImageID: article.CoverImageID,
CoverImageAlt: article.CoverImageAlt,
SocialTitle: article.SocialTitle,
SocialDescription: article.SocialDescription,
SocialImageID: article.SocialImageID,
CanonicalURL: article.CanonicalURL,
Noindex: article.Noindex,
SEODismissed: dismissed,
}, nil
}
func toListItemResponse(article domain.Article, summary domain.SEOSummary, format domain.BodyFormat, viewer access.Caller) (listItemResponse, error) {
base, err := toBaseResponse(article, format, viewer)
if err != nil {
return listItemResponse{}, err
}
return listItemResponse{baseArticleResponse: base, Excerpt: article.Excerpt, Slug: article.Slug, SEOStatus: summary.Status, SEOSuggestions: summary.Suggestions}, nil
}
func encodeArticle(article domain.Article, format domain.BodyFormat, viewer access.Caller) ([]byte, error) {
response, err := toArticleResponse(article, format, viewer)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -106,12 +188,7 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
return return
} }
var article domain.Article article, err := h.GetUseCase.GetArticle(id, format, access.CallerOf(r))
if h.callerIsVerified(r) {
article, err = h.GetUseCase.GetArticle(id, format)
} else {
article, err = h.GetUseCase.GetOnlineArticle(id, format)
}
if err != nil { if err != nil {
if errors.Is(err, domain.ErrArticleNotFound) { if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound) http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
@@ -121,7 +198,7 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
return return
} }
articleJson, err := encodeArticle(article, format) articleJson, err := encodeArticle(article, format, access.CallerOf(r))
if err != nil { if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError) http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return return
@@ -149,33 +226,90 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
return return
} }
createdArticle, err := h.CreateUseCase.CreateArticle(useCases.CreateArticleCommand{ createdArticle, err := h.CreateUseCase.CreateArticle(r.Context(), useCases.CreateArticleCommand{
Title: localArticle.Title, Title: localArticle.Title,
Body: localArticle.Body, Body: localArticle.Body,
Format: format, Format: format,
Caller: access.CallerOf(r),
}) })
if err != nil { if err != nil {
if errors.Is(err, domain.ErrInvalidTitle) { if errors.Is(err, domain.ErrInvalidTitle) {
http.Error(w, apperr.ArticleTitleErrorMessage, http.StatusBadRequest) http.Error(w, apperr.ArticleTitleErrorMessage, http.StatusBadRequest)
return return
} }
if !writeKnownArticleError(w, err) {
http.Error(w, "Failed to create article", http.StatusInternalServerError) http.Error(w, "Failed to create article", http.StatusInternalServerError)
}
return return
} }
writeArticleJsonResponse(w, http.StatusCreated, createdArticle, format) writeArticleJsonResponse(w, r, http.StatusCreated, createdArticle, format)
} }
func writeArticleErrorResponse(w http.ResponseWriter, err error) { func writeArticleErrorResponse(w http.ResponseWriter, err error) {
if errors.Is(err, domain.ErrArticleNotFound) { if !writeKnownArticleError(w, err) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
} }
}
func writeArticleJsonResponse(w http.ResponseWriter, status int, article domain.Article, format domain.BodyFormat) { func writeKnownArticleError(w http.ResponseWriter, err error) bool {
articleJson, err := encodeArticle(article, format) if access.WriteIfMissingPermission(w, err) {
return true
}
if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return true
}
if errors.Is(err, site.ErrSiteNotFound) {
http.Error(w, apperr.SiteNotFoundErrorMessage, http.StatusNotFound)
return true
}
if errors.Is(err, author.ErrAuthorNotFound) {
http.Error(w, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound)
return true
}
if errors.Is(err, domain.ErrSlugTaken) {
writeSlugTaken(w, err)
return true
}
if errors.Is(err, domain.ErrInvalidSlug) {
http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest)
return true
}
if errors.Is(err, domain.ErrInvalidCanonicalURL) {
http.Error(w, apperr.InvalidURLErrorMessage, http.StatusUnprocessableEntity)
return true
}
if errors.Is(err, image.ErrImageNotFound) {
http.Error(w, apperr.ArticleImageNotFoundErrorMessage, http.StatusBadRequest)
return true
}
return false
}
func writeSlugTaken(w http.ResponseWriter, err error) {
var conflict *domain.SlugTakenError
if errors.As(err, &conflict) && conflict.Suggestion != "" {
w.Header().Set(apperr.SlugSuggestionHeader, conflict.Suggestion)
}
http.Error(w, apperr.ArticleSlugTakenErrorMessage, http.StatusConflict)
}
func writeValidationError(w http.ResponseWriter, err error) {
var failures playground.ValidationErrors
if errors.As(err, &failures) {
for _, failure := range failures {
if failure.Tag() == validator.ArticleSlugTag {
http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest)
return
}
}
}
http.Error(w, err.Error(), http.StatusBadRequest)
}
func writeArticleJsonResponse(w http.ResponseWriter, r *http.Request, status int, article domain.Article, format domain.BodyFormat) {
articleJson, err := encodeArticle(article, format, access.CallerOf(r))
if err != nil { if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError) http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return return
@@ -201,17 +335,108 @@ func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline b
return return
} }
updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(id, isOnline, format) updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(r.Context(), id, access.CallerOf(r), isOnline, format)
if err != nil { if err != nil {
writeArticleErrorResponse(w, err) writeArticleErrorResponse(w, err)
return return
} }
writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format) writeArticleJsonResponse(w, r, http.StatusOK, updatedArticle, format)
}
type optionalImageID struct {
Set bool
Value *uint32 `validate:"omitempty,gte=1"`
}
func (o *optionalImageID) UnmarshalJSON(data []byte) error {
o.Set = true
if string(data) == "null" {
o.Value = nil
return nil
}
var value uint32
if err := json.Unmarshal(data, &value); err != nil {
return err
}
o.Value = &value
return nil
}
func (o optionalImageID) toNullable() domain.Nullable[uint32] {
return domain.Nullable[uint32]{Set: o.Set, Value: o.Value}
} }
type UpdateArticleBody struct { type UpdateArticleBody struct {
Body string `json:"body" validate:"required,max=10000"` Body *string `json:"body"`
SiteID *uint32 `json:"site_id" validate:"omitempty,gte=1"`
AuthorIDs *[]uint32 `json:"author_ids" validate:"omitempty,dive,gte=1"`
Slug *string `json:"slug" validate:"omitempty,articleslug"`
Excerpt *string `json:"excerpt" validate:"omitempty,max=300"`
SEOTitle *string `json:"seo_title" validate:"omitempty,max=120"`
SEODescription *string `json:"seo_description" validate:"omitempty,max=320"`
CoverImageID optionalImageID `json:"cover_image_id"`
CoverImageAlt *string `json:"cover_image_alt" validate:"omitempty,max=200"`
SocialTitle *string `json:"social_title" validate:"omitempty,max=120"`
SocialDescription *string `json:"social_description" validate:"omitempty,max=320"`
SocialImageID optionalImageID `json:"social_image_id"`
CanonicalURL *string `json:"canonical_url" validate:"omitempty,max=2048"`
Noindex *bool `json:"noindex"`
SEODismissed *[]string `json:"seo_dismissed" validate:"omitempty,dive,oneof=title_long title_empty desc_short desc_long cover_alt cover_none slug_long no_au no_h body_alt short"`
}
var nonNullableArticleFields = []string{"body", "site_id", "author_ids", "slug", "excerpt", "seo_title", "seo_description", "cover_image_alt", "social_title", "social_description", "canonical_url", "noindex", "seo_dismissed"}
var nullableArticleFields = []string{"cover_image_id", "social_image_id"}
func hasDuplicates(ids []uint32) bool {
seen := make(map[uint32]bool, len(ids))
for _, id := range ids {
if seen[id] {
return true
}
seen[id] = true
}
return false
}
func countKnownFields(fields map[string]json.RawMessage, nonNullable []string) (int, bool) {
known := 0
for name, value := range fields {
switch {
case slices.Contains(nonNullable, name):
if string(value) == "null" {
return 0, false
}
known++
case slices.Contains(nullableArticleFields, name):
known++
}
}
return known, true
}
func readUpdateArticleBody(r *http.Request) (UpdateArticleBody, bool) {
raw, err := io.ReadAll(r.Body)
if err != nil {
return UpdateArticleBody{}, false
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
return UpdateArticleBody{}, false
}
known, ok := countKnownFields(fields, nonNullableArticleFields)
if !ok || known == 0 {
return UpdateArticleBody{}, false
}
var request UpdateArticleBody
if err := json.Unmarshal(raw, &request); err != nil {
return UpdateArticleBody{}, false
}
if request.AuthorIDs != nil && hasDuplicates(*request.AuthorIDs) {
return UpdateArticleBody{}, false
}
return request, true
} }
func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) { func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
@@ -226,24 +451,41 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
return return
} }
var localArticle UpdateArticleBody request, ok := readUpdateArticleBody(r)
if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil { if !ok {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return return
} }
if err := validator.Validate.Struct(localArticle); err != nil { if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) writeValidationError(w, err)
return return
} }
updatedArticle, err := h.UpdateUseCase.UpdateBody(id, localArticle.Body, format) updatedArticle, err := h.UpdateUseCase.Update(r.Context(), id, access.CallerOf(r), useCases.UpdateArticleCommand{
Body: request.Body,
SiteID: request.SiteID,
AuthorIDs: request.AuthorIDs,
Slug: request.Slug,
Excerpt: request.Excerpt,
SEOTitle: request.SEOTitle,
SEODescription: request.SEODescription,
CoverImageID: request.CoverImageID.toNullable(),
CoverImageAlt: request.CoverImageAlt,
SocialTitle: request.SocialTitle,
SocialDescription: request.SocialDescription,
SocialImageID: request.SocialImageID.toNullable(),
CanonicalURL: request.CanonicalURL,
Noindex: request.Noindex,
SEODismissed: request.SEODismissed,
Format: format,
})
if err != nil { if err != nil {
writeArticleErrorResponse(w, err) writeArticleErrorResponse(w, err)
return return
} }
writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format) writeArticleJsonResponse(w, r, http.StatusOK, updatedArticle, format)
} }
func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) { func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) {
@@ -254,18 +496,73 @@ func (h *Handler) UnpublishArticle(w http.ResponseWriter, r *http.Request) {
h.setIsOnline(w, r, false) h.setIsOnline(w, r, false)
} }
func (h *Handler) callerIsVerified(r *http.Request) bool { const (
userId, ok := claims.UserId(r) defaultListPage = 1
if !ok { defaultListSize = 25
return false )
type listQuery struct {
Page int `validate:"gte=1"`
Size int `validate:"oneof=5 25 50 100"`
Q string `validate:"max=200"`
Status string `validate:"omitempty,oneof=online draft"`
Site *string `validate:"omitempty,min=1"`
SEO string `validate:"omitempty,oneof=ready suggestions hidden"`
Sort string `validate:"oneof=title:asc title:desc status:asc status:desc created_at:asc created_at:desc updated_at:asc updated_at:desc"`
} }
currentUser, err := h.GetUserUseCase.GetUser(userId) const defaultListSort = "updated_at:desc"
func parseSort(value string) domain.Sort {
field, direction, _ := strings.Cut(value, ":")
return domain.Sort{Field: domain.SortField(field), Direction: domain.SortDirection(direction)}
}
type listResponse struct {
Items []listItemResponse `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
Counts domain.Counts `json:"counts"`
}
func intQueryParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
}
func readListQuery(values url.Values) (listQuery, error) {
page, err := intQueryParam(values, "page", defaultListPage)
if err != nil { if err != nil {
return false return listQuery{}, err
} }
size, err := intQueryParam(values, "size", defaultListSize)
return currentUser.IsVerified if err != nil {
return listQuery{}, err
}
status := values.Get("status")
if values.Has("status") && status == "" {
return listQuery{}, errors.New("empty status")
}
sortValue := defaultListSort
if values.Has("sort") {
sortValue = values.Get("sort")
}
var siteKey *string
if values.Has("site") {
key := values.Get("site")
if key == "" {
return listQuery{}, errors.New("empty site")
}
siteKey = &key
}
seoStatus := values.Get("seo")
if values.Has("seo") && seoStatus == "" {
return listQuery{}, errors.New("empty seo")
}
return listQuery{Page: page, Size: size, Q: values.Get("q"), Status: status, Sort: sortValue, Site: siteKey, SEO: seoStatus}, nil
} }
func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
@@ -274,21 +571,31 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
return return
} }
var articles []domain.Article query, err := readListQuery(r.URL.Query())
var err error if err != nil || validator.Validate.Struct(query) != nil {
if h.callerIsVerified(r) { http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
articles, err = h.ListUseCase.ListAllArticles(format) return
} else {
articles, err = h.ListUseCase.ListOnlineArticles(format)
} }
page, err := h.ListUseCase.ListArticlesPage(useCases.ListArticlesCommand{
Format: format,
Caller: access.CallerOf(r),
Page: query.Page,
Size: query.Size,
Q: query.Q,
Status: query.Status,
Sort: parseSort(query.Sort),
SiteKey: query.Site,
SEO: query.SEO,
})
if err != nil { if err != nil {
http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError) http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError)
return return
} }
responses := make([]articleResponse, 0, len(articles)) responses := make([]listItemResponse, 0, len(page.Items))
for _, article := range articles { for _, article := range page.Items {
response, err := toArticleResponse(article, format) response, err := toListItemResponse(article, page.SEO[article.ID], format, access.CallerOf(r))
if err != nil { if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError) http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return return
@@ -296,7 +603,7 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
responses = append(responses, response) responses = append(responses, response)
} }
articlesJson, err := json.Marshal(responses) articlesJson, err := json.Marshal(listResponse{Items: responses, Total: page.Total, Page: page.Page, Size: page.Size, Counts: page.Counts})
if err != nil { if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError) http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return return
@@ -314,11 +621,14 @@ func (h *Handler) DeleteArticle(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := h.DeleteUseCase.DeleteArticle(id); err != nil { if err := h.DeleteUseCase.DeleteArticle(r.Context(), id, access.CallerOf(r)); err != nil {
if errors.Is(err, domain.ErrArticleNotFound) { if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound) http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return return
} }
if access.WriteIfMissingPermission(w, err) {
return
}
http.Error(w, "Failed to delete article", http.StatusInternalServerError) http.Error(w, "Failed to delete article", http.StatusInternalServerError)
return return
} }
+127
View File
@@ -0,0 +1,127 @@
package article
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json"
"io"
"log"
"net/http"
)
type SEOHandler struct {
ResolveUseCase *useCases.ResolveArticleSEOUseCase
CheckUseCase *useCases.CheckArticleSEOUseCase
}
func NewSEOHandler(resolve *useCases.ResolveArticleSEOUseCase, check *useCases.CheckArticleSEOUseCase) *SEOHandler {
return &SEOHandler{ResolveUseCase: resolve, CheckUseCase: check}
}
type CheckArticleSEOBody struct {
Body *string `json:"body"`
Slug *string `json:"slug" validate:"omitempty,articleslug"`
Excerpt *string `json:"excerpt" validate:"omitempty,max=300"`
SEOTitle *string `json:"seo_title" validate:"omitempty,max=120"`
SEODescription *string `json:"seo_description" validate:"omitempty,max=320"`
CoverImageID optionalImageID `json:"cover_image_id"`
CoverImageAlt *string `json:"cover_image_alt" validate:"omitempty,max=200"`
SocialTitle *string `json:"social_title" validate:"omitempty,max=120"`
SocialDescription *string `json:"social_description" validate:"omitempty,max=320"`
SocialImageID optionalImageID `json:"social_image_id"`
CanonicalURL *string `json:"canonical_url" validate:"omitempty,max=2048"`
Noindex *bool `json:"noindex"`
SEODismissed *[]string `json:"seo_dismissed" validate:"omitempty,dive,oneof=title_long title_empty desc_short desc_long cover_alt cover_none slug_long no_au no_h body_alt short"`
}
var checkNonNullableFields = []string{"body", "slug", "excerpt", "seo_title", "seo_description", "cover_image_alt", "social_title", "social_description", "canonical_url", "noindex", "seo_dismissed"}
func readCheckBody(r *http.Request) (CheckArticleSEOBody, bool) {
raw, err := io.ReadAll(r.Body)
if err != nil {
return CheckArticleSEOBody{}, false
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil || fields == nil {
return CheckArticleSEOBody{}, false
}
known, ok := countKnownFields(fields, checkNonNullableFields)
if !ok || known != len(fields) {
return CheckArticleSEOBody{}, false
}
var request CheckArticleSEOBody
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
return CheckArticleSEOBody{}, false
}
return request, true
}
func (h *SEOHandler) Resolve(w http.ResponseWriter, r *http.Request) {
id, err := parseArticleId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
resolved, err := h.ResolveUseCase.Resolve(id, access.CallerOf(r))
if err != nil {
writeArticleErrorResponse(w, err)
return
}
writeSEOJson(w, resolved)
}
func (h *SEOHandler) Check(w http.ResponseWriter, r *http.Request) {
id, err := parseArticleId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
request, ok := readCheckBody(r)
if !ok {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
writeValidationError(w, err)
return
}
checked, err := h.CheckUseCase.Check(id, access.CallerOf(r), useCases.CheckArticleSEOCommand{
Body: request.Body,
Slug: request.Slug,
Excerpt: request.Excerpt,
SEOTitle: request.SEOTitle,
SEODescription: request.SEODescription,
CoverImageID: request.CoverImageID.toNullable(),
CoverImageAlt: request.CoverImageAlt,
SocialTitle: request.SocialTitle,
SocialDescription: request.SocialDescription,
SocialImageID: request.SocialImageID.toNullable(),
CanonicalURL: request.CanonicalURL,
Noindex: request.Noindex,
SEODismissed: request.SEODismissed,
})
if err != nil {
writeArticleErrorResponse(w, err)
return
}
writeSEOJson(w, checked)
}
func writeSEOJson(w http.ResponseWriter, payload any) {
encoded, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if _, err := w.Write(encoded); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
+6 -5
View File
@@ -2,6 +2,7 @@ package auth
import ( import (
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/authtoken"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/invite" "RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
@@ -9,12 +10,10 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
"github.com/go-chi/jwtauth/v5"
) )
type AcceptInviteHandler struct { type AcceptInviteHandler struct {
Token *jwtauth.JWTAuth Token *authtoken.Auth
GetInviteUseCase *useCases.GetInviteUseCase GetInviteUseCase *useCases.GetInviteUseCase
AcceptInviteUseCase *useCases.AcceptInviteUseCase AcceptInviteUseCase *useCases.AcceptInviteUseCase
IsProduction bool IsProduction bool
@@ -30,7 +29,7 @@ type inviteEmailResponse struct {
Email string `json:"email"` Email string `json:"email"`
} }
func NewAcceptInviteHandler(getInvite *useCases.GetInviteUseCase, acceptInvite *useCases.AcceptInviteUseCase, token *jwtauth.JWTAuth, isProduction bool) *AcceptInviteHandler { func NewAcceptInviteHandler(getInvite *useCases.GetInviteUseCase, acceptInvite *useCases.AcceptInviteUseCase, token *authtoken.Auth, isProduction bool) *AcceptInviteHandler {
return &AcceptInviteHandler{ return &AcceptInviteHandler{
Token: token, Token: token,
GetInviteUseCase: getInvite, GetInviteUseCase: getInvite,
@@ -67,7 +66,7 @@ func (h *AcceptInviteHandler) AcceptInvite(w http.ResponseWriter, r *http.Reques
return return
} }
createdUser, err := h.AcceptInviteUseCase.AcceptInvite(useCases.AcceptInviteCommand{ createdUser, err := h.AcceptInviteUseCase.AcceptInvite(r.Context(), useCases.AcceptInviteCommand{
Code: request.Code, Code: request.Code,
Username: request.Username, Username: request.Username,
Password: request.Password, Password: request.Password,
@@ -96,6 +95,8 @@ func writeAcceptInviteError(w http.ResponseWriter, err error) {
http.Error(w, apperr.UsernameTakenErrorMessage, http.StatusConflict) http.Error(w, apperr.UsernameTakenErrorMessage, http.StatusConflict)
case errors.Is(err, invite.ErrEmailHasAccount): case errors.Is(err, invite.ErrEmailHasAccount):
http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict) http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict)
case errors.Is(err, user.ErrPasswordTooLong):
http.Error(w, apperr.PasswordTooLongErrorMessage, http.StatusBadRequest)
default: default:
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
} }
+24 -12
View File
@@ -2,6 +2,7 @@ package auth
import ( import (
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/authtoken"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
@@ -9,12 +10,10 @@ import (
"errors" "errors"
"net/http" "net/http"
"time" "time"
"github.com/go-chi/jwtauth/v5"
) )
type LoginHandler struct { type LoginHandler struct {
Token *jwtauth.JWTAuth Token *authtoken.Auth
CreateUseCase *useCases.CreateUserUseCase CreateUseCase *useCases.CreateUserUseCase
AuthenticateUseCase *useCases.AuthenticateUserUseCase AuthenticateUseCase *useCases.AuthenticateUserUseCase
IsProduction bool IsProduction bool
@@ -31,7 +30,7 @@ type LoginCredentials struct {
Password string `json:"password" validate:"required,min=8"` Password string `json:"password" validate:"required,min=8"`
} }
func NewLoginHandler(token *jwtauth.JWTAuth, create *useCases.CreateUserUseCase, authenticate *useCases.AuthenticateUserUseCase, isProduction bool) *LoginHandler { func NewLoginHandler(token *authtoken.Auth, create *useCases.CreateUserUseCase, authenticate *useCases.AuthenticateUserUseCase, isProduction bool) *LoginHandler {
return &LoginHandler{ return &LoginHandler{
Token: token, Token: token,
CreateUseCase: create, CreateUseCase: create,
@@ -42,27 +41,29 @@ func NewLoginHandler(token *jwtauth.JWTAuth, create *useCases.CreateUserUseCase,
func removeJwtCookie(w http.ResponseWriter, isProduction bool) { func removeJwtCookie(w http.ResponseWriter, isProduction bool) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "jwt", Name: authtoken.CookieName,
Value: "", Value: "",
Expires: time.Now(), Expires: time.Now(),
MaxAge: -1, MaxAge: -1,
Secure: isProduction, Secure: isProduction,
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Path: "/", Path: "/",
}) })
} }
func setJwtCookie(w http.ResponseWriter, token *jwtauth.JWTAuth, isProduction bool, u user.User) error { func setJwtCookie(w http.ResponseWriter, token *authtoken.Auth, isProduction bool, u user.User) error {
_, tokenString, err := token.Encode(map[string]any{"user_id": u.ID, "token_version": u.TokenVersion}) tokenString, err := token.Encode(map[string]any{"user_id": u.ID, "token_version": u.TokenVersion})
if err != nil { if err != nil {
return err return err
} }
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "jwt", Name: authtoken.CookieName,
Value: tokenString, Value: tokenString,
Expires: time.Now().Add(24 * time.Hour), Expires: time.Now().Add(authtoken.SessionLifetime),
Secure: isProduction, Secure: isProduction,
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Path: "/", Path: "/",
}) })
return nil return nil
@@ -87,7 +88,7 @@ func (h *LoginHandler) Login(w http.ResponseWriter, r *http.Request) {
return return
} }
dbUser, err := h.AuthenticateUseCase.Authenticate(credentials.Username, credentials.Password) dbUser, err := h.AuthenticateUseCase.Authenticate(r.Context(), credentials.Username, credentials.Password)
if err != nil { if err != nil {
http.Error(w, apperr.LogsErrorMessage, http.StatusForbidden) http.Error(w, apperr.LogsErrorMessage, http.StatusForbidden)
return return
@@ -113,7 +114,7 @@ func (h *LoginHandler) Register(w http.ResponseWriter, r *http.Request) {
return return
} }
createdUser, err := h.CreateUseCase.CreateUser(useCases.CreateUserCommand{ createdUser, err := h.CreateUseCase.CreateUser(r.Context(), useCases.CreateUserCommand{
Username: credentials.Username, Username: credentials.Username,
Password: credentials.Password, Password: credentials.Password,
Email: credentials.Email, Email: credentials.Email,
@@ -123,6 +124,10 @@ func (h *LoginHandler) Register(w http.ResponseWriter, r *http.Request) {
http.Error(w, apperr.RegistrationClosedErrorMessage, http.StatusForbidden) http.Error(w, apperr.RegistrationClosedErrorMessage, http.StatusForbidden)
return return
} }
if errors.Is(err, user.ErrPasswordTooLong) {
http.Error(w, apperr.PasswordTooLongErrorMessage, http.StatusBadRequest)
return
}
http.Error(w, apperr.RegistrationFailedErrorMessage, http.StatusInternalServerError) http.Error(w, apperr.RegistrationFailedErrorMessage, http.StatusInternalServerError)
return return
} }
@@ -139,8 +144,15 @@ func (h *LoginHandler) RegistrationStatus(w http.ResponseWriter, _ *http.Request
_, _ = w.Write(body) _, _ = w.Write(body)
} }
func (h *LoginHandler) Logout(w http.ResponseWriter, _ *http.Request) { func (h *LoginHandler) Logout(w http.ResponseWriter, r *http.Request) {
removeJwtCookie(w, h.IsProduction) removeJwtCookie(w, h.IsProduction)
if claims, err := authtoken.FromContext(r.Context()); err == nil {
if err := h.Token.Revoke(claims); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
h.AuthenticateUseCase.Logout(r.Context())
message, _ := json.Marshal(map[string]any{"message": "User logged out! HTTPonly jwt cookie deleted"}) message, _ := json.Marshal(map[string]any{"message": "User logged out! HTTPonly jwt cookie deleted"})
_, _ = w.Write(message) _, _ = w.Write(message)
} }
+52 -2
View File
@@ -4,6 +4,8 @@ import (
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/publicuser" "RenewCMS/api/pkg/publicuser"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"encoding/json" "encoding/json"
@@ -13,14 +15,50 @@ import (
type MeHandler struct { type MeHandler struct {
GetUseCase *useCases.GetUserUseCase GetUseCase *useCases.GetUserUseCase
GetAuthorUseCase *useCases.GetMyAuthorUseCase
GetAccessUseCase *useCases.GetMyAccessUseCase
} }
func NewMeHandler(getUser *useCases.GetUserUseCase) *MeHandler { func NewMeHandler(getUser *useCases.GetUserUseCase, getAuthor *useCases.GetMyAuthorUseCase, getAccess *useCases.GetMyAccessUseCase) *MeHandler {
return &MeHandler{ return &MeHandler{
GetUseCase: getUser, GetUseCase: getUser,
GetAuthorUseCase: getAuthor,
GetAccessUseCase: getAccess,
} }
} }
type meAuthor struct {
author.Byline
ArticleCount int `json:"article_count"`
}
type meAccess struct {
Role useCases.RoleRef `json:"role"`
Permissions []role.Grant `json:"permissions"`
RoleAssignedBy *useCases.RoleAssigner `json:"role_assigned_by"`
}
type meResponse struct {
publicuser.PublicUser
Author *meAuthor `json:"author"`
Access meAccess `json:"access"`
}
func toMeAccess(access useCases.Access) meAccess {
return meAccess{
Role: useCases.NewRoleRef(access.Role),
Permissions: access.Permissions,
RoleAssignedBy: access.RoleAssignedBy,
}
}
func toMeAuthor(found *author.Author) *meAuthor {
if found == nil {
return nil
}
return &meAuthor{Byline: found.Byline(), ArticleCount: found.ArticleCount}
}
func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) { func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r) userId, ok := claims.UserId(r)
if !ok { if !ok {
@@ -38,7 +76,19 @@ func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
return return
} }
body, err := json.Marshal(publicuser.New(loadedUser)) linked, err := h.GetAuthorUseCase.Get(loadedUser.ID)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
access, err := h.GetAccessUseCase.Get(loadedUser)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
body, err := json.Marshal(meResponse{PublicUser: publicuser.New(loadedUser), Author: toMeAuthor(linked), Access: toMeAccess(access)})
if err != nil { if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
+6 -2
View File
@@ -46,7 +46,7 @@ func (h *PasswordResetHandler) RequestPasswordReset(w http.ResponseWriter, r *ht
return return
} }
_ = h.RequestPasswordResetUseCase.RequestPasswordReset(credentials.Email) _ = h.RequestPasswordResetUseCase.RequestPasswordReset(r.Context(), credentials.Email)
message, _ := json.Marshal(map[string]any{"message": apperr.PasswordResetRequestedMessage}) message, _ := json.Marshal(map[string]any{"message": apperr.PasswordResetRequestedMessage})
_, _ = w.Write(message) _, _ = w.Write(message)
@@ -66,12 +66,16 @@ func (h *PasswordResetHandler) ResetPassword(w http.ResponseWriter, r *http.Requ
return return
} }
err = h.ResetPasswordUseCase.ResetPassword(credentials.Email, credentials.Code, credentials.Password) err = h.ResetPasswordUseCase.ResetPassword(r.Context(), credentials.Email, credentials.Code, credentials.Password)
if err != nil { if err != nil {
if errors.Is(err, user.ErrInvalidPasswordResetCode) || errors.Is(err, user.ErrPasswordResetCodeExpired) { if errors.Is(err, user.ErrInvalidPasswordResetCode) || errors.Is(err, user.ErrPasswordResetCodeExpired) {
http.Error(w, apperr.PasswordResetFailedErrorMessage, http.StatusForbidden) http.Error(w, apperr.PasswordResetFailedErrorMessage, http.StatusForbidden)
return return
} }
if errors.Is(err, user.ErrPasswordTooLong) {
http.Error(w, apperr.PasswordTooLongErrorMessage, http.StatusBadRequest)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
+1 -1
View File
@@ -50,7 +50,7 @@ func (h *VerificationHandler) Verify(w http.ResponseWriter, r *http.Request) {
return return
} }
verifiedUser, err := h.VerifyUseCase.Verify(userId, credentials.Code) verifiedUser, err := h.VerifyUseCase.Verify(r.Context(), userId, credentials.Code)
if err != nil { if err != nil {
if errors.Is(err, user.ErrUserNotFound) { if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
+383
View File
@@ -0,0 +1,383 @@
package author
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"net/url"
"strconv"
"github.com/go-chi/chi/v5"
playground "github.com/go-playground/validator/v10"
)
const (
defaultListPage = 1
defaultListSize = 25
reassignParam = "reassign_to"
reassignNone = "none"
)
type Handler struct {
ListUseCase *useCases.ListAuthorsUseCase
CreateUseCase *useCases.CreateAuthorUseCase
CreateMyUseCase *useCases.CreateMyAuthorUseCase
UpdateUseCase *useCases.UpdateAuthorUseCase
DeleteUseCase *useCases.DeleteAuthorUseCase
}
func NewHandler(list *useCases.ListAuthorsUseCase, create *useCases.CreateAuthorUseCase, createMy *useCases.CreateMyAuthorUseCase, update *useCases.UpdateAuthorUseCase, deleteUseCase *useCases.DeleteAuthorUseCase) *Handler {
return &Handler{ListUseCase: list, CreateUseCase: create, CreateMyUseCase: createMy, UpdateUseCase: update, DeleteUseCase: deleteUseCase}
}
type socialLinkRequest struct {
Type string `json:"type" validate:"socialtype"`
URL string `json:"url"`
}
func toLinks(requests []socialLinkRequest) []domain.SocialLink {
links := make([]domain.SocialLink, 0, len(requests))
for _, request := range requests {
links = append(links, domain.SocialLink{Type: request.Type, URL: request.URL})
}
return links
}
type PostAuthor struct {
Name string `json:"name" validate:"authorname"`
Slug string `json:"slug" validate:"omitempty,authorslug"`
Bio string `json:"bio"`
AvatarID *uint32 `json:"avatar_id" validate:"omitempty,gte=1"`
Website *string `json:"website"`
SocialLinks []socialLinkRequest `json:"social_links" validate:"omitempty,dive"`
UserID *uint32 `json:"user_id" validate:"omitempty,gte=1"`
}
type optional[T any] struct {
Set bool
Value *T
}
func (o *optional[T]) UnmarshalJSON(data []byte) error {
o.Set = true
if string(data) == "null" {
o.Value = nil
return nil
}
var value T
if err := json.Unmarshal(data, &value); err != nil {
return err
}
o.Value = &value
return nil
}
type PatchAuthor struct {
Name *string `json:"name" validate:"authorname"`
Slug *string `json:"slug" validate:"authorslug"`
Bio *string `json:"bio"`
AvatarID optional[uint32] `json:"avatar_id"`
Website optional[string] `json:"website"`
SocialLinks *[]socialLinkRequest `json:"social_links" validate:"omitempty,dive"`
UserID optional[uint32] `json:"user_id"`
}
func (p PatchAuthor) toPatch() domain.Patch {
patch := domain.Patch{
Name: p.Name,
Slug: p.Slug,
Bio: p.Bio,
AvatarID: domain.Nullable[uint32]{Set: p.AvatarID.Set, Value: p.AvatarID.Value},
Website: domain.Nullable[string]{Set: p.Website.Set, Value: p.Website.Value},
UserID: domain.Nullable[uint32]{Set: p.UserID.Set, Value: p.UserID.Value},
}
if p.SocialLinks != nil {
links := toLinks(*p.SocialLinks)
patch.SocialLinks = &links
}
return patch
}
var requiredNonNullFields = []string{"name", "slug", "bio", "social_links"}
var nullableFields = []string{"avatar_id", "website", "user_id"}
func readPatchAuthor(r *http.Request) (PatchAuthor, bool) {
raw, err := io.ReadAll(r.Body)
if err != nil {
return PatchAuthor{}, false
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
return PatchAuthor{}, false
}
known := false
for _, name := range requiredNonNullFields {
value, present := fields[name]
if !present {
continue
}
if string(value) == "null" {
return PatchAuthor{}, false
}
known = true
}
for _, name := range nullableFields {
if _, present := fields[name]; present {
known = true
}
}
if !known {
return PatchAuthor{}, false
}
var request PatchAuthor
if err := json.Unmarshal(raw, &request); err != nil {
return PatchAuthor{}, false
}
return request, true
}
func parseAuthorId(r *http.Request) (uint32, error) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
return uint32(id), err
}
type ownAuthorResponse struct {
domain.Author
SlugAdjusted bool `json:"slug_adjusted"`
}
func presentAuthor(a domain.Author) domain.Author {
if a.SocialLinks == nil {
a.SocialLinks = []domain.SocialLink{}
}
return a
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
body, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if status != http.StatusOK {
w.WriteHeader(status)
}
if _, err := w.Write(body); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
func writeAuthorError(w http.ResponseWriter, err error) {
switch {
case access.WriteIfMissingPermission(w, err):
case errors.Is(err, domain.ErrAuthorNotFound):
http.Error(w, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrSlugTaken):
http.Error(w, apperr.AuthorSlugTakenErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrAuthorHasArticles):
http.Error(w, apperr.AuthorHasArticlesErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrUserAlreadyLinked):
http.Error(w, apperr.UserAlreadyLinkedErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrAuthorAlreadyLinked):
http.Error(w, apperr.AuthorAlreadyLinkedErrorMessage, http.StatusConflict)
case errors.Is(err, user.ErrUserNotFound):
http.Error(w, apperr.UserNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrAuthorRights):
http.Error(w, apperr.AuthorRightsErrorMessage, http.StatusForbidden)
case errors.Is(err, domain.ErrBioTooLong):
http.Error(w, apperr.BioTooLongErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, domain.ErrInvalidURL):
http.Error(w, apperr.InvalidURLErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, domain.ErrTooManyLinks):
http.Error(w, apperr.TooManyLinksErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, domain.ErrInvalidSocialType):
http.Error(w, apperr.InvalidSocialTypeErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidSlug):
http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidReassign):
http.Error(w, apperr.InvalidReassignErrorMessage, http.StatusBadRequest)
case errors.Is(err, image.ErrImageNotFound):
http.Error(w, apperr.AuthorImageNotFoundErrorMessage, http.StatusBadRequest)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func writeValidationError(w http.ResponseWriter, err error) {
var failures playground.ValidationErrors
if errors.As(err, &failures) {
for _, failure := range failures {
if failure.Tag() == validator.AuthorSlugTag {
http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest)
return
}
}
}
http.Error(w, err.Error(), http.StatusBadRequest)
}
type listQuery struct {
Page int `validate:"gte=1"`
Size int `validate:"oneof=5 25 50 100"`
Q string `validate:"max=200"`
}
type listResponse struct {
Items []domain.Author `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
func intQueryParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
}
func readListQuery(values url.Values) (listQuery, error) {
page, err := intQueryParam(values, "page", defaultListPage)
if err != nil {
return listQuery{}, err
}
size, err := intQueryParam(values, "size", defaultListSize)
if err != nil {
return listQuery{}, err
}
return listQuery{Page: page, Size: size, Q: values.Get("q")}, nil
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
query, err := readListQuery(r.URL.Query())
if err != nil || validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
page, err := h.ListUseCase.List(useCases.ListAuthorsCommand{Q: query.Q, Page: query.Page, Size: query.Size, Grants: access.Grants(r)})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
items := make([]domain.Author, 0, len(page.Items))
for _, item := range page.Items {
items = append(items, presentAuthor(item))
}
w.Header().Set("X-Total-Count", strconv.Itoa(page.Total))
writeJSON(w, http.StatusOK, listResponse{Items: items, Total: page.Total, Page: page.Page, Size: page.Size})
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var request PostAuthor
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
writeValidationError(w, err)
return
}
created, err := h.CreateUseCase.Create(r.Context(), useCases.CreateAuthorCommand{
Name: request.Name,
Slug: request.Slug,
Bio: request.Bio,
AvatarID: request.AvatarID,
Website: request.Website,
SocialLinks: toLinks(request.SocialLinks),
UserID: request.UserID,
})
if err != nil {
writeAuthorError(w, err)
return
}
writeJSON(w, http.StatusCreated, presentAuthor(created))
}
func (h *Handler) CreateMine(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
created, err := h.CreateMyUseCase.Create(r.Context(), userId)
if err != nil {
writeAuthorError(w, err)
return
}
writeJSON(w, http.StatusCreated, ownAuthorResponse{Author: presentAuthor(created.Author), SlugAdjusted: created.SlugAdjusted})
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
id, err := parseAuthorId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
request, ok := readPatchAuthor(r)
if !ok {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
writeValidationError(w, err)
return
}
callerID, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
updated, err := h.UpdateUseCase.Update(r.Context(), id, useCases.AuthorCaller{UserID: callerID, Grants: access.Grants(r)}, request.toPatch())
if err != nil {
writeAuthorError(w, err)
return
}
writeJSON(w, http.StatusOK, presentAuthor(updated))
}
func readReassignment(r *http.Request) useCases.Reassignment {
query := r.URL.Query()
if !query.Has(reassignParam) {
return useCases.Reassignment{Mode: useCases.ReassignAbsent}
}
value := query.Get(reassignParam)
if value == reassignNone {
return useCases.Reassignment{Mode: useCases.ReassignNone}
}
parsed, err := strconv.ParseUint(value, 10, 32)
if err != nil {
return useCases.Reassignment{Mode: useCases.ReassignMalformed}
}
return useCases.Reassignment{Mode: useCases.ReassignToAuthor, Destination: uint32(parsed)}
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := parseAuthorId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
if err := h.DeleteUseCase.Delete(r.Context(), id, readReassignment(r)); err != nil {
writeAuthorError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
@@ -0,0 +1,38 @@
package builtintype
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/response"
domain "RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
)
type Handler struct {
ListUseCase *useCases.ListBuiltinTypesUseCase
GetUseCase *useCases.GetBuiltinTypeUseCase
}
func NewHandler(list *useCases.ListBuiltinTypesUseCase, get *useCases.GetBuiltinTypeUseCase) *Handler {
return &Handler{ListUseCase: list, GetUseCase: get}
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, http.StatusOK, h.ListUseCase.List())
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
found, err := h.GetUseCase.Get(chi.URLParam(r, "key"))
if errors.Is(err, domain.ErrNotFound) {
http.Error(w, apperr.BuiltinTypeNotFoundErrorMessage, http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
response.WriteJSON(w, http.StatusOK, found)
}
@@ -0,0 +1,184 @@
package contenttype
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/response"
validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
)
type Handler struct {
CreateUseCase *useCases.CreateContentTypeUseCase
ListUseCase *useCases.ListContentTypesUseCase
GetUseCase *useCases.GetContentTypeUseCase
UpdateUseCase *useCases.UpdateContentTypeUseCase
DeleteUseCase *useCases.DeleteContentTypeUseCase
}
func NewHandler(create *useCases.CreateContentTypeUseCase, list *useCases.ListContentTypesUseCase, get *useCases.GetContentTypeUseCase, update *useCases.UpdateContentTypeUseCase, deleteUseCase *useCases.DeleteContentTypeUseCase) *Handler {
return &Handler{CreateUseCase: create, ListUseCase: list, GetUseCase: get, UpdateUseCase: update, DeleteUseCase: deleteUseCase}
}
type iconInput struct {
value *string
invalid bool
}
func (i *iconInput) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
return nil
}
var text string
if err := json.Unmarshal(data, &text); err != nil {
i.invalid = true
return nil
}
i.value = &text
return nil
}
type PostContentType struct {
Key string `json:"key" validate:"required"`
Name string `json:"name" validate:"required,max=100"`
Icon iconInput `json:"icon"`
Fields []domain.Field `json:"fields"`
}
type PutContentType struct {
Key string `json:"key"`
Name string `json:"name" validate:"required,max=100"`
Icon iconInput `json:"icon"`
Fields []domain.Field `json:"fields"`
}
type contentTypeResponse struct {
ID uint32 `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Icon *string `json:"icon"`
Fields []domain.Field `json:"fields"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func toResponse(contentType domain.ContentType) contentTypeResponse {
fields := contentType.Fields
if fields == nil {
fields = []domain.Field{}
}
return contentTypeResponse{
ID: contentType.ID,
Key: contentType.Key,
Name: contentType.Name,
Icon: contentType.Icon,
Fields: fields,
CreatedAt: contentType.CreatedAt,
UpdatedAt: contentType.UpdatedAt,
}
}
func writeContentTypeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domain.ErrContentTypeNotFound):
http.Error(w, apperr.ContentTypeNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrKeyTaken):
http.Error(w, apperr.ContentTypeKeyTakenErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrHasEntries):
http.Error(w, apperr.ContentTypeHasEntriesErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrReferenced):
http.Error(w, apperr.ContentTypeReferencedErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrSchemaIncompatible):
http.Error(w, apperr.SchemaIncompatibleErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrInvalidName):
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidIcon):
http.Error(w, apperr.InvalidIconErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidKey):
http.Error(w, apperr.InvalidContentTypeKeyErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidField):
http.Error(w, apperr.InvalidFieldErrorMessage, http.StatusUnprocessableEntity)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
found, err := h.ListUseCase.List()
if err != nil {
writeContentTypeError(w, err)
return
}
responses := make([]contentTypeResponse, 0, len(found))
for _, contentType := range found {
responses = append(responses, toResponse(contentType))
}
response.WriteJSON(w, http.StatusOK, responses)
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
found, err := h.GetUseCase.Get(chi.URLParam(r, "key"))
if err != nil {
writeContentTypeError(w, err)
return
}
response.WriteJSON(w, http.StatusOK, toResponse(found))
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var request PostContentType
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if request.Icon.invalid {
writeContentTypeError(w, domain.ErrInvalidIcon)
return
}
created, err := h.CreateUseCase.Create(useCases.CreateContentTypeCommand{Key: request.Key, Name: request.Name, Icon: request.Icon.value, Fields: request.Fields})
if err != nil {
writeContentTypeError(w, err)
return
}
response.WriteJSON(w, http.StatusCreated, toResponse(created))
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
var request PutContentType
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if request.Icon.invalid {
writeContentTypeError(w, domain.ErrInvalidIcon)
return
}
updated, err := h.UpdateUseCase.Update(useCases.UpdateContentTypeCommand{Key: chi.URLParam(r, "key"), Name: request.Name, Icon: request.Icon.value, Fields: request.Fields})
if err != nil {
writeContentTypeError(w, err)
return
}
response.WriteJSON(w, http.StatusOK, toResponse(updated))
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
if err := h.DeleteUseCase.Delete(chi.URLParam(r, "key")); err != nil {
writeContentTypeError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+291
View File
@@ -0,0 +1,291 @@
package entry
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/response"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/contenttype"
domain "RenewCMS/internal/domain/entry"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"time"
"github.com/go-chi/chi/v5"
)
type Handler struct {
CreateUseCase *useCases.CreateEntryUseCase
GetUseCase *useCases.GetEntryUseCase
ListUseCase *useCases.ListEntriesUseCase
UpdateUseCase *useCases.UpdateEntryUseCase
DeleteUseCase *useCases.DeleteEntryUseCase
}
func NewHandler(create *useCases.CreateEntryUseCase, get *useCases.GetEntryUseCase, list *useCases.ListEntriesUseCase, update *useCases.UpdateEntryUseCase, deleteUseCase *useCases.DeleteEntryUseCase) *Handler {
return &Handler{CreateUseCase: create, GetUseCase: get, ListUseCase: list, UpdateUseCase: update, DeleteUseCase: deleteUseCase}
}
type EntryBody struct {
Data map[string]any `json:"data" validate:"required"`
}
type entryResponse struct {
ID uint32 `json:"id"`
ContentType string `json:"content_type"`
Data map[string]any `json:"data"`
CreatedBy uint32 `json:"created_by"`
UpdatedBy uint32 `json:"updated_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Media map[string]any `json:"media"`
Relations map[string]any `json:"relations"`
}
type firstFieldBody struct {
Key string `json:"key"`
Type contenttype.FieldType `json:"type"`
Value any `json:"value"`
}
type relationBody struct {
ID uint32 `json:"id"`
ContentType string `json:"content_type"`
FirstField firstFieldBody `json:"first_field"`
}
type missingRelationBody struct {
ID uint32 `json:"id"`
Missing bool `json:"missing"`
}
func toRelationItem(item useCases.EntryRelation) any {
if item.Missing {
return missingRelationBody{ID: item.ID, Missing: true}
}
return relationBody{ID: item.ID, ContentType: item.ContentType, FirstField: firstFieldBody{Key: item.FirstField.Key, Type: item.FirstField.Type, Value: item.FirstField.Value}}
}
func toRelationsResponse(relations map[string]useCases.EntryRelations) map[string]any {
body := make(map[string]any, len(relations))
for key, held := range relations {
if !held.Many {
if len(held.Items) > 0 {
body[key] = toRelationItem(held.Items[0])
}
continue
}
items := make([]any, 0, len(held.Items))
for _, item := range held.Items {
items = append(items, toRelationItem(item))
}
body[key] = items
}
return body
}
type mediaBody struct {
ID uint32 `json:"id"`
Filename string `json:"filename"`
Width *int `json:"width"`
Height *int `json:"height"`
Size int64 `json:"size"`
ThumbnailURL string `json:"thumbnail_url"`
}
type missingMediaBody struct {
ID uint32 `json:"id"`
Missing bool `json:"missing"`
}
func toMediaResponse(media map[string]useCases.EntryMedia) map[string]any {
body := make(map[string]any, len(media))
for key, item := range media {
if item.Missing {
body[key] = missingMediaBody{ID: item.ID, Missing: true}
continue
}
body[key] = mediaBody{ID: item.ID, Filename: item.Filename, Width: item.Width, Height: item.Height, Size: item.Size, ThumbnailURL: item.ThumbnailURL}
}
return body
}
func toResponse(typed useCases.TypedEntry) entryResponse {
return entryResponse{
ID: typed.ID,
ContentType: typed.ContentTypeKey,
Data: typed.Data,
CreatedBy: typed.CreatedBy,
UpdatedBy: typed.UpdatedBy,
CreatedAt: typed.CreatedAt,
UpdatedAt: typed.UpdatedAt,
Media: toMediaResponse(typed.Media),
Relations: toRelationsResponse(typed.Relations),
}
}
type pageResponse struct {
Items []entryResponse `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
const (
defaultListPage = 1
defaultListSize = 25
)
type listQuery struct {
Page int `validate:"gte=1"`
Size int `validate:"oneof=5 25 50 100"`
Sort domain.Sort
}
func intQueryParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
}
func readListQuery(values url.Values) (listQuery, error) {
page, err := intQueryParam(values, "page", defaultListPage)
if err != nil {
return listQuery{}, err
}
size, err := intQueryParam(values, "size", defaultListSize)
if err != nil {
return listQuery{}, err
}
order := domain.DefaultSort()
if values.Has("sort") {
order, err = domain.ParseSort(values.Get("sort"))
if err != nil {
return listQuery{}, err
}
}
return listQuery{Page: page, Size: size, Sort: order}, nil
}
func parseEntryId(r *http.Request) (uint32, error) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
return uint32(id), err
}
func writeEntryError(w http.ResponseWriter, err error) {
if access.WriteIfMissingPermission(w, err) {
return
}
var fieldError domain.FieldError
switch {
case errors.Is(err, contenttype.ErrContentTypeNotFound):
http.Error(w, apperr.ContentTypeNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrEntryNotFound):
http.Error(w, apperr.EntryNotFoundErrorMessage, http.StatusNotFound)
case errors.As(err, &fieldError):
http.Error(w, apperr.InvalidEntryErrorMessage+": "+fieldError.Error(), fieldErrorStatus(err))
case errors.Is(err, domain.ErrInvalidPage), errors.Is(err, domain.ErrInvalidSort):
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidEntry):
http.Error(w, apperr.InvalidEntryErrorMessage, http.StatusUnprocessableEntity)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func fieldErrorStatus(err error) int {
if errors.Is(err, domain.ErrUniqueViolation) {
return http.StatusConflict
}
return http.StatusUnprocessableEntity
}
func readBody(w http.ResponseWriter, r *http.Request) (EntryBody, bool) {
var body EntryBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || validator.Validate.Struct(body) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return EntryBody{}, false
}
return body, true
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
query, err := readListQuery(r.URL.Query())
if err != nil || validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
page, err := h.ListUseCase.ListPage(useCases.ListEntriesCommand{ContentTypeKey: chi.URLParam(r, "key"), Page: query.Page, Size: query.Size, Sort: query.Sort, Query: r.URL.Query().Get("q"), Caller: access.CallerOf(r)})
if err != nil {
writeEntryError(w, err)
return
}
items := make([]entryResponse, 0, len(page.Items))
for _, typed := range page.Items {
items = append(items, toResponse(typed))
}
response.WriteJSON(w, http.StatusOK, pageResponse{Items: items, Total: page.Total, Page: page.Page, Size: page.Size})
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
id, err := parseEntryId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
found, err := h.GetUseCase.Get(chi.URLParam(r, "key"), id, access.CallerOf(r))
if err != nil {
writeEntryError(w, err)
return
}
response.WriteJSON(w, http.StatusOK, toResponse(found))
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
body, ok := readBody(w, r)
if !ok {
return
}
created, err := h.CreateUseCase.Create(useCases.CreateEntryCommand{ContentTypeKey: chi.URLParam(r, "key"), Data: body.Data, Caller: access.CallerOf(r)})
if err != nil {
writeEntryError(w, err)
return
}
response.WriteJSON(w, http.StatusCreated, toResponse(created))
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
id, err := parseEntryId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
body, ok := readBody(w, r)
if !ok {
return
}
updated, err := h.UpdateUseCase.Update(useCases.UpdateEntryCommand{ContentTypeKey: chi.URLParam(r, "key"), ID: id, Data: body.Data, Caller: access.CallerOf(r)})
if err != nil {
writeEntryError(w, err)
return
}
response.WriteJSON(w, http.StatusOK, toResponse(updated))
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := parseEntryId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
if err := h.DeleteUseCase.Delete(chi.URLParam(r, "key"), id, access.CallerOf(r)); err != nil {
writeEntryError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+8 -4
View File
@@ -2,6 +2,7 @@ package frontend
import ( import (
"RenewCMS/internal/infrastructure/assets" "RenewCMS/internal/infrastructure/assets"
"bytes"
"io" "io"
"io/fs" "io/fs"
"net/http" "net/http"
@@ -93,12 +94,15 @@ func (h *spaHandler) serveIndex(w http.ResponseWriter, r *http.Request) {
} }
defer f.Close() defer f.Close()
content, ok := f.(io.ReadSeeker) html, err := io.ReadAll(f)
if !ok { if err != nil {
http.Error(w, "File not seekable", http.StatusInternalServerError) http.Error(w, "Index not readable", http.StatusInternalServerError)
return return
} }
w.Header().Set("Cache-Control", indexCacheControl) w.Header().Set("Cache-Control", indexCacheControl)
http.ServeContent(w, r, "index.html", h.modTime, content) if policy := w.Header().Get("Content-Security-Policy"); policy != "" {
w.Header().Set("Content-Security-Policy", AllowInlineScripts(policy, html))
}
http.ServeContent(w, r, "index.html", h.modTime, bytes.NewReader(html))
} }
+33
View File
@@ -0,0 +1,33 @@
package frontend
import (
"crypto/sha256"
"encoding/base64"
"regexp"
"strings"
)
const scriptSrcSelf = "script-src 'self'"
var inlineScriptPattern = regexp.MustCompile(`(?is)<script(\s[^>]*)?>(.*?)</script>`)
var srcAttributePattern = regexp.MustCompile(`(?i)\ssrc\s*=`)
func inlineScriptSources(html []byte) []string {
var sources []string
for _, match := range inlineScriptPattern.FindAllSubmatch(html, -1) {
if srcAttributePattern.Match(match[1]) || strings.TrimSpace(string(match[2])) == "" {
continue
}
digest := sha256.Sum256(match[2])
sources = append(sources, "'sha256-"+base64.StdEncoding.EncodeToString(digest[:])+"'")
}
return sources
}
func AllowInlineScripts(policy string, html []byte) string {
sources := inlineScriptSources(html)
if len(sources) == 0 {
return policy
}
return strings.Replace(policy, scriptSrcSelf, scriptSrcSelf+" "+strings.Join(sources, " "), 1)
}
+141
View File
@@ -0,0 +1,141 @@
package imagevariant
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"math"
"net/http"
validatorlib "github.com/go-playground/validator/v10"
)
type cacheUnusedResponse struct {
Files int `json:"files"`
Bytes int64 `json:"bytes"`
Days int `json:"days"`
}
type cacheVariantResponse struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
Files int `json:"files"`
Bytes int64 `json:"bytes"`
}
type cacheImageResponse struct {
ID uint32 `json:"id"`
Filename string `json:"filename"`
URL string `json:"url"`
Files int `json:"files"`
Bytes int64 `json:"bytes"`
}
type cacheResponse struct {
GeneratedFiles int `json:"generated_files"`
DiskUsed int64 `json:"disk_used"`
VariantsDefined int `json:"variants_defined"`
VariantsEnabled int `json:"variants_enabled"`
VariantsDisabled int `json:"variants_disabled"`
RejectedRequests int `json:"rejected_requests"`
RejectedWindowDays int `json:"rejected_window_days"`
Unused cacheUnusedResponse `json:"unused"`
ByVariant []cacheVariantResponse `json:"by_variant"`
TopImages []cacheImageResponse `json:"top_images"`
}
type cachePurgeRequest struct {
Scope string `json:"scope" validate:"required,oneof=all unused variant image"`
Variant string `json:"variant" validate:"required_if=Scope variant"`
ImageID int64 `json:"image_id" validate:"required_if=Scope image,omitempty,gt=0,lte=4294967295"`
}
type cachePurgeResponse struct {
Scope string `json:"scope"`
PurgedFiles int `json:"purged_files"`
PurgedBytes int64 `json:"purged_bytes"`
FailedFiles int `json:"failed_files"`
}
func (r *cachePurgeRequest) ignoreUnusedTargets() {
if r.Scope != string(image.CacheScopeVariant) {
r.Variant = ""
}
if r.Scope != string(image.CacheScopeImage) {
r.ImageID = 0
}
}
func cacheResponseOf(stats useCases.VariantCacheStats) cacheResponse {
response := cacheResponse{
GeneratedFiles: stats.GeneratedFiles,
DiskUsed: stats.DiskUsed,
VariantsDefined: stats.VariantsDefined,
VariantsEnabled: stats.VariantsEnabled,
VariantsDisabled: stats.VariantsDisabled,
RejectedRequests: stats.RejectedRequests,
RejectedWindowDays: stats.RejectedWindowDays,
Unused: cacheUnusedResponse{Files: stats.Unused.Files, Bytes: stats.Unused.Bytes, Days: stats.UnusedDays},
ByVariant: make([]cacheVariantResponse, 0, len(stats.ByVariant)),
TopImages: make([]cacheImageResponse, 0, len(stats.TopImages)),
}
for _, entry := range stats.ByVariant {
response.ByVariant = append(response.ByVariant, cacheVariantResponse{Name: entry.Name, Enabled: entry.Enabled, Files: entry.Files, Bytes: entry.Bytes})
}
for _, top := range stats.TopImages {
response.TopImages = append(response.TopImages, cacheImageResponse{ID: top.ID, Filename: top.Filename, URL: top.URL, Files: top.Files, Bytes: top.Bytes})
}
return response
}
func (h *Handler) CacheStats(w http.ResponseWriter, _ *http.Request) {
stats, err := h.cacheStats.Get()
if err != nil {
h.writeError(w, "", err)
return
}
writeJSON(w, http.StatusOK, cacheResponseOf(stats))
}
func (h *Handler) PurgeCache(w http.ResponseWriter, r *http.Request) {
var request cachePurgeRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
request.ignoreUnusedTargets()
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, cachePurgeValidationMessage(err), http.StatusUnprocessableEntity)
return
}
result, err := h.purgeCache.Purge(r.Context(), useCases.PurgeVariantCacheCommand{
Scope: image.CachePurgeScope(request.Scope),
Variant: request.Variant,
ImageID: uint32(min(request.ImageID, math.MaxUint32)),
})
if err != nil {
h.writePurgeCacheError(w, err)
return
}
writeJSON(w, http.StatusOK, cachePurgeResponse{Scope: string(result.Scope), PurgedFiles: result.Files, PurgedBytes: result.Bytes, FailedFiles: result.Failed})
}
func cachePurgeValidationMessage(err error) string {
var failures validatorlib.ValidationErrors
if errors.As(err, &failures) && len(failures) > 0 && failures[0].StructField() != "Scope" {
return apperr.CachePurgeTargetErrorMessage
}
return apperr.CachePurgeScopeErrorMessage
}
func (h *Handler) writePurgeCacheError(w http.ResponseWriter, err error) {
if errors.Is(err, image.ErrImageNotFound) {
http.Error(w, apperr.ImageNotFoundErrorMessage, http.StatusNotFound)
return
}
h.writeError(w, "", err)
}
@@ -0,0 +1,266 @@
package imagevariant
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
validatorlib "github.com/go-playground/validator/v10"
"github.com/go-chi/chi/v5"
)
type Handler struct {
list *useCases.ListVariantsUseCase
create *useCases.CreateVariantUseCase
update *useCases.UpdateVariantUseCase
setEnabled *useCases.SetVariantEnabledUseCase
remove *useCases.DeleteVariantUseCase
purge *useCases.PurgeVariantUseCase
cacheStats *useCases.GetVariantCacheStatsUseCase
purgeCache *useCases.PurgeVariantCacheUseCase
}
func NewHandler(list *useCases.ListVariantsUseCase, create *useCases.CreateVariantUseCase, update *useCases.UpdateVariantUseCase, setEnabled *useCases.SetVariantEnabledUseCase, remove *useCases.DeleteVariantUseCase, purge *useCases.PurgeVariantUseCase, cacheStats *useCases.GetVariantCacheStatsUseCase, purgeCache *useCases.PurgeVariantCacheUseCase) *Handler {
return &Handler{list, create, update, setEnabled, remove, purge, cacheStats, purgeCache}
}
type definitionRequest struct {
Width *int `json:"width" validate:"required_without=Height,omitempty,min=1,max=8000"`
Height *int `json:"height" validate:"required_without=Width,omitempty,min=1,max=8000"`
Fit string `json:"fit" validate:"omitempty,oneof=cover contain inside"`
Format string `json:"format" validate:"omitempty,oneof=original jpeg png webp"`
Quality *int `json:"quality" validate:"omitempty,min=1,max=100"`
}
type switchesRequest struct {
GenerateOnUpload *bool `json:"generate_on_upload"`
Enabled *bool `json:"enabled"`
}
type createRequest struct {
Name string `json:"name" validate:"required,variantname"`
definitionRequest
switchesRequest
}
type updateRequest struct {
definitionRequest
switchesRequest
}
type enabledRequest struct {
Enabled *bool `json:"enabled" validate:"required"`
}
type variantResponse struct {
Name string `json:"name"`
Width *int `json:"width"`
Height *int `json:"height"`
Fit image.VariantFit `json:"fit"`
Format image.VariantFormat `json:"format"`
Quality *int `json:"quality"`
GenerateOnUpload bool `json:"generate_on_upload"`
Enabled bool `json:"enabled"`
GeneratedFiles int `json:"generated_files"`
CacheSize int64 `json:"cache_size"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type updatedVariantResponse struct {
variantResponse
PurgedFiles int `json:"purged_files"`
}
type listResponse struct {
Items []variantResponse `json:"items"`
}
type purgeResponse struct {
PurgedFiles int `json:"purged_files"`
PurgedBytes int64 `json:"purged_bytes"`
}
func responseOf(summary useCases.VariantSummary) variantResponse {
variant := summary.Variant
return variantResponse{
Name: variant.Name,
Width: variant.Width,
Height: variant.Height,
Fit: variant.Fit,
Format: variant.Format,
Quality: variant.Quality,
GenerateOnUpload: variant.GenerateOnUpload,
Enabled: variant.Enabled,
GeneratedFiles: summary.GeneratedFiles,
CacheSize: summary.CacheSize,
CreatedAt: variant.CreatedAt,
UpdatedAt: variant.UpdatedAt,
}
}
func (h *Handler) List(w http.ResponseWriter, _ *http.Request) {
summaries, err := h.list.List()
if err != nil {
h.writeError(w, "", err)
return
}
items := make([]variantResponse, 0, len(summaries))
for _, summary := range summaries {
items = append(items, responseOf(summary))
}
writeJSON(w, http.StatusOK, listResponse{items})
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var request createRequest
if !h.decode(w, r, &request) {
return
}
summary, err := h.create.Create(r.Context(), useCases.CreateVariantCommand{
Name: request.Name,
VariantDefinitionCommand: request.definitionRequest.command(),
GenerateOnUpload: request.GenerateOnUpload,
Enabled: request.Enabled,
})
if err != nil {
h.writeError(w, request.Name, err)
return
}
writeJSON(w, http.StatusCreated, responseOf(summary))
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
var request updateRequest
if !h.decode(w, r, &request) {
return
}
name := chi.URLParam(r, "name")
result, err := h.update.Update(r.Context(), useCases.UpdateVariantCommand{
Name: name,
VariantDefinitionCommand: request.definitionRequest.command(),
GenerateOnUpload: request.GenerateOnUpload,
Enabled: request.Enabled,
})
if err != nil {
h.writeError(w, name, err)
return
}
writeJSON(w, http.StatusOK, updatedVariantResponse{responseOf(result.VariantSummary), result.PurgedFiles})
}
func (h *Handler) SetEnabled(w http.ResponseWriter, r *http.Request) {
var request enabledRequest
if !h.decode(w, r, &request) {
return
}
name := chi.URLParam(r, "name")
summary, err := h.setEnabled.SetEnabled(r.Context(), name, *request.Enabled)
if err != nil {
h.writeError(w, name, err)
return
}
writeJSON(w, http.StatusOK, responseOf(summary))
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
if err := h.remove.Delete(r.Context(), name); err != nil {
h.writeError(w, name, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Purge(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
purged, err := h.purge.Purge(r.Context(), name)
if err != nil {
h.writeError(w, name, err)
return
}
writeJSON(w, http.StatusOK, purgeResponse{purged.Files, purged.Bytes})
}
func (d definitionRequest) command() useCases.VariantDefinitionCommand {
return useCases.VariantDefinitionCommand{Width: d.Width, Height: d.Height, Fit: d.Fit, Format: d.Format, Quality: d.Quality}
}
func (h *Handler) decode(w http.ResponseWriter, r *http.Request, request any) bool {
if err := json.NewDecoder(r.Body).Decode(request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return false
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, h.validationMessage(err), http.StatusUnprocessableEntity)
return false
}
return true
}
func (h *Handler) validationMessage(err error) string {
var failures validatorlib.ValidationErrors
if !errors.As(err, &failures) || len(failures) == 0 {
return apperr.VariantInvalidErrorMessage
}
switch failures[0].StructField() {
case "Name":
return apperr.VariantNameErrorMessage
case "Width", "Height":
return apperr.VariantDimensionsErrorMessage
case "Fit":
return apperr.VariantFitErrorMessage
case "Format":
return h.formatMessage()
case "Quality":
return apperr.VariantQualityErrorMessage
default:
return apperr.VariantInvalidErrorMessage
}
}
func (h *Handler) formatMessage() string {
encodable := h.create.EncodableFormats()
names := make([]string, 0, len(encodable))
for _, format := range encodable {
names = append(names, string(format))
}
last := len(names) - 1
return fmt.Sprintf(apperr.VariantFormatErrorMessageFormat, strings.Join(names[:last], ", ")+" or "+names[last])
}
func (h *Handler) writeError(w http.ResponseWriter, name string, err error) {
switch {
case errors.Is(err, image.ErrVariantNotFound):
http.Error(w, apperr.VariantNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, image.ErrVariantExists):
http.Error(w, fmt.Sprintf(apperr.VariantExistsErrorMessageFormat, name), http.StatusConflict)
case errors.Is(err, image.ErrVariantFormatUnavailable):
http.Error(w, h.formatMessage(), http.StatusUnprocessableEntity)
case errors.Is(err, image.ErrVariantInvalid):
http.Error(w, apperr.VariantInvalidErrorMessage, http.StatusUnprocessableEntity)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func writeJSON(w http.ResponseWriter, status int, value any) {
body, err := json.Marshal(value)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(status)
_, _ = w.Write(body)
}
+76
View File
@@ -0,0 +1,76 @@
package llms
import (
"io/fs"
"net/http"
"path"
"strings"
"github.com/go-chi/chi/v5"
)
const (
versionPlaceholder = "{{VERSION}}"
baseURLPlaceholder = "{{BASE_URL}}"
pageSuffix = ".md"
cacheControl = "public, max-age=300"
notBuiltNotice = "The LLM documentation was not generated for this build. Run `task build:llms`."
)
type Handler struct {
docs fs.FS
version string
baseURL string
}
func NewHandler(docs fs.FS, version string, publicURL string) *Handler {
return &Handler{
docs: docs,
version: version,
baseURL: strings.TrimRight(publicURL, "/"),
}
}
func (h *Handler) Register(r chi.Router) {
r.Get("/llms.txt", h.serve("llms.txt", "text/plain; charset=utf-8"))
r.Get("/llms-full.txt", h.serve("llms-full.txt", "text/plain; charset=utf-8"))
r.Get("/openapi.json", h.serve("openapi.json", "application/json"))
r.Get("/llms/{page}", h.page)
}
func (h *Handler) page(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "page")
if !strings.HasSuffix(name, pageSuffix) || strings.ContainsAny(name, "/\\") {
h.notFound(w)
return
}
h.write(w, path.Join("llms", name), "text/markdown; charset=utf-8")
}
func (h *Handler) serve(name string, contentType string) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
h.write(w, name, contentType)
}
}
func (h *Handler) write(w http.ResponseWriter, name string, contentType string) {
if h.docs == nil {
h.notFound(w)
return
}
raw, err := fs.ReadFile(h.docs, name)
if err != nil {
h.notFound(w)
return
}
text := strings.NewReplacer(versionPlaceholder, h.version, baseURLPlaceholder, h.baseURL).Replace(string(raw))
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", cacheControl)
_, _ = w.Write([]byte(text))
}
func (h *Handler) notFound(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(notBuiltNotice))
}
+105 -20
View File
@@ -25,6 +25,15 @@ type Handler struct {
DeleteUseCase *useCases.DeleteMediaUseCase DeleteUseCase *useCases.DeleteMediaUseCase
MergeUseCase *useCases.MergeMediaUseCase MergeUseCase *useCases.MergeMediaUseCase
UsageUseCase *useCases.GetMediaUsageUseCase UsageUseCase *useCases.GetMediaUsageUseCase
PurgeSettingsUseCase *useCases.GetPurgeSettingsUseCase
UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase
PreviewPurgeUseCase *useCases.PreviewPurgeUseCase
RunPurgeUseCase *useCases.RunPurgeUseCase
ListVariantsUseCase *useCases.ListImageVariantsUseCase
GenerateVariantUseCase *useCases.GenerateImageVariantUseCase
GenerateAllUseCase *useCases.GenerateAllImageVariantsUseCase
PurgeVariantUseCase *useCases.PurgeImageVariantUseCase
GenerateOnUploadUseCase *useCases.GenerateVariantsOnUploadUseCase
} }
type RenameRequest struct { type RenameRequest struct {
@@ -49,6 +58,9 @@ type mediaItemResponse struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UsedIn int `json:"used_in"` UsedIn int `json:"used_in"`
Duplicate bool `json:"duplicate"` Duplicate bool `json:"duplicate"`
VariantCount int `json:"variant_count"`
VariantSize int64 `json:"variant_size"`
} }
type countsResponse struct { type countsResponse struct {
@@ -59,22 +71,58 @@ type countsResponse struct {
type listResponse struct { type listResponse struct {
Items []mediaItemResponse `json:"items"` Items []mediaItemResponse `json:"items"`
NextCursor *string `json:"next_cursor"` Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
Counts countsResponse `json:"counts"` Counts countsResponse `json:"counts"`
} }
type pagingQuery struct {
Page int `validate:"gte=1"`
Size int `validate:"oneof=5 25 50 100"`
}
func intQueryParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
}
type usageResponse struct { type usageResponse struct {
Type string `json:"type"`
ID uint32 `json:"id"` ID uint32 `json:"id"`
Title string `json:"title"` Title string `json:"title,omitempty"`
Published bool `json:"published"` Published *bool `json:"published,omitempty"`
Fields []string `json:"fields"`
ContentTypeKey string `json:"content_type_key,omitempty"`
ContentTypeName string `json:"content_type_name,omitempty"`
FirstField *usageFirstFieldBody `json:"first_field,omitempty"`
}
type usageFirstFieldBody struct {
Key string `json:"key"`
Type string `json:"type"`
Value any `json:"value"`
}
func toUsageResponse(usage useCases.MediaUsage) usageResponse {
body := usageResponse{Type: usage.Type, ID: usage.ID, Title: usage.Title, Published: usage.Published, Fields: usage.Fields, ContentTypeKey: usage.ContentTypeKey, ContentTypeName: usage.ContentTypeName}
if usage.FirstField != nil {
body.FirstField = &usageFirstFieldBody{Key: usage.FirstField.Key, Type: string(usage.FirstField.Type), Value: usage.FirstField.Value}
}
return body
} }
type mergeResponse struct { type mergeResponse struct {
ArticlesUpdated int `json:"articles_updated"` ArticlesUpdated int `json:"articles_updated"`
SitesUpdated int `json:"sites_updated"`
AuthorsUpdated int `json:"authors_updated"`
Merged int `json:"merged"` Merged int `json:"merged"`
} }
func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUseCase, rename *useCases.RenameMediaUseCase, deleteUseCase *useCases.DeleteMediaUseCase, merge *useCases.MergeMediaUseCase, usage *useCases.GetMediaUsageUseCase) *Handler { func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUseCase, rename *useCases.RenameMediaUseCase, deleteUseCase *useCases.DeleteMediaUseCase, merge *useCases.MergeMediaUseCase, usage *useCases.GetMediaUsageUseCase, purgeSettings *useCases.GetPurgeSettingsUseCase, updatePurgeSettings *useCases.UpdatePurgeSettingsUseCase, previewPurge *useCases.PreviewPurgeUseCase, runPurge *useCases.RunPurgeUseCase, listVariants *useCases.ListImageVariantsUseCase, generateVariant *useCases.GenerateImageVariantUseCase, generateAll *useCases.GenerateAllImageVariantsUseCase, purgeVariant *useCases.PurgeImageVariantUseCase, generateOnUpload *useCases.GenerateVariantsOnUploadUseCase) *Handler {
return &Handler{ return &Handler{
CreateUseCase: create, CreateUseCase: create,
ListUseCase: list, ListUseCase: list,
@@ -82,6 +130,15 @@ func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUse
DeleteUseCase: deleteUseCase, DeleteUseCase: deleteUseCase,
MergeUseCase: merge, MergeUseCase: merge,
UsageUseCase: usage, UsageUseCase: usage,
PurgeSettingsUseCase: purgeSettings,
UpdatePurgeSettingsUseCase: updatePurgeSettings,
PreviewPurgeUseCase: previewPurge,
RunPurgeUseCase: runPurge,
ListVariantsUseCase: listVariants,
GenerateVariantUseCase: generateVariant,
GenerateAllUseCase: generateAll,
PurgeVariantUseCase: purgeVariant,
GenerateOnUploadUseCase: generateOnUpload,
} }
} }
@@ -137,6 +194,9 @@ func toResponse(item useCases.MediaItem) mediaItemResponse {
CreatedAt: item.Image.CreatedAt, CreatedAt: item.Image.CreatedAt,
UsedIn: item.UsedIn, UsedIn: item.UsedIn,
Duplicate: item.Duplicate, Duplicate: item.Duplicate,
VariantCount: item.VariantCount,
VariantSize: item.VariantSize,
} }
} }
@@ -149,20 +209,31 @@ func parseListQuery(r *http.Request) (domainImage.ListQuery, error) {
query := domainImage.NewListQuery() query := domainImage.NewListQuery()
query.Search = values.Get("q") query.Search = values.Get("q")
query.Hash = strings.ToLower(values.Get("hash")) query.Hash = strings.ToLower(values.Get("hash"))
query.Cursor = values.Get("cursor")
if values.Has("filter") { if values.Has("filter") {
query.Filter = domainImage.Filter(values.Get("filter")) query.Filter = domainImage.Filter(values.Get("filter"))
} }
if values.Has("variants") {
if values.Get("variants") == "" {
return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
}
query.Variants = domainImage.VariantsFilter(values.Get("variants"))
}
if values.Has("sort") { if values.Has("sort") {
query.Sort = domainImage.Sort(values.Get("sort")) query.Sort = domainImage.Sort(values.Get("sort"))
} }
if values.Has("limit") { page, err := intQueryParam(values, "page", domainImage.DefaultListPage)
limit, err := strconv.Atoi(values.Get("limit"))
if err != nil { if err != nil {
return domainImage.ListQuery{}, domainImage.ErrInvalidQuery return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
} }
query.Limit = limit size, err := intQueryParam(values, "size", domainImage.DefaultListSize)
if err != nil {
return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
} }
if validator.Validate.Struct(pagingQuery{Page: page, Size: size}) != nil {
return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
}
query.Page = page
query.Size = size
return query, nil return query, nil
} }
@@ -189,14 +260,27 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
} }
response := listResponse{ response := listResponse{
Items: items, Items: items,
Total: page.Total,
Page: page.Page,
Size: page.Size,
Counts: countsResponse{All: page.Counts.All, Unused: page.Counts.Unused, Duplicates: page.Counts.Duplicates}, Counts: countsResponse{All: page.Counts.All, Unused: page.Counts.Unused, Duplicates: page.Counts.Duplicates},
} }
if page.NextCursor != "" {
response.NextCursor = &page.NextCursor
}
writeJSON(w, http.StatusOK, response) writeJSON(w, http.StatusOK, response)
} }
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
item, err := h.ListUseCase.Get(id)
if err != nil {
writeImageError(w, err)
return
}
writeJSON(w, http.StatusOK, toResponse(item))
}
func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) { func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
userId, _ := claims.UserId(r) userId, _ := claims.UserId(r)
input, closeFile, ok := upload.ReadImageInput(w, r, userId) input, closeFile, ok := upload.ReadImageInput(w, r, userId)
@@ -205,12 +289,13 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
} }
defer closeFile() defer closeFile()
created, err := h.CreateUseCase.CreateImage(input) created, err := h.CreateUseCase.CreateImage(r.Context(), input)
if err != nil { if err != nil {
upload.WriteCreateError(w, err) upload.WriteCreateError(w, err)
return return
} }
h.GenerateOnUploadUseCase.Generate(r.Context(), created)
h.writeItem(w, http.StatusCreated, created) h.writeItem(w, http.StatusCreated, created)
} }
@@ -239,7 +324,7 @@ func (h *Handler) Rename(w http.ResponseWriter, r *http.Request) {
return return
} }
renamed, err := h.RenameUseCase.Rename(id, request.Filename) renamed, err := h.RenameUseCase.Rename(r.Context(), id, request.Filename)
if err != nil { if err != nil {
if errors.Is(err, domainImage.ErrInvalidFilename) { if errors.Is(err, domainImage.ErrInvalidFilename) {
http.Error(w, apperr.MediaFilenameErrorMessage, http.StatusBadRequest) http.Error(w, apperr.MediaFilenameErrorMessage, http.StatusBadRequest)
@@ -258,7 +343,7 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := h.DeleteUseCase.Delete(id); err != nil { if err := h.DeleteUseCase.Delete(r.Context(), id); err != nil {
writeImageError(w, err) writeImageError(w, err)
return return
} }
@@ -272,15 +357,15 @@ func (h *Handler) Usage(w http.ResponseWriter, r *http.Request) {
return return
} }
articles, err := h.UsageUseCase.Usage(id) usages, err := h.UsageUseCase.Usage(id)
if err != nil { if err != nil {
writeImageError(w, err) writeImageError(w, err)
return return
} }
response := make([]usageResponse, 0, len(articles)) response := make([]usageResponse, 0, len(usages))
for _, a := range articles { for _, usage := range usages {
response = append(response, usageResponse{ID: a.ID, Title: a.Title, Published: a.IsOnline}) response = append(response, toUsageResponse(usage))
} }
writeJSON(w, http.StatusOK, response) writeJSON(w, http.StatusOK, response)
} }
@@ -296,13 +381,13 @@ func (h *Handler) Merge(w http.ResponseWriter, r *http.Request) {
return return
} }
result, err := h.MergeUseCase.Merge(request.KeepID, request.MergeIDs) result, err := h.MergeUseCase.Merge(r.Context(), request.KeepID, request.MergeIDs)
if err != nil { if err != nil {
writeMergeError(w, err) writeMergeError(w, err)
return return
} }
writeJSON(w, http.StatusOK, mergeResponse{ArticlesUpdated: result.ArticlesUpdated, Merged: result.Merged}) writeJSON(w, http.StatusOK, mergeResponse{ArticlesUpdated: result.ArticlesUpdated, SitesUpdated: result.SitesUpdated, AuthorsUpdated: result.AuthorsUpdated, Merged: result.Merged})
} }
func writeMergeError(w http.ResponseWriter, err error) { func writeMergeError(w http.ResponseWriter, err error) {
+173
View File
@@ -0,0 +1,173 @@
package media
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/purge"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"io"
"net/http"
"time"
)
type PurgeSettingsRequest struct {
AutoPurgeEnabled *bool `json:"auto_purge_enabled"`
AutoPurgeIntervalDays *int `json:"auto_purge_interval_days" validate:"omitempty,gte=1,lte=365"`
}
type PurgeRunRequest struct {
Fingerprint *string `json:"fingerprint"`
}
type purgeSettingsResponse struct {
AutoPurgeEnabled bool `json:"auto_purge_enabled"`
AutoPurgeIntervalDays int `json:"auto_purge_interval_days"`
LastPurgeAt *time.Time `json:"last_purge_at"`
LastPurgeMerged int `json:"last_purge_merged"`
LastPurgeArticlesUpdated int `json:"last_purge_articles_updated"`
EnabledAt *time.Time `json:"enabled_at"`
NextPurgeAt *time.Time `json:"next_purge_at"`
}
type purgePreviewGroupResponse struct {
KeepID uint32 `json:"keep_id"`
Filename string `json:"filename"`
URL string `json:"url"`
UsedIn int `json:"used_in"`
Merged int `json:"merged"`
}
type purgePreviewResponse struct {
Groups int `json:"groups"`
Copies int `json:"copies"`
Articles int `json:"articles"`
List []purgePreviewGroupResponse `json:"list"`
More int `json:"more"`
Fingerprint string `json:"fingerprint"`
}
type purgeRunResponse struct {
GroupsTotal int `json:"groups_total"`
GroupsPurged int `json:"groups_purged"`
Merged int `json:"merged"`
ArticlesUpdated int `json:"articles_updated"`
SitesUpdated int `json:"sites_updated"`
AuthorsUpdated int `json:"authors_updated"`
FailedGroups int `json:"failed_groups"`
}
func toPurgeSettingsResponse(settings purge.Settings) purgeSettingsResponse {
return purgeSettingsResponse{
AutoPurgeEnabled: settings.AutoPurgeEnabled,
AutoPurgeIntervalDays: settings.AutoPurgeIntervalDays,
LastPurgeAt: settings.LastPurgeAt,
LastPurgeMerged: settings.LastPurgeMerged,
LastPurgeArticlesUpdated: settings.LastPurgeArticlesUpdated,
EnabledAt: settings.EnabledAt,
NextPurgeAt: settings.NextPurgeAt(),
}
}
func writePurgeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, purge.ErrInvalidPurgeInterval):
http.Error(w, apperr.PurgeIntervalErrorMessage, http.StatusBadRequest)
case errors.Is(err, purge.ErrPurgeConflict):
http.Error(w, apperr.PurgeConflictErrorMessage, http.StatusConflict)
case errors.Is(err, purge.ErrPurgeBusy):
http.Error(w, apperr.PurgeBusyErrorMessage, http.StatusConflict)
default:
writeInternalServerError(w)
}
}
func (h *Handler) GetPurgeSettings(w http.ResponseWriter, _ *http.Request) {
settings, err := h.PurgeSettingsUseCase.Get()
if err != nil {
writePurgeError(w, err)
return
}
writeJSON(w, http.StatusOK, toPurgeSettingsResponse(settings))
}
func (h *Handler) UpdatePurgeSettings(w http.ResponseWriter, r *http.Request) {
var request PurgeSettingsRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, apperr.PurgeIntervalErrorMessage, http.StatusBadRequest)
return
}
settings, err := h.UpdatePurgeSettingsUseCase.Update(useCases.UpdatePurgeSettingsCommand{
AutoPurgeEnabled: request.AutoPurgeEnabled,
AutoPurgeIntervalDays: request.AutoPurgeIntervalDays,
})
if err != nil {
writePurgeError(w, err)
return
}
writeJSON(w, http.StatusOK, toPurgeSettingsResponse(settings))
}
func (h *Handler) PreviewPurge(w http.ResponseWriter, _ *http.Request) {
preview, err := h.PreviewPurgeUseCase.Preview()
if err != nil {
writePurgeError(w, err)
return
}
list := make([]purgePreviewGroupResponse, 0, len(preview.List))
for _, group := range preview.List {
list = append(list, purgePreviewGroupResponse{
KeepID: group.KeepID,
Filename: group.Filename,
URL: group.URL,
UsedIn: group.UsedIn,
Merged: group.Merged,
})
}
writeJSON(w, http.StatusOK, purgePreviewResponse{
Groups: preview.Groups,
Copies: preview.Copies,
Articles: preview.Articles,
List: list,
More: preview.More,
Fingerprint: preview.Fingerprint,
})
}
func (h *Handler) RunPurge(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
var request PurgeRunRequest
if len(raw) > 0 {
if err := json.Unmarshal(raw, &request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
}
result, err := h.RunPurgeUseCase.Run(r.Context(), request.Fingerprint)
if err != nil {
writePurgeError(w, err)
return
}
writeJSON(w, http.StatusOK, purgeRunResponse{
GroupsTotal: result.GroupsTotal,
GroupsPurged: result.GroupsPurged,
Merged: result.Merged,
ArticlesUpdated: result.ArticlesUpdated,
SitesUpdated: result.SitesUpdated,
AuthorsUpdated: result.AuthorsUpdated,
FailedGroups: result.FailedGroups,
})
}
+161
View File
@@ -0,0 +1,161 @@
package media
import (
"RenewCMS/api/pkg/apperr"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
)
type imageVariantResponse struct {
Name string `json:"name"`
Width *int `json:"width"`
Height *int `json:"height"`
Fit domainImage.VariantFit `json:"fit"`
Format domainImage.VariantFormat `json:"format"`
OutputWidth int `json:"output_width"`
OutputHeight int `json:"output_height"`
OutputFormat domainImage.VariantFormat `json:"output_format"`
Generated bool `json:"generated"`
Size *int64 `json:"size"`
URL string `json:"url"`
}
type imageVariantListResponse struct {
Unsupported *string `json:"unsupported"`
Items []imageVariantResponse `json:"items"`
}
type variantFailureResponse struct {
Name string `json:"name"`
Cause string `json:"cause"`
}
type generateAllResponse struct {
Generated int `json:"generated"`
Failed int `json:"failed"`
Failures []variantFailureResponse `json:"failures"`
Items []imageVariantResponse `json:"items"`
}
type variantPurgeResponse struct {
PurgedFiles int `json:"purged_files"`
PurgedBytes int64 `json:"purged_bytes"`
}
func toImageVariantResponse(view useCases.ImageVariantView) imageVariantResponse {
response := imageVariantResponse{
Name: view.Variant.Name,
Width: view.Variant.Width,
Height: view.Variant.Height,
Fit: view.Variant.Fit,
Format: view.Variant.Format,
OutputWidth: view.OutputWidth,
OutputHeight: view.OutputHeight,
OutputFormat: view.OutputFormat,
Generated: view.Generated,
URL: view.URL,
}
if view.Generated {
size := view.Size
response.Size = &size
}
return response
}
func toImageVariantResponses(views []useCases.ImageVariantView) []imageVariantResponse {
items := make([]imageVariantResponse, 0, len(views))
for _, view := range views {
items = append(items, toImageVariantResponse(view))
}
return items
}
func writeImageVariantError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domainImage.ErrVariantNotFound):
http.Error(w, apperr.VariantNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domainImage.ErrVariantSourceUnsupported):
http.Error(w, apperr.VariantSourceUnsupportedErrorMessage, http.StatusUnprocessableEntity)
case errors.Is(err, domainImage.ErrSourceTooLarge):
http.Error(w, apperr.VariantSourceTooLargeErrorMessage, http.StatusRequestEntityTooLarge)
case errors.Is(err, domainImage.ErrVariantSourceUnreadable):
http.Error(w, apperr.VariantSourceUnreadableErrorMessage, http.StatusUnsupportedMediaType)
default:
writeImageError(w, err)
}
}
func (h *Handler) ListVariants(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
list, err := h.ListVariantsUseCase.List(id)
if err != nil {
writeImageVariantError(w, err)
return
}
response := imageVariantListResponse{Items: toImageVariantResponses(list.Items)}
if list.Unsupported != "" {
response.Unsupported = &list.Unsupported
}
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) GenerateVariant(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
view, err := h.GenerateVariantUseCase.Generate(r.Context(), id, chi.URLParam(r, "name"))
if err != nil {
writeImageVariantError(w, err)
return
}
writeJSON(w, http.StatusOK, toImageVariantResponse(view))
}
func (h *Handler) GenerateAllVariants(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
result, err := h.GenerateAllUseCase.Generate(r.Context(), id)
if err != nil {
writeImageVariantError(w, err)
return
}
failures := make([]variantFailureResponse, 0, len(result.Failures))
for _, failure := range result.Failures {
failures = append(failures, variantFailureResponse{Name: failure.Name, Cause: failure.Cause})
}
writeJSON(w, http.StatusOK, generateAllResponse{
Generated: result.Generated,
Failed: result.Failed,
Failures: failures,
Items: toImageVariantResponses(result.Items),
})
}
func (h *Handler) PurgeVariant(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
purged, err := h.PurgeVariantUseCase.Purge(r.Context(), id, chi.URLParam(r, "name"))
if err != nil {
writeImageVariantError(w, err)
return
}
writeJSON(w, http.StatusOK, variantPurgeResponse{PurgedFiles: purged.Files, PurgedBytes: purged.Bytes})
}
@@ -0,0 +1,68 @@
package ownership
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
)
type Handler struct {
TransferUseCase *useCases.TransferOwnershipUseCase
}
type TransferRequest struct {
UserID uint32 `json:"user_id" validate:"required"`
ConfirmEmail string `json:"confirm_email" validate:"required"`
}
func NewHandler(transfer *useCases.TransferOwnershipUseCase) *Handler {
return &Handler{TransferUseCase: transfer}
}
func (h *Handler) Transfer(w http.ResponseWriter, r *http.Request) {
var request TransferRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
transferred, err := h.TransferUseCase.Transfer(r.Context(), useCases.TransferOwnershipCommand{
Caller: access.CallerOf(r),
UserID: request.UserID,
ConfirmEmail: request.ConfirmEmail,
})
if err != nil {
writeTransferError(w, err)
return
}
body, err := json.Marshal(transferred)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
func writeTransferError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, user.ErrInvalidTransfer):
http.Error(w, apperr.InvalidTransferErrorMessage, http.StatusBadRequest)
case errors.Is(err, user.ErrConfirmationMismatch):
http.Error(w, apperr.ConfirmationMismatchErrorMessage, http.StatusBadRequest)
case errors.Is(err, user.ErrRoleChanged):
http.Error(w, apperr.UserRoleChangedErrorMessage, http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
@@ -0,0 +1,53 @@
package permission
import (
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"net/http"
)
type Handler struct {
ListUseCase *useCases.ListPermissionsUseCase
}
func NewHandler(list *useCases.ListPermissionsUseCase) *Handler {
return &Handler{ListUseCase: list}
}
type permissionItem struct {
Key string `json:"key"`
ScopeKind role.Kind `json:"scope_kind"`
OwnerOnly bool `json:"owner_only"`
PerType bool `json:"per_type"`
}
type permissionGroup struct {
Key string `json:"key"`
Items []permissionItem `json:"items"`
}
type permissionResponse struct {
Groups []permissionGroup `json:"groups"`
}
func toResponse(groups []useCases.PermissionGroup) permissionResponse {
response := permissionResponse{Groups: make([]permissionGroup, 0, len(groups))}
for _, group := range groups {
items := make([]permissionItem, 0, len(group.Items))
for _, permission := range group.Items {
items = append(items, permissionItem{Key: permission.Key, ScopeKind: permission.Kind, OwnerOnly: permission.OwnerOnly, PerType: permission.PerType()})
}
response.Groups = append(response.Groups, permissionGroup{Key: group.Key, Items: items})
}
return response
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
body, err := json.Marshal(toResponse(h.ListUseCase.List()))
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
_, _ = w.Write(body)
}
+249
View File
@@ -0,0 +1,249 @@
package role
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"log"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
type Handler struct {
ListUseCase *useCases.ListRolesUseCase
GetUseCase *useCases.GetRoleUseCase
CreateUseCase *useCases.CreateRoleUseCase
UpdateUseCase *useCases.UpdateRoleUseCase
DeleteUseCase *useCases.DeleteRoleUseCase
ReassignUseCase *useCases.ReassignRoleMembersUseCase
}
func NewHandler(list *useCases.ListRolesUseCase, get *useCases.GetRoleUseCase, create *useCases.CreateRoleUseCase, update *useCases.UpdateRoleUseCase, deleteUseCase *useCases.DeleteRoleUseCase, reassign *useCases.ReassignRoleMembersUseCase) *Handler {
return &Handler{ListUseCase: list, GetUseCase: get, CreateUseCase: create, UpdateUseCase: update, DeleteUseCase: deleteUseCase, ReassignUseCase: reassign}
}
type GrantRequest struct {
Key string `json:"key" validate:"required,max=100"`
Scope string `json:"scope"`
}
type PostRole struct {
Name string `json:"name" validate:"required,max=100"`
Description string `json:"description" validate:"max=255"`
Grants []GrantRequest `json:"grants" validate:"dive"`
}
type PutRole struct {
Name string `json:"name" validate:"required,max=100"`
Description string `json:"description" validate:"max=255"`
Grants []GrantRequest `json:"grants" validate:"dive"`
Version uint32 `json:"version" validate:"required,min=1"`
}
type ResetRole struct {
Version uint32 `json:"version" validate:"required,min=1"`
}
type ReassignRole struct {
TargetRoleID uint32 `json:"target_role_id" validate:"required"`
}
func toGrants(requested []GrantRequest) []domain.Grant {
grants := make([]domain.Grant, 0, len(requested))
for _, grant := range requested {
grants = append(grants, domain.Grant{Key: grant.Key, Scope: domain.Scope(grant.Scope)})
}
return grants
}
func parseRoleId(r *http.Request) (uint32, error) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
return uint32(id), err
}
func decodeRequest(w http.ResponseWriter, r *http.Request, request any) bool {
if err := json.NewDecoder(r.Body).Decode(request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return false
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return false
}
return true
}
func readId(w http.ResponseWriter, r *http.Request) (uint32, bool) {
id, err := parseRoleId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return 0, false
}
return id, true
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
body, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if status != http.StatusOK {
w.WriteHeader(status)
}
if _, err := w.Write(body); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
type refusal struct {
cause error
status int
message string
}
var refusals = []refusal{
{domain.ErrRoleNotFound, http.StatusNotFound, apperr.RoleNotFoundErrorMessage},
{domain.ErrRoleLocked, http.StatusForbidden, apperr.RoleLockedErrorMessage},
{domain.ErrBuiltInRole, http.StatusConflict, apperr.RoleBuiltInErrorMessage},
{domain.ErrBuiltInName, http.StatusBadRequest, apperr.BuiltInNameErrorMessage},
{domain.ErrNotBuiltIn, http.StatusBadRequest, apperr.NotBuiltInErrorMessage},
{domain.ErrOwnRole, http.StatusForbidden, apperr.OwnRoleErrorMessage},
{domain.ErrRoleNameTaken, http.StatusConflict, apperr.RoleNameTakenErrorMessage},
{domain.ErrRoleConflict, http.StatusConflict, apperr.RoleConflictErrorMessage},
{domain.ErrRoleHasMembers, http.StatusConflict, apperr.RoleHasMembersErrorMessage},
{domain.ErrInvalidReassign, http.StatusBadRequest, apperr.InvalidReassignErrorMessage},
{domain.ErrInvalidRoleName, http.StatusBadRequest, apperr.InvalidRoleNameErrorMessage},
{domain.ErrUnknownPermission, http.StatusBadRequest, apperr.UnknownPermissionErrorMessage},
{domain.ErrInvalidScope, http.StatusBadRequest, apperr.InvalidScopeErrorMessage},
{domain.ErrOwnerOnlyGrant, http.StatusBadRequest, apperr.OwnerOnlyPermissionErrorMessage},
{domain.ErrDuplicateGrant, http.StatusBadRequest, apperr.DuplicatePermissionErrorMessage},
{domain.ErrUnknownContentType, http.StatusBadRequest, apperr.UnknownContentTypeErrorMessage},
}
func writeRoleError(w http.ResponseWriter, err error) {
if access.WriteIfMissingPermission(w, err) {
return
}
var escalation domain.EscalationError
if errors.As(err, &escalation) {
w.Header().Set(apperr.EscalationHeader, escalation.Key)
http.Error(w, apperr.EscalationErrorMessage, http.StatusForbidden)
return
}
for _, candidate := range refusals {
if errors.Is(err, candidate.cause) {
http.Error(w, candidate.message, candidate.status)
return
}
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
func (h *Handler) List(w http.ResponseWriter, _ *http.Request) {
listed, err := h.ListUseCase.List()
if err != nil {
writeRoleError(w, err)
return
}
writeJSON(w, http.StatusOK, listed)
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
id, ok := readId(w, r)
if !ok {
return
}
found, err := h.GetUseCase.Get(id)
if err != nil {
writeRoleError(w, err)
return
}
writeJSON(w, http.StatusOK, found)
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var request PostRole
if !decodeRequest(w, r, &request) {
return
}
created, err := h.CreateUseCase.Create(r.Context(), useCases.CreateRoleCommand{
Caller: access.CallerOf(r), Name: request.Name, Description: request.Description, Grants: toGrants(request.Grants),
})
if err != nil {
writeRoleError(w, err)
return
}
writeJSON(w, http.StatusCreated, created)
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
id, ok := readId(w, r)
if !ok {
return
}
var request PutRole
if !decodeRequest(w, r, &request) {
return
}
updated, err := h.UpdateUseCase.Update(r.Context(), useCases.UpdateRoleCommand{
Caller: access.CallerOf(r), ID: id, Name: request.Name, Description: request.Description, Grants: toGrants(request.Grants), Version: request.Version,
})
if err != nil {
writeRoleError(w, err)
return
}
writeJSON(w, http.StatusOK, updated)
}
func (h *Handler) Reset(w http.ResponseWriter, r *http.Request) {
id, ok := readId(w, r)
if !ok {
return
}
var request ResetRole
if !decodeRequest(w, r, &request) {
return
}
restored, err := h.UpdateUseCase.Reset(r.Context(), useCases.ResetRoleCommand{Caller: access.CallerOf(r), ID: id, Version: request.Version})
if err != nil {
writeRoleError(w, err)
return
}
writeJSON(w, http.StatusOK, restored)
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
id, ok := readId(w, r)
if !ok {
return
}
if err := h.DeleteUseCase.Delete(r.Context(), useCases.DeleteRoleCommand{Caller: access.CallerOf(r), ID: id}); err != nil {
writeRoleError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Reassign(w http.ResponseWriter, r *http.Request) {
id, ok := readId(w, r)
if !ok {
return
}
var request ReassignRole
if !decodeRequest(w, r, &request) {
return
}
moved, err := h.ReassignUseCase.Reassign(r.Context(), useCases.ReassignRoleMembersCommand{Caller: access.CallerOf(r), SourceID: id, TargetID: request.TargetRoleID})
if err != nil {
writeRoleError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]int{"moved": moved})
}
+87 -3
View File
@@ -1,21 +1,105 @@
package settings package settings
import ( import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"encoding/json" "encoding/json"
"errors"
"io"
"net/http" "net/http"
"time"
) )
type Handler struct { type Handler struct {
GetSettingsUseCase *useCases.GetSettingsUseCase GetSettingsUseCase *useCases.GetSettingsUseCase
GetActivityRetentionUseCase *useCases.GetActivityRetentionUseCase
UpdateActivityRetentionUseCase *useCases.UpdateActivityRetentionUseCase
} }
func NewHandler(getSettings *useCases.GetSettingsUseCase) *Handler { type UpdateSettingsRequest struct {
return &Handler{GetSettingsUseCase: getSettings} ActivityRetentionDays *int `json:"activity_retention_days" validate:"omitempty,oneof=0 30 90 365"`
ActivityRetentionMaxGB *int `json:"activity_retention_max_gb" validate:"omitempty,oneof=0 1 5 10 25 50"`
}
type settingsResponse struct {
Version string `json:"version"`
ActivityRetentionDays int `json:"activity_retention_days"`
ActivityRetentionMaxGB int `json:"activity_retention_max_gb"`
ActivityLogSizeBytes int64 `json:"activity_log_size_bytes"`
ActivityLastCleanupAt *time.Time `json:"activity_last_cleanup_at"`
ActivityLastCleanupRemoved int `json:"activity_last_cleanup_removed"`
ActivityNextCleanupAt *time.Time `json:"activity_next_cleanup_at"`
}
func NewHandler(getSettings *useCases.GetSettingsUseCase, getRetention *useCases.GetActivityRetentionUseCase, updateRetention *useCases.UpdateActivityRetentionUseCase) *Handler {
return &Handler{GetSettingsUseCase: getSettings, GetActivityRetentionUseCase: getRetention, UpdateActivityRetentionUseCase: updateRetention}
} }
func (h *Handler) GetSettings(w http.ResponseWriter, _ *http.Request) { func (h *Handler) GetSettings(w http.ResponseWriter, _ *http.Request) {
body, err := json.Marshal(h.GetSettingsUseCase.GetSettings()) retention, err := h.GetActivityRetentionUseCase.GetActivityRetention()
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
h.write(w, retention)
}
func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil || hasNullValue(body) {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
var request UpdateSettingsRequest
if err := json.Unmarshal(body, &request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
retention, err := h.UpdateActivityRetentionUseCase.UpdateActivityRetention(r.Context(), useCases.UpdateActivityRetentionCommand{
Days: request.ActivityRetentionDays,
MaxGB: request.ActivityRetentionMaxGB,
})
if err != nil {
if errors.Is(err, audit.ErrInvalidRetention) {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
h.write(w, retention)
}
func hasNullValue(body []byte) bool {
var fields map[string]json.RawMessage
if err := json.Unmarshal(body, &fields); err != nil {
return false
}
for _, value := range fields {
if string(value) == "null" {
return true
}
}
return false
}
func (h *Handler) write(w http.ResponseWriter, retention audit.Settings) {
body, err := json.Marshal(settingsResponse{
Version: h.GetSettingsUseCase.GetSettings().Version,
ActivityRetentionDays: retention.RetentionDays,
ActivityRetentionMaxGB: retention.RetentionMaxGB,
ActivityLogSizeBytes: retention.LogSizeBytes,
ActivityLastCleanupAt: retention.LastCleanupAt,
ActivityLastCleanupRemoved: retention.LastCleanupRemoved,
ActivityNextCleanupAt: retention.NextCleanupAt,
})
if err != nil { if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
+296
View File
@@ -0,0 +1,296 @@
package site
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/image"
domain "RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"log"
"net/http"
"net/url"
"strconv"
"github.com/go-chi/chi/v5"
)
type Handler struct {
ListUseCase *useCases.ListSitesUseCase
CreateUseCase *useCases.CreateSiteUseCase
UpdateUseCase *useCases.UpdateSiteUseCase
SetDefaultUseCase *useCases.SetDefaultSiteUseCase
DeleteUseCase *useCases.DeleteSiteUseCase
}
func NewHandler(list *useCases.ListSitesUseCase, create *useCases.CreateSiteUseCase, update *useCases.UpdateSiteUseCase, setDefault *useCases.SetDefaultSiteUseCase, deleteUseCase *useCases.DeleteSiteUseCase) *Handler {
return &Handler{ListUseCase: list, CreateUseCase: create, UpdateUseCase: update, SetDefaultUseCase: setDefault, DeleteUseCase: deleteUseCase}
}
type PostSite struct {
Name string `json:"name" validate:"required,sitename"`
Key string `json:"key" validate:"required,sitekey"`
BaseURL string `json:"base_url" validate:"required"`
DefaultRobots string `json:"default_robots" validate:"omitempty,oneof=index noindex"`
Locale *string `json:"locale"`
DefaultSocialImageID *uint32 `json:"default_social_image_id"`
IsDefault bool `json:"is_default"`
SiteName string `json:"site_name" validate:"max=100"`
TitleTemplate *string `json:"title_template" validate:"omitempty,max=200"`
DefaultDescription string `json:"default_description" validate:"max=320"`
}
type optional[T any] struct {
Set bool
Value *T
}
func (o *optional[T]) UnmarshalJSON(data []byte) error {
o.Set = true
if string(data) == "null" {
o.Value = nil
return nil
}
var value T
if err := json.Unmarshal(data, &value); err != nil {
return err
}
o.Value = &value
return nil
}
type PatchSite struct {
Name *string `json:"name" validate:"omitempty,sitename"`
Key *string `json:"key" validate:"omitempty,sitekey"`
BaseURL *string `json:"base_url"`
DefaultRobots *string `json:"default_robots" validate:"omitempty,oneof=index noindex"`
Locale optional[string] `json:"locale"`
DefaultSocialImageID optional[uint32] `json:"default_social_image_id"`
IsDefault *bool `json:"is_default"`
SiteName *string `json:"site_name" validate:"omitempty,max=100"`
TitleTemplate *string `json:"title_template" validate:"omitempty,max=200"`
DefaultDescription *string `json:"default_description" validate:"omitempty,max=320"`
}
func (p PatchSite) toPatch() domain.Patch {
return domain.Patch{
Name: p.Name,
Key: p.Key,
BaseURL: p.BaseURL,
DefaultRobots: p.DefaultRobots,
Locale: domain.Nullable[string]{Set: p.Locale.Set, Value: p.Locale.Value},
DefaultSocialImageID: domain.Nullable[uint32]{Set: p.DefaultSocialImageID.Set, Value: p.DefaultSocialImageID.Value},
IsDefault: p.IsDefault,
SiteName: p.SiteName,
TitleTemplate: p.TitleTemplate,
DefaultDescription: p.DefaultDescription,
}
}
func parseSiteId(r *http.Request) (uint32, error) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
return uint32(id), err
}
func writeSite(w http.ResponseWriter, status int, s domain.Site) {
body, err := json.Marshal(s)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if status != http.StatusOK {
w.WriteHeader(status)
}
if _, err := w.Write(body); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
func writeSiteError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domain.ErrSiteNotFound):
http.Error(w, apperr.SiteNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrSiteKeyTaken):
http.Error(w, apperr.SiteKeyTakenErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrSiteHasArticles):
http.Error(w, apperr.SiteHasArticlesErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrDefaultSite):
http.Error(w, apperr.DefaultSiteErrorMessage, http.StatusForbidden)
case errors.Is(err, domain.ErrKeyImmutable):
http.Error(w, apperr.SiteKeyImmutableErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidSiteURL):
http.Error(w, apperr.InvalidSiteURLErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidTitleTemplate):
http.Error(w, apperr.InvalidTitleTemplateErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidLocale):
http.Error(w, apperr.InvalidLocaleErrorMessage, http.StatusBadRequest)
case errors.Is(err, image.ErrImageNotFound):
http.Error(w, apperr.SiteImageNotFoundErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrUseSetDefault):
http.Error(w, apperr.UseSetDefaultErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrInvalidReassign):
http.Error(w, apperr.InvalidReassignErrorMessage, http.StatusBadRequest)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
const (
defaultListPage = 1
defaultListSize = 25
)
type listQuery struct {
Page int `validate:"gte=1"`
Size int `validate:"omitempty,oneof=5 25 50 100"`
Q string `validate:"max=200"`
}
func intQueryParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
}
func readListQuery(values url.Values) (listQuery, error) {
paged := values.Has("page") || values.Has("size")
page, err := intQueryParam(values, "page", defaultListPage)
if err != nil {
return listQuery{}, err
}
size := 0
if paged {
size, err = intQueryParam(values, "size", defaultListSize)
if err != nil {
return listQuery{}, err
}
if size == 0 {
return listQuery{}, errors.New("invalid size")
}
}
return listQuery{Page: page, Size: size, Q: values.Get("q")}, nil
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
query, err := readListQuery(r.URL.Query())
if err != nil || validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
page, err := h.ListUseCase.ListPage(useCases.ListSitesCommand{Q: query.Q, Page: query.Page, Size: query.Size})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
body, err := json.Marshal(page.Items)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
w.Header().Set("X-Total-Count", strconv.Itoa(page.Total))
if _, err := w.Write(body); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var request PostSite
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
created, err := h.CreateUseCase.Create(r.Context(), useCases.CreateSiteCommand{
Name: request.Name,
Key: request.Key,
BaseURL: request.BaseURL,
DefaultRobots: request.DefaultRobots,
Locale: request.Locale,
DefaultSocialImageID: request.DefaultSocialImageID,
IsDefault: request.IsDefault,
SiteName: request.SiteName,
TitleTemplate: request.TitleTemplate,
DefaultDescription: request.DefaultDescription,
})
if err != nil {
writeSiteError(w, err)
return
}
writeSite(w, http.StatusCreated, created)
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
id, err := parseSiteId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
var request PatchSite
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
updated, err := h.UpdateUseCase.Update(r.Context(), id, request.toPatch())
if err != nil {
writeSiteError(w, err)
return
}
writeSite(w, http.StatusOK, updated)
}
func (h *Handler) SetDefault(w http.ResponseWriter, r *http.Request) {
id, err := parseSiteId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
updated, err := h.SetDefaultUseCase.SetDefault(r.Context(), id)
if err != nil {
writeSiteError(w, err)
return
}
writeSite(w, http.StatusOK, updated)
}
const reassignQueryParam = "reassign_to"
func readReassign(r *http.Request) (*uint32, bool) {
query := r.URL.Query()
if !query.Has(reassignQueryParam) {
return nil, false
}
parsed, err := strconv.ParseUint(query.Get(reassignQueryParam), 10, 32)
if err != nil {
return nil, true
}
destination := uint32(parsed)
return &destination, false
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := parseSiteId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
reassignTo, invalid := readReassign(r)
if err := h.DeleteUseCase.Delete(r.Context(), useCases.DeleteSiteCommand{ID: id, ReassignTo: reassignTo, ReassignInvalid: invalid}); err != nil {
writeSiteError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+87
View File
@@ -0,0 +1,87 @@
package site
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"log"
"net/http"
"net/url"
"strconv"
"time"
"github.com/go-chi/chi/v5"
)
const (
defaultSitemapPage = 1
defaultSitemapSize = 100
totalCountHeader = "X-Total-Count"
)
type SitemapHandler struct {
ListUseCase *useCases.ListSitemapUseCase
}
func NewSitemapHandler(list *useCases.ListSitemapUseCase) *SitemapHandler {
return &SitemapHandler{ListUseCase: list}
}
type sitemapQuery struct {
Page int `validate:"gte=1"`
Size int `validate:"oneof=25 50 100 500"`
}
type sitemapItem struct {
URL string `json:"url"`
ModifiedAt string `json:"modified_at"`
Noindex bool `json:"noindex"`
}
func sitemapIntParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
}
func readSitemapQuery(values url.Values) (sitemapQuery, error) {
page, err := sitemapIntParam(values, "page", defaultSitemapPage)
if err != nil {
return sitemapQuery{}, err
}
size, err := sitemapIntParam(values, "size", defaultSitemapSize)
if err != nil {
return sitemapQuery{}, err
}
return sitemapQuery{Page: page, Size: size}, nil
}
func (h *SitemapHandler) Sitemap(w http.ResponseWriter, r *http.Request) {
query, err := readSitemapQuery(r.URL.Query())
if err != nil || validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
page, err := h.ListUseCase.List(chi.URLParam(r, "key"), query.Page, query.Size)
if err != nil {
writeSiteError(w, err)
return
}
items := make([]sitemapItem, 0, len(page.Entries))
for _, entry := range page.Entries {
items = append(items, sitemapItem{URL: entry.URL, ModifiedAt: entry.ModifiedAt.UTC().Format(time.RFC3339), Noindex: entry.Noindex})
}
body, err := json.Marshal(items)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
w.Header().Set(totalCountHeader, strconv.Itoa(page.Total))
if _, err := w.Write(body); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
+114 -30
View File
@@ -1,11 +1,15 @@
package staticimages package staticimages
import ( import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"io" "io"
"io/fs"
"net/http" "net/http"
"os"
"path" "path"
"strconv"
"strings" "strings"
"time" "time"
@@ -15,51 +19,147 @@ import (
const cacheControl = "public, no-cache" const cacheControl = "public, no-cache"
const svgContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; sandbox" const svgContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
const fallbackContentType = "application/octet-stream" const fallbackContentType = "application/octet-stream"
const unknownVariantCode = "unknown_variant"
const unsupportedParameterCode = "unsupported_parameter"
var contentTypeExtensions = map[string]string{ var contentTypeExtensions = map[string]string{
".png": "image/png", ".png": "image/png",
".jpeg": "image/jpeg", ".jpeg": "image/jpeg",
".webp": "image/webp", ".webp": "image/webp",
".svg": "image/svg+xml", ".svg": "image/svg+xml",
".gif": "image/gif",
} }
type Handler struct { type Handler struct {
uploadDir fs.FS openMedia *useCases.OpenMediaUseCase
serveVariant *useCases.ServeVariantUseCase
modTime time.Time modTime time.Time
} }
func NewHandler(uploadDir string) *Handler { func NewHandler(openMedia *useCases.OpenMediaUseCase, serveVariant *useCases.ServeVariantUseCase) *Handler {
return &Handler{ return &Handler{
uploadDir: os.DirFS(uploadDir), openMedia: openMedia,
serveVariant: serveVariant,
modTime: time.Now(), modTime: time.Now(),
} }
} }
type requestErrorBody struct {
Status int `json:"status"`
Error string `json:"error"`
Message string `json:"message"`
Detail string `json:"detail"`
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")
cleaned := cleanPath(requestedPath(r)) cleaned := cleanPath(requestedPath(r))
file, stat, ok := h.openFile(cleaned) served, err := h.serveVariant.Serve(variantRequestFrom(r, cleaned))
if !ok { if err != nil {
writeServeError(w, r, err)
return
}
switch {
case served.NotModified:
writeNotModified(w, served)
case served.Original:
h.serveOriginal(w, r, cleaned)
default:
serveVariantFile(w, r, cleaned, served)
}
}
func variantRequestFrom(r *http.Request, storedName string) useCases.ServeVariantRequest {
query := r.URL.Query()
request := useCases.ServeVariantRequest{Context: r.Context(), StoredName: storedName, IfNoneMatch: r.Header.Get("If-None-Match")}
for parameter := range query {
request.Parameters = append(request.Parameters, parameter)
}
if query.Has(image.VariantQueryParameter) {
name := query.Get(image.VariantQueryParameter)
request.Variant = &name
}
return request
}
func writeServeError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, image.ErrUnknownVariant):
writeRequestError(w, unknownVariantCode, apperr.VariantUnknownMessage, err)
case errors.Is(err, image.ErrUnsupportedParameter):
writeRequestError(w, unsupportedParameterCode, apperr.VariantUnsupportedParameterMessage, err)
case errors.Is(err, image.ErrBlobNotFound), errors.Is(err, image.ErrImageNotFound):
http.NotFound(w, r)
case errors.Is(err, image.ErrSourceTooLarge):
http.Error(w, apperr.VariantSourceTooLargeErrorMessage, http.StatusRequestEntityTooLarge)
default:
http.Error(w, "Unable to read the file", http.StatusInternalServerError)
}
}
func writeRequestError(w http.ResponseWriter, code string, message string, err error) {
body, marshalErr := json.Marshal(requestErrorBody{
Status: http.StatusBadRequest,
Error: code,
Message: message,
Detail: image.VariantRequestDetail(err),
})
if marshalErr != nil {
http.Error(w, "Unable to read the file", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write(body)
}
func writeNotModified(w http.ResponseWriter, served useCases.ServedVariant) {
w.Header().Set("ETag", served.ETag)
w.Header().Set("Cache-Control", cacheControl)
w.WriteHeader(http.StatusNotModified)
}
func serveVariantFile(w http.ResponseWriter, r *http.Request, name string, served useCases.ServedVariant) {
defer served.Content.Close()
w.Header().Set("ETag", served.ETag)
serveContent(w, r, name, served.ContentType, image.Blob{Content: served.Content, Size: served.Size, ModTime: served.ModTime})
}
func (h *Handler) serveOriginal(w http.ResponseWriter, r *http.Request, name string) {
blob, err := h.openMedia.Open(name)
if errors.Is(err, image.ErrBlobNotFound) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
defer file.Close() if err != nil {
http.Error(w, "Unable to read the file", http.StatusInternalServerError)
content, ok := file.(io.ReadSeeker)
if !ok {
http.Error(w, "File not seekable", http.StatusInternalServerError)
return return
} }
defer blob.Content.Close()
contentType := contentTypeFor(cleaned) serveContent(w, r, name, contentTypeFor(name), blob)
}
func serveContent(w http.ResponseWriter, r *http.Request, name string, contentType string, blob image.Blob) {
w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", cacheControl) w.Header().Set("Cache-Control", cacheControl)
if contentType == "image/svg+xml" { if contentType == "image/svg+xml" {
w.Header().Set("Content-Security-Policy", svgContentSecurityPolicy) w.Header().Set("Content-Security-Policy", svgContentSecurityPolicy)
} }
http.ServeContent(w, r, cleaned, stat.ModTime(), content) if content, ok := blob.Content.(io.ReadSeeker); ok {
http.ServeContent(w, r, name, blob.ModTime, content)
return
}
w.Header().Set("Content-Length", strconv.FormatInt(blob.Size, 10))
if !blob.ModTime.IsZero() {
w.Header().Set("Last-Modified", blob.ModTime.UTC().Format(http.TimeFormat))
}
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
_, _ = io.Copy(w, blob.Content)
}
} }
func requestedPath(r *http.Request) string { func requestedPath(r *http.Request) string {
@@ -73,22 +173,6 @@ func cleanPath(p string) string {
return strings.TrimPrefix(path.Clean("/"+p), "/") return strings.TrimPrefix(path.Clean("/"+p), "/")
} }
func (h *Handler) openFile(p string) (fs.File, fs.FileInfo, bool) {
if p == "" || p == "." {
return nil, nil, false
}
file, err := h.uploadDir.Open(p)
if err != nil {
return nil, nil, false
}
stat, err := file.Stat()
if err != nil || stat.IsDir() {
file.Close()
return nil, nil, false
}
return file, stat, true
}
func contentTypeFor(p string) string { func contentTypeFor(p string) string {
ext := path.Ext(p) ext := path.Ext(p)
if contentType, ok := contentTypeExtensions[ext]; ok { if contentType, ok := contentTypeExtensions[ext]; ok {
+11 -34
View File
@@ -1,11 +1,12 @@
package update package update
import ( import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/role"
domain "RenewCMS/internal/domain/update" domain "RenewCMS/internal/domain/update"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -21,45 +22,35 @@ type Handler struct {
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
RequestRestartUseCase *useCases.RequestRestartUseCase RequestRestartUseCase *useCases.RequestRestartUseCase
GetUserUseCase *useCases.GetUserUseCase
} }
type restartRequest struct { type restartRequest struct {
Kind *string `json:"kind" validate:"omitempty,oneof=update restart"` Kind *string `json:"kind" validate:"omitempty,oneof=update restart"`
} }
func NewHandler(getStatus *useCases.GetUpdateStatusUseCase, checkForUpdate *useCases.CheckForUpdateUseCase, startDownload *useCases.StartUpdateDownloadUseCase, requestRestart *useCases.RequestRestartUseCase, getUser *useCases.GetUserUseCase) *Handler { func NewHandler(getStatus *useCases.GetUpdateStatusUseCase, checkForUpdate *useCases.CheckForUpdateUseCase, startDownload *useCases.StartUpdateDownloadUseCase, requestRestart *useCases.RequestRestartUseCase) *Handler {
return &Handler{ return &Handler{
GetUpdateStatusUseCase: getStatus, GetUpdateStatusUseCase: getStatus,
CheckForUpdateUseCase: checkForUpdate, CheckForUpdateUseCase: checkForUpdate,
StartUpdateDownloadUseCase: startDownload, StartUpdateDownloadUseCase: startDownload,
RequestRestartUseCase: requestRestart, RequestRestartUseCase: requestRestart,
GetUserUseCase: getUser,
} }
} }
func (h *Handler) GetStatus(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetStatus(w http.ResponseWriter, r *http.Request) {
isOwner, ok := h.isOwner(w, r) writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(canApply(r)))
if !ok {
return
}
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner))
} }
func (h *Handler) Check(w http.ResponseWriter, r *http.Request) { func (h *Handler) Check(w http.ResponseWriter, r *http.Request) {
if _, err := h.CheckForUpdateUseCase.Check(); err != nil { if _, err := h.CheckForUpdateUseCase.Check(r.Context()); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
isOwner, ok := h.isOwner(w, r) writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(canApply(r)))
if !ok {
return
}
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner))
} }
func (h *Handler) Download(w http.ResponseWriter, _ *http.Request) { func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
status, err := h.StartUpdateDownloadUseCase.Start() status, err := h.StartUpdateDownloadUseCase.Start(r.Context())
if err != nil { if err != nil {
writeDownloadError(w, err) writeDownloadError(w, err)
return return
@@ -77,7 +68,7 @@ func (h *Handler) Restart(w http.ResponseWriter, r *http.Request) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return return
} }
status, err := h.RequestRestartUseCase.Request(userId, kind) status, err := h.RequestRestartUseCase.Request(r.Context(), userId, kind)
if err != nil { if err != nil {
writeRestartError(w, err) writeRestartError(w, err)
return return
@@ -105,22 +96,8 @@ func decodeRestartKind(w http.ResponseWriter, r *http.Request) (string, bool) {
return *request.Kind, true return *request.Kind, true
} }
func (h *Handler) isOwner(w http.ResponseWriter, r *http.Request) (bool, bool) { func canApply(r *http.Request) bool {
userId, ok := claims.UserId(r) return access.Grants(r).Holds(role.KeySystemApplyUpdates)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return false, false
}
currentUser, err := h.GetUserUseCase.GetUser(userId)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return false, false
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return false, false
}
return currentUser.Role == user.RoleOwner, true
} }
func writeDownloadError(w http.ResponseWriter, err error) { func writeDownloadError(w http.ResponseWriter, err error) {
+40 -19
View File
@@ -1,6 +1,7 @@
package user package user
import ( import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
@@ -25,6 +26,7 @@ type InviteHandler struct {
type CreateInviteRequest struct { type CreateInviteRequest struct {
Email string `json:"email" validate:"required,inviteemail"` Email string `json:"email" validate:"required,inviteemail"`
RoleID uint32 `json:"role_id"`
} }
type inviteResponse struct { type inviteResponse struct {
@@ -32,6 +34,7 @@ type inviteResponse struct {
Kind string `json:"kind"` Kind string `json:"kind"`
Email string `json:"email"` Email string `json:"email"`
Status string `json:"status"` Status string `json:"status"`
Role useCases.RoleRef `json:"role"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"` ExpiresAt time.Time `json:"expires_at"`
} }
@@ -40,6 +43,7 @@ type inviteListItem struct {
ID uint32 `json:"id"` ID uint32 `json:"id"`
Kind string `json:"kind"` Kind string `json:"kind"`
Email string `json:"email"` Email string `json:"email"`
Role *useCases.RoleRef `json:"role"`
Status string `json:"status"` Status string `json:"status"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
@@ -50,7 +54,8 @@ type userListItem struct {
Username string `json:"username"` Username string `json:"username"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Email string `json:"email"` Email string `json:"email"`
Role string `json:"role"` Role *useCases.RoleRef `json:"role"`
RoleAssignedBy *useCases.RoleAssigner `json:"role_assigned_by"`
Status string `json:"status"` Status string `json:"status"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
@@ -82,9 +87,11 @@ func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
return return
} }
created, err := h.CreateInviteUseCase.CreateInvite(useCases.CreateInviteCommand{ created, err := h.CreateInviteUseCase.CreateInvite(r.Context(), useCases.CreateInviteCommand{
Caller: access.CallerOf(r),
Email: request.Email, Email: request.Email,
InvitedBy: userId, InvitedBy: userId,
RoleID: request.RoleID,
}) })
if err != nil { if err != nil {
writeCreateInviteError(w, err) writeCreateInviteError(w, err)
@@ -94,12 +101,13 @@ func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
writeInviteResponse(w, http.StatusCreated, created) writeInviteResponse(w, http.StatusCreated, created)
} }
func writeInviteResponse(w http.ResponseWriter, status int, entry invite.Invite) { func writeInviteResponse(w http.ResponseWriter, status int, entry useCases.InviteView) {
body, err := json.Marshal(inviteResponse{ body, err := json.Marshal(inviteResponse{
ID: entry.ID, ID: entry.ID,
Kind: useCases.EntryKindInvite, Kind: useCases.EntryKindInvite,
Email: entry.Email, Email: entry.Email,
Status: string(entry.StatusAt(time.Now())), Status: string(entry.StatusAt(time.Now())),
Role: entry.Role,
CreatedAt: entry.CreatedAt, CreatedAt: entry.CreatedAt,
ExpiresAt: entry.ExpiresAt, ExpiresAt: entry.ExpiresAt,
}) })
@@ -124,7 +132,7 @@ func (h *InviteHandler) CancelInvite(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := h.CancelInviteUseCase.CancelInvite(id); err != nil { if err := h.CancelInviteUseCase.CancelInvite(r.Context(), id); err != nil {
writeManageInviteError(w, err) writeManageInviteError(w, err)
return return
} }
@@ -144,7 +152,7 @@ func (h *InviteHandler) RenewInvite(w http.ResponseWriter, r *http.Request) {
return return
} }
renewed, err := h.RenewInviteUseCase.RenewInvite(useCases.RenewInviteCommand{ID: id, RenewedBy: userId}) renewed, err := h.RenewInviteUseCase.RenewInvite(r.Context(), useCases.RenewInviteCommand{Caller: access.CallerOf(r), ID: id, RenewedBy: userId})
if err != nil { if err != nil {
writeManageInviteError(w, err) writeManageInviteError(w, err)
return return
@@ -153,6 +161,9 @@ func (h *InviteHandler) RenewInvite(w http.ResponseWriter, r *http.Request) {
} }
func writeManageInviteError(w http.ResponseWriter, err error) { func writeManageInviteError(w http.ResponseWriter, err error) {
if writeRoleRefusal(w, err) {
return
}
switch { switch {
case errors.Is(err, invite.ErrInviteNotFound): case errors.Is(err, invite.ErrInviteNotFound):
http.Error(w, apperr.InviteNotFoundErrorMessage, http.StatusNotFound) http.Error(w, apperr.InviteNotFoundErrorMessage, http.StatusNotFound)
@@ -168,10 +179,15 @@ func writeManageInviteError(w http.ResponseWriter, err error) {
} }
func writeCreateInviteError(w http.ResponseWriter, err error) { func writeCreateInviteError(w http.ResponseWriter, err error) {
if writeRoleRefusal(w, err) {
return
}
switch { switch {
case errors.Is(err, invite.ErrEmailHasAccount): case errors.Is(err, invite.ErrEmailHasAccount):
w.Header().Set(apperr.ErrorCodeHeader, apperr.EmailHasAccountErrorCode)
http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict) http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict)
case errors.Is(err, invite.ErrPendingInvite): case errors.Is(err, invite.ErrPendingInvite):
w.Header().Set(apperr.ErrorCodeHeader, apperr.InvitePendingErrorCode)
http.Error(w, apperr.PendingInviteErrorMessage, http.StatusConflict) http.Error(w, apperr.PendingInviteErrorMessage, http.StatusConflict)
case errors.Is(err, invite.ErrInviteEmailNotSent): case errors.Is(err, invite.ErrInviteEmailNotSent):
http.Error(w, apperr.InviteNotSentErrorMessage, http.StatusInternalServerError) http.Error(w, apperr.InviteNotSentErrorMessage, http.StatusInternalServerError)
@@ -183,22 +199,27 @@ func writeCreateInviteError(w http.ResponseWriter, err error) {
} }
func (h *InviteHandler) ListUsers(w http.ResponseWriter, _ *http.Request) { func (h *InviteHandler) ListUsers(w http.ResponseWriter, _ *http.Request) {
entries := h.ListUsersAndInvitesUseCase.ListUsersAndInvites() entries, err := h.ListUsersAndInvitesUseCase.ListUsersAndInvites()
items := make([]any, 0, len(entries))
for _, entry := range entries {
if entry.Kind == useCases.EntryKindInvite {
items = append(items, inviteListItem{entry.ID, entry.Kind, entry.Email, entry.Status, entry.CreatedAt})
continue
}
items = append(items, userListItem{entry.ID, entry.Kind, entry.Username, entry.DisplayName, entry.Email, entry.Role, entry.Status, entry.CreatedAt})
}
body, err := json.Marshal(items)
if err != nil { if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body) items := make([]any, 0, len(entries))
for _, entry := range entries {
if entry.Kind == useCases.EntryKindInvite {
items = append(items, inviteListItem{
ID: entry.ID,
Kind: entry.Kind,
Email: entry.Email,
Role: entry.Role,
Status: entry.Status,
CreatedAt: entry.CreatedAt,
})
continue
}
items = append(items, userListItemOf(entry))
}
writeNoStoreJSON(w, http.StatusOK, items)
} }
+66
View File
@@ -0,0 +1,66 @@
package user
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/response"
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
"strconv"
"strings"
)
type LookupHandler struct {
LookupUseCase *useCases.LookupUserNamesUseCase
}
type userNameResponse struct {
ID uint32 `json:"id"`
DisplayName string `json:"display_name"`
}
func NewLookupHandler(lookup *useCases.LookupUserNamesUseCase) *LookupHandler {
return &LookupHandler{LookupUseCase: lookup}
}
func parseLookupIDs(raw string) ([]uint32, bool) {
if raw == "" {
return nil, false
}
if strings.Count(raw, ",")+1 > domainUser.MaxLookupIDs {
return nil, false
}
parts := strings.Split(raw, ",")
ids := make([]uint32, 0, len(parts))
for _, part := range parts {
id, err := strconv.ParseUint(part, 10, 32)
if err != nil {
return nil, false
}
ids = append(ids, uint32(id))
}
return ids, true
}
func (h *LookupHandler) Lookup(w http.ResponseWriter, r *http.Request) {
ids, ok := parseLookupIDs(r.URL.Query().Get("ids"))
if !ok {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
names, err := h.LookupUseCase.Lookup(ids)
if errors.Is(err, domainUser.ErrInvalidLookup) {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
items := make([]userNameResponse, 0, len(names))
for _, name := range names {
items = append(items, userNameResponse{ID: name.ID, DisplayName: name.DisplayName})
}
response.WriteJSON(w, http.StatusOK, items)
}
+73
View File
@@ -0,0 +1,73 @@
package user
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/role"
domain "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
type RoleHandler struct {
ChangeUserRoleUseCase *useCases.ChangeUserRoleUseCase
}
type ChangeRoleRequest struct {
RoleID uint32 `json:"role_id" validate:"required"`
}
func NewRoleHandler(changeRole *useCases.ChangeUserRoleUseCase) *RoleHandler {
return &RoleHandler{ChangeUserRoleUseCase: changeRole}
}
func (h *RoleHandler) Change(w http.ResponseWriter, r *http.Request) {
targetID, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
var request ChangeRoleRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
changed, err := h.ChangeUserRoleUseCase.ChangeRole(r.Context(), useCases.ChangeUserRoleCommand{
Caller: access.CallerOf(r),
TargetID: uint32(targetID),
RoleID: request.RoleID,
})
if err != nil {
writeChangeRoleError(w, err)
return
}
writeNoStoreJSON(w, http.StatusOK, userListItemOf(changed))
}
func writeChangeRoleError(w http.ResponseWriter, err error) {
if writeRoleRefusal(w, err) {
return
}
switch {
case errors.Is(err, role.ErrOwnRole):
http.Error(w, apperr.OwnRoleErrorMessage, http.StatusForbidden)
case errors.Is(err, domain.ErrUserNotFound):
http.Error(w, apperr.UserNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, domain.ErrRoleChanged):
http.Error(w, apperr.UserRoleChangedErrorMessage, http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+51
View File
@@ -0,0 +1,51 @@
package user
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
)
func userListItemOf(entry useCases.DirectoryEntry) userListItem {
return userListItem{
ID: entry.ID,
Kind: entry.Kind,
Username: entry.Username,
DisplayName: entry.DisplayName,
Email: entry.Email,
Role: entry.Role,
RoleAssignedBy: entry.RoleAssignedBy,
Status: entry.Status,
CreatedAt: entry.CreatedAt,
}
}
func writeNoStoreJSON(w http.ResponseWriter, status int, payload any) {
body, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_, _ = w.Write(body)
}
func writeRoleRefusal(w http.ResponseWriter, err error) bool {
var escalation role.EscalationError
switch {
case errors.As(err, &escalation):
w.Header().Set(apperr.EscalationHeader, escalation.Key)
http.Error(w, apperr.EscalationErrorMessage, http.StatusForbidden)
case errors.Is(err, role.ErrRoleLocked):
http.Error(w, apperr.RoleLockedErrorMessage, http.StatusForbidden)
case errors.Is(err, role.ErrUnknownRole):
http.Error(w, apperr.InvalidRoleErrorMessage, http.StatusBadRequest)
default:
return false
}
return true
}
+1 -1
View File
@@ -46,7 +46,7 @@ func (h *Handler) UpdateMe(w http.ResponseWriter, r *http.Request) {
return return
} }
updatedUser, err := h.UpdateProfileUseCase.UpdateDisplayName(userId, *request.DisplayName) updatedUser, err := h.UpdateProfileUseCase.UpdateDisplayName(r.Context(), userId, *request.DisplayName)
if err != nil { if err != nil {
if errors.Is(err, user.ErrInvalidDisplayName) { if errors.Is(err, user.ErrInvalidDisplayName) {
http.Error(w, apperr.DisplayNameErrorMessage, http.StatusBadRequest) http.Error(w, apperr.DisplayNameErrorMessage, http.StatusBadRequest)
+24 -18
View File
@@ -2,6 +2,7 @@ package middleware
import ( import (
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/authtoken"
"RenewCMS/internal/domain/apikey" "RenewCMS/internal/domain/apikey"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"context" "context"
@@ -10,15 +11,14 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/go-chi/jwtauth/v5"
"github.com/lestrrat-go/jwx/v2/jwt"
) )
const bearerScheme = "bearer " const bearerScheme = "bearer "
type apiKeyIdContextKey struct{} type apiKeyIdContextKey struct{}
type apiKeyNameContextKey struct{}
func bearerAPIKey(r *http.Request) (string, bool) { func bearerAPIKey(r *http.Request) (string, bool) {
header := r.Header.Get("Authorization") header := r.Header.Get("Authorization")
if len(header) < len(bearerScheme) || !strings.EqualFold(header[:len(bearerScheme)], bearerScheme) { if len(header) < len(bearerScheme) || !strings.EqualFold(header[:len(bearerScheme)], bearerScheme) {
@@ -34,6 +34,11 @@ func apiKeyIdFrom(r *http.Request) (uint32, bool) {
return id, ok return id, ok
} }
func apiKeyNameFrom(r *http.Request) string {
name, _ := r.Context().Value(apiKeyNameContextKey{}).(string)
return name
}
func RequireSession(next http.Handler) http.Handler { func RequireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, isKey := bearerAPIKey(r); isKey { if _, isKey := bearerAPIKey(r); isKey {
@@ -44,6 +49,16 @@ func RequireSession(next http.Handler) http.Handler {
}) })
} }
func RejectAPIKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, authenticatedByKey := apiKeyIdFrom(r); authenticatedByKey {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func APIKeyAuth(authenticate *useCases.AuthenticateAPIKeyUseCase, maxRequests int, window time.Duration) func(http.Handler) http.Handler { func APIKeyAuth(authenticate *useCases.AuthenticateAPIKeyUseCase, maxRequests int, window time.Duration) func(http.Handler) http.Handler {
limiter := newKeyedRateLimiter(maxRequests, window, apiKeyRateLimitKey) limiter := newKeyedRateLimiter(maxRequests, window, apiKeyRateLimitKey)
@@ -62,14 +77,9 @@ func APIKeyAuth(authenticate *useCases.AuthenticateAPIKeyUseCase, maxRequests in
return return
} }
token, err := sessionTokenFor(authenticated) ctx := authtoken.NewContext(r.Context(), sessionClaimsFor(authenticated), nil)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
ctx := jwtauth.NewContext(r.Context(), token, nil)
ctx = context.WithValue(ctx, apiKeyIdContextKey{}, authenticated.Key.ID) ctx = context.WithValue(ctx, apiKeyIdContextKey{}, authenticated.Key.ID)
ctx = context.WithValue(ctx, apiKeyNameContextKey{}, authenticated.Key.Name)
limited.ServeHTTP(w, r.WithContext(ctx)) limited.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
@@ -83,15 +93,11 @@ func writeAPIKeyError(w http.ResponseWriter, err error) {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
} }
func sessionTokenFor(authenticated useCases.AuthenticatedAPIKey) (jwt.Token, error) { func sessionClaimsFor(authenticated useCases.AuthenticatedAPIKey) authtoken.Claims {
token := jwt.New() return authtoken.Claims{
if err := token.Set("user_id", float64(authenticated.User.ID)); err != nil { "user_id": float64(authenticated.User.ID),
return nil, err "token_version": float64(authenticated.User.TokenVersion),
} }
if err := token.Set("token_version", float64(authenticated.User.TokenVersion)); err != nil {
return nil, err
}
return token, nil
} }
func apiKeyRateLimitKey(r *http.Request) string { func apiKeyRateLimitKey(r *http.Request) string {
+66
View File
@@ -0,0 +1,66 @@
package middleware
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/claims"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/useCases"
"net/http"
)
func AuditContext(getUser *useCases.GetUserUseCase, trustedProxies []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = access.WithCurrentUser(r)
origin := audit.Origin{
Actor: auditActor(getUser, r),
Source: auditSource(r),
IP: clientIP(r, trustedProxies),
UserAgent: truncatedUserAgent(r),
}
next.ServeHTTP(w, r.WithContext(audit.WithOrigin(r.Context(), origin)))
})
}
}
func auditActor(getUser *useCases.GetUserUseCase, r *http.Request) audit.Actor {
if id, ok := apiKeyIdFrom(r); ok {
return audit.APIKeyActor(id, apiKeyNameFrom(r))
}
userId, ok := claims.UserId(r)
if !ok {
return audit.AnonymousActor("")
}
current, err := access.LoadUser(getUser, r, userId)
if err != nil {
return audit.AnonymousActor("")
}
tokenVersion, ok := claims.TokenVersion(r)
if !ok || tokenVersion != current.TokenVersion {
return audit.AnonymousActor("")
}
return audit.UserActor(current)
}
func auditSource(r *http.Request) audit.Source {
if _, ok := apiKeyIdFrom(r); ok {
return audit.SourceAPIKey
}
return audit.SourceWeb
}
func clientIP(r *http.Request, trustedProxies []string) string {
realIP := r.Header.Get("X-Real-IP")
if realIP != "" && isTrustedProxy(r.RemoteAddr, trustedProxies) {
return realIP
}
return hostOf(r.RemoteAddr)
}
func truncatedUserAgent(r *http.Request) string {
runes := []rune(r.Header.Get("User-Agent"))
if len(runes) > audit.MaxUserAgentLength {
runes = runes[:audit.MaxUserAgentLength]
}
return string(runes)
}
+4 -19
View File
@@ -1,6 +1,7 @@
package middleware package middleware
import ( import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/claims"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
@@ -23,7 +24,7 @@ func resolveCurrentUser(getUser *useCases.GetUserUseCase, w http.ResponseWriter,
return user.User{}, false return user.User{}, false
} }
currentUser, err := getUser.GetUser(userId) currentUser, err := access.LoadUser(getUser, r, userId)
if err != nil { if err != nil {
if errors.Is(err, user.ErrUserNotFound) { if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
@@ -39,6 +40,7 @@ func resolveCurrentUser(getUser *useCases.GetUserUseCase, w http.ResponseWriter,
func RequireCurrentToken(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler { func RequireCurrentToken(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = access.WithCurrentUser(r)
currentUser, ok := resolveCurrentUser(getUser, w, r) currentUser, ok := resolveCurrentUser(getUser, w, r)
if !ok { if !ok {
return return
@@ -63,6 +65,7 @@ func RequireCurrentToken(getUser *useCases.GetUserUseCase) func(http.Handler) ht
func RequireVerifiedUser(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler { func RequireVerifiedUser(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = access.WithCurrentUser(r)
currentUser, ok := resolveCurrentUser(getUser, w, r) currentUser, ok := resolveCurrentUser(getUser, w, r)
if !ok { if !ok {
return return
@@ -77,21 +80,3 @@ func RequireVerifiedUser(getUser *useCases.GetUserUseCase) func(http.Handler) ht
}) })
} }
} }
func RequireOwner(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
currentUser, ok := resolveCurrentUser(getUser, w, r)
if !ok {
return
}
if currentUser.Role != user.RoleOwner {
http.Error(w, apperr.OwnerRequiredErrorMessage, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
+138
View File
@@ -0,0 +1,138 @@
package middleware
import (
"net/http"
"strconv"
"strings"
)
const (
corsAllowAnyOrigin = "*"
corsWildcardMarker = "*"
corsPreflightMaxAge = 300
corsPreflightSuccess = http.StatusNoContent
)
var (
corsAllowedMethods = []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodOptions}
corsAllowedHeaders = []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}
corsExposedHeaders = []string{"Link", "X-Total-Count"}
)
type originWildcard struct {
prefix string
suffix string
}
func (w originWildcard) matches(origin string) bool {
return len(origin) >= len(w.prefix)+len(w.suffix) && strings.HasPrefix(origin, w.prefix) && strings.HasSuffix(origin, w.suffix)
}
type originPolicy struct {
anyOrigin bool
exact map[string]struct{}
wildcards []originWildcard
}
func newOriginPolicy(origins []string) originPolicy {
policy := originPolicy{exact: map[string]struct{}{}}
for _, origin := range origins {
origin = strings.ToLower(strings.TrimSpace(origin))
prefix, suffix, hasWildcard := strings.Cut(origin, corsWildcardMarker)
switch {
case origin == "":
case origin == corsAllowAnyOrigin:
policy.anyOrigin = true
case hasWildcard:
policy.wildcards = append(policy.wildcards, originWildcard{prefix: prefix, suffix: suffix})
default:
policy.exact[origin] = struct{}{}
}
}
return policy
}
func (p originPolicy) allows(origin string) bool {
if p.anyOrigin {
return true
}
origin = strings.ToLower(origin)
if _, ok := p.exact[origin]; ok {
return true
}
for _, wildcard := range p.wildcards {
if wildcard.matches(origin) {
return true
}
}
return false
}
func isPreflight(r *http.Request) bool {
return r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != ""
}
func containsFold(allowed []string, value string) bool {
for _, candidate := range allowed {
if strings.EqualFold(candidate, value) {
return true
}
}
return false
}
func requestedHeadersAllowed(r *http.Request) bool {
requested := r.Header.Get("Access-Control-Request-Headers")
if requested == "" {
return true
}
for _, header := range strings.Split(requested, ",") {
if header = strings.TrimSpace(header); header != "" && !containsFold(corsAllowedHeaders, header) {
return false
}
}
return true
}
func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
policy := newOriginPolicy(allowedOrigins)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin == "" {
next.ServeHTTP(w, r)
return
}
w.Header().Add("Vary", "Origin")
if isPreflight(r) {
answerPreflight(w, r, origin, policy)
return
}
if policy.allows(origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Expose-Headers", strings.Join(corsExposedHeaders, ", "))
}
next.ServeHTTP(w, r)
})
}
}
func answerPreflight(w http.ResponseWriter, r *http.Request, origin string, policy originPolicy) {
w.Header().Add("Vary", "Access-Control-Request-Method")
w.Header().Add("Vary", "Access-Control-Request-Headers")
method := r.Header.Get("Access-Control-Request-Method")
if !policy.allows(origin) || !containsFold(corsAllowedMethods, method) || !requestedHeadersAllowed(r) {
w.WriteHeader(http.StatusOK)
return
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", strings.ToUpper(method))
w.Header().Set("Access-Control-Allow-Headers", strings.Join(corsAllowedHeaders, ", "))
w.Header().Set("Access-Control-Max-Age", strconv.Itoa(corsPreflightMaxAge))
w.WriteHeader(corsPreflightSuccess)
}
+36
View File
@@ -0,0 +1,36 @@
package middleware
import (
"RenewCMS/api/pkg/apperr"
"net/http"
"net/url"
)
func CrossOriginProtection(publicURL string) func(http.Handler) http.Handler {
protection := http.NewCrossOriginProtection()
if origin, ok := originOf(publicURL); ok {
_ = protection.AddTrustedOrigin(origin)
}
protection.SetDenyHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, apperr.CrossOriginErrorMessage, http.StatusForbidden)
}))
return func(next http.Handler) http.Handler {
protected := protection.Handler(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "" {
next.ServeHTTP(w, r)
return
}
protected.ServeHTTP(w, r)
})
}
}
func originOf(rawURL string) (string, bool) {
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", false
}
return parsed.Scheme + "://" + parsed.Host, true
}
+53
View File
@@ -0,0 +1,53 @@
package middleware
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/claims"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
)
func OptionalAccess(getUser *useCases.GetUserUseCase, check *useCases.CheckPermissionUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = access.WithCurrentUser(r)
account, signedIn, err := verifiedAccount(getUser, r)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if !signedIn {
next.ServeHTTP(w, r)
return
}
grants, err := check.Grants(account)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
caller := access.Caller{UserID: account.ID, Grants: grants}
next.ServeHTTP(w, r.WithContext(access.WithCaller(r.Context(), caller)))
})
}
}
func verifiedAccount(getUser *useCases.GetUserUseCase, r *http.Request) (user.User, bool, error) {
userID, ok := claims.UserId(r)
if !ok {
return user.User{}, false, nil
}
account, err := access.LoadUser(getUser, r, userID)
if errors.Is(err, user.ErrUserNotFound) {
return user.User{}, false, nil
}
if err != nil {
return user.User{}, false, err
}
tokenVersion, ok := claims.TokenVersion(r)
if !ok || tokenVersion != account.TokenVersion {
return user.User{}, false, nil
}
return account, account.IsVerified, nil
}
+62
View File
@@ -0,0 +1,62 @@
package middleware
import (
"RenewCMS/api/pkg/access"
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"github.com/go-chi/chi/v5"
"net/http"
)
type grantCheck func(account user.User, r *http.Request) (role.GrantSet, error)
func ArticleTypeKey(*http.Request) string {
return builtintype.KeyArticle
}
func PathTypeKey(r *http.Request) string {
return chi.URLParam(r, "key")
}
func RequirePermission(getUser *useCases.GetUserUseCase, check *useCases.CheckPermissionUseCase, key string) func(http.Handler) http.Handler {
return enforce(getUser, func(account user.User, _ *http.Request) (role.GrantSet, error) {
return check.Require(account, key)
})
}
func RequireFullScope(getUser *useCases.GetUserUseCase, check *useCases.CheckPermissionUseCase, key string) func(http.Handler) http.Handler {
return enforce(getUser, func(account user.User, _ *http.Request) (role.GrantSet, error) {
return check.RequireAll(account, key)
})
}
func RequireTypedPermission(getUser *useCases.GetUserUseCase, check *useCases.CheckPermissionUseCase, key string, typeKey func(r *http.Request) string) func(http.Handler) http.Handler {
return enforce(getUser, func(account user.User, r *http.Request) (role.GrantSet, error) {
return check.RequireFor(account, key, typeKey(r))
})
}
func enforce(getUser *useCases.GetUserUseCase, verify grantCheck) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = access.WithCurrentUser(r)
currentUser, ok := resolveCurrentUser(getUser, w, r)
if !ok {
return
}
grants, err := verify(currentUser, r)
if err != nil {
if !access.WriteIfMissingPermission(w, err) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
caller := access.Caller{UserID: currentUser.ID, Grants: grants}
next.ServeHTTP(w, r.WithContext(access.WithCaller(r.Context(), caller)))
})
}
}
+34 -12
View File
@@ -14,8 +14,19 @@ type rateLimitWindow struct {
count int count int
} }
type rateLimitCounters struct {
mu sync.Mutex
window time.Duration
windows map[string]*rateLimitWindow
lastSweep time.Time
}
func NewRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler { func NewRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler {
return newKeyedRateLimiter(maxAttempts, window, func(r *http.Request) string { return r.RemoteAddr }) return newKeyedRateLimiter(maxAttempts, window, connectionHostKey)
}
func connectionHostKey(r *http.Request) string {
return hostOf(r.RemoteAddr)
} }
func NewUserRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler { func NewUserRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler {
@@ -28,12 +39,11 @@ func userRateLimitKey(r *http.Request) string {
} }
func newKeyedRateLimiter(maxAttempts int, window time.Duration, keyOf func(*http.Request) string) func(http.Handler) http.Handler { func newKeyedRateLimiter(maxAttempts int, window time.Duration, keyOf func(*http.Request) string) func(http.Handler) http.Handler {
var mu sync.Mutex counters := &rateLimitCounters{window: window, windows: make(map[string]*rateLimitWindow)}
windows := make(map[string]*rateLimitWindow)
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
retryAfter, allowed := recordAttempt(&mu, windows, keyOf(r), maxAttempts, window) retryAfter, allowed := counters.recordAttempt(keyOf(r), maxAttempts, time.Now())
if !allowed { if !allowed {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
http.Error(w, apperr.TooManyRequestsErrorMessage, http.StatusTooManyRequests) http.Error(w, apperr.TooManyRequestsErrorMessage, http.StatusTooManyRequests)
@@ -44,24 +54,36 @@ func newKeyedRateLimiter(maxAttempts int, window time.Duration, keyOf func(*http
} }
} }
func recordAttempt(mu *sync.Mutex, windows map[string]*rateLimitWindow, key string, maxAttempts int, window time.Duration) (int, bool) { func (c *rateLimitCounters) recordAttempt(key string, maxAttempts int, now time.Time) (int, bool) {
mu.Lock() c.mu.Lock()
defer mu.Unlock() defer c.mu.Unlock()
now := time.Now() c.sweepExpired(now)
current, exists := windows[key] current, exists := c.windows[key]
if !exists || now.Sub(current.start) >= window { if !exists || now.Sub(current.start) >= c.window {
current = &rateLimitWindow{start: now} current = &rateLimitWindow{start: now}
windows[key] = current c.windows[key] = current
} }
current.count++ current.count++
if current.count > maxAttempts { if current.count > maxAttempts {
return retryAfterSeconds(window - now.Sub(current.start)), false return retryAfterSeconds(c.window - now.Sub(current.start)), false
} }
return 0, true return 0, true
} }
func (c *rateLimitCounters) sweepExpired(now time.Time) {
if now.Sub(c.lastSweep) < c.window {
return
}
c.lastSweep = now
for key, current := range c.windows {
if now.Sub(current.start) >= c.window {
delete(c.windows, key)
}
}
}
func retryAfterSeconds(remaining time.Duration) int { func retryAfterSeconds(remaining time.Duration) int {
seconds := int(remaining / time.Second) seconds := int(remaining / time.Second)
if remaining%time.Second > 0 { if remaining%time.Second > 0 {
+17
View File
@@ -0,0 +1,17 @@
package middleware
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/authtoken"
"net/http"
)
func RequireSignedIn(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, err := authtoken.FromContext(r.Context()); err != nil {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
+3 -3
View File
@@ -3,9 +3,9 @@ package middleware
import "net/http" import "net/http"
const contentSecurityPolicy = "default-src 'self'; " + const contentSecurityPolicy = "default-src 'self'; " +
"script-src 'self'; " + "script-src 'self' 'wasm-unsafe-eval'; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + "style-src 'self' 'unsafe-inline'; " +
"font-src 'self' https://fonts.gstatic.com; " + "font-src 'self'; " +
"img-src 'self' data: blob: https:; " + "img-src 'self' data: blob: https:; " +
"connect-src 'self'; " + "connect-src 'self'; " +
"object-src 'none'; " + "object-src 'none'; " +
+41
View File
@@ -0,0 +1,41 @@
package access
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"net/http"
)
type Caller = useCases.ContentCaller
type callerContextKey struct{}
func WithCaller(ctx context.Context, caller Caller) context.Context {
return context.WithValue(ctx, callerContextKey{}, caller)
}
func CallerOf(r *http.Request) Caller {
caller, _ := r.Context().Value(callerContextKey{}).(Caller)
return caller
}
func Grants(r *http.Request) role.GrantSet {
return CallerOf(r).Grants
}
func WriteMissingPermission(w http.ResponseWriter, key string) {
w.Header().Set(apperr.MissingPermissionHeader, key)
http.Error(w, apperr.MissingPermissionErrorMessage, http.StatusForbidden)
}
func WriteIfMissingPermission(w http.ResponseWriter, err error) bool {
var missing role.MissingPermissionError
if !errors.As(err, &missing) {
return false
}
WriteMissingPermission(w, missing.Key)
return true
}
+37
View File
@@ -0,0 +1,37 @@
package access
import (
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"context"
"net/http"
)
type currentUserContextKey struct{}
type currentUserHolder struct {
loaded bool
userID uint32
account user.User
err error
}
func WithCurrentUser(r *http.Request) *http.Request {
if _, present := r.Context().Value(currentUserContextKey{}).(*currentUserHolder); present {
return r
}
return r.WithContext(context.WithValue(r.Context(), currentUserContextKey{}, &currentUserHolder{}))
}
func LoadUser(getUser *useCases.GetUserUseCase, r *http.Request, userID uint32) (user.User, error) {
holder, present := r.Context().Value(currentUserContextKey{}).(*currentUserHolder)
if !present {
return getUser.GetUser(userID)
}
if !holder.loaded || holder.userID != userID {
holder.account, holder.err = getUser.GetUser(userID)
holder.userID = userID
holder.loaded = true
}
return holder.account, holder.err
}
+89 -1
View File
@@ -16,15 +16,19 @@ const VerificationRequiredErrorMessage = "You must verify your account to access
const PasswordResetRequestedMessage = "If an account exists for that email, a password reset link has been sent." const PasswordResetRequestedMessage = "If an account exists for that email, a password reset link has been sent."
const PasswordResetFailedErrorMessage = "The password reset code is invalid or has expired." const PasswordResetFailedErrorMessage = "The password reset code is invalid or has expired."
const TooManyRequestsErrorMessage = "Too many requests. Please try again later." const TooManyRequestsErrorMessage = "Too many requests. Please try again later."
const CrossOriginErrorMessage = "Cross-origin requests are not allowed."
const EmailHasAccountErrorMessage = "This address already has an account." const EmailHasAccountErrorMessage = "This address already has an account."
const PendingInviteErrorMessage = "There's already a pending invite for this address." const PendingInviteErrorMessage = "There's already a pending invite for this address."
const ErrorCodeHeader = "X-Error-Code"
const EmailHasAccountErrorCode = "email_has_account"
const InvitePendingErrorCode = "invite_pending"
const InviteNotSentErrorMessage = "Couldn't send the invite. Try again in a moment." const InviteNotSentErrorMessage = "Couldn't send the invite. Try again in a moment."
const InviteNotFoundErrorMessage = "Invite not found" const InviteNotFoundErrorMessage = "Invite not found"
const InviteAlreadyAcceptedErrorMessage = "This invite has already been accepted." const InviteAlreadyAcceptedErrorMessage = "This invite has already been accepted."
const InviteInvalidErrorMessage = "This invite link doesn't work." const InviteInvalidErrorMessage = "This invite link doesn't work."
const InviteExpiredErrorMessage = "This invite has expired." const InviteExpiredErrorMessage = "This invite has expired."
const OwnerRequiredErrorMessage = "Only the owner can do this."
const UsernameTakenErrorMessage = "That username is taken. Try another." const UsernameTakenErrorMessage = "That username is taken. Try another."
const PasswordTooLongErrorMessage = "The password must be 72 bytes or fewer."
const MediaQueryErrorMessage = "The media query is invalid." const MediaQueryErrorMessage = "The media query is invalid."
const MediaFilenameErrorMessage = "Enter a filename." const MediaFilenameErrorMessage = "Enter a filename."
const MediaMergeConflictErrorMessage = "Some of the images to merge changed. Refresh and try again." const MediaMergeConflictErrorMessage = "Some of the images to merge changed. Refresh and try again."
@@ -37,4 +41,88 @@ const UpdateRestartMismatchErrorMessage = "The update state changed. Reload the
const ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html, json." const ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html, json."
const APIKeyNotFoundErrorMessage = "API key not found" const APIKeyNotFoundErrorMessage = "API key not found"
const APIKeyExpiryErrorMessage = "The expiry must be a date in the future." const APIKeyExpiryErrorMessage = "The expiry must be a date in the future."
const MissingPermissionErrorMessage = "missing_permission"
const MissingPermissionHeader = "X-Missing-Permission"
const SessionRequiredErrorMessage = "This action needs a real session." const SessionRequiredErrorMessage = "This action needs a real session."
const PurgeConflictErrorMessage = "The library changed, so nothing was purged. Check the new summary and try again."
const PurgeBusyErrorMessage = "A purge is already running."
const PurgeIntervalErrorMessage = "Enter a whole number of days between 1 and 365."
const VariantNotFoundErrorMessage = "Variant not found"
const VariantNameErrorMessage = "Use lowercase letters, numbers and dashes, 2 to 32 characters."
const VariantDimensionsErrorMessage = "Width and height must be between 1 and 8000, and one of them is required."
const VariantFitErrorMessage = "Fit must be cover, contain or inside."
const VariantQualityErrorMessage = "Quality must be between 1 and 100."
const VariantInvalidErrorMessage = "The variant is not valid."
const VariantExistsErrorMessageFormat = "A variant named “%s” already exists. Choose another name."
const VariantFormatErrorMessageFormat = "Format must be %s."
const VariantSourceTooLargeErrorMessage = "The source image is too large to generate this variant."
const VariantSourceUnsupportedErrorMessage = "Variants aren't generated for this format."
const VariantSourceUnreadableErrorMessage = "The source image can't be read, so this variant can't be generated."
const CachePurgeScopeErrorMessage = "Scope must be all, unused, variant or image."
const CachePurgeTargetErrorMessage = "A variant name or an image id is required for this scope."
const VariantUnknownMessage = "Unknown variant"
const VariantUnsupportedParameterMessage = "Unsupported parameter"
const SiteNotFoundErrorMessage = "site_not_found"
const SiteKeyTakenErrorMessage = "site_key_taken"
const SiteHasArticlesErrorMessage = "site_has_articles"
const DefaultSiteErrorMessage = "default_site"
const SiteKeyImmutableErrorMessage = "key_immutable"
const InvalidSiteURLErrorMessage = "invalid_site_url"
const InvalidLocaleErrorMessage = "invalid_locale"
const SiteImageNotFoundErrorMessage = "image_not_found"
const UseSetDefaultErrorMessage = "use_set_default"
const InvalidReassignErrorMessage = "invalid_reassign"
const InvalidSocialTypeErrorMessage = "invalid_social_type"
const AuthorNotFoundErrorMessage = "author_not_found"
const AuthorSlugTakenErrorMessage = "author_slug_taken"
const AuthorHasArticlesErrorMessage = "author_has_articles"
const UserAlreadyLinkedErrorMessage = "user_already_linked"
const AuthorAlreadyLinkedErrorMessage = "author_already_linked"
const UserNotFoundErrorMessage = "user_not_found"
const AuthorRightsErrorMessage = "author_rights"
const BioTooLongErrorMessage = "bio_too_long"
const InvalidURLErrorMessage = "invalid_url"
const TooManyLinksErrorMessage = "too_many_links"
const InvalidSlugErrorMessage = "invalid_slug"
const AuthorImageNotFoundErrorMessage = "image_not_found"
const ArticleSlugTakenErrorMessage = "slug_taken"
const ArticleImageNotFoundErrorMessage = "image_not_found"
const InvalidTitleTemplateErrorMessage = "invalid_title_template"
const SlugSuggestionHeader = "X-Slug-Suggestion"
const ContentTypeNotFoundErrorMessage = "content_type_not_found"
const ContentTypeKeyTakenErrorMessage = "content_type_key_taken"
const ContentTypeHasEntriesErrorMessage = "content_type_has_entries"
const ContentTypeReferencedErrorMessage = "content_type_referenced"
const InvalidContentTypeKeyErrorMessage = "invalid_content_type_key"
const InvalidIconErrorMessage = "invalid_icon"
const BuiltinTypeNotFoundErrorMessage = "builtin_type_not_found"
const InvalidFieldErrorMessage = "invalid_field"
const SchemaIncompatibleErrorMessage = "schema_incompatible"
const EntryNotFoundErrorMessage = "entry_not_found"
const InvalidEntryErrorMessage = "invalid_entry"
const RoleNotFoundErrorMessage = "role_not_found"
const RoleLockedErrorMessage = "role_locked"
const RoleBuiltInErrorMessage = "role_built_in"
const BuiltInNameErrorMessage = "built_in_name"
const NotBuiltInErrorMessage = "not_built_in"
const OwnRoleErrorMessage = "own_role"
const EscalationErrorMessage = "escalation"
const EscalationHeader = "X-Escalation-Key"
const RoleNameTakenErrorMessage = "role_name_taken"
const RoleConflictErrorMessage = "role_conflict"
const RoleHasMembersErrorMessage = "role_has_members"
const InvalidRoleNameErrorMessage = "invalid_role_name"
const UnknownPermissionErrorMessage = "unknown_permission"
const InvalidScopeErrorMessage = "invalid_scope"
const OwnerOnlyPermissionErrorMessage = "owner_only_permission"
const DuplicatePermissionErrorMessage = "duplicate_permission"
const UnknownContentTypeErrorMessage = "unknown_content_type"
const InvalidRoleErrorMessage = "invalid_role"
const UserRoleChangedErrorMessage = "user_role_changed"
const InvalidTransferErrorMessage = "invalid_transfer"
const ConfirmationMismatchErrorMessage = "confirmation_mismatch"
+225
View File
@@ -0,0 +1,225 @@
package authtoken
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"maps"
"strings"
"time"
)
const (
SessionLifetime = 24 * time.Hour
algorithmHS256 = "HS256"
tokenType = "JWT"
maxTokenBytes = 4096
segmentCount = 3
segmentSep = "."
)
var (
ErrNoToken = errors.New("no token found")
ErrInvalid = errors.New("token is invalid")
ErrExpired = errors.New("token is expired")
ErrNotYetValid = errors.New("token is not yet valid")
ErrRevoked = errors.New("token is revoked")
)
type RevocationStore interface {
Revoke(id string, expiresAt time.Time) error
IsRevoked(id string) (bool, error)
}
type Claims map[string]any
type header struct {
Algorithm string `json:"alg"`
Type string `json:"typ,omitempty"`
}
type Auth struct {
secret []byte
clock func() time.Time
revocations RevocationStore
}
type Option func(*Auth)
func WithClock(clock func() time.Time) Option {
return func(a *Auth) { a.clock = clock }
}
func WithRevocationStore(store RevocationStore) Option {
return func(a *Auth) { a.revocations = store }
}
func New(secret []byte, options ...Option) *Auth {
auth := &Auth{secret: secret, clock: time.Now}
for _, option := range options {
option(auth)
}
return auth
}
func (a *Auth) Encode(claims map[string]any) (string, error) {
payload := maps.Clone(claims)
if payload == nil {
payload = map[string]any{}
}
now := a.clock()
if _, ok := payload["iat"]; !ok {
payload["iat"] = now.Unix()
}
if _, ok := payload["exp"]; !ok {
payload["exp"] = now.Add(SessionLifetime).Unix()
}
if _, ok := payload["jti"]; !ok {
payload["jti"] = rand.Text()
}
headerSegment, err := encodeSegment(header{Algorithm: algorithmHS256, Type: tokenType})
if err != nil {
return "", err
}
payloadSegment, err := encodeSegment(payload)
if err != nil {
return "", err
}
signingInput := headerSegment + segmentSep + payloadSegment
return signingInput + segmentSep + a.sign(signingInput), nil
}
func (a *Auth) Decode(token string) (Claims, error) {
if token == "" {
return nil, ErrNoToken
}
if len(token) > maxTokenBytes {
return nil, ErrInvalid
}
segments := strings.Split(token, segmentSep)
if len(segments) != segmentCount {
return nil, ErrInvalid
}
var tokenHeader header
if err := decodeSegment(segments[0], &tokenHeader); err != nil || tokenHeader.Algorithm != algorithmHS256 {
return nil, ErrInvalid
}
signingInput := segments[0] + segmentSep + segments[1]
if !hmac.Equal([]byte(a.sign(signingInput)), []byte(segments[2])) {
return nil, ErrInvalid
}
var claims Claims
if err := decodeSegment(segments[1], &claims); err != nil || claims == nil {
return nil, ErrInvalid
}
if err := a.validateTimes(claims); err != nil {
return nil, err
}
if err := a.checkRevocation(claims); err != nil {
return nil, err
}
return claims, nil
}
func (a *Auth) Revoke(claims Claims) error {
id, ok := claims["jti"].(string)
if !ok || a.revocations == nil {
return nil
}
expiry, present, err := numericDate(claims, "exp")
if err != nil || !present {
return ErrInvalid
}
return a.revocations.Revoke(id, expiry)
}
func (a *Auth) checkRevocation(claims Claims) error {
raw, present := claims["jti"]
if !present {
return nil
}
id, ok := raw.(string)
if !ok {
return ErrInvalid
}
if a.revocations == nil {
return nil
}
revoked, err := a.revocations.IsRevoked(id)
if err != nil {
return errors.Join(ErrInvalid, err)
}
if revoked {
return ErrRevoked
}
return nil
}
func (a *Auth) validateTimes(claims Claims) error {
now := a.clock()
expiry, present, err := numericDate(claims, "exp")
if err != nil {
return err
}
if !present {
return ErrInvalid
}
if !now.Before(expiry) {
return ErrExpired
}
notBefore, present, err := numericDate(claims, "nbf")
if err != nil {
return err
}
if present && now.Before(notBefore) {
return ErrNotYetValid
}
return nil
}
func numericDate(claims Claims, name string) (time.Time, bool, error) {
raw, present := claims[name]
if !present {
return time.Time{}, false, nil
}
seconds, ok := raw.(float64)
if !ok {
return time.Time{}, false, ErrInvalid
}
return time.Unix(int64(seconds), 0), true, nil
}
func (a *Auth) sign(signingInput string) string {
mac := hmac.New(sha256.New, a.secret)
mac.Write([]byte(signingInput))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func encodeSegment(value any) (string, error) {
encoded, err := json.Marshal(value)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(encoded), nil
}
func decodeSegment(segment string, into any) error {
decoded, err := base64.RawURLEncoding.DecodeString(segment)
if err != nil {
return err
}
return json.Unmarshal(decoded, into)
}
+79
View File
@@ -0,0 +1,79 @@
package authtoken
import (
"RenewCMS/api/pkg/apperr"
"context"
"net/http"
"strings"
)
const (
CookieName = "jwt"
bearerPrefix = "bearer "
)
type contextKey struct{}
type verdict struct {
claims Claims
err error
}
func Verifier(auth *Auth) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, err := auth.Decode(tokenFromRequest(r))
next.ServeHTTP(w, r.WithContext(NewContext(r.Context(), claims, err)))
})
}
}
func Authenticator(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, err := FromContext(r.Context()); err != nil {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func NewContext(ctx context.Context, claims Claims, err error) context.Context {
return context.WithValue(ctx, contextKey{}, verdict{claims: claims, err: err})
}
func FromContext(ctx context.Context) (Claims, error) {
stored, ok := ctx.Value(contextKey{}).(verdict)
switch {
case !ok:
return nil, ErrNoToken
case stored.err != nil:
return nil, stored.err
case stored.claims == nil:
return nil, ErrNoToken
}
return stored.claims, nil
}
func tokenFromRequest(r *http.Request) string {
if token := tokenFromHeader(r); token != "" {
return token
}
return tokenFromCookie(r)
}
func tokenFromHeader(r *http.Request) string {
header := r.Header.Get("Authorization")
if len(header) <= len(bearerPrefix) || !strings.EqualFold(header[:len(bearerPrefix)], bearerPrefix) {
return ""
}
return header[len(bearerPrefix):]
}
func tokenFromCookie(r *http.Request) string {
cookie, err := r.Cookie(CookieName)
if err != nil {
return ""
}
return cookie.Value
}
+3 -4
View File
@@ -1,14 +1,13 @@
package claims package claims
import ( import (
"RenewCMS/api/pkg/authtoken"
"math" "math"
"net/http" "net/http"
"github.com/go-chi/jwtauth/v5"
) )
func UserId(r *http.Request) (uint32, bool) { func UserId(r *http.Request) (uint32, bool) {
_, claims, err := jwtauth.FromContext(r.Context()) claims, err := authtoken.FromContext(r.Context())
if err != nil { if err != nil {
return 0, false return 0, false
} }
@@ -31,7 +30,7 @@ func UserId(r *http.Request) (uint32, bool) {
} }
func TokenVersion(r *http.Request) (uint32, bool) { func TokenVersion(r *http.Request) (uint32, bool) {
_, claims, err := jwtauth.FromContext(r.Context()) claims, err := authtoken.FromContext(r.Context())
if err != nil { if err != nil {
return 0, false return 0, false
} }
-2
View File
@@ -13,7 +13,6 @@ type PublicUser struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Role user.Role `json:"role"`
} }
func New(u user.User) PublicUser { func New(u user.User) PublicUser {
@@ -25,6 +24,5 @@ func New(u user.User) PublicUser {
CreatedAt: u.CreatedAt, CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt, UpdatedAt: u.UpdatedAt,
DisplayName: u.DisplayName, DisplayName: u.DisplayName,
Role: u.Role,
} }
} }
+21
View File
@@ -0,0 +1,21 @@
package response
import (
"encoding/json"
"log"
"net/http"
)
func WriteJSON(w http.ResponseWriter, status int, payload any) {
body, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if status != http.StatusOK {
w.WriteHeader(status)
}
if _, err := w.Write(body); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
+55
View File
@@ -1,12 +1,24 @@
package validator package validator
import ( import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"reflect"
"regexp" "regexp"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
) )
const inviteEmailTag = "inviteemail" const inviteEmailTag = "inviteemail"
const variantNameTag = "variantname"
const siteKeyTag = "sitekey"
const siteNameTag = "sitename"
const AuthorNameTag = "authorname"
const AuthorSlugTag = "authorslug"
const SocialTypeTag = "socialtype"
const ArticleSlugTag = "articleslug"
var inviteEmailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$`) var inviteEmailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$`)
@@ -15,6 +27,49 @@ var Validate *validator.Validate
func InitValidator() { func InitValidator() {
Validate = validator.New() Validate = validator.New()
_ = Validate.RegisterValidation(inviteEmailTag, isInviteEmail) _ = Validate.RegisterValidation(inviteEmailTag, isInviteEmail)
_ = Validate.RegisterValidation(variantNameTag, isVariantName)
_ = Validate.RegisterValidation(siteKeyTag, isSiteKey)
_ = Validate.RegisterValidation(siteNameTag, isSiteName)
_ = Validate.RegisterValidation(AuthorNameTag, isAuthorName, true)
_ = Validate.RegisterValidation(AuthorSlugTag, isAuthorSlug, true)
_ = Validate.RegisterValidation(SocialTypeTag, isSocialType, true)
_ = Validate.RegisterValidation(ArticleSlugTag, isArticleSlug, true)
}
func isArticleSlug(field validator.FieldLevel) bool {
if isAbsent(field) {
return true
}
value := field.Field().String()
return value == "" || article.IsValidSlug(value)
}
func isAbsent(field validator.FieldLevel) bool {
return field.Field().Kind() == reflect.Ptr && field.Field().IsNil()
}
func isAuthorName(field validator.FieldLevel) bool {
return isAbsent(field) || author.IsValidName(field.Field().String())
}
func isSocialType(field validator.FieldLevel) bool {
return author.IsValidSocialType(field.Field().String())
}
func isAuthorSlug(field validator.FieldLevel) bool {
return isAbsent(field) || author.IsValidSlug(field.Field().String())
}
func isSiteKey(field validator.FieldLevel) bool {
return site.IsValidKey(field.Field().String())
}
func isSiteName(field validator.FieldLevel) bool {
return site.IsValidName(field.Field().String())
}
func isVariantName(field validator.FieldLevel) bool {
return image.IsValidVariantName(field.Field().String())
} }
func isInviteEmail(field validator.FieldLevel) bool { func isInviteEmail(field validator.FieldLevel) bool {
+354 -64
View File
@@ -1,31 +1,46 @@
package api package api
import ( import (
"RenewCMS/api/handlers/activity"
"RenewCMS/api/handlers/apikey" "RenewCMS/api/handlers/apikey"
"RenewCMS/api/handlers/article" "RenewCMS/api/handlers/article"
"RenewCMS/api/handlers/auth" "RenewCMS/api/handlers/auth"
"RenewCMS/api/handlers/author"
"RenewCMS/api/handlers/builtintype"
"RenewCMS/api/handlers/contenttype"
"RenewCMS/api/handlers/entry"
"RenewCMS/api/handlers/frontend" "RenewCMS/api/handlers/frontend"
"RenewCMS/api/handlers/imagevariant"
"RenewCMS/api/handlers/llms"
"RenewCMS/api/handlers/media" "RenewCMS/api/handlers/media"
"RenewCMS/api/handlers/ownership"
"RenewCMS/api/handlers/permission"
rolehandler "RenewCMS/api/handlers/role"
"RenewCMS/api/handlers/settings" "RenewCMS/api/handlers/settings"
"RenewCMS/api/handlers/site"
"RenewCMS/api/handlers/staticimages" "RenewCMS/api/handlers/staticimages"
"RenewCMS/api/handlers/update" "RenewCMS/api/handlers/update"
"RenewCMS/api/handlers/user" "RenewCMS/api/handlers/user"
"RenewCMS/api/middleware" "RenewCMS/api/middleware"
"RenewCMS/api/pkg/authtoken"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/llmdocs"
"encoding/json" "encoding/json"
"log"
"net/http" "net/http"
"os"
"time" "time"
"github.com/MadAppGang/httplog"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware" chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/jwtauth/v5"
) )
func enableLoggingIfDev(r *chi.Mux, routerName string, environment string) { func enableLoggingIfDev(r *chi.Mux, routerName string, environment string) {
if environment == "development" { if environment == "development" {
r.Use(httplog.LoggerWithName(routerName)) r.Use(chimiddleware.RequestLogger(&chimiddleware.DefaultLogFormatter{
Logger: log.New(os.Stdout, "["+routerName+"] ", log.LstdFlags),
}))
} }
} }
@@ -46,35 +61,63 @@ func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler {
return c.apiKeyAuthMiddleware return c.apiKeyAuthMiddleware
} }
func NewArticleRouter(container *UseCases) http.Handler { func (c *UseCases) require(key string) func(http.Handler) http.Handler {
return middleware.RequirePermission(c.GetUserUseCase, c.CheckPermissionUseCase, key)
}
func (c *UseCases) requireTyped(key string, typeKey func(*http.Request) string) func(http.Handler) http.Handler {
return middleware.RequireTypedPermission(c.GetUserUseCase, c.CheckPermissionUseCase, key, typeKey)
}
func (c *UseCases) requireArticle(key string) func(http.Handler) http.Handler {
return c.requireTyped(key, middleware.ArticleTypeKey)
}
func (c *UseCases) requireEntry(key string) func(http.Handler) http.Handler {
return c.requireTyped(key, middleware.PathTypeKey)
}
func (c *UseCases) requireAll(key string) func(http.Handler) http.Handler {
return middleware.RequireFullScope(c.GetUserUseCase, c.CheckPermissionUseCase, key)
}
func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase) articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase)
seoHandler := article.NewSEOHandler(container.ResolveArticleSEOUseCase, container.CheckArticleSEOUseCase)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.OptionalAccess(container.GetUserUseCase, container.CheckPermissionUseCase))
r.Get("/", articleHandler.ListArticles) r.Get("/", articleHandler.ListArticles)
r.Get("/{id}", articleHandler.GetArticle) r.Get("/{id}", articleHandler.GetArticle)
r.Get("/{id}/seo", seoHandler.Resolve)
}) })
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Post("/", articleHandler.PostArticle) r.With(container.requireArticle(role.KeyContentCreate)).Post("/", articleHandler.PostArticle)
r.Delete("/{id}", articleHandler.DeleteArticle) r.With(container.requireArticle(role.KeyContentDelete)).Delete("/{id}", articleHandler.DeleteArticle)
r.Patch("/{id}", articleHandler.UpdateArticleBody) r.With(container.requireArticle(role.KeyContentUpdate)).Patch("/{id}", articleHandler.UpdateArticleBody)
r.Patch("/{id}/publish", articleHandler.PublishArticle) r.With(container.requireArticle(role.KeyContentPublish)).Patch("/{id}/publish", articleHandler.PublishArticle)
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle) r.With(container.requireArticle(role.KeyContentPublish)).Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
r.With(container.requireArticle(role.KeyContentRead)).Post("/{id}/seo/check", seoHandler.Check)
}) })
return r return r
} }
func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler { func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
loginHandler := auth.NewLoginHandler(container.Token, container.CreateUserUseCase, container.AuthenticateUserUseCase, isProduction) loginHandler := auth.NewLoginHandler(container.Token, container.CreateUserUseCase, container.AuthenticateUserUseCase, isProduction)
verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction) verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction)
meHandler := auth.NewMeHandler(container.GetUserUseCase) meHandler := auth.NewMeHandler(container.GetUserUseCase, container.GetMyAuthorUseCase, container.GetMyAccessUseCase)
passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase) passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase)
acceptInviteHandler := auth.NewAcceptInviteHandler(container.GetInviteUseCase, container.AcceptInviteUseCase, container.Token, isProduction) acceptInviteHandler := auth.NewAcceptInviteHandler(container.GetInviteUseCase, container.AcceptInviteUseCase, container.Token, isProduction)
@@ -100,16 +143,16 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
}) })
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession) r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Post("/verify", verificationHandler.Verify) r.Post("/verify", verificationHandler.Verify)
r.Delete("/register", verificationHandler.CancelRegistration) r.Delete("/register", verificationHandler.CancelRegistration)
}) })
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
r.Use(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Get("/me", meHandler.Me) r.Get("/me", meHandler.Me)
}) })
@@ -120,37 +163,229 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
r := chi.NewRouter() r := chi.NewRouter()
userHandler := user.NewHandler(container.UpdateUserProfileUseCase) userHandler := user.NewHandler(container.UpdateUserProfileUseCase)
lookupHandler := user.NewLookupHandler(container.LookupUserNamesUseCase)
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase) inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase)
roleHandler := user.NewRoleHandler(container.ChangeUserRoleUseCase)
r.Use(jwtauth.Verifier(container.Token)) r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
r.Use(jwtauth.Authenticator(container.Token)) r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Patch("/me", userHandler.UpdateMe) r.Patch("/me", userHandler.UpdateMe)
r.Get("/", inviteHandler.ListUsers) r.With(container.require(role.KeyUsersView)).Get("/", inviteHandler.ListUsers)
r.Get("/lookup", lookupHandler.Lookup)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwner(container.GetUserUseCase)) r.Use(container.require(role.KeyUsersInvite))
r.Use(middleware.TrustedProxyRealIP(trustedProxies)) r.Use(middleware.TrustedProxyRealIP(trustedProxies))
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window)) r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
r.Post("/invite", inviteHandler.CreateInvite) r.Post("/invite", inviteHandler.CreateInvite)
r.Delete("/invite/{id}", inviteHandler.CancelInvite) r.Delete("/invite/{id}", inviteHandler.CancelInvite)
r.Post("/invite/{id}/renew", inviteHandler.RenewInvite) r.Post("/invite/{id}/renew", inviteHandler.RenewInvite)
}) })
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeyUsersChangeRole))
r.Put("/{id}/role", roleHandler.Change)
})
return r return r
} }
func NewSettingsRouter(container *UseCases) http.Handler { func NewSettingsRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
settingsHandler := settings.NewHandler(container.GetSettingsUseCase) settingsHandler := settings.NewHandler(container.GetSettingsUseCase, container.GetActivityRetentionUseCase, container.UpdateActivityRetentionUseCase)
r.Use(jwtauth.Verifier(container.Token)) r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
r.Use(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeySettingsView))
r.Get("/", settingsHandler.GetSettings) r.Get("/", settingsHandler.GetSettings)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeySystemEditRetention))
r.Patch("/", settingsHandler.UpdateSettings)
})
return r
}
func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
siteHandler := site.NewHandler(container.ListSitesUseCase, container.CreateSiteUseCase, container.UpdateSiteUseCase, container.SetDefaultSiteUseCase, container.DeleteSiteUseCase)
sitemapHandler := site.NewSitemapHandler(container.ListSitemapUseCase)
ownershipHandler := ownership.NewHandler(container.TransferOwnershipUseCase)
r.Get("/{key}/sitemap", sitemapHandler.Sitemap)
r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", siteHandler.List)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.With(container.require(role.KeySettingsManageSites)).Post("/", siteHandler.Create)
r.With(container.require(role.KeySettingsManageSites)).Patch("/{id}", siteHandler.Update)
r.With(container.require(role.KeySettingsManageSites)).Patch("/{id}/default", siteHandler.SetDefault)
r.With(container.require(role.KeySettingsDeleteSite)).Delete("/{id}", siteHandler.Delete)
r.With(container.require(role.KeySystemTransferOwnership)).Post("/transfer-ownership", ownershipHandler.Transfer)
})
return r
}
func NewRoleRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
roleHandler := rolehandler.NewHandler(container.ListRolesUseCase, container.GetRoleUseCase, container.CreateRoleUseCase, container.UpdateRoleUseCase, container.DeleteRoleUseCase, container.ReassignRoleMembersUseCase)
r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeyRolesView))
r.Get("/", roleHandler.List)
r.Get("/{id}", roleHandler.Get)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeyRolesManage))
r.Post("/", roleHandler.Create)
r.Put("/{id}", roleHandler.Update)
r.Delete("/{id}", roleHandler.Delete)
r.Post("/{id}/reassign", roleHandler.Reassign)
r.Post("/{id}/reset", roleHandler.Reset)
})
return r
}
func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
authorHandler := author.NewHandler(container.ListAuthorsUseCase, container.CreateAuthorUseCase, container.CreateMyAuthorUseCase, container.UpdateAuthorUseCase, container.DeleteAuthorUseCase)
r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeyAuthorsView))
r.Get("/", authorHandler.List)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Post("/me", authorHandler.CreateMine)
r.With(container.require(role.KeyAuthorsEdit)).Patch("/{id}", authorHandler.Update)
r.With(container.require(role.KeyAuthorsCreate)).Post("/", authorHandler.Create)
r.With(container.require(role.KeyAuthorsDelete)).Delete("/{id}", authorHandler.Delete)
})
return r
}
func NewContentTypeRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
contentTypeHandler := contenttype.NewHandler(container.CreateContentTypeUseCase, container.ListContentTypesUseCase, container.GetContentTypeUseCase, container.UpdateContentTypeUseCase, container.DeleteContentTypeUseCase)
entryHandler := entry.NewHandler(container.CreateEntryUseCase, container.GetEntryUseCase, container.ListEntriesUseCase, container.UpdateEntryUseCase, container.DeleteEntryUseCase)
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.With(container.require(role.KeySchemaView)).Get("/", contentTypeHandler.List)
r.With(container.require(role.KeySchemaView)).Get("/{key}", contentTypeHandler.Get)
r.With(container.require(role.KeySchemaEdit)).Post("/", contentTypeHandler.Create)
r.With(container.require(role.KeySchemaEdit)).Put("/{key}", contentTypeHandler.Update)
r.With(container.require(role.KeySchemaDelete)).Delete("/{key}", contentTypeHandler.Delete)
r.With(container.requireEntry(role.KeyContentRead)).Get("/{key}/entry", entryHandler.List)
r.With(container.requireEntry(role.KeyContentCreate)).Post("/{key}/entry", entryHandler.Create)
r.With(container.requireEntry(role.KeyContentRead)).Get("/{key}/entry/{id}", entryHandler.Get)
r.With(container.requireEntry(role.KeyContentUpdate)).Put("/{key}/entry/{id}", entryHandler.Update)
r.With(container.requireEntry(role.KeyContentDelete)).Delete("/{key}/entry/{id}", entryHandler.Delete)
return r
}
func NewBuiltinTypeRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
builtinTypeHandler := builtintype.NewHandler(container.ListBuiltinTypesUseCase, container.GetBuiltinTypeUseCase)
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeySchemaView))
r.Get("/", builtinTypeHandler.List)
r.Get("/{key}", builtinTypeHandler.Get)
return r
}
func NewActivityRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
activityHandler := activity.NewHandler(container.ListActivityUseCase, container.GetActivitySummaryUseCase, container.ExportActivityUseCase)
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.With(container.require(role.KeyActivityView)).Get("/", activityHandler.List)
r.With(container.requireAll(role.KeyActivityView)).Get("/summary", activityHandler.Summary)
r.With(container.require(role.KeyActivityExport), container.require(role.KeyActivityView)).Get("/export", activityHandler.Export)
return r return r
} }
@@ -161,66 +396,118 @@ const (
updateDownloadWindow = time.Minute updateDownloadWindow = time.Minute
) )
func NewSystemRouter(container *UseCases) http.Handler { func NewSystemRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.RequestRestartUseCase, container.GetUserUseCase) updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.RequestRestartUseCase)
r.Use(middleware.RequireSession) r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(jwtauth.Authenticator(container.Token)) r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/update", updateHandler.GetStatus) r.With(container.require(role.KeySettingsView)).Get("/update", updateHandler.GetStatus)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwner(container.GetUserUseCase)) r.Use(container.require(role.KeySystemApplyUpdates))
r.Use(middleware.NewUserRateLimiter(updateCheckMaxAttempts, updateCheckWindow)) r.Use(middleware.NewUserRateLimiter(updateCheckMaxAttempts, updateCheckWindow))
r.Post("/update/check", updateHandler.Check) r.Post("/update/check", updateHandler.Check)
}) })
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwner(container.GetUserUseCase)) r.Use(container.require(role.KeySystemApplyUpdates))
r.Use(middleware.NewUserRateLimiter(updateDownloadMaxAttempts, updateDownloadWindow)) r.Use(middleware.NewUserRateLimiter(updateDownloadMaxAttempts, updateDownloadWindow))
r.Post("/update/download", updateHandler.Download) r.Post("/update/download", updateHandler.Download)
}) })
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwner(container.GetUserUseCase)) r.Use(container.require(role.KeySystemApplyUpdates))
r.Post("/update/restart", updateHandler.Restart) r.Post("/update/restart", updateHandler.Restart)
}) })
return r return r
} }
func NewAPIKeyRouter(container *UseCases) http.Handler { func NewAPIKeyRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
apiKeyHandler := apikey.NewHandler(container.CreateAPIKeyUseCase, container.ListAPIKeysUseCase, container.RevokeAPIKeyUseCase) apiKeyHandler := apikey.NewHandler(container.CreateAPIKeyUseCase, container.ListAPIKeysUseCase, container.RevokeAPIKeyUseCase)
r.Use(middleware.RequireSession) r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(jwtauth.Authenticator(container.Token)) r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Post("/", apiKeyHandler.Create) r.With(container.require(role.KeyAPIKeysCreate)).Post("/", apiKeyHandler.Create)
r.Get("/", apiKeyHandler.List) r.With(container.require(role.KeyAPIKeysView)).Get("/", apiKeyHandler.List)
r.Delete("/{id}", apiKeyHandler.Revoke) r.With(container.require(role.KeyAPIKeysRevoke)).Delete("/{id}", apiKeyHandler.Revoke)
return r return r
} }
func NewMediaRouter(container *UseCases) http.Handler { func NewPermissionRouter(container *UseCases) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase) permissionHandler := permission.NewHandler(container.ListPermissionsUseCase)
r.Use(jwtauth.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
r.Use(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", mediaHandler.List) r.Get("/", permissionHandler.List)
r.Post("/", mediaHandler.Upload) return r
r.Post("/merge", mediaHandler.Merge) }
r.Patch("/{id}", mediaHandler.Rename)
r.Delete("/{id}", mediaHandler.Delete) func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler {
r.Get("/{id}/usage", mediaHandler.Usage) r := chi.NewRouter()
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase, container.GetPurgeSettingsUseCase, container.UpdatePurgeSettingsUseCase, container.PreviewPurgeUseCase, container.RunPurgeUseCase, container.ListImageVariantsUseCase, container.GenerateImageVariantUseCase, container.GenerateAllImageVariantsUseCase, container.PurgeImageVariantUseCase, container.GenerateVariantsOnUploadUseCase)
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.With(container.require(role.KeySettingsView)).Get("/purge-settings", mediaHandler.GetPurgeSettings)
r.With(container.require(role.KeySettingsEditGeneral)).Patch("/purge-settings", mediaHandler.UpdatePurgeSettings)
r.With(container.requireAll(role.KeyMediaDelete)).Get("/purge-preview", mediaHandler.PreviewPurge)
r.With(container.requireAll(role.KeyMediaDelete)).Post("/purge", mediaHandler.RunPurge)
r.Group(func(r chi.Router) {
r.Use(middleware.RejectAPIKey)
r.With(container.require(role.KeyMediaView)).Get("/{id}/variants", mediaHandler.ListVariants)
r.With(container.require(role.KeySettingsEditGeneral)).Post("/{id}/variants", mediaHandler.GenerateAllVariants)
r.With(container.require(role.KeySettingsEditGeneral)).Post("/{id}/variants/{name}", mediaHandler.GenerateVariant)
r.With(container.require(role.KeySettingsEditGeneral)).Delete("/{id}/variants/{name}", mediaHandler.PurgeVariant)
})
r.With(container.require(role.KeyMediaView)).Get("/", mediaHandler.List)
r.With(container.require(role.KeyMediaUpload)).Post("/", mediaHandler.Upload)
r.With(container.requireAll(role.KeyMediaDelete)).Post("/merge", mediaHandler.Merge)
r.With(container.require(role.KeyMediaUpload)).Patch("/{id}", mediaHandler.Rename)
r.With(container.requireAll(role.KeyMediaDelete)).Delete("/{id}", mediaHandler.Delete)
r.With(container.require(role.KeyMediaView)).Get("/{id}", mediaHandler.Get)
r.With(container.require(role.KeyMediaView)).Get("/{id}/usage", mediaHandler.Usage)
return r
}
func NewImageVariantRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
variantHandler := imagevariant.NewHandler(container.ListVariantsUseCase, container.CreateVariantUseCase, container.UpdateVariantUseCase, container.SetVariantEnabledUseCase, container.DeleteVariantUseCase, container.PurgeVariantUseCase, container.GetVariantCacheStatsUseCase, container.PurgeVariantCacheUseCase)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.With(container.require(role.KeyMediaView)).Get("/", variantHandler.List)
r.With(container.require(role.KeySettingsEditGeneral)).Post("/", variantHandler.Create)
r.With(container.require(role.KeySettingsView)).Get("/cache", variantHandler.CacheStats)
r.With(container.require(role.KeySettingsEditGeneral)).Post("/cache/purge", variantHandler.PurgeCache)
r.With(container.require(role.KeySettingsEditGeneral)).Put("/{name}", variantHandler.Update)
r.With(container.require(role.KeySettingsEditGeneral)).Patch("/{name}", variantHandler.SetEnabled)
r.With(container.require(role.KeySettingsEditGeneral)).Delete("/{name}", variantHandler.Delete)
r.With(container.require(role.KeySettingsEditGeneral)).Post("/{name}/purge", variantHandler.Purge)
return r return r
} }
@@ -233,14 +520,22 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r.Use(middleware.JsonContentTypeMiddleware) r.Use(middleware.JsonContentTypeMiddleware)
r.Get("/", GetHelloWorld) r.Get("/", GetHelloWorld)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Mount("/article", NewArticleRouter(container)) r.Mount("/article", NewArticleRouter(container, settings.TrustedProxies))
}) })
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies)) r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
r.Mount("/settings", NewSettingsRouter(container)) r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
r.Mount("/media", NewMediaRouter(container)) r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies))
r.Mount("/api-key", NewAPIKeyRouter(container)) r.Mount("/role", NewRoleRouter(container, settings.TrustedProxies))
r.Mount("/system", NewSystemRouter(container)) r.Mount("/author", NewAuthorRouter(container, settings.TrustedProxies))
r.Mount("/content-type", NewContentTypeRouter(container, settings.TrustedProxies))
r.Mount("/builtin-type", NewBuiltinTypeRouter(container, settings.TrustedProxies))
r.Mount("/activity", NewActivityRouter(container, settings.TrustedProxies))
r.Mount("/media", NewMediaRouter(container, settings.TrustedProxies))
r.Mount("/image-variants", NewImageVariantRouter(container, settings.TrustedProxies))
r.Mount("/api-key", NewAPIKeyRouter(container, settings.TrustedProxies))
r.Mount("/permission", NewPermissionRouter(container))
r.Mount("/system", NewSystemRouter(container, settings.TrustedProxies))
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies)) r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
return r return r
@@ -265,14 +560,7 @@ func enableCorsIfConfigured(r *chi.Mux, origins []string) {
if len(origins) == 0 { if len(origins) == 0 {
return return
} }
r.Use(cors.Handler(cors.Options{ r.Use(middleware.CORS(origins))
AllowedOrigins: origins,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: false,
MaxAge: 300,
}))
} }
func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux { func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
@@ -282,10 +570,12 @@ func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
apiRouter := chi.NewRouter() apiRouter := chi.NewRouter()
apiRouter.Use(middleware.SecurityHeaders(settings.IsProduction())) apiRouter.Use(middleware.SecurityHeaders(settings.IsProduction()))
enableCorsIfConfigured(apiRouter, settings.CorsAllowedOrigins) enableCorsIfConfigured(apiRouter, settings.CorsAllowedOrigins)
apiRouter.Use(middleware.CrossOriginProtection(settings.PublicURL))
apiRouter.Use(chimiddleware.GetHead) apiRouter.Use(chimiddleware.GetHead)
apiRouter.Get("/healthz", GetHealth) apiRouter.Get("/healthz", GetHealth)
llms.NewHandler(llmdocs.Docs, settings.Version, settings.PublicURL).Register(apiRouter)
apiRouter.Mount("/v1", backend) apiRouter.Mount("/v1", backend)
apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(settings.UploadDir)) apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase, container.ServeVariantUseCase))
apiRouter.Mount("/", frontend) apiRouter.Mount("/", frontend)
return apiRouter return apiRouter
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"RenewCMS/internal/infrastructure/mcpserver"
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
var version = "dev"
func main() {
allowDestructive := flag.Bool("allow-destructive", false, "also expose delete_article and delete_media")
showVersion := flag.Bool("version", false, "print the version and exit")
flag.Parse()
if *showVersion {
fmt.Println(version)
return
}
baseURL := os.Getenv("RENEWCMS_URL")
apiKey := os.Getenv("RENEWCMS_API_KEY")
if baseURL == "" || apiKey == "" {
log.Fatal("RENEWCMS_URL and RENEWCMS_API_KEY must be set")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
server := mcpserver.New(mcpserver.NewClient(baseURL, apiKey, nil), mcpserver.Options{
Version: version,
AllowDestructive: *allowDestructive,
})
if err := server.Run(ctx, &mcp.StdioTransport{}); err != nil {
log.Fatal(err)
}
}
+40 -10
View File
@@ -7,15 +7,15 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io/fs"
"log" "log"
"net" "net"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"slices"
"syscall" "syscall"
"time" "time"
"github.com/joho/godotenv"
) )
var version = "dev" var version = "dev"
@@ -44,12 +44,26 @@ var envDefaults = map[string]string{
"ENVIRONMENT": "production", "ENVIRONMENT": "production",
"PORT": "8080", "PORT": "8080",
"DB_FILE": "./data/renewcms.db", "DB_FILE": "./data/renewcms.db",
config.EnvDatabaseDriver: config.DefaultDatabaseDriver,
"DB_TLS": config.DefaultDatabaseTLS,
"DB_SSLMODE": config.DefaultDatabaseSSLMode,
"DB_MAX_OPEN_CONNS": config.DefaultMaxOpenConns,
"DB_MAX_IDLE_CONNS": config.DefaultMaxIdleConns,
"DB_CONN_MAX_LIFETIME_SECONDS": config.DefaultConnMaxLifetimeSeconds,
"UPLOAD_DIR": "./data/uploads", "UPLOAD_DIR": "./data/uploads",
config.EnvStorageDriver: config.DefaultStorageDriver,
"S3_REGION": config.DefaultS3Region,
"S3_PATH_STYLE": config.DefaultS3PathStyle,
"S3_TLS": config.DefaultS3TLS,
"AUTH_RATE_LIMIT_MAX_ATTEMPTS": "5", "AUTH_RATE_LIMIT_MAX_ATTEMPTS": "5",
"AUTH_RATE_LIMIT_WINDOW_SECONDS": "60", "AUTH_RATE_LIMIT_WINDOW_SECONDS": "60",
"TRUSTED_PROXIES": "", "TRUSTED_PROXIES": "",
config.EnvSmtpAllowInsecure: config.DefaultSmtpAllowInsecure,
"UPDATE_FEED_URL": "https://git.floriansylvain.fr", "UPDATE_FEED_URL": "https://git.floriansylvain.fr",
"RENEWCMS_CONTAINER": "false", "RENEWCMS_CONTAINER": "false",
config.EnvImageVariantMaxSourceBytes: config.DefaultImageVariantMaxSourceBytes,
config.EnvImageVariantMaxSourcePixels: config.DefaultImageVariantMaxSourcePixels,
config.EnvImageVariantMaxConcurrentRenders: config.DefaultImageVariantMaxConcurrentRenders,
} }
const versionFlag = "--version" const versionFlag = "--version"
@@ -81,7 +95,7 @@ func run() error {
validator.InitValidator() validator.InitValidator()
server := newServer(api.InitRoutes(container, settings), settings) server := newServer(api.InitRoutes(container, settings), settings)
return serveUntilStopped(server, settings, container.RecordRunningInfoUseCase.Record) return serveUntilStopped(server, settings, container.RecordRunningInfoUseCase.Record, container.RunSchedulers)
} }
func initEnvVariables() { func initEnvVariables() {
@@ -92,9 +106,13 @@ func initEnvVariables() {
func loadEnvFile() { func loadEnvFile() {
for _, envLocation := range possibleEnvFileLocations { for _, envLocation := range possibleEnvFileLocations {
if godotenv.Load(envLocation) == nil { err := config.LoadDotenv(envLocation)
if err == nil {
return return
} }
if !errors.Is(err, fs.ErrNotExist) {
log.Println("WARNING: Could not parse env file:", err)
}
} }
log.Println("WARNING: Could not load any .env file") log.Println("WARNING: Could not load any .env file")
} }
@@ -108,10 +126,13 @@ func applyEnvDefaults() {
} }
func ensureRequiredEnvVars() { func ensureRequiredEnvVars() {
for _, envVar := range requiredEnvVars { required := slices.Concat(
if _, isSet := os.LookupEnv(envVar); !isSet { requiredEnvVars,
panic(fmt.Sprintf("Environment variable %s is not set", envVar)) config.DatabaseRequiredEnvVars(os.Getenv(config.EnvDatabaseDriver)),
} config.StorageRequiredEnvVars(os.Getenv(config.EnvStorageDriver)),
)
if err := config.MissingEnvVarError(required, os.LookupEnv); err != nil {
panic(err.Error())
} }
} }
@@ -132,9 +153,18 @@ func newServer(handler http.Handler, settings config.Settings) *http.Server {
} }
} }
func serveUntilStopped(server *http.Server, settings config.Settings, recordRunning func() error) error { func serveUntilStopped(server *http.Server, settings config.Settings, recordRunning func() error, runScheduler func(context.Context)) error {
stopSignal, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) stopSignal, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
schedulerDone := make(chan struct{})
go func() {
defer close(schedulerDone)
runScheduler(stopSignal)
}()
defer func() {
stop()
<-schedulerDone
}()
serverErr := make(chan error, 1) serverErr := make(chan error, 1)
go listen(server, serverErr, settings, recordRunning) go listen(server, serverErr, settings, recordRunning)
+51
View File
@@ -0,0 +1,51 @@
services:
mysql:
image: mysql:8.4
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?set MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: renewcms
MYSQL_USER: renewcms
MYSQL_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
volumes:
- mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p\"$$MYSQL_ROOT_PASSWORD\" --silent"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
renewcms:
image: ${RENEWCMS_IMAGE:-git.floriansylvain.fr/fsylvain/renewcms:beta}
restart: unless-stopped
depends_on:
mysql:
condition: service_healthy
env_file:
- docker.env
environment:
DB_DRIVER: mysql
DB_HOST: mysql
DB_PORT: "3306"
DB_NAME: renewcms
DB_USER: renewcms
DB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
DATA_DIR: /data
UPLOAD_DIR: /data/uploads
RENEWCMS_CONTAINER: "true"
ports:
- "8080:8080"
volumes:
- renewcms-data:/data
stop_grace_period: 15s
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
renewcms-data:
mysql-data:
+51
View File
@@ -0,0 +1,51 @@
services:
postgres:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_DB: renewcms
POSTGRES_USER: renewcms
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U renewcms -d renewcms"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
renewcms:
image: ${RENEWCMS_IMAGE:-git.floriansylvain.fr/fsylvain/renewcms:beta}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
env_file:
- docker.env
environment:
DB_DRIVER: postgres
DB_HOST: postgres
DB_PORT: "5432"
DB_NAME: renewcms
DB_USER: renewcms
DB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
DB_SSLMODE: disable
DATA_DIR: /data
UPLOAD_DIR: /data/uploads
RENEWCMS_CONTAINER: "true"
ports:
- "8080:8080"
volumes:
- renewcms-data:/data
stop_grace_period: 15s
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
renewcms-data:
postgres-data:
+56
View File
@@ -0,0 +1,56 @@
services:
seaweedfs:
image: ${SEAWEEDFS_IMAGE:-chrislusf/seaweedfs:4.48}
restart: unless-stopped
command: ["server", "-dir=/data", "-s3", "-s3.port=8333"]
environment:
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-renewcms}
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:?set S3_SECRET_ACCESS_KEY}
volumes:
- seaweedfs-data:/data
seaweedfs-init:
image: ${SEAWEEDFS_IMAGE:-chrislusf/seaweedfs:4.48}
restart: "no"
depends_on:
- seaweedfs
entrypoint: ["sh", "-c"]
command:
- |
until echo "s3.bucket.create -name renewcms-media" | timeout 30 weed shell -master=seaweedfs:9333 && echo "s3.bucket.list" | timeout 30 weed shell -master=seaweedfs:9333 | grep -q renewcms-media; do
sleep 2
done
renewcms:
image: ${RENEWCMS_IMAGE:-git.floriansylvain.fr/fsylvain/renewcms:beta}
restart: unless-stopped
depends_on:
seaweedfs-init:
condition: service_completed_successfully
env_file:
- docker.env
environment:
STORAGE_DRIVER: s3
S3_ENDPOINT: http://seaweedfs:8333
S3_BUCKET: renewcms-media
S3_REGION: us-east-1
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-renewcms}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:?set S3_SECRET_ACCESS_KEY}
S3_PATH_STYLE: "true"
DB_FILE: /data/renewcms.db
RENEWCMS_CONTAINER: "true"
ports:
- "8080:8080"
volumes:
- renewcms-data:/data
stop_grace_period: 15s
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
renewcms-data:
seaweedfs-data:
+21
View File
@@ -11,3 +11,24 @@ CORS_ALLOWED_ORIGINS=
AUTH_RATE_LIMIT_MAX_ATTEMPTS=5 AUTH_RATE_LIMIT_MAX_ATTEMPTS=5
AUTH_RATE_LIMIT_WINDOW_SECONDS=60 AUTH_RATE_LIMIT_WINDOW_SECONDS=60
TRUSTED_PROXIES= TRUSTED_PROXIES=
DB_DRIVER=sqlite
DATA_DIR=
DB_HOST=
DB_PORT=
DB_USER=
DB_PASSWORD=
DB_NAME=
DB_TLS=false
DB_SSLMODE=disable
DB_MAX_OPEN_CONNS=10
DB_MAX_IDLE_CONNS=5
DB_CONN_MAX_LIFETIME_SECONDS=300
STORAGE_DRIVER=local
S3_BUCKET=
S3_REGION=us-east-1
S3_ENDPOINT=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_KEY_PREFIX=
S3_PATH_STYLE=false
S3_TLS=true
+492 -9
View File
@@ -2,38 +2,521 @@
The full, searchable reference is the Integration page of the running app (`/integration`). It The full, searchable reference is the Integration page of the running app (`/integration`). It
renders `web/src/data/apiCatalogue.ts`, which is the single source for this page. This file is for renders `web/src/data/apiCatalogue.ts`, which is the single source for this page. This file is for
readers without the UI. readers without the UI. Reading articles (pagination, `format`, body schemas) has its own page,
[api/articles.md](api/articles.md), and API keys are covered in [api/api-keys.md](api/api-keys.md).
## Base URL ## Base URL
The origin that serves the app, for example `https://cms.example`. Every path starts with `/v1`, The origin that serves the app, for example `https://cms.example`. Every path starts with `/v1`,
for example `https://cms.example/v1/article/`. for example `https://cms.example/v1/article/`.
## Authentication ## Authentication
- Public routes need nothing: `GET /v1/article/` and `GET /v1/article/{id}`. They return online - Public routes need nothing: `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo`
articles only. and `GET /v1/site/{key}/sitemap`. They return online articles, plus the drafts the caller's role may read
(see [Content permissions](#content-permissions)); any other draft answers 404 `Article not found`.
- Everything else needs a signed-in browser session (the `jwt` cookie) or an API key: - Everything else needs a signed-in browser session (the `jwt` cookie) or an API key:
`Authorization: Bearer rcms_...`. Create keys at `/integration/api-keys`. A key acts as its `Authorization: Bearer rcms_...`. Create keys at `/integration/api-keys`. A key acts as its
creator, with that user's role, and shows in full once. creator, with that user's role, and shows in full once.
- A key cannot call `POST /v1/auth/verify`, `DELETE /v1/auth/register`, `/v1/api-key/*` or - A key cannot call `POST /v1/auth/verify`, `DELETE /v1/auth/register`, `/v1/api-key/*` or
`/v1/system/*`. Those answer 403 "This action needs a real session." An invalid, unknown, expired `/v1/system/*`. Those answer 403 "This action needs a real session." An invalid, unknown, expired
or revoked key answers 401, even on public routes. or revoked key answers 401, even on public routes. The site writes (`POST`, `PATCH` and `DELETE`
under `/v1/site`) and the author writes (`POST`, `PATCH` and `DELETE` under `/v1/author`, `POST /v1/author/me` included) are session only too.
- Each key can make 120 requests per minute. Beyond that: 429 with a `Retry-After` header. - Each key can make 120 requests per minute. Beyond that: 429 with a `Retry-After` header.
- A session lasts 24 hours. `POST /v1/auth/logout` revokes it on the server, so a copied cookie
stops working at once; other sessions of the same user stay valid. A password reset ends every
session and revokes every key of that user.
- A browser `POST`, `PUT`, `PATCH` or `DELETE` sent from another origin without an
`Authorization` header answers 403 "Cross-origin requests are not allowed." This blocks
cross-site request forgery with the cookie; scripts on other origins must send their key.
## Routes a key can call (20) ## Routes a key can call (30)
| Group | Routes | | Group | Routes |
| --- | --- | | --- | --- |
| Articles | GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} | | Articles | GET /v1/article/, GET /v1/article/{id}, GET /v1/article/{id}/seo, POST /v1/article/{id}/seo/check, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} |
| Media | GET /v1/media/, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage | | Media | GET /v1/media/, GET /v1/media/{id}, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin), GET /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants/{name} (owner or admin, session only), DELETE /v1/media/{id}/variants/{name} (owner or admin, session only) |
| Users and invites | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite (owner only), DELETE /v1/user/invite/{id} (owner only), POST /v1/user/invite/{id}/renew (owner only), GET /v1/auth/me | | Users and invites | GET /v1/user/ (`users.view`), PATCH /v1/user/me, POST /v1/user/invite (`users.invite`), DELETE /v1/user/invite/{id} (`users.invite`), POST /v1/user/invite/{id}/renew (`users.invite`), GET /v1/auth/me (with the linked `author`) |
| Settings | GET /v1/settings | | Settings | GET /v1/settings |
| Sites | GET /v1/site/, GET /v1/site/{key}/sitemap |
| Authors | GET /v1/author/ |
## Activity log
The activity routes are session only: a key answers 403 "This action needs a real session." and a
key never reads the audit trail. Events are append-only; there is no `POST`, `PUT`, `PATCH` or
`DELETE` under `/v1/activity`.
| Route | Who | Purpose |
| --- | --- | --- |
| `GET /v1/activity` | Any verified user | Filtered, paged events. A role with `activity.view` at all sees every event; a role at own is scoped to its own events, and `actor` set to someone else answers 403 `missing_permission`. |
| `GET /v1/activity/summary` | Owner, admin | Counts for the range, the previous period, active people and seven-bucket series. |
| `GET /v1/activity/export` | Owner, admin | The same filters as the list without `page` or `size`, as a `text/csv; charset=utf-8` attachment (`when,actor,action,target,outcome,source,ip`). Cells starting with `=`, `+`, `-` or `@` get a leading quote. |
Query: `q`, `actor` (`user:ID`, `key:ID`, `system`), `category` (`articles`, `media`, `users`,
`api_keys`, `auth`, `system`, `sites`, `authors`, `roles`), `outcome` (`all`, `success`, `failed`), `security` (`true`), `range`
(`today`, `7d`, `30d`, `all`, `custom`; default `7d`), `from` and `to` (dates, with `custom`), `page` (from
1) and `size` (25, 50 or 100). Any invalid value answers 400 with the body-mismatch message. Dates
are UTC. The list answers `{ items, total, page, size }`, newest first (`created_at`, then `id`).
`GET /v1/settings` also returns `activity_retention_days` (30, 90, 365 or 0 for forever, default
90), `activity_retention_max_gb` (0 for no size limit), `activity_log_size_bytes`,
`activity_last_cleanup_at` and `activity_last_cleanup_removed`. `PATCH /v1/settings` with
`{"activity_retention_days": 30}`, `{"activity_retention_max_gb": 5}` or both is session only and
needs `system.edit_retention` (403 `missing_permission`); any other value answers 400. The daily cleanup
removes events past the age limit, then the oldest events until the log fits the size limit.
Whichever limit is reached first wins.
### What is recorded
Every real action is recorded by its use case, once, after the change was made (outcome `success`)
or after the use case refused it (outcome `failed`, with a plain `failure_reason`). Nothing is
recorded for a read (no `GET`, no list, no public article read, no status poll), for a request that
fails validation, or for a target that does not exist (404). A failed audit write is logged and
never changes the response or the change itself.
| Category | Actions |
| --- | --- |
| `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` |
| `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge`, `media.variant_create`, `media.variant_update`, `media.variant_delete`, `media.variant_enable`, `media.variant_disable`, `media.variant_purge`, `media.variant_generate_all`, `media.variant_purge_image`, `media.variant_purge_all`, `media.variant_purge_unused` |
| `users` | `user.register`, `user.verify`, `user.update_profile`, `user.change_role`, `user.transfer_ownership` (flagged `security`), `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` |
| `sites` | `site.create`, `site.edit`, `site.set_default`, `site.delete` |
| `authors` | `author.create`, `author.edit`, `author.delete` |
| `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` |
Target types: `article`, `site`, `author`, `image`, `image_variant` (id and name are the variant name), `user`, `invite`,
`api_key` and `release` (id and name are the tag).
Only the fields `title`, `online`, `body`, `site`, `name`, `base_url`, `default_robots`, `locale`, `default_social_image`, `slug`, `canonical_url`, `noindex`, `cover_image`, `social_image`, `excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_alt`, `site_name`, `title_template`, `default_description`, `bio`, `website`, `social_links`, `avatar`, `user`, `authors`, `filename`, `display_name`, `width`, `height`, `fit`, `format` and `quality` appear in `changes` (a site key is never a change; moving an article records `site` with the site names; changing the credited authors records `authors` with the names in order; the `user` change of an author holds usernames, never an email), with the
kinds `text` and `body`; a body edit carries a signed character count delta and never the text; the free-text SEO fields (`excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_alt`) do the same under their own field name, while `slug`, `canonical_url`, `noindex`, `cover_image`, `social_image` (image file names) and the site fields `site_name`, `title_template`, `default_description` carry `before` and `after`. `seo_dismissed` is not an edit and records nothing. A
change that leaves a value as it was (same body, same publish state, same file name, same display
name) records nothing. Params: `media.merge` and `media.purge` and `system.media_purge` carry
`merged` and `articles_updated`, plus `failed_groups` when a group failed (still outcome `success`);
`media.variant_generate_all` carries `generated`, `failed` and `causes` (a list of `too_large`,
`unsupported_format` or `failed`) and is `failed` only when nothing was generated and something failed;
`media.variant_purge_image` carries `purged_files` and `purged_bytes`; both have an `image` target
and are recorded only when files were generated or removed;
`media.variant_purge_all` and `media.variant_purge_unused` (no target) carry `purged_files` and
`purged_bytes`, the latter also `older_than_days`; a partial purge adds `failed_files` and stays
`success`, and a purge that removed nothing while some files failed is `failed`; nothing is recorded
when no file was removed or failed;
`system.update_checked` carries `update_available`; `system.restart_requested` carries `kind` and,
for an update, `tag` (it has no target). `system.update_downloaded` is recorded when the job reaches
`ready` or `failed`, with the origin captured at request time; a refusal at the start is recorded
immediately. A failed registration or invite acceptance has an `anonymous` actor named by the
attempted username.
`failure_reason` is one fixed sentence per domain error, matched with `errors.Is`, at most 255
characters, for example "The title must be between 3 and 200 characters.", "That file name is not
valid.", "The merge request is not valid.", "A purge is already running.", "Registration is closed
on this site.", "That invitation has expired.", "The release feed could not be reached." or "The
update could not be downloaded.". Any other error becomes "Something went wrong on our side. Please
try again."; the text of the error never reaches the event, the API or the CSV. Passwords, hashes,
codes, article bodies, file bytes, storage paths and asset urls are never recorded.
Article routes accept `?format=markdown|html|json` (default `markdown`). `GET /v1/media/` accepts Article routes accept `?format=markdown|html|json` (default `markdown`). `GET /v1/media/` accepts
`q`, `hash`, `cursor`, `filter`, `sort` and `limit`. `q`, `hash`, `filter` (`all`, `unused` or `duplicates`), `sort` (`newest`, `oldest`, `name`,
`largest` or `total_size`, the original plus its generated variants), `variants` (`with` or
`without` generated variant files), `page` (a whole number from 1, default 1) and `size` (5, 25, 50
or 100, default 25). The body is `{ "items": [...], "total", "page", "size", "counts" }`: `total` is
the number of images matching `q`, `hash`, `filter` and `variants` before paging, `page` and `size`
echo the values applied, and `counts` (`all`, `unused`, `duplicates`) stays library-wide, ignoring
every filter and the paging. Ordering is applied to the whole matching list before slicing, with the
image id as the final tie-break, so pages never overlap or skip. A page past the last answers 200
with `items: []` and the real `total`. Any invalid `page`, `size`, `filter`, `sort`, `variants` or
`hash` is 400 "The media query is invalid.". Every item carries `variant_count` and `variant_size`
(the index rows of the image and their bytes, 0 when none).
`GET /v1/media/{id}` reads one image with the same auth as the list (a verified session or an API key; 403 for an
unverified account, 401 without credentials) and writes nothing, so it records no activity event. It answers 200 with
the object of one `items` element of the list, a missing image is 404 "Image not found" and a malformed id is 400 with
the id-format message.
Breaking change: `next_cursor` is gone and no `X-Total-Count` header is sent. `cursor` and `limit`
are no longer parameters; they are ignored, never an error, and have no effect on paging.
## Sites
Every article belongs to exactly one site, and exactly one site is the default. A site is `{ id,
name, key, base_url, default_social_image_id, default_robots, locale, is_default, article_count }`:
`name` is 1 to 100 characters, `key` is 2 to 32 characters of lowercase letters, digits and single
dashes (`^[a-z0-9]+(-[a-z0-9]+)*$`) and never changes, `base_url` is `https` (or `http` for
`localhost`, `127.0.0.1` and `[::1]`) with no path, query or user info (a trailing slash is
stripped, uniqueness is not enforced), `default_robots` is `index` (default) or `noindex`, `locale`
is `null` or a tag such as `en` or `fr-CA` (an empty string means none) and `default_social_image_id`
is `null` or the id of an image. Read access needs a verified session or an API key; every write is
session only and need `settings.manage_sites` (`settings.delete_site` for DELETE): 403 `missing_permission`, or "This action needs a real
session." with a key. The role is checked before the body.
| Route | Purpose |
| --- | --- |
| `GET /v1/site/` | The sites, default first and then by name, each with `article_count`. Query `q` (up to 200 characters, matched in `name`, `key` or `base_url`, ignoring case and accents, `%` and `_` literal), `page` (a whole number from 1) and `size` (5, 25, 50 or 100). Without `page` and `size` every matching site is returned; with only `page`, `size` is 25; with only `size`, `page` is 1. The body stays a JSON array (`[]` when nothing matches, never `null`) and `X-Total-Count` holds the number of matching sites before paging. A page past the end is `200 []` with the total. An invalid `page`, `size` or `q` is 400 with the body-mismatch message and no `X-Total-Count`. |
| `POST /v1/site/` | Create. 201 with the site. `is_default: true` makes it the default. `site_name`, `title_template` and `default_description` are optional and validated like on `PATCH`. |
| `PATCH /v1/site/{id}` | Edit `name`, `base_url`, `default_robots`, `locale`, `default_social_image_id`, `site_name`, `title_template`, `default_description`. Absent fields are untouched. `key` may only be repeated unchanged; `is_default` is refused. |
| `PATCH /v1/site/{id}/default` | Make the site the default, atomically. Setting the current default is a no-op. |
| `DELETE /v1/site/{id}?reassign_to={id}` | 204. A site with articles needs `reassign_to`; the articles move and the site is deleted in one transaction. `reassign_to` is ignored when the site has no article. |
Errors are plain text whose body is exactly the token: 409 `site_key_taken`, 409 `site_has_articles`,
403 `default_site` (checked before the articles), 400 `key_immutable`, 400 `invalid_site_url`, 400
`invalid_locale`, 400 `invalid_title_template`, 400 `image_not_found`, 400 `use_set_default`, 400 `invalid_reassign` (missing
number, the site itself or not a whole number) and 404 `site_not_found`. Refused writes except `site_not_found` and `image_not_found` are recorded as failed events. A body that cannot be
decoded is 400 with the body-mismatch message, a malformed id is 400 with the uint32 message, and a
field that fails validation (`name`, `key` format, `default_robots`, `site_name` over 100 characters, `title_template` over 200, `default_description` over 320) is 400 with the validator text.
A site also carries `site_name` (at most 100 characters, `""` by default), `title_template` (at most 200 characters, `%title% | %site%` by default; it must contain `%title%`, case sensitive, otherwise 400 `invalid_title_template`, an empty template included) and `default_description` (at most 320 characters, `""` by default). They appear on every site response, `POST` accepts them and `PATCH` edits them like the other site fields. Existing sites get the defaults when the server starts after the upgrade.
Articles carry `site_id`. A new article starts on the default site. `GET /v1/article/?site=<key>`
keeps only the articles of that site and composes with `status`, `q`, `sort`, `size` and `page`;
`counts` stay global, an unknown key answers 200 with no item and an empty `site=` is 400 with the
body-mismatch message. `PATCH /v1/article/{id}` takes `body`, `site_id`, `author_ids` and the SEO
fields below, and nothing else, such as `title`: absent fields are untouched, an empty object, an object without
those fields, a `null` value (except on `cover_image_id` and `social_image_id`) or a malformed value is 400 with the body-mismatch message, `site_id: 0` is
400 with the validator text and an unknown site is 404 `site_not_found`. Moving an article never
changes `is_online`. `{"body": ""}` still sets an empty body, but `{}` and `{"body": null}` no longer do: they are 400.
### Resolved SEO, check, list status and sitemap
RenewCMS renders no page and injects no meta tag: it returns the data a frontend needs.
| Route | Who | Purpose |
| --- | --- | --- |
| `GET /v1/article/{id}/seo` | public for online articles; a verified session or API key also reads the drafts its role may read | The resolved object `{ title, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks, json_ld }`. It never carries the body, the id or `seo_dismissed`, ignores `format`, and writes nothing. An offline article for any other caller, and a missing one, is 404 `Article not found`. |
| `POST /v1/article/{id}/seo/check` | verified session or API key (401 without credentials, 403 unverified) | Evaluates the article without saving. The body is `{}` or any of the twelve SEO fields of `PATCH` plus `body`; each replaces the saved value for this check only. `site_id`, `author_ids`, unknown fields and `null` (except on `cover_image_id` and `social_image_id`) are 400 with the body-mismatch message. The other validation is the one of `PATCH` (`invalid_slug`, `invalid_url` as 422, `image_not_found`, validator text). Answers `{ status, suggestions, slug }` where `suggestions` is `[{ code, field, params }]` and `slug` is `{ available, suggestion }`. A taken slug is a 200 with `available: false` and the first free `<slug>-N` in `suggestion`, never a 409. |
| `GET /v1/site/{key}/sitemap` | public | A JSON array `[{ url, modified_at, noindex }]` of the online articles of the site with that key, by id ascending. `url` is `base_url/slug` (never the canonical override). `page` (default 1) and `size` (25, 50, 100 or 500, default 100); an invalid value is 400 with the body-mismatch message. The total is in the `X-Total-Count` header. An unknown key, an id included, is 404 `site_not_found`. |
`GET /v1/article/` also takes `seo=ready|suggestions|hidden` (any other value, an empty one included, is 400 with the body-mismatch message, after the `format` check). It is applied before paging and composes with `q`, `status`, `site`, `sort`, `page` and `size`; `total` follows it and `counts` ignore it. Every item carries `seo_status` (`ready`, `suggestions` or `hidden`) and `seo_suggestions` (the number of suggestions that are not dismissed). The filter is evaluated in memory over every matching article, so its cost grows with the number of articles on the site; without it only the returned page is assessed.
`X-Total-Count` is added to `Access-Control-Expose-Headers` next to `Link`, so a browser on an allowed origin can read it.
### Article SEO fields
An article carries twelve search and sharing fields, all edited through `PATCH /v1/article/{id}` and returned by
every single-article read (`GET /v1/article/{id}`, `POST`, `PATCH`, `publish`, `unpublish`):
`slug`, `excerpt`, `seo_title`, `seo_description`, `cover_image_id`, `cover_image_alt`, `social_title`,
`social_description`, `social_image_id`, `canonical_url`, `noindex` and `seo_dismissed`. Text fields are `""`
when empty, the two image ids are `null` when unset, `noindex` is `false` and `seo_dismissed` is `[]` by
default. Each item of `GET /v1/article/` carries `excerpt` and `slug` and none of the other ten.
The limits are counted in characters: `excerpt` 300, `seo_title` 120, `seo_description` 320,
`cover_image_alt` 200, `social_title` 120, `social_description` 320, `canonical_url` 2048; going over is
400 with the validator text. `cover_image_id` and `social_image_id` accept `null` (and only those two do);
`0` is 400 with the validator text and an unknown image is 400 `image_not_found`. `canonical_url` is `""` or an absolute
`https` URL with a host, otherwise 422 `invalid_url`. `seo_dismissed` is a list of the codes `title_long`,
`title_empty`, `desc_short`, `desc_long`, `cover_alt`, `cover_none`, `slug_long`, `no_au`, `no_h`, `body_alt`
and `short`: an unknown code is 400 with the validator text and duplicates are collapsed with the order
kept. `noindex` is only a flag, it does not take the article offline.
The `slug` matches `^[a-z0-9]+(-[a-z0-9]+)*$` with 1 to 100 characters (400 `invalid_slug`) and is unique per
site, never globally. It is generated from the title when the article is created: lowercase, accents folded,
every run of other characters becomes one dash, dashes trimmed, cut to 100 characters, and `article` when no
letter or digit is left; a slug already used on the site gets `-2`, `-3`... silently. The same rule
backfills the existing articles when the server starts after the upgrade (in id order within each site, so
the oldest article keeps the plain slug). Sending `slug: ""` regenerates the slug from the title. A slug
used by another article on the target site (the new site when `site_id` is sent too, and a move re-checks
the current slug) is 409 with the plain-text body `slug_taken`; the response then carries the header
`X-Slug-Suggestion` holding the first free `<slug>-N` from 2 on that site, fitting 100 characters. The header
is set on this 409 only, never on another response. A regeneration that collides is refused the same way, never suffixed
silently. Saving an article with its own slug is not a collision. Publishing never changes a slug, a deleted article
frees its slug.
New error tokens are plain text from the handler: 409 `slug_taken`, 400 `invalid_slug`, 422 `invalid_url`, 400
`image_not_found` and 400 `invalid_title_template` (sites). Edits that change a SEO field are recorded as
`article.edit` events (`slug`, `canonical_url`, `noindex`, `cover_image`, `social_image` with before and after;
the free-text fields as a size delta only); a refused slug is recorded as failed.
A site's `default_social_image_id` counts as a use of that image: it raises `used_in` and keeps the
image out of the `unused` filter. Deleting the image sets the field to `null` and succeeds. `POST /v1/media/merge` and `POST
/v1/media/purge` repoint the sites of the merged copies to the kept image and report it as
`sites_updated` next to `articles_updated`.
An article's `cover_image_id` and `social_image_id` count the same way. `used_in` counts every article that uses the
image in its body, as cover or as social image once, plus the authors using it as an avatar and the sites using it as default
social image. Deleting an image never fails because of its uses: the covers and social images pointing at it are set to `null`
(the `cover_image_alt` and the bodies are untouched), and a failed delete leaves every reference in place. A merge or a purge repoints
the covers and social images of the copies to the kept image, and an article rewritten in its body and its cover counts once in
`articles_updated`.
`GET /v1/media/{id}/usage` stays an array. Every item keeps the keys it had and gains `type` (`article`,
`author` or `site`) and `fields`. An article item is `{ "type": "article", "id", "title", "published", "fields" }` with
`fields` from `body`, `cover` and `social` (one item per article, in that order); an author item is
`{ "type": "author", "id", "title": <name>, "fields": ["avatar"] }` and a site item is `{ "type": "site", "id", "title": <name>,
"fields": ["default_social_image"] }`; author and site items have no `published` key. The array is `[]` for an unused image.
## Authors
An author is a public byline, with or without an account. It is `{ id, name, slug, bio, avatar_id,
website, social_links, user_id, is_linked, article_count }`, plus `user_email` for owners and admins
only (absent for every other caller). `name` is 1 to 100 characters after trimming, `slug` matches
`^[a-z0-9]+(-[a-z0-9]+)*$` with 1 to 100 characters and is unique (generated from the name when
omitted: accents folded, punctuation runs become one dash, `author` when nothing is left), `bio` has
at most 280 characters (counted in runes), `avatar_id` is `null` or an image id, `website` is `null`
or an `https` URL with a host (an empty string means none, at most 2048 characters),
`social_links` holds at most 5 `{ "type", "url" }` with `type` one of `x`, `linkedin`, `github`,
`mastodon`, `custom` and an `https` URL. `user_id` is the unique link to a user. Zero authors on an
article is allowed and never blocks publishing.
| Route | Who | Purpose |
| --- | --- | --- |
| `GET /v1/author/` | verified session or API key | The authors newest first. Query `q` (name or slug, accent and case insensitive), `page` (default 1), `size` (5, 25, 50 or 100, default 25). Body `{ items, total, page, size }`, with `total` repeated in the `X-Total-Count` header on every 200; a bad query is 400 with the body-mismatch message and no header. |
| `POST /v1/author/` | owner or admin, session only | Create. 201 with the author. `user_id` links an account that has no author. |
| `POST /v1/author/me` | any verified session | Create the caller's own author from the display name (or the username). A taken slug gets `-2`, `-3`... silently. 201 with the author plus `slug_adjusted`: `true` when the stored slug differs from the plain slug of the name (a collision suffix was added), `false` otherwise. No other author response carries this field. |
| `PATCH /v1/author/{id}` | owner or admin on any author; any verified session on the author linked to it; session only | Edit `name`, `slug`, `bio`, `avatar_id`, `website`, `social_links`, `user_id`. Absent fields are untouched, `avatar_id`, `website` and `user_id` accept `null`, `social_links` is replaced as a whole. `user_id` links or unlinks the account and is owner or admin only. |
| `DELETE /v1/author/{id}?reassign_to={id\|none}` | owner or admin, session only | 204. An author credited on articles needs `reassign_to`: an author id (that author takes the deleted one's position and is never duplicated on an article) or `none` (the credit is removed). The move and the delete are one transaction. `reassign_to` is ignored when the author has no article. The linked account is untouched. |
The role checks use the existing bodies: 403 `missing_permission` for `POST /` and
`DELETE`, 403 `author_rights` for a `PATCH` by a role that holds `authors.edit` at own on an author
that is not its own (or that sends `user_id`), and 403 "This action needs a real session." for a key.
Errors are plain text whose body is exactly the token: 404 `author_not_found`, 404 `user_not_found`,
409 `author_slug_taken`, 409 `author_has_articles`, 409 `user_already_linked`, 409
`author_already_linked`, 400 `invalid_slug`, 400 `invalid_reassign` (own id, not a whole number,
negative or empty), 400 `image_not_found`, 422 `bio_too_long`, 422 `invalid_url` and 422
`too_many_links`. A body that cannot be decoded, an empty `PATCH` or one that nulls a required field
is 400 with the body-mismatch message, a malformed id is 400 with the uint32 message and other
validation failures (`name`, link `type`) are 400 with the validator text. Writes refused for a
reason other than a missing target or an invalid value are recorded as failed events.
Articles carry `authors`, the ordered credits (the first is the primary byline), each with only `id`,
`name`, `slug`, `bio`, `avatar_id`, `website` and `social_links`; it is `[]` when there is none and it
is visible on online articles to anonymous readers. `PATCH /v1/article/{id}` also takes
`author_ids`, the whole ordered list: it replaces the credits, `[]` empties them, an unknown id is 404
`author_not_found`, a duplicate, `null` or a malformed list is 400 with the body-mismatch message and
`0` is 400 with the validator text. Absent means unchanged. A new article is credited to the
creator's linked author when there is one. `GET /v1/auth/me` returns the existing user fields plus
`author`: the caller's linked author (`id`, `name`, `slug`, `bio`, `avatar_id`, `website`,
`social_links`, `article_count`) or `null`.
An author's `avatar_id` counts as a use of that image like a site's social image: it raises `used_in`
and keeps the image out of the `unused` filter, and `GET /v1/media/{id}/usage` lists the author with the field `avatar`.
Deleting the image sets `avatar_id` to `null`. `POST /v1/media/merge` and `POST /v1/media/purge`
repoint the avatars of the merged copies and report it as `authors_updated`.
## Image variants
Owners and admins manage named variants of the stored images. These routes are session only (no API
key) and sit behind the verified-user check; a role without the needed permission gets 403 `missing_permission`. Behaviour, caching and the public `?v=<name>` contract are in [image-variants.md](image-variants.md).
| Route | Purpose |
| --- | --- |
| `GET /v1/image-variants` | `{ "items": [...] }`, ordered by name. |
| `POST /v1/image-variants` | Create. 201 with the variant. 409 when the name exists. |
| `PUT /v1/image-variants/{name}` | Replace the definition (the name in the body is ignored). 200 with the variant and `purged_files`. |
| `PATCH /v1/image-variants/{name}` | `{ "enabled": true }` or `{ "enabled": false }`. Keeps the generated files. |
| `DELETE /v1/image-variants/{name}` | 204. Removes the definition and its generated files. |
| `POST /v1/image-variants/{name}/purge` | `{ "purged_files", "purged_bytes" }`. Keeps the definition. |
| `GET /v1/image-variants/cache` | Cache statistics, see below. |
| `POST /v1/image-variants/cache/purge` | Purge generated files by scope, see below. |
A variant is `{ name, width, height, fit, format, quality, generate_on_upload, enabled,
generated_files, cache_size, created_at, updated_at }`. `width` and `height` are 1 to 8000 or `null`
(at least one is required), `fit` is `cover`, `contain` or `inside` (default `cover`), `format` is
`original`, `jpeg`, `png` or `webp` (default `original`), `quality` is 1 to 100 (default 82, `null`
for `png`), `generate_on_upload` defaults to `false`, `enabled` to `true`, and `cache_size` is in
bytes. A `PUT` that omits `generate_on_upload` or `enabled` keeps the stored value.
Errors are plain text: 400 for a body that is not valid JSON, 404 "Variant not found", 409 "A variant
named “thumb” already exists. Choose another name." and 422 with one sentence for the first invalid
field (name, width and height, fit, format, quality).
### Cache statistics and purge by scope
`GET /v1/image-variants/cache` answers `{ generated_files, disk_used, variants_defined,
variants_enabled, variants_disabled, rejected_requests, rejected_window_days, unused: { files, bytes,
days }, by_variant: [{ name, enabled, files, bytes }], top_images: [{ id, filename, url, files, bytes }] }`.
`by_variant` lists every defined variant by bytes descending then name; `top_images` lists the ten
images using the most variant bytes (ties by id, images that no longer exist are skipped). `unused`
counts the files of enabled variants whose reference time (the last public request, or the creation
time when never requested) is older than 30 days. `rejected_requests` counts the public requests
refused with 400 over today and the six previous UTC days. Pending request tracking is flushed before
the read. Arrays are never `null`.
`POST /v1/image-variants/cache/purge` takes `{ "scope": "all" | "unused" | "variant" | "image",
"variant": "<name>", "image_id": <id> }` (`variant` for the scope `variant`, `image_id` for `image`; a
target given for another scope is ignored) and answers `{ scope, purged_files, purged_bytes,
failed_files }`. A purge that fails on some files still answers 200 with `failed_files` above 0; the
failed rows are kept so the same request can be sent again. Errors are plain text: 400 for a body that
is not valid JSON, 422 "Scope must be all, unused, variant or image." or "A variant name or an image
id is required for this scope.", 404 "Variant not found" or "Image not found", 500 when the rows
cannot be listed.
### Variants of one image
Owners and admins only, session only: a request with an API key is 401. All ids are image ids.
| Route | Purpose |
| --- | --- |
| `GET /v1/media/{id}/variants` | `{ "unsupported": null, "items": [...] }` for the enabled variants in name order. `unsupported` is `"svg"` (with `items: []`) for an SVG. |
| `POST /v1/media/{id}/variants` | Generates every enabled variant that is missing. `{ "generated", "failed", "failures": [{ "name", "cause" }], "items": [...] }`, 200 even when some fail. |
| `POST /v1/media/{id}/variants/{name}` | Generates one enabled variant. 200 with the item. |
| `DELETE /v1/media/{id}/variants/{name}` | Removes the generated file. `{ "purged_files", "purged_bytes" }`, also for a disabled variant. |
An item is `{ name, width, height, fit, format, output_width, output_height, output_format,
generated, size, url }`: `width` and `height` are the settings (`null` when auto), `output_*` the
size and format the file has or will have (never above the source), `generated` is true only when
the file exists with the variant's current settings, `size` is its bytes or `null`, and `url` is
`/static/uploadedImages/<stored name>?v=<name>`. A `cause` is `too_large`, `unsupported_format` or
`failed`. A file generated here is the same file the public url serves, and the other way round.
Uploads generate the variants flagged `generate_on_upload` (enabled ones, raster sources only)
before answering; a failure there is logged and never fails the upload.
Errors are plain text: 400 for a malformed id, 404 "Image not found" or "Variant not found" (also for a
disabled variant on `POST`), 413 "The source image is too large to generate this variant.", 415 "The
source image can't be read, so this variant can't be generated.", 422 "Variants aren't generated for
this format." and 500 "Internal server error".
## Roles
Routes under `/v1/role`, behind the verified chain. Reads accept a key; every write is session only (a key answers
403 "This action needs a real session."). The permission is checked before the id, the body and any lookup.
| Route | Permission | Purpose |
| --- | --- | --- |
| `GET /v1/role` | `roles.view` | Every role by id, built-ins first. |
| `GET /v1/role/{id}` | `roles.view` | One role. |
| `POST /v1/role` | `roles.manage` | Create a role (201). `built_in` and `locked` in the body are ignored. |
| `PUT /v1/role/{id}` | `roles.manage` | Replace name, description and grants. `version` is required and must match. |
| `DELETE /v1/role/{id}` | `roles.manage` | Delete a role with no members (204). |
| `POST /v1/role/{id}/reassign` | `roles.manage` | Move every member to `{"target_role_id": N}`; answers `{"moved": n}`. |
| `POST /v1/role/{id}/reset` | `roles.manage` and `roles.edit_owner_only` | Restore a built-in role to its seeded grants and description; body `{"version": N}`. |
A role is `{ id, name, description, built_in, locked, version, grants, members, updated_at, updated_by }`.
`grants` is the normalised effective list: catalogue order, unknown keys and owner-only keys on an unlocked role
dropped, duplicates resolved to the most restrictive scope, and a per-type entry (`content.update:events`, scope
`none` included) right after its wildcard. A response is always valid as the `grants` of a `PUT`. `members` counts
the users holding the role plus the open invites (pending or expired, never accepted ones) for it, counted in two
statements whatever the number of roles. A user whose `role_id` is 0 or names no role counts for nobody.
`updated_at` (RFC 3339, UTC) and `updated_by` (`{ id, name }`, the display name else the username) are `null` for a role
never edited since seeding, and `updated_by` is also `null` once the saving user is deleted.
Refusals are plain text, one snake_case code per body:
| Status | Body | When |
| --- | --- | --- |
| 400 | `unknown_permission`, `invalid_scope`, `owner_only_permission`, `duplicate_permission`, `unknown_content_type` | A grant is not valid (`unknown_content_type` for a `content.*:{type}` key that is neither `article` nor a content type). |
| 400 | `invalid_role_name`, `built_in_name`, `not_built_in`, `invalid_reassign` | Blank name after trim; renaming a built-in role; resetting a custom role; a bad reassign target (self, the Owner role, unknown). |
| 403 | `role_locked` | Editing, deleting or moving from the Owner role. |
| 403 | `missing_permission` | The usual gate, plus `X-Missing-Permission: roles.edit_owner_only` when a built-in role is edited or reset without that permission. |
| 403 | `own_role` | Editing, deleting or moving members out of the caller's own role. |
| 403 | `escalation` | The caller would grant, or touch a role holding, more than they hold. `X-Escalation-Key` names the first offending key, such as `content.publish` or `content.publish:events`. |
| 404 | `role_not_found` | Unknown role id. |
| 409 | `role_name_taken`, `role_conflict`, `role_has_members`, `role_built_in` | Name already used (case-insensitive); stale `version`; the role still has members (users or open invites); deleting or moving members out of a built-in role. |
Names are trimmed and unique case-insensitively. The order of checks on a write is: permission, id, body, role lookup,
locked, built-in rules, own role, existing role covered by the caller, version, grant validation, new grants covered
by the caller, name taken, write. Only the Owner holds `roles.edit_owner_only`, so only the Owner edits the Admin and
Writer roles; owner-only keys are refused on every role a client can write. A save with nothing changed answers 200
at the same version and records nothing. Members see an edit on their next request, and so do the keys they created.
Activity: `role.create`, `role.edit`, `role.delete` and `role.reassign` in the `roles` category, target
`{ type: "role", id, name }`. `role.edit` carries `name` and `description` changes and the params `added`, `removed`,
`changed` (`{ key, from, to }`) and `members`; `role.reassign` carries `from`, `to` and `moved` (users and open invites together) and is recorded only when
`moved` is above 0. A refusal records nothing.
`GET /v1/auth/me` lists per-type entries in `access.permissions` (scope `none` included), right after their wildcard.
Deleting a content type removes its per-type rows from every role (the affected roles' `version` goes up by one), and
creating one with the key of a deleted type clears any stale row for that key.
## Users, invites and ownership
A user has exactly one stored role, `role_id`, and nothing else: there is no `role` string any more (the
`users.role` column of older databases stays in place, unused). `RoleRef` is the one role summary shape,
`{ id, name, built_in, locked }`. It appears as `access.role` in `GET /v1/auth/me`, as `role` on user and invite items
(`null` for a user whose role cannot be resolved) and in the responses below. `PublicUser` is exactly `id`, `username`,
`email`, `is_verified`, `created_at`, `updated_at` and `display_name` in every response that includes a user (login,
verify, register, invite accept, `GET /v1/auth/me`); `GET /v1/auth/me` adds `author` and `access`.
`role_assigned_by` is `{ id, name }` or `null`: who last set the user's role. It is stored as the nullable column
`role_assigned_by_id` on the user (no foreign key, no backfill, existing rows are `NULL`) and the name is never stored: it
is read from the current record of that user at read time (`display_name` when set, else `username`), so a rename shows
at once. When that record is gone the whole object is `null`. It is set by `PUT /v1/user/{id}/role` to the caller (a
request with the user's current role writes nothing), by invite accept to the inviter (`NULL` when the inviter record is
missing), and by the ownership transfer: the new Owner is assigned by the former Owner, whose own value becomes `NULL`.
First-user registration and `POST /v1/role/{id}/reassign` leave it alone. The key is always present on accepted-user
items of `GET /v1/user` (gate `users.view`, unchanged; assigners are resolved from the users already loaded) and on
`access` of `GET /v1/auth/me` (own data, one extra lookup at most). Invite items never carry it, and `PublicUser` and the
login, verify, register and invite-accept bodies do not change.
| Route | Permission | Session only | Purpose |
| --- | --- | --- | --- |
| `GET /v1/user` | `users.view` | no | Users and open invites, newest first. A user is `{ id, kind: "user", username, display_name, email, role, role_assigned_by, status: "accepted", created_at }`; an invite is `{ id, kind: "invite", email, role, status, created_at }`. The roles are read with one `List` call. |
| `PUT /v1/user/{id}/role` | `users.change_role` | yes | Body `{"role_id": N}` (required). Answers 200 with the user item, whose `role_assigned_by` is the caller. |
| `POST /v1/user/invite` | `users.invite` | no | Body `{"email", "role_id"}`; `role_id` is optional and 0 or absent means the Writer role. Answers 201 `{ id, kind: "invite", email, status, role, created_at, expires_at }`. |
| `POST /v1/user/invite/{id}/renew` | `users.invite` | no | Renews an expired or pending invite; it keeps its role and the response carries it. |
| `DELETE /v1/user/invite/{id}` | `users.invite` | no | Cancels an invite (204). No coverage is needed: cancelling removes power. |
| `POST /v1/site/transfer-ownership` | `system.transfer_ownership` | yes | Body `{"user_id", "confirm_email"}` (both required). Answers 200 `{ "owner": { id, name }, "former_owner": { id, name, role } }`. |
The no-escalation rule applies to people as it does to roles: the caller must cover (`GrantSet.Covers`) the target's
current role and the new role on a role change, the role of an invite on create and renew. An API key may create and
renew invites, and the check then uses the live role of the key's creator. Nobody can give, take away or invite to the
Owner role: `PUT /v1/user/{id}/role` to or from it, and an invite for it, are 403 `role_locked`; only the transfer moves
the Owner role. Changing the role of your own account is 403 `own_role`. Giving the role a user already holds answers
200 and writes and records nothing. A role change keeps `token_version`, sessions and keys: the next request is decided
by the new grants. An unverified user may be given a role.
The order of checks on `PUT /v1/user/{id}/role` is: session, permission, id, body, target user, target holds a locked
role, `role_id` lookup, new role locked, own account, current role covered, new role covered, same role (no-op), write.
On `POST /v1/user/invite`: permission, rate limiter, body, inviter, role (default Writer), role locked, role covered,
email already has an account, pending invite, create, mail. On `POST /v1/user/invite/{id}/renew`: invite lookup (404),
already accepted (409), invite role covered, renew, mail. On `POST /v1/site/transfer-ownership`: session, permission,
body, target, `confirm_email`, write. Every refusal writes nothing, not even the deletion of an expired invite.
| Status | Body | When |
| --- | --- | --- |
| 409 | the human message | `POST /v1/user/invite` only. "This address already has an account." carries `X-Error-Code: email_has_account`; "There's already a pending invite for this address." carries `X-Error-Code: invite_pending`. |
| 400 | `invalid_role` | `role_id` names no role (user change or invite), or the invite's role no longer exists on renew. |
| 400 | `invalid_transfer` | The transfer target is unknown, yourself, unverified, or not a user (an invite id is never valid). |
| 400 | `confirmation_mismatch` | `confirm_email` differs from the target's email (exact, case-sensitive match). |
| 403 | `role_locked` | The target holds the Owner role, or the role to give is the Owner role. |
| 403 | `own_role` | The target of a role change is the caller's own account. |
| 403 | `escalation` | A role to cover is above the caller's grants; `X-Escalation-Key` names the first offending key. |
| 403 | `missing_permission` | The gate, with `X-Missing-Permission`. A key on a session-only route answers 403 "This action needs a real session." first. |
| 404 | `user_not_found` | Unknown user id on `PUT /v1/user/{id}/role`. |
| 409 | `user_role_changed` | The target's role changed between read and write (a compare-and-swap on the role that was read), or the caller no longer holds the Owner role on a transfer. |
Transferring ownership moves the Owner role to the target and gives the former owner the built-in Admin role in one
transaction; both keep `token_version`, cookies and API keys, so the former owner's next request is decided by the
Admin grants and the new owner's by the Owner grants. Exactly one verified user holds the Owner role afterwards, also
under two simultaneous transfers (one answers 200, the other 409).
Accepting an invite (`POST /v1/auth/invite/accept`) creates the user with the role the invite names, read at accept
time; an invite whose `role_id` is 0 or names a deleted role answers the usual invalid-invite 404 and creates nothing.
Registration creates the first user with the built-in Owner role; when the Owner role is not seeded it answers 500.
A user whose `role_id` is 0 or names no role is denied on every gated route (403 `missing_permission`) and gets 500 on
`GET /v1/auth/me`; it is never promoted.
Activity: `user.change_role` (category `users`, target the user, `changes` holds `{ field: "role", kind: "text", before,
after }` with the role names, params `from` and `to` hold the role ids) and `user.transfer_ownership` (category `users`,
`security: true`, target the new owner, params `from` and `to` hold the two user ids). `invite.create` and
`invite.accept` carry the params `role_id` and `role` (the role name); `invite.renew` is unchanged. A refusal and a
no-op record nothing; a storage failure at the write records the same event with outcome `failed`.
## Content permissions
Articles and the entries of custom content types are checked against the `content.*` permissions of the
caller's role, which is read on every request (a key follows its creator's role).
| Route | Permission | Scope check after the lookup |
| --- | --- | --- |
| `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo` | public, `content.read` widens what drafts show | online articles, plus drafts when `content.read` is `all`, or the caller's own drafts when it is `own` |
| `POST /v1/article/{id}/seo/check` | `content.read` | the article must be visible, else 404 |
| `POST /v1/article/` | `content.create` | none; `created_by` is the caller and the article is always a draft |
| `PATCH /v1/article/{id}` | `content.update` | `own` needs `created_by` to be the caller |
| `DELETE /v1/article/{id}` | `content.delete` | same |
| `PATCH /v1/article/{id}/publish`, `/unpublish` | `content.publish` | same; `PATCH` never changes `is_online` |
| `GET /v1/content-type/{key}/entry`, `GET .../entry/{id}` | `content.read` for `{key}` | `own` lists only the caller's entries (total, page, size and `q` follow) and answers 404 `entry_not_found` for another's entry |
| `POST /v1/content-type/{key}/entry` | `content.create` for `{key}` | `created_by` is the caller |
| `PUT`, `DELETE .../entry/{id}` | `content.update`, `content.delete` for `{key}` | `own` needs `created_by` to be the caller |
Each action reads its own permission: publish does not imply update, update does not imply read. `own`
means `created_by` equals the caller; an unattributed item (`created_by` 0) is owned by nobody. A permission
can also be set for one content type (`content.update:blog`, `content.update:article` for articles); that
row replaces the wildcard for the type, whether it lowers or raises it, and a `none` row denies. Only the
wildcard applies to a type with no row. An id that does not exist is 404 even for a caller at `own`. Article responses expose `created_by` to signed-in callers and API keys only (never to anonymous callers), so a client can tell which rows are its own; see `docs/api/articles.md`.
A refusal is 403 `missing_permission` with `X-Missing-Permission` set to the base key (`content.update`,
never `content.update:blog`). The permission is checked before the id and the body are read, then the scope
after the lookup, and a refused call writes nothing and records no activity event. The verification and
token checks still come first. Creating a content type whose key is `article`, `author`, `site`, `media`
or `user` is 409 `content_type_key_taken`. Owner-only permissions only count on the locked Owner role: the
same key placed by hand on another role is ignored and `GET /v1/auth/me` does not list it.
## Errors ## Errors
Every error is a plain-text body with one sentence; the status is in the HTTP status line. The Every error is a plain-text body with one sentence; the status is in the HTTP status line. The
statuses are 400, 401, 403, 404, 409, 429 and 500. Messages are listed on the Integration page. statuses are 400, 401, 403, 404, 409, 429 and 500. Messages are listed on the Integration page.
A caller whose role lacks the permission a route needs gets 403 with the body `missing_permission` and the
header `X-Missing-Permission` set to the permission key (for example `users.invite`). The role is read on
every request, so an API key follows the current role of its creator.
## Notes ## Notes
- Images in `body` use paths like `/static/uploadedImages/<uuid>.png`. Prefix them with the base URL. - Images in `body` use paths like `/static/uploadedImages/<uuid>.png`. Prefix them with the base URL.
- Browsers can call the API only from origins in the server's CORS setting. - Browsers can call the API only from origins in the server's CORS setting.
+125 -1
View File
@@ -2,6 +2,8 @@
Every route that returns an article accepts the optional `format` query parameter and returns the article body in that format. Articles are stored as HTML (Tiptap output). Storage and the request bodies of `POST /v1/article/` and `PATCH /v1/article/{id}` stay HTML. Every route that returns an article accepts the optional `format` query parameter and returns the article body in that format. Articles are stored as HTML (Tiptap output). Storage and the request bodies of `POST /v1/article/` and `PATCH /v1/article/{id}` stay HTML.
`PATCH /v1/article/{id}` takes `body`, `site_id`, `author_ids` (the whole ordered list of author ids, see `docs/api.md`) and the SEO fields listed below, and nothing else. The request must contain at least one of them, and `null` answers `400` except for `cover_image_id` and `social_image_id`. Behaviour change: `{}` and `{"body": null}` used to set an empty body and now answer `400`; send `{"body": ""}` to empty a body.
## Routes that honour `format` ## Routes that honour `format`
- `GET /v1/article/` - `GET /v1/article/`
@@ -11,7 +13,41 @@ Every route that returns an article accepts the optional `format` query paramete
- `PATCH /v1/article/{id}/publish` - `PATCH /v1/article/{id}/publish`
- `PATCH /v1/article/{id}/unpublish` - `PATCH /v1/article/{id}/unpublish`
Visibility is unchanged: anonymous and unverified callers only see online articles, and an offline article answers `404 Article not found`. Anonymous and unverified callers only see online articles, and an offline article answers `404 Article not found`. A verified caller also sees the drafts that `content.read` covers: every draft at `all`, only their own at `own`, and none without the permission. The `all`, `online` and `draft` counts follow the same visible set.
## Pagination, search and counts
`GET /v1/article/` always answers one page inside an envelope, with or without query parameters:
```json
{
"items": [],
"total": 30,
"page": 1,
"size": 25,
"counts": { "all": 30, "online": 30, "draft": 0, "suggestions": 4 }
}
```
| Parameter | Values | Default |
| --- | --- | --- |
| `page` | integer >= 1 | `1` |
| `size` | `5`, `25`, `50` or `100` | `25` |
| `q` | up to 200 characters, matched anywhere in the title, ignoring case and accents | none |
| `status` | `online` or `draft` | none |
| `site` | the `key` of a site; an unknown key gives an empty page, an empty value is `400` | none |
| `seo` | `ready`, `suggestions` or `hidden`; any other value, an empty one included, is `400` | none |
| `sort` | `title`, `status`, `created_at` or `updated_at`, then `:asc` or `:desc` (lowercase, for example `title:asc`) | `updated_at:desc` |
- `items` is `[]` when empty, never `null`. Each item is the article object below.
- Items follow `sort` (default `updated_at:desc`) across the whole list before paging. `title` ignores case, `status` orders on online state (`status:asc` puts drafts first), and every ordering ends with `id` descending so pages never overlap.
- `total` is the number of visible articles matching `q` and `status`, before paging. `page` and `size` echo the values applied.
- `counts` ignores `q`, `status`, `site`, `seo`, `sort`, `page` and `size`, so tab counts do not move while searching. `suggestions` is the number of visible articles whose `seo_status` is `suggestions` (hidden and ready articles and dismissed codes are not counted); it equals the `total` of `?seo=suggestions` sent with no other filter by the same caller. For anonymous and unverified callers `all` equals `online` and `draft` is `0`, and `status=draft` answers `items: []` and `total: 0`.
- Each item also carries `seo_status` and `seo_suggestions` (see [SEO status and suggestions](#seo-status-and-suggestions)). `seo` filters on the status before paging, so `total` follows it while `counts` do not.
- A page beyond the last answers `200` with `items: []` and the real `total`.
- An invalid `page`, `size`, `status`, `sort` or an over-long `q` answers `400` with the plain-text message `The request cannot be processed due to a mismatch in the format of the body.` An invalid `format` is reported first.
Breaking change: this route used to answer a bare JSON array. Clients must read the `items` property.
## The `format` parameter ## The `format` parameter
@@ -35,11 +71,96 @@ Breaking change: the default used to be HTML. Clients that need HTML must send `
"body": "## Plan\n\nReplace the **old** deck.", "body": "## Plan\n\nReplace the **old** deck.",
"body_format": "markdown", "body_format": "markdown",
"is_online": true, "is_online": true,
"site_id": 1,
"created_by": 1,
"authors": [],
"slug": "backyard-renovation-update",
"excerpt": "",
"seo_title": "",
"seo_description": "",
"cover_image_id": null,
"cover_image_alt": "",
"social_title": "",
"social_description": "",
"social_image_id": null,
"canonical_url": "",
"noindex": false,
"seo_dismissed": [],
"created_at": "2026-03-12T09:14:00Z", "created_at": "2026-03-12T09:14:00Z",
"updated_at": "2026-09-10T07:00:00Z" "updated_at": "2026-09-10T07:00:00Z"
} }
``` ```
`created_by` is the id of the user who created the article (`0` when it is attributed to nobody). It is present on every article object (list, read, create and update responses) only when the caller is signed in with a verified session or authenticates with an API key; the key acts as its creator. Anonymous callers, and unverified or stale sessions, get the same object without the field. The SPA reads it to decide which rows a role limited to `own` can edit, delete or publish.
Every single-article read (`GET /v1/article/{id}`, `POST`, `PATCH`, `publish`, `unpublish`) carries the twelve SEO fields. Each item of `GET /v1/article/` carries `slug` and `excerpt` only, and none of the other ten.
## SEO fields
| Field | Type | Limit and rule |
| --- | --- | --- |
| `slug` | string | `^[a-z0-9]+(-[a-z0-9]+)*$`, 1 to 100 characters, unique per site. `""` regenerates it from the title. |
| `excerpt` | string | 300 characters |
| `seo_title` | string | 120 characters |
| `seo_description` | string | 320 characters |
| `cover_image_id` | number or `null` | an existing image id (`0` is refused) |
| `cover_image_alt` | string | 200 characters, independent of the cover |
| `social_title` | string | 120 characters |
| `social_description` | string | 320 characters |
| `social_image_id` | number or `null` | an existing image id (`0` is refused) |
| `canonical_url` | string | `""` or an absolute `https` URL with a host, at most 2048 characters |
| `noindex` | boolean | only a flag: the article stays online |
| `seo_dismissed` | array of strings | any of `title_long`, `title_empty`, `desc_short`, `desc_long`, `cover_alt`, `cover_none`, `slug_long`, `no_au`, `no_h`, `body_alt`, `short`; duplicates are collapsed, order kept |
Limits are counted in characters (runes), not bytes. Absent fields are untouched; text fields and `noindex` are cleared with `""`, `false` and `[]`, the two image ids with `null`.
### The slug
A slug is unique per site (`site_id`, `slug`), never globally. At creation it is generated from the title: lowercase, accents folded, every run of other characters becomes one dash, dashes trimmed, cut to 100 characters without a trailing dash, and `article` when no letter or digit is left. A slug already used on the site gets `-2`, `-3`... silently, the base being truncated so the result fits 100 characters. The same rule backfills existing articles on the first start after the upgrade, in id order within each site.
Editing a slug that another article of the target site already uses (the new site when `site_id` is sent in the same request; moving an article also re-checks its current slug there) answers `409` with the plain-text body `slug_taken`, and the response header `X-Slug-Suggestion` holds the first free `<slug>-N` from 2 on that site, fitting 100 characters. `X-Slug-Suggestion` is set on this `409` only and on no other response. A request with `slug: ""` that regenerates onto another article's slug is refused the same way and never suffixed silently. Re-sending an article's own slug is not a conflict. A refused request changes nothing.
Publishing, unpublishing and `noindex` never change a slug. A deleted article frees its slug.
## Resolved SEO object
`GET /v1/article/{id}/seo` answers the data a frontend renders in its `<head>`. Online articles are public; a draft needs a verified session or API key whose role may read it (see [Content permissions](../api.md#content-permissions)) and is otherwise `404 Article not found`. The query is ignored (no `format`). Keys:
`title`, `description`, `canonical_url`, `robots`, `og` (`type`, `title`, `description`, `image`, `image_alt`, `url`, `site_name`, `locale`), `twitter` (`card`, `title`, `description`, `image`, `image_alt`), `image` (`url`, `alt`, `source`), `authors` (`name`, `url`, `same_as`), `published_at`, `modified_at`, `fallbacks` and `json_ld` (a ready schema.org `Article`).
The fallback chain, first non-empty wins:
| Output | Chain | `fallbacks` value |
| --- | --- | --- |
| `title` | `seo_title`, then the article title, then the site `title_template` (`%title%`, `%site%` replaced by `site_name`, result trimmed) | `seo_title`, `title` |
| `description` | `seo_description`, `excerpt`, the first sentence of the body (tags removed), the site `default_description` | `seo_description`, `excerpt`, `body`, `site`, `none` |
| `image` | `social_image_id`, `cover_image_id` (with `cover_image_alt`), the site `default_social_image_id`; `url` is the site `base_url` plus the image path, `""` and `none` when there is none | `social`, `cover`, `site`, `none` |
| `canonical_url` | `canonical_url`, then site `base_url` + `/` + `slug` | `canonical_url`, `site` |
| `robots` | `noindex, nofollow` when `noindex` is true, otherwise from the site `default_robots` (`index, follow` or `noindex, nofollow`) | none |
| `og` and `twitter` title and description | `social_title` and `social_description`, then the resolved values | none |
`og.type` is `article`, `og.locale` is the site locale (`""` when none), `twitter.card` is `summary_large_image` with an image and `summary` without. `published_at` is the creation time of an online article and `null` for a draft; `modified_at` is `updated_at`. `authors[].url` is the author website (`""` when none) and `same_as` lists the social link URLs only. `json_ld.headline` is the meta title without the site template, and `author`, `image` and `datePublished` are left out when empty.
## SEO status and suggestions
`POST /v1/article/{id}/seo/check` and the list use one rule set. Soft ranges never block saving or publishing; lengths are counted in characters.
| Code | Field | Fires when | `params` |
| --- | --- | --- | --- |
| `title_long` | `seo_title` | the meta title (`seo_title`, else the title) is over 60 characters | `length`, `max` |
| `title_empty` | `seo_title` | the meta title is empty after trimming | none |
| `desc_short` | `seo_description` | the resolved description is under 70 characters | `length`, `min` |
| `desc_long` | `seo_description` | the resolved description is over 160 characters | `length`, `max` |
| `cover_alt` | `cover_image_alt` | there is a cover and its alt text is empty | none |
| `cover_none` | `cover_image_id` | there is no cover | none |
| `slug_long` | `slug` | the slug is over 75 characters | `length`, `max` |
| `no_au` | `author_ids` | no author is credited | none |
| `no_h` | `body` | the text has at least 1000 characters and no `h2` to `h6` | none |
| `body_alt` | `body` | an image of the body has no alt text | `count` |
| `short` | `body` | the text has fewer than 300 characters | `length`, `min` |
Suggestions come in that order. Codes in `seo_dismissed` are never reported or counted. The status is `hidden` when `noindex` is true, else `suggestions` when at least one suggestion remains, else `ready`. The list items carry `seo_status` and `seo_suggestions` (the count, also for a hidden article).
## Markdown output ## Markdown output
Headings, bold, italic, links, bullet and ordered lists, blockquotes, fenced code blocks, rules, images and inline code are converted. Anything else is reduced to its text. `script` and `style` content is removed, and the source HTML is never passed through; the only HTML in the output is the alignment wrapper described below. List indentation attributes are dropped. Links with a scheme other than http, https, mailto or tel are reduced to their text. Characters that markdown would read as formatting (`*`, `_`, `` ` ``, `[`, `]`, `<`, `\`) and line starts that would read as a heading, list, quote or rule (`#`, `-`, `+`, `>`, `1.`) are backslash-escaped, so plain text stays plain text. A line break becomes a backslash followed by a newline. Headings, bold, italic, links, bullet and ordered lists, blockquotes, fenced code blocks, rules, images and inline code are converted. Anything else is reduced to its text. `script` and `style` content is removed, and the source HTML is never passed through; the only HTML in the output is the alignment wrapper described below. List indentation attributes are dropped. Links with a scheme other than http, https, mailto or tel are reduced to their text. Characters that markdown would read as formatting (`*`, `_`, `` ` ``, `[`, `]`, `<`, `\`) and line starts that would read as a heading, list, quote or rule (`#`, `-`, `+`, `>`, `1.`) are backslash-escaped, so plain text stays plain text. A line break becomes a backslash followed by a newline.
@@ -93,5 +214,8 @@ Example for `<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>`:
| --- | --- | | --- | --- |
| `400` | `The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).` for an invalid id | | `400` | `The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).` for an invalid id |
| `400` | `The format must be one of: markdown, html, json.` for an unsupported format | | `400` | `The format must be one of: markdown, html, json.` for an unsupported format |
| `400` | `invalid_slug`, `image_not_found` or the validator text for an over-long SEO field, `cover_image_id: 0` or an unknown `seo_dismissed` code |
| `404` | `Article not found` | | `404` | `Article not found` |
| `409` | `slug_taken`, with the header `X-Slug-Suggestion: <first free slug>` (this response only) |
| `422` | `invalid_url` for a `canonical_url` that is not an absolute `https` URL with a host |
| `500` | `Internal server error` when the body cannot be converted | | `500` | `Internal server error` when the body cannot be converted |
+124 -3
View File
@@ -15,8 +15,10 @@ api/ ──▶ internal/infrastructure/useCases ──▶ internal/domain
| Domain | `internal/domain/<name>/` | Entities, `Err…` sentinel errors, `Repository` interfaces. Imports only the stdlib and other domain packages, with no GORM tags. | | Domain | `internal/domain/<name>/` | Entities, `Err…` sentinel errors, `Repository` interfaces. Imports only the stdlib and other domain packages, with no GORM tags. |
| Use cases | `internal/infrastructure/useCases/` | Application logic. Depends only on domain interfaces, never on persistence, mailer, GORM or `net/http`. | | Use cases | `internal/infrastructure/useCases/` | Application logic. Depends only on domain interfaces, never on persistence, mailer, GORM or `net/http`. |
| Adapters | `internal/infrastructure/persistence/{models,mappers,repositories}`, `internal/infrastructure/mailer` | Implement a domain `Repository` (assert with `var _ domain.Repository = &X{}`). GORM structs stay in `models` and are converted by `mappers`. | | Adapters | `internal/infrastructure/persistence/{models,mappers,repositories}`, `internal/infrastructure/mailer` | Implement a domain `Repository` (assert with `var _ domain.Repository = &X{}`). GORM structs stay in `models` and are converted by `mappers`. |
| Database drivers | `internal/infrastructure/persistence/database` | `database.Connect(Config{Driver, Source})` looks the driver up in a registry and returns a `Connection` (GORM handle, `NewBackup`, `Close`). A `Driver` implements `Open` and `NewBackup`, so a new database is one new file plus one registry entry. `database.Migrate` owns the `AutoMigrate` model list and the role backfills (legacy `users.role` into `role_id` while that column exists, invites without a role to Admin, then `created_by`). Only `sqlite` exists today. |
| Blob storage | `domain/image.Storage`, `internal/infrastructure/storage` | `Storage` (`Put`, `Open`, `Delete`) is the port for uploaded image bytes. `storage.Open(Config{Driver, Location})` picks the adapter; `local` is the only one. Repositories never touch files: use cases coordinate `Storage` and `image.Repository`. |
| Delivery | `api/handlers/<name>/`, `api/router.go` | HTTP handlers receive use cases through `NewHandler(...)`. They never import persistence, mailer or GORM. | | Delivery | `api/handlers/<name>/`, `api/router.go` | HTTP handlers receive use cases through `NewHandler(...)`. They never import persistence, mailer or GORM. |
| Composition root | `api/dependecyInjection.go` | The only place that opens the DB, builds repositories and use cases, and lists models for `AutoMigrate`. | | Composition root | `api/dependecyInjection.go` | The only place that picks the database and storage drivers, builds repositories and use cases, and runs `database.Migrate` and the image metadata backfill at boot. |
| Entry point | `cmd/server/main.go` | Env/config, HTTP server, graceful shutdown. | | Entry point | `cmd/server/main.go` | Env/config, HTTP server, graceful shutdown. |
Nothing under `internal/` imports `api/`. Nothing under `internal/` imports `api/`.
@@ -25,14 +27,100 @@ Nothing under `internal/` imports `api/`.
| Package | Role | | Package | Role |
|---|---| |---|---|
| `api/handlers/user` | Authenticated user endpoints: profile update (`UpdateMe`) and the users list with invitations (`CreateInvite`, `ListUsers`). | | `api/handlers/user` | Authenticated user endpoints: profile update (`UpdateMe`), the users list with invitations (`CreateInvite`, `ListUsers`) and the role change (`RoleHandler.Change`). |
| `api/handlers/ownership` | `POST /v1/site/transfer-ownership`, built by `NewHandler(TransferOwnershipUseCase)` and mounted by `NewSiteRouter` in the session group. |
| `api/handlers/media` | The media library under `/v1/media` (list, upload, rename, delete, merge, usage). Images belong to the library, not to an article: deleting an article never deletes an image, and an image's "used in" is read from article bodies each time. | | `api/handlers/media` | The media library under `/v1/media` (list, upload, rename, delete, merge, usage). Images belong to the library, not to an article: deleting an article never deletes an image, and an image's "used in" is read from article bodies each time. |
| `api/pkg/upload` | Multipart `file` reading and the mapping of upload errors (415, 413, 400) shared by both upload routes. | | `api/pkg/upload` | Multipart `file` reading and the mapping of upload errors (415, 413, 400) shared by both upload routes. |
| `api/pkg/publicuser` | `PublicUser`, the JSON shape of a user with no password, codes or token version. `publicuser.New` converts a domain `user.User`, so handlers never serialise the domain struct directly. | | `api/pkg/publicuser` | `PublicUser`, the JSON shape of a user with no password, codes or token version. `publicuser.New` converts a domain `user.User`, so handlers never serialise the domain struct directly. |
### Media purge
`internal/domain/purge` holds `Settings` (automatic purge on or off, interval of 1 to 365 days, `last_purge_*` history, `enabled_at`), the `Repository` interface (`Get` returns the defaults when nothing was saved, `Save`, and `SaveLastRun`, which writes only the `last_purge_*` columns so a settings change made during a run is never overwritten) and the sentinels `ErrInvalidPurgeInterval`, `ErrPurgeConflict` (stale fingerprint) and `ErrPurgeBusy` (a run is already in progress). `Settings.NextPurgeAt` is `max(last_purge_at, enabled_at)` plus the interval when automatic purge is on, and null otherwise. The settings live in a single-row `purge_settings` table (`models.PurgeSettings`, `mappers.PurgeSettings*`, `persistence.PurgeSettingsRepository`), created by `AutoMigrate`.
The use cases `GetPurgeSettings`, `UpdatePurgeSettings`, `PreviewPurge` and `RunPurge` depend only on domain interfaces, an injected clock and an injected `*log.Logger`, so a scheduler can call them. `PreviewPurge` and `RunPurge` share one plan (`purgePlan.go`): a duplicate group (same non-empty sha256) is planned only when a copy is used in an article, the most used copy is kept (ties: oldest `created_at`, then lowest id) and every other copy is merged into it. The fingerprint is a SHA-256 over the planned groups. `RunPurge` takes a non-blocking in-process lock (`ErrPurgeBusy` for a concurrent call), refuses a non-matching fingerprint with `ErrPurgeConflict`, merges group by group with `MergeMedia` (a failing group is counted and skipped), unions the `UpdatedArticleIDs` of the fully merged groups, and records `last_purge_*` after every run.
#### Automatic purge scheduler
`PurgeSchedulerUseCase.Tick(now)` returns the `purge.RunResult` of the run, whether it ran and an error. It reads the purge settings on every call and runs `RunPurge.Run(nil)` (no fingerprint) when `Settings.NextPurgeAt()` is set and `now` is not before it. It never writes settings: only `RunPurge` records `last_purge_*`, through `SaveLastRun`, which moves the anchor forward even when nothing was purged. The runner in `internal/infrastructure/scheduler` calls `Tick` and logs one line with `groups_purged`, `merged`, `articles_updated` and `failed_groups` when a run happened. It calls `Tick` once after a startup delay of 1 minute, which catches up a run missed during downtime in a single purge, then once every hour. `api/dependecyInjection.go` builds it and `cmd/server/main.go` starts it with the signal context of the server. Changes to the settings apply on the next tick, with no restart.
- Overlap guard: a manual run holds the `RunPurge` lock, so `Tick` returns `ErrPurgeBusy` at once. The runner treats it as a skip, logs nothing and re-evaluates at the next tick.
- Failures: any other error is logged and the loop keeps going. There is no retry inside a tick; the next hourly tick evaluates the schedule again.
- Shutdown: on SIGINT or SIGTERM the runner stops taking ticks and waits, with no time limit, for a `Tick` in progress. `main` waits for the runner to return before the database is closed.
`middleware.RequireOwnerOrAdmin` lets only the roles owner and admin through (403 otherwise, including an empty role). The media router mounts these routes after the verified chain and before the `/{id}` routes:
| Route | Purpose |
|---|---|
| `GET /v1/media/purge-settings` | Settings with `next_purge_at`. |
| `PATCH /v1/media/purge-settings` | Partial update of `auto_purge_enabled` and `auto_purge_interval_days`. |
| `GET /v1/media/purge-preview` | Groups, copies, distinct articles, the first 5 groups, `more` and the fingerprint. |
| `POST /v1/media/purge` | Runs the purge. Optional `fingerprint`; 409 when stale or when a run is in progress. |
### Sessions and personal data retention
A session is an HS256 token from `api/pkg/authtoken`. `Encode` adds a random `jti` and an `exp` 24 hours ahead; `Decode` refuses a token without `exp`. `authtoken.WithRevocationStore` plugs in `useCases.SessionRevocationUseCase`, which stores a `session.Revocation` (`jti`, `exp`) through the `internal/domain/session` `Repository` (`persistence.RevokedSessionRepository`, table `revoked_sessions`). `LoginHandler.Logout` revokes the presented token; from then on `Decode` answers `ErrRevoked` for it, and a store that cannot be read makes every token invalid rather than valid. Revoking every session of a user stays the job of `token_version`, bumped by a password reset.
`useCases.EnforceRetentionUseCase.Tick(now)` deletes revocations past their expiry, unverified accounts 7 days after their code expired (`user.UnverifiedRetention`) and invites 30 days after they expired or were accepted (`invite.Retention`), and returns a `retention.Result`. `scheduler.NewSystemRetentionRunner` calls it one minute after startup and then hourly, logs one line only when something was removed, and stops with the other runners on shutdown.
Uploads go through the `image.MetadataStripper` port before they are measured for size, hashed and stored. `imaging.MetadataStripper` walks the JPEG segments, PNG chunks and WebP RIFF chunks without re-encoding pixels, drops metadata, keeps colour profiles and re-adds an orientation-only EXIF block when the photo was rotated. A container it cannot walk fails the upload with `image.ErrImageUnreadable`.
### Activity log
`internal/domain/audit` holds the append-only `Event` (actor, target and failure-reason snapshots, outcome, security flag, source, ip, user agent, `params` and `changes`), the closed vocabularies (actor type, category, outcome, source, change kind), the `Filter`, `Summary` and `Settings` types, the `Repository` interface (`Append`, `List`, `Summary`, `DeleteOlderThan`: no way to edit or delete one event), the `SettingsRepository` (`Get`, `SaveRetention`, `SaveLastCleanup`; no row means 90 days) and the sentinels `ErrInvalidFilter`, `ErrInvalidRetention`, `ErrInvalidEvent` and `ErrActorNotAllowed`. The `Recorder` port is `Record(ctx, event)`; it returns nothing, so a caller can never fail on it.
`internal/infrastructure/auditing.Recorder` is the adapter. It fills a missing actor, source, ip and user agent from the `audit.Origin` carried by the request context, stamps `created_at` from the injected clock in UTC, bounds long names, drops an event outside the vocabulary, and recovers from any error or panic of the repository by writing one line to the process log (the action and the error only). `middleware.AuditContext` builds the origin after the credential middleware: a session gives a `user` actor and source `web`, an API key an `api_key` actor and source `api_key`, no valid credential an `anonymous` actor; the ip is the peer address, or `X-Real-IP` when the peer is a trusted proxy; the user agent is cut to 255 characters. Use cases that produce events (authenticate and logout, password reset request and completion, API key create and revoke, retention change, cleanup) take the context and an `audit.Recorder`; secrets (passwords, raw keys, reset codes, tokens and their hashes) are never put in an event or in its log line.
Persistence is `models.AuditEvent` (`audit_events`, indexes on `created_at`, `(actor_type, actor_id, created_at)` and `(category, created_at)`; `params` and `changes` are JSON text) and `models.AuditSettings` (one row), both in `AutoMigrate`. The queries use only portable SQL (`LOWER(...) LIKE ... ESCAPE '!'`, `COUNT(DISTINCT ...)`, `CASE` sums), so they run on SQLite, MySQL/MariaDB and PostgreSQL. The summary splits the range into seven equal buckets, oldest first.
The use cases `ListActivity`, `GetActivitySummary` and `ExportActivity` turn the raw query into a domain filter (`range` defaults to 7 days; `custom` needs two dates, `to` included), scope a caller who is not owner or admin to its own events, and resolve `target.exists` at read time. The export reads the log through `List` in pages of 500 and writes the CSV with `encoding/csv`. `/v1/activity` and the settings `PATCH` sit behind `RequireSession`, the verifier, the current-token and verified-user checks; summary and export also behind `RequireOwnerOrAdmin`, the settings `PATCH` behind `RequireOwner`.
`CleanupActivityUseCase.Tick(now)` follows the `PurgeScheduler` model. With a retention of 0 it never runs. Otherwise it is due once a day, at or after 03:00 in the location of `now` and when the last cleanup is before today's 03:00. It deletes events older than `now` minus the retention days (never newer than 24 hours), records the run (`activity_last_cleanup_at`, `activity_last_cleanup_removed`) even when nothing was removed, and only then records a `system.activity_cleanup` event when something was removed. A failing delete returns the error and leaves the last-cleanup fields as they were, so the next tick retries. `scheduler.Runner` now wraps a task with a label: `NewRunner` keeps the purge behaviour and log line, and `NewCleanupRunner` ticks the cleanup. `UseCases.RunSchedulers` runs both runners and `cmd/server/main.go` starts it with the server's signal context.
Producers: `ArticleChanges`, `TextChange` and `BodyChange` in `internal/domain/audit` build the change lists (the body change holds only a signed rune count delta), and `NewTarget` and `ReleaseTarget` build the target snapshots (types `article`, `image`, `user`, `invite`, `api_key`, `release`). The use cases create, edit, publish, unpublish and delete article, upload, rename, delete, merge and purge media, register, verify, update profile, create, cancel, renew and accept invite, check, download and restart take the request context first and an `audit.Recorder` last, and record exactly one event after the change or the refusal (the action catalogue is in `docs/api.md`). Events are never written by handlers, middleware or GORM hooks, and nothing is recorded for reads or for a target that does not exist. `useCases/auditFailures.go` holds the fixed table of failure sentences keyed by domain `Err...` sentinels (matched with `errors.Is`, each at most 255 characters; the restart action overrides `ErrJobInProgress`) with the generic fallback "Something went wrong on our side. Please try again."; raw error text is never stored. The manual purge runs the groups through the non-recording `MergeMediaUseCase.merge`, so only one `media.purge` event is written; `RunPurgeUseCase.Execute` is the non-recording run that `PurgeSchedulerUseCase.Tick(now)` uses to record one `system.media_purge` event with the system actor and `context.Background()`. `StartUpdateDownloadUseCase` hands `context.WithoutCancel(ctx)` to its job goroutine so the `system.update_downloaded` event, written when the job becomes `ready` or `failed`, keeps the origin of the request. `middleware.AuditContext` is mounted on the article, user, media and system routers (after the API key middleware where keys are accepted), so API key calls are attributed to the key.
### Sites
`internal/domain/site` holds the `Site` entity (name, immutable key, normalised base URL, optional default social image, `index` or `noindex` robots, optional locale, the default flag and the computed `article_count`), `NormalizeBaseURL`, `SeedBaseURL`, `NormalizeLocale`, `IsValidKey`, the `Patch` type (with `Nullable` for the two nullable fields), the `Err...` sentinels (`ErrSiteNotFound`, `ErrSiteKeyTaken`, `ErrSiteHasArticles`, `ErrDefaultSite`, `ErrKeyImmutable`, `ErrInvalidSiteURL`, `ErrInvalidLocale`, `ErrUseSetDefault`, `ErrInvalidReassign`) and the `Repository` interface. `persistence.SiteRepository` implements it over `models.Site` (`sites`, unique `site_key`); the single default is kept by the transactional operations `Create` (when flagged) and `SetDefault`, not by an index, and `DeleteMovingArticles` moves the articles and deletes the site in one transaction. Articles carry a `site_id` (indexed, no foreign key, so the SQL stays portable).
The use cases `ListSites`, `CreateSite`, `UpdateSite`, `SetDefaultSite` and `DeleteSite` live in `internal/infrastructure/useCases`; the article, media and purge use cases take the site repository as a required constructor argument, wired by the composition root (new articles take the default site, `UpdateArticle` moves an article and records `site` with site names, the media use cases count, clear and repoint default social images). `api/handlers/site` serves `/v1/site`; reads accept a session or an API key, writes are session only behind `RequireOwner`. `database.Migrate(db, publicURL)` registers the model, seeds one default site from `PUBLIC_URL` when the table is empty and backfills `articles.site_id`.
### Authors
`internal/domain/author` holds the `Author` entity (name, unique slug, bio, optional avatar, optional website, social links, the unique optional `user_id`, `is_linked`, the computed `article_count` and a `user_email` that only owner and admin responses carry), the public `Byline` shape embedded in articles, the `Patch` type, the slug generation (a hand-written Latin diacritic folding table, no external dependency), the value rules (`ValidateBio`, `ValidateURL`, `ValidateLinks`, `IsValidSlug`, `IsValidName`), the `Err...` sentinels (`ErrAuthorNotFound`, `ErrSlugTaken`, `ErrAuthorHasArticles`, `ErrUserAlreadyLinked`, `ErrAuthorAlreadyLinked`, `ErrAuthorRights`, `ErrBioTooLong`, `ErrInvalidURL`, `ErrTooManyLinks`, `ErrInvalidSlug`, `ErrInvalidReassign`) and the `Repository` interface. `persistence.AuthorRepository` implements it over `models.Author` (`authors`, unique `slug` and unique nullable `user_id`) and the ordered join `models.ArticleAuthor` (`article_authors`, unique `(article_id, author_id)` and a `position`); uniqueness is checked inside the transaction and re-explained after a failed write, so a race still answers a domain sentinel, and `DeleteMovingCredits` moves the credits (keeping the position, never duplicating) and deletes the author in one transaction. `ArticleRepository` attaches the ordered `Byline`s to the articles it returns, writes `author_ids` in the article update transaction and removes the credits when an article is deleted.
The use cases `ListAuthors`, `CreateAuthor`, `CreateMyAuthor`, `UpdateAuthor`, `DeleteAuthor` and `GetMyAuthor` live in `internal/infrastructure/useCases`; `CreateArticle` credits the creator's author, `UpdateArticle` validates and records `author_ids`, and the media, purge and activity use cases take the author repository as a required constructor argument (avatars count as media usage, are cleared on delete and repointed on merge and purge). `api/handlers/author` serves `/v1/author`: the list accepts a session or an API key, every write is session only, `POST /` and `DELETE` sit behind `RequireOwnerOrAdmin`, and the edit rights (owner or admin, or the caller's own linked author) are decided by `UpdateAuthor` from the caller the handler passes in. `GET /v1/auth/me` adds the linked author through `GetMyAuthor`.
### Article SEO data
`internal/domain/slug` holds the one slug rule (`Generate`, `WithSuffix`, `IsValid`: lowercase, accents folded, runs of other characters become one dash, 100 characters, a caller-chosen fallback). `internal/domain/article` uses it with the fallback `article` and holds the twelve SEO fields of `Article`, the `Update` fields for them (`Nullable` for the two image ids), `ValidateCanonicalURL`, the eleven dismiss codes and `NormalizeDismissed`, and the sentinels `ErrInvalidSlug`, `ErrSlugTaken` and `ErrInvalidCanonicalURL`. `*SlugTakenError` wraps `ErrSlugTaken` and carries the suggested free slug; the handler turns it into the `X-Slug-Suggestion` header of the 409. `author.GenerateSlug` delegates to the same package.
Slugs are unique per site, enforced by the unique index `idx_articles_site_slug` on `(site_id, slug)`. The use cases decide first: `CreateArticle` generates the slug from the title and takes the first free `-N` on the default site (retrying when the repository reports a race), `UpdateArticle` re-checks the slug on the target site for an edited slug, an empty slug (regenerated from the title) and a move, and computes the suggestion as the first free `-N` from 2, fitting 100 characters. `ArticleRepository` locks the site row, checks the slug inside the transaction and re-explains a failed write as `ErrSlugTaken`. Deleting an article replaces its slug with the tombstone `#deleted-<id>` (it can never match a valid slug) before the soft delete, so the slug is free again while the unique index keeps covering soft-deleted rows.
`database.Migrate` adds the columns with `AutoMigrate`, then runs `BackfillSiteTitleTemplates` and `BackfillArticleSlugs` (only rows with an empty slug, in id order, per site, applying the same rule and the same suffixes, with the tombstone for soft-deleted rows) and only then creates the unique index, so it is idempotent and completes a partly backfilled table. The default `title_template` is written by the backfill and the seed, not by a column default, because the SQLite migrator rebuilds the table on every start when the default contains `%` and `|`.
Media usage has more sources than article bodies: `Article.CoverImageID` and `SocialImageID`, `Author.AvatarID` and `Site.DefaultSocialImageID`. `ListMedia`, the purge plan and `GetMediaUsage` count an article once however many places use the image. `DeleteMedia` clears the cover and social references (`article.Repository.ClearImage`, restored with `RestoreImage` when the delete fails), `MergeMedia` repoints them (`RepointImage`) and counts an article rewritten in its body and its cover once, and `GetMediaUsage` (now built with the site and author repositories) returns articles, authors and sites with `type` and `fields`.
### Image variants
`internal/domain/image` holds the `Variant` entity and its rules (`VariantSettings.Normalized`, the settings `Fingerprint`, `VariantETag`, `ETagMatches`, `IsAnimatedGIF`), the sentinels (`ErrVariantNotFound`, `ErrVariantExists`, `ErrVariantInvalid`, `ErrVariantFormatUnavailable`, `ErrUnknownVariant`, `ErrUnsupportedParameter`, `ErrSourceTooLarge`) and three ports: `VariantRepository` (definitions), `VariantFileRepository` (the index of generated files: variant name, source stored name, cached stored name, content type, size and the settings fingerprint, needed because `Storage.Put` chooses the stored name itself) and `VariantRenderer` (decode a source, encode a variant, report the formats it can encode). `internal/infrastructure/imaging` is the renderer adapter, free of GORM and HTTP; the persistence adapters are `VariantRepository` and `VariantFileRepository` over `models.ImageVariant` and `models.ImageVariantFile`.
Generated bytes are stored only through `image.Storage`, so they follow `STORAGE_DRIVER`, and are never rows of `images`. `ServeVariantUseCase` checks, in order: unsupported parameters, the variant (it must exist and be enabled), the source blob, SVG and animated GIF (served as they are), the size cap, the `If-None-Match` ETag, then the cached file, and generates under an in-process single-flight group keyed by variant, settings and source. A row whose fingerprint no longer matches, or whose blob vanished, is replaced. `UpdateVariantUseCase` purges a variant's files when a rendering setting changes; `DeleteVariantUseCase` and `PurgeVariantUseCase` purge a blob and then its row, so a failed blob delete keeps the row and a retry can finish; `mediaRemover` purges the files of a source before it deletes the source (delete and merge).
The cache screen adds three ports in `internal/domain/image`: `VariantCacheRepository` (unused totals, top sources by bytes and cursor-paged row listing, implemented by the same persistence type as `VariantFileRepository`), `VariantUsageRepository` (one batched write of request times and per-day rejection counts, and the rejection total of a window) and `VariantUsageTracker` (in-memory hits and rejections). `internal/infrastructure/usage.Tracker` implements the tracker; `GetVariantCacheStatsUseCase` and `PurgeVariantCacheUseCase` use the ports only. See [image-variants.md](image-variants.md).
### Image metadata backfill ### Image metadata backfill
At boot, `BackfillImageMetadata` fills `filename`, `size`, `content_type`, `width`, `height` and `sha256` for image rows that lack them, reading the files in `UPLOAD_DIR`. It is idempotent, and a row whose file is missing or unreadable is left as it is without stopping the boot. The old `article_id` column and the `article_images` table are kept and never written. At boot, `BackfillImageMetadataUseCase` (through `image.Repository.ListMissingMetadata` and `UpdateMetadata`) fills `filename`, `size`, `content_type`, `width`, `height` and `sha256` for image rows that lack them, reading the bytes through `image.Storage` (the files in `UPLOAD_DIR` with the local adapter, the bucket objects with the S3 adapter). It is idempotent, and a row whose file is missing or unreadable is left as it is without stopping the boot. The old `article_id` column and the `article_images` table are kept and never written.
### Roles and permissions
`internal/domain/role` holds the permission catalogue, the built-in roles, one exported constant per catalogue key (`roleKeys.go`, the only place a key is spelled in `api/`) and the pure rules over grants: `ValidateGrants`, `ParseTypedKey` and `Role.GrantSet()`. `Role.GrantSet()` is the single constructor of a `GrantSet` and the only place a stored grant becomes an effective one: it drops unknown keys, typed keys on non-content permissions or with an invalid type key, and owner-only permissions on a role that is not locked; it treats a toggle stored at `own` as `none`; and it resolves duplicate rows to the most restrictive scope. `ValidateGrants` rejects the same rows at write time (`ErrDuplicateGrant`, `ErrOwnerOnlyGrant`, `ErrInvalidScope`, `ErrUnknownPermission`), so a bad row is refused when written and ignored when read. A toggle permission only counts at scope `all`, a scoped one at `own` or `all`. A content permission can be narrowed or widened per content type with a key such as `content.update:blog`; the typed row replaces the wildcard for that type and a `none` row denies. A `GrantSet` is opaque: `ScopeFor(key, typeKey)` resolves wildcard and typed rows, `Permits(key, typeKey, callerID, createdBy)` is the one implementation of own and all (`created_by` 0 and caller 0 own nothing), and `Holds`, `HoldsFor` and `HoldsAll` answer the gate questions. Use cases, handlers and middleware never compare a caller id with `CreatedBy` and never read grant rows. `role.Repository` holds `List` (with every role's grants, in one pass), `Get`, `GetByName`, `Create`, `Save`, `Delete`, `ReassignMembers`, `RemoveTypedGrants` and `MemberCounts` (users grouped by `role_id` plus invites with `accepted_at` NULL grouped by `role_id`, two statements for any number of roles); no role use case scans `user.Repository.GetAll`. `user.Repository` gains `UpdateRole(id, from, to)`, a compare-and-swap on the role the use case read (`ErrRoleChanged`, `ErrUserNotFound`, `role.ErrRoleNotFound`), and `TransferOwnership(from, to, ownerRoleID, formerRoleID)`, which updates both rows in one transaction and fails whole when `from` no longer holds the Owner role (`ErrRoleChanged`) or `to` is missing or unverified (`ErrInvalidTransfer`). `invite.Invite` carries `RoleID`. `ChangeUserRoleUseCase`, `TransferOwnershipUseCase` and the amended invite use cases find the built-in roles with one helper, `builtInRole(roles, name)` (`GetByName`, then `built_in` must be true), and a user's role with `resolveRole`, which reads `role_id` only. `Delete` re-counts users and open invites inside its transaction and `ReassignMembers` moves users and open invites in one transaction and returns their sum. `Save` is version checked: it updates the row only when the caller's version matches, bumps the version, writes `updated_at` and `updated_by`, replaces the grants in the same transaction and returns `ErrRoleConflict` otherwise. It never changes `built_in` or `locked`. `Delete` removes the role and its grants in one transaction and returns `ErrRoleHasMembers` when a user still has the role id; `ReassignMembers(from, to)` changes only `role_id` and returns the count; `RemoveTypedGrants(typeKey)` removes the five `content.*:{typeKey}` rows from every role and bumps the version of each affected role once. The role domain also owns `GrantSet.Covers(other, typeKeys)`, the single implementation of "the caller holds at least these grants" behind the no-escalation rule (it returns an `EscalationError` naming the first offending key), `Role.EffectiveGrants()` (the normalised list used by role responses, the audit diff and `/v1/auth/me`) and `DiffGrants`. The roles API (`/v1/role`) is served by `ListRolesUseCase`, `GetRoleUseCase`, `CreateRoleUseCase`, `UpdateRoleUseCase` (which also resets a built-in role), `DeleteRoleUseCase` and `ReassignRoleMembersUseCase`; they depend on `role.Repository`, `user.Repository`, `contenttype.Repository` and `audit.Recorder` only, and `api/handlers/role` maps their domain errors to the status and code. `DeleteContentTypeUseCase` and `CreateContentTypeUseCase` also take the role repository to clear per-type grants. `DeleteContentTypeUseCase` deletes the type and then clears its typed rows; if that cleanup fails the rows stay, which is safe because the role views (`GET /v1/role`, `GET /v1/role/{id}` and every write response) drop typed rows for content types that no longer exist, so a GET body stays a valid PUT body, and `CreateContentTypeUseCase` clears the rows of a key right after a successful create, before any role can match the new type. A failed create never touches the roles. The repository re-checks the name case-insensitively inside the `Create` and `Save` transactions (`ErrRoleNameTaken`), locks the role row in `Delete` before counting members (a no-op on SQLite) and guards the `ReassignMembers` update with an `EXISTS` on the target role, answering `ErrInvalidReassign` when the target vanished.
Enforcement path. The first middleware that needs the signed-in user (`AuditContext`, `RequireCurrentToken`, `RequireVerifiedUser` or a permission gate) stores a request-scoped holder in the context (`access.WithCurrentUser`), so the user is loaded once per request; an API key adds the one load of its own authentication. `CheckPermissionUseCase` resolves the caller's role with one `role.Repository.Get` (a user whose `role_id` is 0 resolves to `ErrRoleNotFound`, an empty grant set), builds the `GrantSet` from the grants the role already carries and never calls `List`. `RequireTypedPermission` gates a content route on the typed scope before any lookup (the type key is `article` for articles and the `{key}` of the URL for entries) and puts the caller (`access.Caller`: user id and grants) in the context. The optional `OptionalAccess` middleware does the same for the public article routes without ever refusing: no cookie, an unverified user, a stale token or an unresolvable role leave the caller anonymous, and only a role repository failure answers 500. The handler passes the caller to the use case as `useCases.ContentCaller`; the use case loads the article or entry, decides the scope with `Permits` right after the load and before any other lookup or validation, and returns a `role.MissingPermissionError`, which `access.WriteIfMissingPermission` writes as 403 with the base key in `X-Missing-Permission`. Reading follows the same helper: an article is visible when it is online or `content.read` permits its `created_by`, and an entry the caller may not read is `entry.ErrEntryNotFound`. A list under scope `own` is filtered before totals, search and paging. `CreateContentTypeUseCase` refuses the built-in type keys with `contenttype.ErrKeyTaken`.
Built-in reconciliation: `database.SeedBuiltInRoles` creates the three built-in roles when they are missing and never overwrites an edited one. On every start it adds the catalogue keys a built-in role lacks: always for Owner, at scope `all`, and for Admin and Writer only while their version is still 1. It never removes or downgrades a grant and never bumps the version.
Created-by backfill: `database.BackfillCreatedBy` assigns articles (soft-deleted ones included) and entries whose `created_by` is 0 to the first owner by id. A database without an owner is left alone. Entries have no soft delete, so no unscoped query is needed for them.
## Frontend ↔ backend ## Frontend ↔ backend
@@ -50,3 +138,36 @@ bin/renewcms ── /v1/* API
- **Production:** a single origin. The SPA calls the API with relative URLs, so the JWT cookie works without CORS. Files under `assets/` are content-hashed by Vite and cached for a year. `index.html` is never cached. - **Production:** a single origin. The SPA calls the API with relative URLs, so the JWT cookie works without CORS. Files under `assets/` are content-hashed by Vite and cached for a year. `index.html` is never cached.
- **Development:** built with `-tags nofrontend` (`assets_stub.go`, `assets.Dist == nil`). Vite serves the SPA on :5173 and proxies `/v1` to the API on :8080. See [development.md](development.md). - **Development:** built with `-tags nofrontend` (`assets_stub.go`, `assets.Dist == nil`). Vite serves the SPA on :5173 and proxies `/v1` to the API on :8080. See [development.md](development.md).
## Storage and database seams
Uploaded images are addressed by a stored name (`<uuid>.<ext>`). `CreateImageUseCase` calls `Storage.Put` (which generates the name), then `Repository.Create` with `Path = /static/uploadedImages/<name>`, and deletes the blob again if the row cannot be created. `DeleteMediaUseCase` and `MergeMediaUseCase` remove the blob first and the row second (`mediaRemover`), so a failed blob delete keeps the row and the delete can be retried. `GET /static/uploadedImages/<name>` is served by `OpenMediaUseCase` through `api/handlers/staticimages`, which uses `http.ServeContent` when the blob is seekable and streams it otherwise; `ErrBlobNotFound` maps to 404 and any other error to 500.
SQL in repositories must run on SQLite, MySQL/MariaDB and PostgreSQL: substring searches use `LIKE ... ESCAPE '!'` through `containsPattern` instead of `instr`. `Settings.DataDir()` is where update state, staging and backups live; it is `DATA_DIR` when set and the directory of `DB_FILE` otherwise, so it does not depend on where the database is.
Three drivers are registered in `database.drivers`: `sqlite` (default), `mysql` (MySQL 8 and MariaDB 11, through `gorm.io/driver/mysql`) and `postgres` (PostgreSQL 16 and 17, alias `postgresql`, through `gorm.io/driver/postgres` on pgx v5); all are pure Go so `CGO_ENABLED=0` and the six release targets still build. `DB_DRIVER` picks one in `databaseConfig` (composition root); an unknown name fails in `database.Connect` with `unsupported database driver "<name>"`. `database.MySQLDSN` and `database.PostgresDSN` build the DSN from separate variables (the PostgreSQL one as an escaped URL with `sslmode`, `timezone=UTC` and a connect timeout), and the driver redacts the password from every error and names only host and port. `Connect` applies the optional `PoolSettings` to any driver. What keeps the repositories identical on both engines, all without touching the domain, use cases or handlers:
- Indexed and unique strings carry `size:` tags in the models (a MySQL index needs a bounded `varchar`). SQLite maps them to the same `text` column, so an existing SQLite file migrates with no schema or row change (`test/sqlite_schema_unchanged_test.go` builds a file from the old model definitions and compares `sqlite_master` and every row).
- `Migrate` creates MySQL tables with `utf8mb4_bin` (`gorm:table_options`), so `Bob` and `bob` are distinct like in SQLite. The DSN also sets `collation=utf8mb4_bin`.
- The DSN sets `clientFoundRows=true`: an update that matches a row but changes nothing still reports one affected row, as in SQLite. `InviteRepository.Renew`, `deleteOrNotFound` and `PurgeSettingsRepository.SaveLastRun` depend on that.
- The Go driver sends a zero `time.Time` as `0000-00-00`, which MySQL's default `NO_ZERO_DATE` rejects, so each session sets `sql_mode` to the server default minus `NO_ZERO_DATE`. Reading `0000-00-00` gives the zero time back.
- `UserRepository.Create` returns the filled model instead of re-reading it, because MySQL has no `RETURNING`.
On PostgreSQL the MySQL-only table options are not applied, pgx returns `timestamptz` values in the local zone so a query callback in the driver converts them to UTC, and only the `public` schema is used.
Backups are a `domain/update.DatabaseBackup` per driver. SQLite uses `VACUUM INTO`. MySQL uses `DumpBackup`, a pure Go logical dump (`SHOW CREATE TABLE` plus every row, strings escaped and binary values as hex, NULL and zero dates kept) written inside one repeatable-read transaction to `DATA_DIR/backups/renewcms-<time>.sql.partial` and renamed once complete. Any failure removes the partial file and returns an error, so the update download fails instead of claiming a backup. PostgreSQL reuses `DumpBackup` with its own `dumpDialect` (double-quoted identifiers, doubled single quotes, `\x` bytea literals): `CREATE TABLE` is rebuilt from `pg_catalog` (serial columns, defaults, primary key, unique and check constraints), followed by the indexes, the rows and a `setval` per `id` sequence, all inside one repeatable-read read-only transaction. All drivers keep the newest 3 files (`pruneBackups`).
`task test` runs everything that needs no server. The repository contract tests (`test/repository_contract_test.go`) run on SQLite always, and on MySQL when `RENEWCMS_TEST_MYSQL_DSN` points at a server (admin DSN; each test creates and drops its own database). `task test:mysql` starts `mysql:8.4` and then `mariadb:11` in Docker and runs the `MySQL` tests against each. The same contract tests run on PostgreSQL when `RENEWCMS_TEST_POSTGRES_DSN` points at a server (admin DSN, for example `postgres://postgres:pw@127.0.0.1:5432/postgres?sslmode=disable`), and `task test:postgres` starts `postgres:16` then `postgres:17` in Docker and runs the `Postgres` tests against each.
Two storage adapters implement `image.Storage`: `local` (default, files in `UPLOAD_DIR`) and `s3` (`storage.S3Storage`, `aws-sdk-go-v2` over plain HTTPS, pure Go so `CGO_ENABLED=0` still holds). `STORAGE_DRIVER` picks one in `imageStorageConfig` (composition root, from `config.StorageSettings`); `storage.Open` fails with `unsupported storage driver "<name>"` for anything else, and `openImageStorage` panics with the error. The S3 adapter works as follows, with no change to the domain, use cases, repositories or the `staticimages` handler:
- `Open` calls `GetObject` and returns a `Blob` whose `Content` is an `io.ReadSeeker` (`s3Reader`): `Size` and `ModTime` come from `ContentLength` and `LastModified`, the first bytes are read from that response and every `Seek` to another offset makes the next `Read` issue a ranged `GetObject`. `http.ServeContent` therefore answers `Range` (206, 416) and `If-Modified-Since` (304) with no `ETag`. A 404 maps to `image.ErrBlobNotFound`; any other failure is a plain error (500 in the handler).
- `Put` does one `PutObject` of `<S3_KEY_PREFIX><uuid>.<ext>` with the upload's `Content-Type`. `Delete` treats 404 as success. Names that are not plain file names are never requested, as in the local adapter.
- Each call has a 30 second timeout to get a response (the body then streams without a deadline) and the SDK default retries. Request checksums are only sent when required, so older S3-compatible servers work.
- `storage.Open` checks the bucket with `HeadBucket`. The failure text names the bucket and the reason (404, 403, other status or transport error) and never the credentials.
- At boot, `RunBackfillReportingReads` wraps the storage in a counter for the `BackfillImageMetadataUseCase` run and logs how many images it read; the use case itself is unchanged.
- Update backups (`DATA_DIR`) never include uploads, whatever the driver.
`task test:s3` starts SeaweedFS in Docker and runs `TestS3StorageIntegration` against it (skipped without Docker, and not part of `task test`). Everything else uses an in-process fake S3 server listening on `127.0.0.1`.
Adding a database driver: implement `database.Driver` (`Open`, and `NewBackup` returning a `domain/update.DatabaseBackup`), register it in `drivers`, then select it in `databaseConfig` in the composition root (and add the env vars to `main.go`, `.env.example` and `configuration.md`). Adding a storage backend: implement `image.Storage`, add a case to `storage.Open`, and select it in `openImageStorage`. No use case, handler or repository changes.
+82 -8
View File
@@ -1,6 +1,6 @@
# Configuration # Configuration
RenewCMS reads its configuration from environment variables. On startup it also loads a `.env` file from the working directory (or its parent) if one exists. Real environment variables take precedence over `.env`. RenewCMS reads its configuration from environment variables. On startup it also loads a `.env` file from the working directory (or its parent) if one exists. Real environment variables take precedence over `.env`. The parser reads `NAME=value` lines, an optional `export ` prefix, `#` comments (inline ones need a space before the `#`), and single- or double-quoted values (`\n`, `\t`, `\"` and `\\` are unescaped in double quotes). It does not expand `$VARIABLES` and does not support multi-line values. A malformed line makes the whole file ignored, and the server logs the line number.
Copy [`.env.example`](../.env.example) to `.env` to get started. When a required variable is missing, the server refuses to start and names the variable. Copy [`.env.example`](../.env.example) to `.env` to get started. When a required variable is missing, the server refuses to start and names the variable.
@@ -10,8 +10,8 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
| Name | Description | Example | | Name | Description | Example |
|-----------------|---------------------------------------------------------------------------------------------------------------------|----------------------------| |-----------------|---------------------------------------------------------------------------------------------------------------------|----------------------------|
| `PUBLIC_URL` | Public base URL of the site, including scheme and no trailing slash. Used for links in emails and in the startup log. | `https://cms.example.com` | | `PUBLIC_URL` | Public base URL of the site, including scheme and no trailing slash. Used for links in emails and in the startup log. It is also the CMS's own address: at the first migration, when the sites table is empty, it seeds the default site (name "Default", key "default") with its scheme and host (path dropped, non-loopback `http` upgraded to `https`); an unparseable value stops the migration. It is never read for sites afterwards. | `https://cms.example.com` |
| `JWT_SECRET` | Secret used to sign the auth JWT. Use a long random value; changing it logs everyone out. | `openssl rand -hex 32` | | `JWT_SECRET` | Secret used to sign the auth JWT. It must be at least 32 bytes, and the example values from `.env.example` and `docker.env.example` are refused: the server stops at startup with `JWT_SECRET is too weak` and how to generate one. Changing it logs everyone out. | `openssl rand -hex 32` |
| `SMTP_LOGIN` | SMTP username used to authenticate with the server. Some providers (e.g. Brevo) issue a login distinct from the sender address. | `noreply@example.com` | | `SMTP_LOGIN` | SMTP username used to authenticate with the server. Some providers (e.g. Brevo) issue a login distinct from the sender address. | `noreply@example.com` |
| `SMTP_SENDER` | Sender address used in the `From` header. Must be an address/domain the SMTP provider allows that login to send as. | `noreply@example.com` | | `SMTP_SENDER` | Sender address used in the `From` header. Must be an address/domain the SMTP provider allows that login to send as. | `noreply@example.com` |
| `SMTP_PASSWORD` | SMTP password. | | | `SMTP_PASSWORD` | SMTP password. | |
@@ -24,23 +24,97 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
|------------------------|----------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |------------------------|----------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `ENVIRONMENT` | `production` | `production` or `development`. In `production` the auth cookie is `Secure`, so it is only sent over HTTPS. `development` turns on HTTP request logging. | | `ENVIRONMENT` | `production` | `production` or `development`. In `production` the auth cookie is `Secure`, so it is only sent over HTTPS. `development` turns on HTTP request logging. |
| `PORT` | `8080` | Port the HTTP server listens on (all interfaces). | | `PORT` | `8080` | Port the HTTP server listens on (all interfaces). |
| `DB_FILE` | `./data/renewcms.db` | Path to the SQLite database file. Missing parent directories are created. The schema is migrated automatically at startup. | | `DB_DRIVER` | `sqlite` | `sqlite`, `mysql` (MySQL and MariaDB) or `postgres` (alias `postgresql`). Any other value stops the server with `unsupported database driver "<value>"`. See [Database](#database). |
| `UPLOAD_DIR` | `./data/uploads` | Directory where uploaded images are stored. | | `DB_FILE` | `./data/renewcms.db` | Path to the SQLite database file. Missing parent directories are created. The schema is migrated automatically at startup. Ignored when `DB_DRIVER` is `mysql` or `postgres`. |
| `CORS_ALLOWED_ORIGINS` | *(empty)* | Semicolon-separated list of origins allowed to call the API. When empty, no CORS headers are sent. That is the right setting whenever the SPA is served by RenewCMS itself (production) or through the Vite proxy (development). | | `DATA_DIR` | directory of `DB_FILE` | Directory for update state, staged updates and database backups (`backups/`). Set it with `DB_DRIVER=mysql` or `postgres`, where there is no database file to sit beside (the default still follows `DB_FILE`). With SQLite, leave it unset to keep everything beside the database. |
| `STORAGE_DRIVER` | `local` | `local` (files in `UPLOAD_DIR`) or `s3` (an S3-compatible bucket). Any other value stops the server with `unsupported storage driver "<value>"`. See [Image storage](#image-storage). |
| `UPLOAD_DIR` | `./data/uploads` | Directory where uploaded images are stored. Ignored, without error, when `STORAGE_DRIVER=s3`. |
| `CORS_ALLOWED_ORIGINS` | *(empty)* | Semicolon-separated list of origins allowed to call the API. Matching is case-insensitive. An entry may contain one `*` (`https://*.preview.example.com`), and a lone `*` allows every origin. Credentials are never allowed cross-origin. When empty, no CORS headers are sent. That is the right setting whenever the SPA is served by RenewCMS itself (production) or through the Vite proxy (development). |
| `AUTH_RATE_LIMIT_MAX_ATTEMPTS` | `5` | Maximum number of requests a single client IP may send to `POST /v1/auth/login`, `POST /v1/auth/password-reset` or the invite-accept pair (`GET /v1/auth/invite` and `POST /v1/auth/invite/accept`, sharing one counter) within the window, tracked independently per route. | | `AUTH_RATE_LIMIT_MAX_ATTEMPTS` | `5` | Maximum number of requests a single client IP may send to `POST /v1/auth/login`, `POST /v1/auth/password-reset` or the invite-accept pair (`GET /v1/auth/invite` and `POST /v1/auth/invite/accept`, sharing one counter) within the window, tracked independently per route. |
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the routes above. | | `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the routes above. |
| `SMTP_ALLOW_INSECURE` | `false` | `true` lets RenewCMS deliver mail without encryption to a server on another host that does not offer STARTTLS (a relay on a private network, a local Mailpit container). By default such delivery fails with an error naming this variable; port `465` always uses implicit TLS and `localhost`, `127.0.0.1` and `::1` are always allowed in clear text. Credentials (`SMTP_LOGIN`/`SMTP_PASSWORD`) are never sent unencrypted to another host, whatever this says. Any value other than `true` or `false` (case-insensitive) stops the server at boot. |
| `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for the rate limiting of the routes above. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). | | `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for the rate limiting of the routes above. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). |
| `RENEWCMS_CONTAINER` | `false` | Set to `true` (case-insensitive; any other value means `false`) when the app runs in a container. Settings then tells the owner to update by pulling a new image instead of offering browser updates, and `can_apply` is always `false`. The Dockerfile and `docker-compose.yml` set it to `true`. Update checks still work. | | `RENEWCMS_CONTAINER` | `false` | Set to `true` (case-insensitive; any other value means `false`) when the app runs in a container. Settings then tells the owner to update by pulling a new image instead of offering browser updates, and `can_apply` is always `false`. The Dockerfile and `docker-compose.yml` set it to `true`. Update checks still work. |
| `UPDATE_FEED_URL` | `https://git.floriansylvain.fr` | Base URL of the Gitea server that publishes RenewCMS releases. The Settings page asks `{UPDATE_FEED_URL}/api/v1/repos/fsylvain/RenewCMS/releases` when the owner checks for updates. The last result is kept in `update-status.json`, next to `DB_FILE`. | | `IMAGE_VARIANT_MAX_SOURCE_BYTES` | `10485760` | Largest stored image, in bytes, that an [image variant](image-variants.md) may be generated from. A request for a bigger source answers 413 with a plain-text body. The original is never affected, and SVG and animated GIF are always served as they are. A value that is not a whole number greater than zero stops the server at boot with an error naming the variable. |
| `IMAGE_VARIANT_MAX_SOURCE_PIXELS` | `50000000` | Largest declared width x height, in pixels, that an [image variant](image-variants.md) may be generated from. It is read from the image header before any pixel buffer is allocated; a bigger source answers 413 with the same plain-text body as the byte cap. The original is never affected, and SVG and animated GIF are always served as they are. A value that is not a whole number greater than zero stops the server at boot with an error naming the variable. |
| `IMAGE_VARIANT_MAX_CONCURRENT_RENDERS` | `4` | Number of [image variant](image-variants.md) renders that may run at once. Other requests wait for a free slot until their client goes away; cached files never wait. A value that is not a whole number greater than zero stops the server at boot with an error naming the variable. |
| `UPDATE_FEED_URL` | `https://git.floriansylvain.fr` | Base URL of the Gitea server that publishes RenewCMS releases. The Settings page asks `{UPDATE_FEED_URL}/api/v1/repos/fsylvain/RenewCMS/releases` when the owner checks for updates. The last result is kept in `update-status.json`, in the data directory. |
Automatic media purge has no environment variable. Its schedule (on or off, interval, `enabled_at`, `last_purge_at`) lives in the purge settings row of the database, is changed from the Settings page or `PATCH /v1/media/purge-settings`, and is read again at every hourly tick.
## Image storage
By default uploaded images are files in `UPLOAD_DIR`. To keep them in an S3-compatible bucket (AWS S3, MinIO, Ceph, Cloudflare R2, Backblaze B2 and similar), set `STORAGE_DRIVER=s3` and the variables below. They are only read when `STORAGE_DRIVER=s3`. Image URLs do not change: the app still serves `/static/uploadedImages/<name>`, reading the object from the bucket (the bucket stays private, and `Range` and `If-Modified-Since` requests keep working; there is no `ETag`).
| Name | Default | Description |
|------------------------|-------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `S3_BUCKET` | required | Name of an existing bucket. A missing value stops the server with `Environment variable S3_BUCKET is not set`. |
| `S3_REGION` | `us-east-1` | Bucket region. Most S3-compatible servers accept the default. |
| `S3_ENDPOINT` | *(empty)* | Host (with optional port) or full URL of an S3-compatible server. Leave empty for AWS S3. |
| `S3_ACCESS_KEY_ID` | *(empty)* | Access key. Set it together with `S3_SECRET_ACCESS_KEY`, or set neither. Setting only one stops the server with an error naming the missing one. |
| `S3_SECRET_ACCESS_KEY` | *(empty)* | Secret key. Never logged and never in an error message. When both keys are empty, the default AWS credential chain is used (`AWS_*` variables, shared credentials file, instance or task role). |
| `S3_KEY_PREFIX` | *(empty)* | Prefix put before every object key, for example `uploads/`. Include the trailing slash. |
| `S3_PATH_STYLE` | `false` | `true` addresses the bucket as `endpoint/bucket` instead of `bucket.endpoint`. SeaweedFS, MinIO and most self-hosted servers need `true`. |
| `S3_TLS` | `true` | Only used when `S3_ENDPOINT` has no `http://` or `https://` prefix: `true` means `https`, `false` means `http`. |
At startup the server checks the bucket (`HeadBucket`). A missing bucket, refused credentials or an unreachable server stop startup with `Unable to open the image storage: S3 bucket "<name>" is not usable: <reason>`; the error never contains the secret. Each S3 call has a 30 second timeout and the SDK default retries. At boot, images whose metadata is missing are read back from the bucket to fill it; when there were some, one log line says how many.
### Moving existing uploads to a bucket
There is no automatic migration and the database does not change: image rows only hold the stored name, so the same names must exist in the bucket. With the site stopped, copy the files under the same names, under `S3_KEY_PREFIX` if you set one, then set `STORAGE_DRIVER=s3` and restart:
```sh
aws s3 sync ./data/uploads s3://my-bucket/uploads/
rclone sync ./data/uploads remote:my-bucket/uploads
```
Keep `UPLOAD_DIR` until you have checked the site. Moving back is the same copy in the other direction.
### Backups
The pre-update backup covers the database only (and `DATA_DIR` state). Neither the update backup nor the data directory includes uploads, so back the bucket up with your provider (versioning, replication) or with `aws s3 sync` / `mc mirror`.
## Database
SQLite is the default and needs nothing but `DB_FILE`. To run against MySQL 8 or MariaDB 11 instead, set `DB_DRIVER=mysql` and the connection variables below. They are only read (and the four required ones only checked) when `DB_DRIVER` is `mysql` or `postgres`. A required variable that is not set stops the server with `Environment variable DB_HOST is not set` (or `DB_USER`, `DB_PASSWORD`, `DB_NAME`).
| Name | Default | Description |
|--------------------------------|----------|----------------------------------------------------------------------------------------------------------------------------------|
| `DB_HOST` | required | Server host name or IP address. |
| `DB_PORT` | `3306` (`5432` for PostgreSQL) | Server port. |
| `DB_USER` | required | Database user. It needs full rights on `DB_NAME`, because the schema is created and migrated at startup. |
| `DB_PASSWORD` | required | Database password. It is never logged and never appears in an error message. |
| `DB_NAME` | required | Name of an existing database. The server does not create it. |
| `DB_TLS` | `false` | `false`, `true`, `skip-verify` or `preferred`. Any other value stops the server with an error naming `DB_TLS` and these values. `true` verifies the server certificate; `skip-verify` encrypts without verifying it. |
| `DB_SSLMODE` | `disable` | PostgreSQL only: `disable`, `require`, `verify-ca` or `verify-full`. Any other value stops the server with an error naming `DB_SSLMODE` and these values. Ignored for SQLite and MySQL (use `DB_TLS` there). |
| `DB_MAX_OPEN_CONNS` | `10` | Maximum open connections in the pool. `0` means unlimited. |
| `DB_MAX_IDLE_CONNS` | `5` | Maximum idle connections kept in the pool. |
| `DB_CONN_MAX_LIFETIME_SECONDS` | `300` | Seconds after which a connection is closed and replaced. `0` keeps connections forever. Keep it below the server's `wait_timeout`. |
A non numeric (or negative) pool value stops the server with an error naming the variable. There is no DSN variable: the connection string is built from the variables above, with `parseTime=true`, `loc=UTC`, `charset=utf8mb4`, `collation=utf8mb4_bin` and `clientFoundRows=true`. Connecting uses a 5 second timeout, so a wrong host, port, password or database name stops startup quickly with `Unable to open the database`, naming the host and port but never the password.
### PostgreSQL
Set `DB_DRIVER=postgres` (or `postgresql`) and the same connection variables, with `DB_PORT` defaulting to `5432` and `DB_SSLMODE` instead of `DB_TLS`. PostgreSQL 16 and 17 are tested. The database must already exist and `DB_USER` needs full rights on the `public` schema, which is the only schema used. The connection string is built with escaping from the variables (a password may contain spaces, quotes, `@` or `/`), sets the session time zone to UTC and a 5 second connect timeout, and the pool variables above apply. A failure to connect stops startup with `Unable to open the database`, naming the host and port but never the password. Usernames and emails compare case-sensitively, as on SQLite. Before an update download RenewCMS writes a logical dump to `DATA_DIR/backups/renewcms-<time>.sql` (the newest 3 are kept); replay it into an empty database with `psql -f`. For routine backups prefer `pg_dump`.
### MySQL settings
Why those settings: tables are created with the `utf8mb4_bin` collation so that usernames and emails compare case-sensitively exactly like in SQLite (`Bob` and `bob` are different users). The collation of `DB_NAME` itself does not matter, but tables that already exist keep the collation they have. `clientFoundRows` makes an update that does not change a row still count as matched, which the invite and purge-settings code relies on. Each session also drops the server's `NO_ZERO_DATE` SQL mode, because the Go driver sends a zero time as `0000-00-00`. The other strict modes stay on.
With MySQL the data directory (`DATA_DIR`) holds update state and backups only. The pre-update backup is a `renewcms-<time>.sql` logical dump (schema and rows, written by RenewCMS itself, no `mysqldump` needed) that keeps the newest 3. If the dump fails, the update download fails with it. The dump holds your data, such as password hashes, but not the connection password.
Relative paths are resolved against the process's working directory. In production, use absolute paths (see [deployment.md](deployment.md)). Relative paths are resolved against the process's working directory. In production, use absolute paths (see [deployment.md](deployment.md)).
For example, `WorkingDirectory=/var/lib/renewcms` with `DB_FILE=./data/renewcms.db` puts the database in `/var/lib/renewcms/data`. Browser-update `--data-dir` must name that directory, because the app reads helper status and update records beside the database. It is not necessarily `WorkingDirectory` or `ReadWritePaths`. Do not change an existing `DB_FILE` merely to match the helper: that could open a different, empty database. See [browser-update setup](deployment.md#browser-updates). For example, `WorkingDirectory=/var/lib/renewcms` with `DB_FILE=./data/renewcms.db` puts the database in `/var/lib/renewcms/data`. Browser-update `--data-dir` must name that directory, because the app reads helper status and update records beside the database. It is not necessarily `WorkingDirectory` or `ReadWritePaths`. Do not change an existing `DB_FILE` merely to match the helper: that could open a different, empty database. See [browser-update setup](deployment.md#browser-updates).
## Activity log retention
The activity log needs no environment variable. Its retention (an age of 30, 90, 365 days or forever, 90 by default, and an optional maximum size in GB, none by default; whichever limit is reached first removes the oldest events) is a setting the owner changes with `PATCH /v1/settings`, and the daily cleanup runs at 03:00 in the server's time zone (`TZ`, or the host's zone).
## Adding a variable ## Adding a variable
1. Read it in the code where it is needed (infrastructure or `api/`, never `internal/domain`). 1. Read it in the code where it is needed (infrastructure or `api/`, never `internal/domain`).
2. Register it in [`cmd/server/main.go`](../cmd/server/main.go): add it to `requiredEnvVars` if it is required, or to `envDefaults` if it has a default. 2. Register it in [`cmd/server/main.go`](../cmd/server/main.go): add it to `requiredEnvVars` if it is required (or to `DatabaseRequiredEnvVars` in `internal/infrastructure/config` if only one database driver needs it), or to `envDefaults` if it has a default.
3. Add it to [`.env.example`](../.env.example) and to the tables above. 3. Add it to [`.env.example`](../.env.example) and to the tables above.
Never hardcode a secret, and never commit `.env`. Never hardcode a secret, and never commit `.env`.
+20
View File
@@ -0,0 +1,20 @@
# Custom content types
A content type is a named schema (a key, a name and 1 to 50 fields) stored as one row with the fields as a JSON text column. Entries of every type share one generic table whose `data` column holds a JSON object. No DDL runs at runtime, so creating or changing a type never alters the database schema.
## Icon, built-in types and user lookup
- A content type has an optional `icon`: `null` or a string matching `^[a-z][a-z0-9-]{0,39}$`, validated in the domain (`ErrInvalidIcon`, `400 invalid_icon`) and never checked against a list of known icons. `""` is stored as `null`. `PUT` replaces the icon like the name and the fields, so an absent icon clears it. The column is nullable, so rows stored before it existed read as `null`.
- `GET /v1/builtin-type` and `GET /v1/builtin-type/{key}` expose a static, read-only catalogue (`internal/domain/builtintype`) of the built-in types `article`, `author`, `site`, `media` and `user` as `key`, `name` and `fields` shaped like content type fields. Owners and admins only. It uses no repository; an unknown key is `404 builtin_type_not_found`. A test checks that every catalogue field key exists in the JSON of the matching entity.
- `GET /v1/user/lookup?ids=1,2,3` returns `[{id, display_name}]` for any verified user, ordered by id, with the username when the display name is empty. 1 to 100 distinct uint32 ids; anything else is `400`. Unknown ids are omitted.
## Layering
The domain packages `internal/domain/contenttype` and `internal/domain/entry` own the rules: key, name and field validation, entry validation against a schema, the sort whitelist, the page limits and the value equality used by uniqueness. The use cases orchestrate them through the two repositories. The repositories only store and load rows. Entry counting for the "type still has entries" rule lives on the entry repository.
## Known limitations of phase 1
- Uniqueness and schema re-validation are O(n). Checking a unique field loads every entry of the type and compares values in Go, and updating a type with stored entries loads all of them to re-validate. This keeps the SQL portable across SQLite, MySQL/MariaDB and PostgreSQL. The path to fix it is the filterable-fields phase: project the values of unique and filterable fields into an indexed table, so uniqueness becomes an indexed lookup and re-validation can be restricted to the changed fields.
- Unique values are checked without a transaction, so two concurrent writes of the same unique value can both succeed. A content type key is protected by a unique index, and a violation is reported as `content_type_key_taken` (409).
- `entry.Repository.DeleteByContentType` is intentionally unused for now. A content type that still has entries cannot be deleted; the method is kept as part of the repository contract for a later cascade.
- Listing is limited to page 1,000,000 and to page sizes of 100 so that the SQL offset cannot overflow.
+47 -2
View File
@@ -8,7 +8,7 @@ RenewCMS ships as **one self-contained executable**. It serves:
| `/healthz` | Liveness check, returns `200 ok` | | `/healthz` | Liveness check, returns `200 ok` |
| `/*` | The Vue SPA (embedded), with fallback to `index.html` for client-side routes | | `/*` | The Vue SPA (embedded), with fallback to `index.html` for client-side routes |
Runtime state includes the SQLite database (`DB_FILE`), uploads (`UPLOAD_DIR`), and update records, staged binaries and backups beside the database. Running a release binary needs no Go, Bun or Node installation. Browser updates additionally require Linux with systemd and the helper setup below. Runtime state includes the SQLite database (`DB_FILE`, or a MySQL/MariaDB or PostgreSQL server, see [Using MySQL or MariaDB](#using-mysql-or-mariadb) and [Using PostgreSQL](#using-postgresql)), uploads (`UPLOAD_DIR`, or an S3-compatible bucket with `STORAGE_DRIVER=s3`, see [configuration.md](configuration.md#image-storage)), and update records, staged binaries and backups beside the database. Running a release binary needs no Go, Bun or Node installation. Browser updates additionally require Linux with systemd and the helper setup below.
To run it in a container instead, see [docker.md](docker.md). The published image `git.floriansylvain.fr/fsylvain/renewcms` is upgraded with `docker compose pull && docker compose up -d`. Tags are `vX.Y.Z`, `X.Y.Z`, `X.Y` and `latest` for a stable release, and `vX.Y.Z-<pre>`, `X.Y.Z-<pre>` and the moving `<pre-name>` (`beta`, `rc`) for a pre-release, which never moves `latest`. Maintainers publish with `task docker:publish` after `docker login git.floriansylvain.fr`; there is no CI. See [Maintainer: publishing](docker.md#maintainer-publishing). To run it in a container instead, see [docker.md](docker.md). The published image `git.floriansylvain.fr/fsylvain/renewcms` is upgraded with `docker compose pull && docker compose up -d`. Tags are `vX.Y.Z`, `X.Y.Z`, `X.Y` and `latest` for a stable release, and `vX.Y.Z-<pre>`, `X.Y.Z-<pre>` and the moving `<pre-name>` (`beta`, `rc`) for a pre-release, which never moves `latest`. Maintainers publish with `task docker:publish` after `docker login git.floriansylvain.fr`; there is no CI. See [Maintainer: publishing](docker.md#maintainer-publishing).
@@ -274,10 +274,55 @@ Either way, set `TRUSTED_PROXIES` (see [configuration.md](configuration.md)) to
RenewCMS sends `Content-Security-Policy` (including `frame-ancestors 'none'`), `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy` and `Cross-Origin-Opener-Policy` itself, so the proxy doesn't need to add them. With `ENVIRONMENT=production` it also sends `Strict-Transport-Security: max-age=31536000` (browsers ignore it over plain HTTP, so serve the site over HTTPS only). Don't add the same headers in the proxy, or they will be duplicated. RenewCMS sends `Content-Security-Policy` (including `frame-ancestors 'none'`), `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy` and `Cross-Origin-Opener-Policy` itself, so the proxy doesn't need to add them. With `ENVIRONMENT=production` it also sends `Strict-Transport-Security: max-age=31536000` (browsers ignore it over plain HTTP, so serve the site over HTTPS only). Don't add the same headers in the proxy, or they will be duplicated.
The CSP allows Google Fonts (`fonts.googleapis.com`, `fonts.gstatic.com`) and `https:` images, because the SPA loads its fonts from Google and articles may embed remote images. `style-src` keeps `'unsafe-inline'` for the article editor; `script-src` is `'self'` only. The CSP serves fonts from `'self'` only (the SPA bundles its fonts, so no visitor IP address is sent to a font CDN) and allows `https:` images, because articles may embed remote images. `style-src` keeps `'unsafe-inline'` for the article editor; `script-src` is `'self'` only.
## Using MySQL or MariaDB
SQLite is the default. To use an existing MySQL 8 or MariaDB 11 server instead, create an empty database and a user with full rights on it, then set in `/etc/renewcms/renewcms.env`:
```sh
DB_DRIVER=mysql
DB_HOST=db.example.com
DB_USER=renewcms
DB_PASSWORD=<generated password>
DB_NAME=renewcms
DB_TLS=true
DATA_DIR=/var/lib/renewcms
```
`DB_FILE` is then ignored. `DATA_DIR` is where update state, staged updates and backups live, so give it the same value as the helper's `--data-dir` (see [browser updates](#browser-updates)). Use `DB_TLS=true` (or `preferred`) whenever the server is not on the same host. Every variable is described in [configuration.md](configuration.md#database). The schema is created and migrated at startup, so the first start on an empty database needs nothing else.
## Using PostgreSQL
To use an existing PostgreSQL 16 or 17 server, create an empty database and a user with full rights on its `public` schema, then set in `/etc/renewcms/renewcms.env`:
```sh
DB_DRIVER=postgres
DB_HOST=db.example.com
DB_USER=renewcms
DB_PASSWORD=<generated password>
DB_NAME=renewcms
DB_SSLMODE=require
DATA_DIR=/var/lib/renewcms
```
`DB_FILE` is then ignored, and `DB_PORT` defaults to `5432`. `DATA_DIR` is where update state, staged updates and backups live, so give it the same value as the helper's `--data-dir`. Use `DB_SSLMODE=require` (or `verify-full`) whenever the server is not on the same host. Every variable is described in [configuration.md](configuration.md#database). The schema is created and migrated at startup. Moving an existing SQLite site is a manual procedure like the MySQL one below, and RenewCMS writes a logical `.sql` dump to `DATA_DIR/backups` before each update download.
### Moving an existing SQLite site to MySQL
There is no migration tool. The manual procedure, with the site stopped:
1. Take a backup of the data directory (database file and uploads).
2. Start RenewCMS once on the empty MySQL database so it creates the schema, then stop it.
3. Export the rows from the SQLite file with `sqlite3 renewcms.db .dump`, keep only the `INSERT INTO` statements for `articles`, `images`, `users`, `invites`, `api_keys` and `purge_settings`, and import them into MySQL. Quoting and date formats differ between the two engines, so check a sample first: SQLite writes times such as `2026-01-02 03:04:05.123456789+00:00`, and the `+00:00` suffix must be removed for MariaDB and older MySQL versions. An unset time (`0001-01-01 00:00:00`) is valid as it is in MySQL and is read back as unset.
4. Copy `uploads/` to the new `UPLOAD_DIR`, set `DB_DRIVER=mysql` and the connection variables, and start the service.
Keep the SQLite file until you have checked the site.
## Backups ## Backups
With MySQL, MariaDB or PostgreSQL, back the database up with your server's own tools (for example `mysqldump --single-transaction` or `pg_dump`), plus the uploads directory. RenewCMS also writes a logical dump to `DATA_DIR/backups` before each update download. The rest of this section is about SQLite.
Everything worth backing up is under the data directory. Take a consistent SQLite copy while the service is running: Everything worth backing up is under the data directory. Take a consistent SQLite copy while the service is running:
```sh ```sh
+135 -41
View File
@@ -1,49 +1,143 @@
# RenewCMS design # RenewCMS design pass 2
The visual spec of record for the back-office SPA and the transactional emails, exported from Claude Design. `features/` stays the behavioural spec; if the two disagree, stop and ask. Agents read this folder and never edit it; a design change is a new export. Nuxt UI v4 back-office. Tokens only, no raw hex (brand mark excepted). Light and dark. 1440×900 and 390×844. Radius 0.
## Files ## Boards
- `RenewCMS Screens Back-office.dc.html`: every back-office screen, Settings → Media (variants, cache), and the group "States matrix: controls". Frames: `AppFrame` (shell), `DirectoryFrame` (Sites and Authors), `ActivityFrame`, `MediaFrame`, `VariantsFrame`, `IntegrationPage`, `IntegrationSearch`, `IntegrationImages`, `CodeSnippet`, `StatesMatrix`.
- `RenewCMS Screens Auth.dc.html` (7 groups, 50 states), `RenewCMS Screens API keys.dc.html` (12 states).
- `RenewCMS Prototype.dc.html`: updated to match (title-only New article, no body limit or counter, neutral count, pagination, link button, AppFrame's mobile drawer).
- Unchanged: Foundations, Components, Handoff, Directions and Emails boards.
| File | What it holds | ## Pass 3: sites and authors (revised)
Roles, decisions and the slice order are in `sites-authors-slices.md` (Sites and Authors) and `seo-slices.md` (SEO).
Back-office board groups: Sites, Sites: form, Sites: delete, Authors, Authors: form, Authors: delete, Editor: site and authors, Profile: author profile, Articles: sites and authors. Plus tables: decisions to confirm (13 rows, 5 marked Decided), deviations, API and errors (with the 403 rows), components, accessibility, copy per state.
- Roles: only creating, editing, deleting and setting the default site are owner only (no sidebar entry or Publishing card for others, 403 page on a direct link). Anyone who can edit an article can set its site and read the site list. Authors: owner and admin manage; other roles read-only, and any role can create and edit their own author (user link locked, no delete).
- The site key is immutable: read-only with a lock in the edit form; the warning state and dialog are gone.
- The "Author profile" card is drawn in every existing Profile frame. The "Site and authors" section shows collapsed in every existing Editor frame that has the details sidebar (39 already did; the Loading frame now shows the header with a skeleton summary). The Not found frame has no editor, and on mobile the section is inside the Details sheet. The dedicated groups keep only the feature-specific states.
- Shared pieces (list, slide-over, delete-with-reassign dialog, slug field, media field) are defined once in `DirectoryFrame`, used by Sites and Authors. The Profile card is defined once in `AppFrame`.
- States trimmed from 87 to 68 (Sites 9, Sites: form 7, Sites: delete 3, Authors 8, Authors: form 11, Authors: delete 6, Editor: site and authors 16, Profile: author profile 5, Articles: sites and authors 3). Dropped: in-progress and done variants (covered by the button loading pattern and the confirm state's trigger), per-error URL variants, the site picker (same picker as the avatar), manual slug override, long names (merged into Many authors), per-field duplicates. The blocked-delete dialog is one frame, "Server fallback (409)".
- Only individual states that render a relative time or a countdown carry the "Clock-sensitive" tag (13 states, listed in states.md). No Home or Articles frame is tagged as a whole.
- Role-restricted variants drawn: read-only Authors list and detail, hidden Sites entry, Sites 403, own-author edit.
## Pass 4: custom content types
Board `RenewCMS Screens Schema.dc.html` (frames: `SchemaFrame`, mounted in `AppFrame`): 11 groups, 97 states. 1 Schema list 7, 2 Schema builder 19, 3 Review and delete 9, 4 Field types 7, 5 Content and entry list 12, 6 Entry editor 23, 7 Relations 3, 8 Permissions 2, 9 Export and import 3, 10 Media picker for a field 8, 11 Media library usage 4. The Media field type (pass 5) is specified in `media-field-changes.md`; the exact copy and measurements are there, and the boards are the visual reference. Plus tables: scope, routes, new components with their Nuxt UI mapping, errors to copy, needs backend (4 rows), decisions to confirm, accessibility. Specs: screens/schema.md, content.md, entry.md, schema-later.md, media.md, media-picker.md, and media-field-changes.md for the Media field type.
- AppFrame gains a Schema item (owner and admin) and a Content group (sidebar and mobile drawer). Nothing else in AppFrame changed.
- Backend gaps still open: built-in types and fields, display names for created_by and updated_by, a type icon, per-change entry counts.
- Not done: Back-office and Prototype boards were not compared visually after the sidebar change (see Checks below).
## Specs
routes.md · components.md · states.md · foundations.md · change-list.md · activity-spec.js · screens/*.md (24 files)
## Status
| Section | Status |
|---|---| |---|---|
| `routes.md` | Every route: layout, auth requirement, page component, API calls | | A1 Activity export | Done: CSV only, synchronous; progress state removed; JSON proposed |
| `components.md` | The component inventory with its Nuxt UI mapping, props, emits and slots, then the full component and primitive specs | | A2 Pagination `page`/`size` | Done in frames, routes.md, change-list.md |
| `states.md` | Every state of every screen with its trigger, and the HTTP-status-to-copy table | | A3 Unknown variant and unsupported parameter | Done: both 400 bodies in the Responses table |
| `screens/<screen>.md` | One file per screen: route, API, and per state the exact copy, fields, alerts, dialogs and toasts | | A4 New tokens | Done: foundations.md table |
| `foundations.md` | Colors, contrast, type, spacing, motion and icons, and where the token files are | | A5 screens/*.md | Done: 22 files, copy generated from the board source |
| `emails.md` | The three email templates and their variables | | B1 Editor link | Done: 11 states |
| `source/` | The raw Claude Design export. Open the `.dc.html` boards in a browser for the drawings (they load `support.js` locally and fonts from Google). | | B2 Duplicates | Done for sidebar, topbar, drawer, code block, modal geometry; see Not done |
| B3 CodeSnippet | Done |
| B4 Overlays | Done |
| B5 Prototype | Done |
| B6 Contrast and states matrix | Done for the pairs listed; matrix drawn |
| B7 Consistency sweep | Done for the roles listed |
| B8 Copy glossary | Done by search; not every line re-read |
| B9 Media and picker | Badge added; Media pagination drawn (9 states); filter, sort and size not folded in |
| B10 "proposed" labels | Only article pagination and JSON export |
## Screens ## State counts (Back-office board: 341 before pass 3, plus 68 for sites and authors = 409)
| Group | States |
|---|---|
| Boot | 1 |
| Home | 10 |
| Articles | 14 |
| Articles: pagination | 11 |
| New article | 5 |
| Editor | 41 |
| Media | 52 |
| Media picker | 13 |
| Integration | 8 |
| Activity | 9 |
| Activity: filter cards | 10 |
| Activity: export | 15 |
| Activity details | 6 |
| My activity | 3 |
| Users | 17 |
| Settings | 25 |
| Settings: Media cleanup | 9 |
| Purge dialog | 8 |
| Purge results | 2 |
| Media duplicates entry | 2 |
| Settings: Retention | 6 |
| Profile | 13 |
| Global | 4 |
| Integration: images | 9 |
| Settings navigation | 5 |
| Variants list | 12 |
| Add edit variant | 11 |
| Media library (variants) | 6 |
| Image detail | 8 |
| Cache panel | 10 |
| Editor picker | 5 |
| Screen | Route | States | Board | Back-office total 341 (pass 2 groups, table above) + 68 (sites and authors) = 409. Auth board 50 (Log in 9, Register 6, Pending 6, Verify 6, Accept invite 10, Forgot password 6, Reset password 7). API keys board 12. Total 471 states; every one has a desktop and a mobile frame, and the theme switch gives light and dark. The matrix has 24 control rows, 5 states on desktop and 4 on mobile (no hover). screens/*.md list the states of the pass 2 groups and the 68 pass 3 states.
|---|---|---|---|
| [Log in](screens/log-in.md) | `/login` | 9 | `source/RenewCMS Screens Auth.dc.html` |
| [Register](screens/register.md) | `/register` | 6 | `source/RenewCMS Screens Auth.dc.html` |
| [Pending](screens/pending.md) | `/register/pending` | 6 | `source/RenewCMS Screens Auth.dc.html` |
| [Verify](screens/verify.md) | `/register/validate?c=CODE` | 6 | `source/RenewCMS Screens Auth.dc.html` |
| [Forgot password](screens/forgot-password.md) | `/register/reset/request` | 6 | `source/RenewCMS Screens Auth.dc.html` |
| [Reset password](screens/reset-password.md) | `/register/reset/validate?c=CODE&email=EMAIL` | 7 | `source/RenewCMS Screens Auth.dc.html` |
| [Boot](screens/boot.md) | `any route, first load` | 1 | `source/RenewCMS Screens Back-office.dc.html` |
| [Home](screens/home.md) | `/home` | 6 | `source/RenewCMS Screens Back-office.dc.html` |
| [Articles](screens/articles.md) | `/article` | 14 | `source/RenewCMS Screens Back-office.dc.html` |
| [New article](screens/new-article.md) | `modal from anywhere · /article/new` | 5 | `source/RenewCMS Screens Back-office.dc.html` |
| [Editor](screens/editor.md) | `/article/:id/edit` | 22 | `source/RenewCMS Screens Back-office.dc.html` |
| [Media](screens/media.md) | `/media` | 41 | `source/RenewCMS Screens Back-office.dc.html` |
| [Media picker](screens/media-picker.md) | `/article/:id/edit` (dialog) | 13 | `source/RenewCMS Screens Back-office.dc.html` |
| [Integration](screens/integration.md) | `/integration` | 8 | `source/RenewCMS Screens Back-office.dc.html` |
| [API keys](screens/integration-api-keys.md) | `/integration/api-keys` | 12 | `source/RenewCMS Screens API keys.dc.html` |
| [Users](screens/users.md) | `/users` | 17 | `source/RenewCMS Screens Back-office.dc.html` |
| [Settings](screens/settings.md) | `/settings` | 25 | `source/RenewCMS Screens Back-office.dc.html` |
| [Profile](screens/profile.md) | `/profile` | 13 | `source/RenewCMS Screens Back-office.dc.html` |
| [Accept invite](screens/accept-invite.md) | `/invite/accept?c=CODE` | 9 | `source/RenewCMS Screens Auth.dc.html` |
| [Global](screens/global.md) | `any route` | 4 | `source/RenewCMS Screens Back-office.dc.html` |
## Other boards ## Fixes applied (pass 2)
1. Activity export dialog: removed the 42% progress bar, the "large export" state, the server-job copy and the "ready later" toast; added "Preparing download…" (indeterminate), "Download started", failed, nothing to export, 403; CSV only, with one proposed JSON state.
2. Export dialog and menu copy now match the implemented endpoint (current view with filters, all with optional range).
3. Articles pagination used `per_page`, sizes 10/25/50, default 10, 32 pages. Now `page`/`size`, 5/25/50/100, default 25, 13 pages; frames, route, URL examples and the change list updated.
4. The Integration responses table now shows both 400 bodies, `unknown_variant` and `unsupported_parameter`.
5. `--rc-sand` and `--rc-on-sand` (and the other `--rc-*` tokens) documented as new tokens with light and dark values and their uses.
6. Raw colours removed from frames: crop handles and mask, tooltip, transparency checker, drag-drop text, selected-row text in MediaFrame, hex in the Prototype. Only the brand mark keeps hex.
7. ActivityFrame, MediaFrame and VariantsFrame sidebars, topbars and (Variants) mobile drawer deleted; they render inside AppFrame. Activity now follows the sidebar order (Users, Activity, Integration, Settings).
8. Prototype's own mobile drawer (Home, Articles, Users, Integration only) replaced by AppFrame's drawer.
9. All code blocks on Integration (auth example, endpoint examples, error response), the base URL, the image URL builder and the one-time API key use CodeSnippet; the three older block styles are gone. CodeSnippet tabs changed from sand pills to the underline style used by tabs elsewhere.
10. Variant form: mobile full page replaced by a 92% bottom sheet with grabber and close ×; back arrow removed.
11. Dialog geometry unified (140 px top offset on desktop, flush bottom sheets with grabber on mobile, header 20 24 0, footer secondary left and primary right, close × 44 px on mobile). Changed: Media and Variants dialogs (mobile inset), Activity export (centered, header bar, 480 wide), API keys dialogs (border, 18 px title, 44 px buttons, 120 px offset).
12. Current page in the paginator: border `--ui-primary` instead of sand (was 1.3:1).
13. Page padding 24/16 everywhere (was 32 and 24); card padding 16 (was 16 and 20); section gap 16 (was 16, 20, 28); page title 20/28 (was 28/36, 24/32, 20/28); stat figure 24/32; card title 15/24; inputs 32/44 (were 40); one 13 px icon became 14; toast width 356 (was 360, 372).
14. Copy: "Removed N …" in activity lines became "Deleted N …"; export titles and footers per the glossary.
15. Media screen: variant count badge added to grid card, list row and mobile row, matching the Variants board.
16. Editor toolbar on mobile was a cut-down 9-button subset; it now has the full toolbar with sideways scroll and edge fades.
17. Editor words and characters show "0 words · 0 characters" for a new empty article (was a fallback sample count).
- `source/MediaFrame.dc.html`: the frame that draws the Media and Media picker states (the Back-office board imports it). Like `AppFrame.dc.html`, it holds the exact values. ## Defaults chosen
- `source/IntegrationPage.dc.html` and `source/ApiKeysPage.dc.html`: the page components for the Integration and API keys screens (imported by `AppFrame.dc.html`). They hold the exact values. - Slide-over rule: only forms with a live preview and record details; on mobile always a 92% bottom sheet.
- `source/RenewCMS Prototype.dc.html`: the clickable flows (first run, edit and publish, password reset, delete, session expiry mid-edit). - Hover, active, disabled and focus tokens for the matrix (components not specified before): primary hover `--ui-text`, active `--ui-text-toned`; destructive hover opacity 0.85 and active 0.75; disabled 40% (rows 55%).
- `source/RenewCMS Handoff.dc.html`: the handoff page the first markdown here was generated from. It predates the Users and Profile screens and the invitation email, so it does not list them. Those are written by hand from the Back-office board, `AppFrame.dc.html`, the Prototype and `emails/invitation.html`. - Article pagination: API shape (array body, total in `X-Total-Count`) is a design assumption to confirm with the specifier.
- `source/RenewCMS Components Product.dc.html`: not regenerated for the new screens either. The matching component changes are in `components.md`, under "Added with the Users and Profile screens" and "Added with roles, invite actions and Settings". - The Remove action in the link popover has no confirmation; undo restores the link.
- `source/RenewCMS Directions.dc.html`: the three brand directions (history only). - The link button is disabled when an image is selected.
- The tooltip text is "Insert link (Ctrl+K)" and "Insert link (⌘K)" on macOS.
- Mobile toolbar scrolls sideways; no wrap, no overflow menu.
- Selected nav items keep the sand fill plus weight 500 and `aria-current` (the fill alone is 1.3:1 in light).
- Syntax colouring dropped from code samples (one rendering).
- Export "Download started" offers only Close.
- New article keeps the title counter "n / 200" (title is limited to 200); the body has no limit and no counter.
- Page title 20/28, page padding 24/16, card padding 16 (foundations.md).
- Activity position in the sidebar follows AppFrame.
- The Prototype shows a toast "Not in this prototype" for sidebar items it has no screen for (Media, Activity, Settings).
## Checks run
- Opened `RenewCMS Screens Back-office.dc.html` in the preview: it loaded, 38 sections rendered, and I read the DOM of the Activity, Export, Media, Variants, Editor, Integration, Pagination, Picker, Editor picker and States matrix groups to confirm the frames render text and the sidebar. I did not read the console.
- Computed the contrast table from token values with a script.
- Searched frames and boards for raw hex and rgb values (only token definitions on the board roots and the brand mark remain), for exclamation marks, Remove and Delete, Update and Save, and per_page.
- Generated screens/*.md and counted states from the board source; the counts equal the DOM counts for the Back-office board.
## Not done
- Console errors on the Auth, API keys, Prototype, Foundations and Components boards were not read by me. The delivery check covers the Back-office board; the other boards use the changed frames (AppFrame, ApiKeysPage, AuthCard, MediaFrame) and may need a look.
- Light and dark were checked for the Back-office board by reading DOM only; I did not view the screenshots of each state at 1440 and 390, so visual regressions from the shell change (slot mode) are possible.
- Media screen: the "With variants / Without" filter, the "Largest total size" sort and a total-size column exist only on the Variants board states; they are not folded into the Media toolbar.
- Media picker tiles and the Editor picker were not compared field by field.
- Duplicated implementations that look alike are not merged in code: toasts (5), badges (2), empty states (2), modal shells (4). Only their visuals were aligned.
- Hover, active and focus-visible are drawn once in the matrix; screens do not redraw them. Hover and active tokens for destructive buttons use opacity, not a token.
- Contrast was not computed for every hover and active background, only the pairs listed in foundations.md.
- The glossary pass was a search for known patterns, not a line-by-line read of every string. Titles in Title Case were not searched.
- The Components, Handoff and Foundations boards were not updated to mention the new tokens, CodeSnippet or the matrix.
- Integration still lists "max 10000" for article bodies, as the backend does today (change-list item 11).
- The sandbox cannot export; no files were bundled.
## Pass 6: roles and permissions
Board `RenewCMS Screens Roles.dc.html` (frame `RolesFrame`, data `roles-spec.js`): 11 groups, 61 states, all Available now. Spec `screens/roles.md`; additions in components.md, states.md, routes.md, change-list.md. The Permissions grid is folded into the role editor and `/settings/permissions` is retired. Writer frames use AppFrame's existing restricted `user` navigation. AppFrame gained the Access group with a Roles item (sidebar and drawer). Not done: Schema board group 8 (Permissions) should be marked superseded.
+122
View File
@@ -0,0 +1,122 @@
# Change list for implementation (status after pass 2; SEO round drawn; cleanup round drawn)
## Cleanup round
1. Editor: site and authors, "Create author" states (4 states, desktop and mobile): the mobile dialog sat under the Details sheet (z-index 50 against 55) and was hidden. It now stacks above the sheet (z-index 60), as the SEO panel does. Full-width bottom sheet with grabber, same fields, Cancel left and "Create author" right (Cancel below on mobile, as the other mobile dialogs). Frame: `DirectoryFrame.dc.html`. The four states share the fix: dialog, "for me", slug taken, failed.
2. Integration, "6. Errors": the 403 row gets the muted note "Site SEO defaults are owner only. Article SEO fields need only the right to edit the article." The row below shifts down by the wrapped note height (two 18 px lines desktop, three mobile, plus the 2 px row gap). The old inline parenthetical with the same sentence in the 403 cause text was removed, so it isn't said twice. Frame: `IntegrationPage.dc.html`.
3. Integration, sitemap errors: 404 now shows the body `site_not_found` in code style (was "Site not found"); 400 now shows the generic body-mismatch text used by the other routes (was "Invalid page or size.").
4. New groups on the Back-office board: "Sites: pagination" and "Authors: pagination", 10 states each (20 states, every one desktop and mobile; light and dark by the theme switch). Back-office total 409 → 429. Same pager as Articles and Media, sizes 5, 25, 50, 100, default 25 (no difference to report). One deviation: on a single page the page-size select is hidden and only "N sites" or "N authors" remains.
5. Specs: screens/sites.md, screens/authors.md (pager states, URL rules, out-of-range fallback), screens/integration.md, states.md (pagination states, three HTTP rows), components.md (single-page variant, no new component), routes.md (Sites pagination in the URL).
**Backend assumptions to confirm.** GET /v1/site/ takes `q`, `page`, `size` and returns `X-Total-Count` (it returns every site today). GET /v1/author/ gets `page`, `size` and `X-Total-Count` (it has `q`).
**Open points.**
- Sites has no search field. The two states that need one (empty after search, search resets to page 1) draw the Authors field on Sites with "Search by name or key". Drop those states if you don't want search on Sites.
- Stale copy found, not changed: GET /v1/article/ on Integration still shows the 400 caption "Invalid page or size." while every other route, and now the sitemap, uses the body-mismatch text. The 404 row of the Errors table still lists only the four resource messages and doesn't mention `site_not_found`. The existing Sites and Authors "Default" frames don't draw the count line. README.md state counts and the "Pagination" notes were not updated (outside this round).
## SEO round: decisions (frames drawn)
**Placement.** A "Search and sharing" section sits under "Site and authors" in the Editor sidebar and in the mobile Details sheet. It is collapsed by default, shows a one-line summary ("Ready", "2 suggestions" or "Hidden from search") and is remembered per browser like Site and authors. It holds only the slug, the full URL and three actions: "Edit search and sharing", "What the frontend gets" and the suggestion list. The fields and live previews open in a slide-over (desktop) or a 92% bottom sheet stacked over the Details sheet (mobile), because the 280 px sidebar is too narrow for previews and the slide-over rule already covers forms with a live preview. The API preview is a 720 px dialog (desktop) or a bottom sheet (mobile).
**Principles.** RenewCMS does not render pages or inject meta tags. Every field is optional and the UI shows its fallback. No scores. Suggestions never block saving or publishing; publishing is allowed regardless. Suggestions are dismissible per article.
**Fallback chain (resolved server-side, shown in the UI).**
| Output | Order |
|---|---|
| title | seo_title, then article title; site title_template applied last |
| description | seo_description, then excerpt, then first sentence of the body, then site default_description |
| image | og_image_id, then cover_image_id, then site social image |
| canonical | canonical_url, then site base_url + "/" + slug |
| robots | noindex true gives "noindex, nofollow"; otherwise site default_robots |
| og/twitter title and description | social_title, social_description, then the resolved values above |
**Soft ranges (counters, never blocks).** Meta title 30–60 characters; description 70–160; slug "very long" above 75 characters. Counter turns neutral-warning (`--ui-warning`) outside the range, never red.
**New article fields (PATCH /v1/article/{id}, all optional, snake_case).** `slug`, `excerpt` (max 300), `seo_title` (max 120), `seo_description` (max 320), `cover_image_id`, `cover_image_alt` (max 200; the cover is "ready" only with alt text), `social_title`, `social_description`, `social_image_id`, `canonical_url` (absolute https URL), `noindex` (boolean; only sets a flag in the API, it doesn't change visibility), `seo_dismissed` (string[] of suggestion codes). Slug: lowercase letters, numbers, hyphens; unique per site; 409 `slug_taken` with `suggestion` ("<slug>-2"); auto-generated from the title until edited.
**New site fields (PATCH /v1/site/{id}, owner only).** `site_name`, `title_template` (default "%title% | %site%", must contain %title%), `default_description` (max 320), `social_image_id` (existing), `default_robots` ("index" | "noindex"; existing as robots).
**New endpoints.**
- `GET /v1/article/{id}/seo`: resolved object, public for online articles, 404 for drafts to anonymous callers. Shape: `{ title, description, canonical_url, robots, og: { type, title, description, image, image_alt, url, site_name, locale }, twitter: { card, title, description, image, image_alt }, image: { url, alt, source }, authors: [{ name, url, same_as: [] }], published_at, modified_at, fallbacks: { title, description, image, canonical }, json_ld }`. `json_ld` is a ready Article object (headline, description, image, author[] with name, url and sameAs from author profile URL and social links, datePublished, dateModified, mainEntityOfPage).
- `GET /v1/site/{key}/sitemap?page=&size=`: published URLs only, total in `X-Total-Count`. Items: `{ url, modified_at, noindex }`. Decision: yes, shown on Integration under "Search and sharing" with a copy button; noindex articles are included with the flag so the frontend can skip them.
- `GET /v1/article/?seo=ready|suggestions|hidden`: filter behind "Needs attention" (suggestions) with `seo_status` and `seo_suggestions` (count) added to list items.
**Articles list.** Quiet indicator inside the Status cell (and under the title on mobile cards): nothing when ready, a lightbulb chip "N" for suggestions, an eye-off chip "Hidden" for noindex. Toolbar gets a "Needs attention" toggle next to search; tabs and counts are unchanged. It behaves the same on a single-site install (independent of the Site filter, which stays hidden).
**Authors.** No change to the Authors screen. Profile URL (website) and social links feed `authors[].url` and `same_as`, and JSON-LD `author[].sameAs`; the API preview shows them.
**Permissions.** Anyone who can edit an article edits its SEO fields. Only the owner edits site SEO defaults. 401 and 403 rows join the existing HTTP table: 403 `owner_only` on Site SEO fields, 401 on a missing session.
**Left out on purpose.** Keyword scoring and focus keywords, readability scores, redirects management, schema types beyond Article, hreflang, robots.txt and sitemap rendering (RenewCMS only provides data), automatic image generation for social cards.
**Open questions.**
1. Is `slug` immutable after first publish, or can it change (and break URLs with no redirects)?
2. Does the API return the SEO object for drafts to authenticated callers (for frontend previews)?
3. Should noindex articles be excluded from the sitemap endpoint instead of flagged?
4. Is title_template applied by the API (resolved title) or only returned for the frontend to apply?
5. Backfill: generate slugs for existing articles from their titles at migration, with `-2` suffixes on conflicts?
6. Should excerpt be exposed in the list payload as the article teaser?
**Status.** Frames drawn on the Back-office board (groups: Articles: search status, Editor: search and sharing, Editor: what the frontend gets, Sites: SEO defaults), Integration page group added, specs updated. Placement rationale: the sidebar holds only the summary, address and suggestions because it is 280 px wide; previews and fields need a slide-over.
Ordered by implementation dependency: tokens and shell first, then shared components, then screens. "Frontend only" means no backend work is needed.
| # | Screen | Change | Done in design? | Needs backend (yes/no, what) | Needs new tokens or components | Frontend only |
|---|---|---|---|---|---|---|
| 1 | All | Add the `--rc-*` tokens (sand, on-sand, four soft grounds, overlay, two shadows) to the app CSS, light and dark (foundations.md) | Yes | No | 9 new tokens | Yes |
| 2 | All | One sidebar, topbar and mobile drawer; Settings parent and child selected states; Activity sits between Users and Integration | Yes | No | NavItem, NavGroup | Yes |
| 3 | All | Overlay pattern: dialog (desktop) and bottom sheet (mobile); slide-over only for the variant form and record details; footer secondary left, primary right | Yes | No | Modal wrapper rules (components.md) | Yes |
| 4 | All | CodeSnippet is the only code block (Integration, Images, API keys, base URL, image URL) | Yes | No | CodeSnippet | Yes |
| 5 | All | Copy glossary pass (Delete vs Remove, Save vs Update, toast phrasing) | Yes, by search; not every line re-read | No | None | Yes |
| 6 | Settings | "Media" card with "Manage image variants" and "Cache usage" | Yes | No | MediaLinksCard | Yes |
| 7 | Media, picker | Variant count badge on grid, list and mobile rows | Yes | No (variant counts already come with GET /v1/media?variants=) | Badge "N variants" | Yes |
| 8 | Media | "With variants / Without" filter and "Largest total size" sort in the Media toolbar | Drawn on the Variants board only, not folded into the Media screen | No (GET /v1/media?variants=&sort=total_size exists) | None | Yes |
| 9 | Image variants | Variant form as slide-over (desktop) and tall sheet (mobile) | Yes | No | None | Yes |
| 10 | New article | Title only. Body removed from the modal; the editor opens straight away | Yes | Maybe: confirm `POST /v1/article/` accepts an empty `body` (it is "required" today); otherwise the frontend sends an empty string | NewArticleModal | Yes if empty body is accepted |
| 11 | Editor | Remove the 10,000 character limit, counter, limit and over-limit states; neutral count | Yes | Yes: remove the body length validation on `POST /v1/article/` and `PATCH /v1/article/{id}` | EditorStatusBar | No |
| 12 | Editor | Insert-link button (default, hover, active, disabled), tooltip, LinkModal, link popover and touch sheet, rendered link, toolbar scrolling at 390 px | Yes | No (editor only) | LinkModal, LinkPopover, ToolbarButton | Yes |
| 13 | Articles | Pagination with `page` and `size` (5, 25, 50, 100; default 25), URL state, loading, error, out of range | Yes | **Yes:** `page`, `size` on `GET /v1/article/` (same convention and 400 as Activity); total in `X-Total-Count`, array body unchanged; Integration reference updated | Paginator, PageSizeSelect | No |
| 13b | Media | Pagination under the grid and list, same Paginator and PageSizeSelect as Articles; `page` and `size` in the URL; saved size preference | Yes (9 states in the Media group) | **Yes:** `page`, `size` on `GET /v1/media/` (replaces `cursor` and `limit`); total in `X-Total-Count`, array body unchanged; Integration reference | None (reuses Paginator, PageSizeSelect) | No |
| 14 | Activity | Failed-action and security cards as toggles, synced chip and segment | Yes | No | FilterCardToggle | Yes |
| 15 | Activity | Export menu and dialog, CSV: current view (active filters) and all activity (no filters, optional `from`/`to`). Preparing, started, failed, nothing to export, 403 | Yes | No: `GET /v1/activity/export` is implemented (CSV, synchronous, owner and admin, same filters as the list) | ExportMenu, ExportDialog | Yes |
| 16 | Activity | JSON export (format control CSV or JSON) | Yes, one state, labelled proposed | **Yes (proposed):** `format=json` on `GET /v1/activity/export` | Segmented format control | No |
| 17 | Integration | Images and variants section, URL builder, response bodies `unknown_variant` and `unsupported_parameter` | Yes | No | UrlBuilder | Yes |
| 18 | All | States matrix for controls (hover, focus-visible, active, disabled) | Yes (board "States matrix: controls") | No | None | Yes |
| 19 | All | Contrast fixes: paginator current page border, no muted text on sand | Yes | No | None | Yes |
| 20 | All | Spacing, type and icon sizes made consistent (foundations.md, "One value per role") | Yes in the frames listed there | No | None | Yes |
| 21 | Settings, sidebar | Sites screen under Settings → Sites ("Publishing" group); "Publishing" card on Settings. Owner only: hidden for other roles, 403 page on a direct link. Key immutable after creation | Yes | **Yes:** `site` table, GET/POST/PATCH/DELETE /v1/site/ (writes owner only; PATCH rejects a changed key), `reassign_to`, seed from PUBLIC_URL | SitesTable, SiteForm, SlugField (locked variant), ReassignDialog | No |
| 22 | Sidebar | Authors entry between Media and Users, all roles. Owner and admin manage; other roles read-only list and detail | Yes | **Yes:** `author` table, GET /v1/author/ (any role), POST/PATCH/DELETE (owner and admin; a user may PATCH their own linked author), POST /v1/author/me (any role) | AuthorsTable, AuthorForm, SocialLinksRepeater, MediaField | No |
| 23 | Editor | "Site and authors" section in every Editor frame (collapsed by default): Site select (any role that can edit the article), reorderable authors, inline create (owner and admin), hint | Yes | **Yes:** `site_id`, `author_ids[]` on PATCH /v1/article/{id} | MetaSection, AuthorList, AuthorSelect, SiteSelect, CreateAuthorHint | No |
| 24 | Articles | Authors column; Site column and filter when more than one site | Yes | **Yes:** `site` filter, `site` and `authors` in the list payload | AuthorStack | No |
| 25 | Profile | "Author profile" card in every Profile frame, one-click create, edit own author (user link locked) | Yes | **Yes:** POST /v1/author/me | AuthorProfileCard | No |
| 26 | Backfill | Existing articles get the default site; authors start empty | n/a | **Yes:** migration | None | No |
## Roles and permissions: screens that change
| Screen | Change |
|---|---|
| Sidebar and mobile drawer (AppFrame) | Items are listed per permission, not per fixed role. Writer sees Home, Articles, Media, Authors, Activity and Content. A Roles entry appears under Settings for people who can view roles. |
| Settings navigation (AppFrame, done) | New "Access" group with a "Roles" item (shield icon) under Settings in the sidebar and the mobile drawer, shown to everyone except the restricted `user` role. "Permissions" is removed. |
| /settings/permissions | Retired. 301 to /settings/roles. The grid lives in the role editor. |
| Users (list, invite, row menu) | Role badges, Change role dialog, role picker in invite (default Writer), pending invites show the role, Transfer ownership, protected owner row. |
| Articles list and Editor | Publish hidden for roles without it; status shows Draft with "Ask an editor to publish." Delete and Edit disabled with a reason when scope is Own and the item is someone else's. |
| Content entry lists and editor | Same Own rules. View only mark. |
| Settings pages | Read-only mode with banner for people who can view but not edit. Owner-only actions disabled with an Owner only tag. |
| Schema | Hidden for roles without Schema view. Read-only for view-only roles. |
| API keys | Info alert "Keys follow their creator's role", a Follows role column. |
| Profile | Your role card and See what I can do. |
| Activity and filters | New event types, role diff in the detail panel. |
| Error and 403 copy | Two codes and the page-level state added. |
Decisions to confirm:
| # | Decision made | Alternative |
|---|---|---|
| 1 | Content permissions come before General permissions in the editor, because the common case (Contributor) only touches the grid. | General first, as in the brief. |
| 2 | Writer gets Authors: view and edit own, and Activity: own. | No Authors or Activity access. |
| 3 | A new role starts with nothing granted; presets fill the grid. | Start from a copy of Writer. |
| 4 | Own-scope rows others created show "View only" with a lock icon, not a hidden row. | Hide other people's entries. |
| 5 | Reset to default exists for built-in roles edited by the owner. | No reset. |
| 6 | Activity own/all scope uses the same None / Own / All control. | Toggle plus a separate scope select. |
| 7 | Transfer ownership takes effect at once, no email to the new owner. | The new owner must accept. |
| 8 | A user with Roles: manage cannot edit their own role, even in a custom role. | Allow narrowing only. |
+143 -800
View File
@@ -1,816 +1,159 @@
# Components # Components
## Inventory Frames: `AppFrame.dc.html` (shell: sidebar, topbar, mobile nav drawer, modal, toasts, articles, editor, users, settings, profile), `ActivityFrame`, `MediaFrame`, `VariantsFrame` (page bodies only), `IntegrationPage`, `IntegrationImages`, `ApiKeysPage`, `CodeSnippet`, `StatesMatrix`. The page-body frames mount inside AppFrame through two props (`slotBody`, `slotOverlays`) so there is one sidebar, one topbar and one drawer.
| Component | Nuxt UI base | Props | Emits | Slots | Used on | ## Overlay pattern (one rule, applies everywhere)
|---|---|---|---|---|---| | Kind | Desktop | Mobile (390) | Use |
| AuthShell | UPageCard, UColorModeSelect | title, description? | none | default, notice, footer | All public routes |
| DashboardShell | UDashboardGroup, UDashboardSidebar, UNavigationMenu, UDashboardPanel, UDashboardNavbar, UBreadcrumb | none (reads route.meta and the session store) | none | default, navbar-right | All verified routes |
| UserMenu | UDropdownMenu, UButton, UAvatar (a bottom sheet below md) | displayName?, username, email | logout | none | DashboardShell |
| UsersTable | UTable | rows: UserRow[], loading, canManage: boolean, busyIds: Set | cancel-invite, renew-invite | none | Users (md and up) |
| UserCardList | custom list | same as UsersTable | same as UsersTable | none | Users (below md) |
| RoleBadge | UBadge | role: 'owner'\|'admin' | none | none | Users |
| SettingsPage | custom card | none (reads GET /v1/settings) | none | none | Settings |
| InviteStatusBadge | UBadge | status: 'accepted'\|'pending'\|'expired' | none | none | Users |
| InviteUserModal | UModal, UForm, UFormField, UInput | none | invited(invite) | none | Users |
| ProfileCard | custom card (border, padding, optional footer strip) | title, description | none | default, footer | Profile |
| PageHeader | UPageHeader | title, description? | none | actions, meta | Home, Articles, Integration |
| ArticleStatusBadge | UBadge | online: boolean, size?: 'sm'\|'md', pending?: 'publish'\|'unpublish' | none | none | Home, Articles, Editor |
| ArticleTable | UTabs, UInput, UTable, UDropdownMenu, UPagination | articles: Article[], loading, busyIds: Set<number> | publish, unpublish, delete, copy-url | empty, toolbar-right | Articles (md and up) |
| ArticleCardList | ULink, UDropdownMenu (custom list) | same as ArticleTable | same as ArticleTable | empty | Articles (below md) |
| EmptyState | UEmpty | variant: 'no-articles'\|'no-results'\|'no-drafts'\|'no-online', query? | action | none | Home, Articles |
| ConfirmDialog | UModal via useOverlay | title, description, confirmLabel, pendingLabel, tone: 'danger'\|'neutral', action: () => Promise<void> | close(confirmed) | none | Articles, Editor, Pending |
| NewArticleModal | UModal, UForm, UFormField, UInput, UTextarea | none | created(article) | none | Home, Articles, /article/new |
| ArticleEditor | UEditor, UEditorToolbar, UDropdownMenu (+ custom Indent extension) | modelValue: string, articleId: number, disabled? | update:modelValue, request-image | none | Editor |
| ImageUploader | UModal, UFileUpload, UProgress | articleId: number | uploaded(location), close | none | Editor |
| SaveStatus | custom (span + UButton) | state: 'clean'\|'dirty'\|'saving'\|'saved'\|'error'\|'offline', savedAt?: Date | retry | none | Editor |
| PublishControl | UButton | online: boolean, dirty: boolean, pending?: string, disabled? | publish, unpublish | none | Editor |
| ArticleDetails | UCard (desktop) / USlideover (mobile) | article: Article | copy-url | none | Editor |
| CharacterCounter | custom | value: number, max: number, min?: number, warnAt?: number | none | none | NewArticleModal, Editor, Register, Accept invite |
| PasswordStrengthHint | custom (in UFormField #help) | rules: { label, met }[], showErrors: boolean | none | none | Register, Reset, Accept invite |
| ResultPanel | custom + UButton | tone: 'success'\|'error'\|'info', icon, title, description, actions: ButtonProps[] | none | default | Pending, Verify, Reset request, Reset, Accept invite, Register (403) |
| CodeBlock | custom (shiki) + UButton | code: string, lang: string, label? | none | none | Integration |
| EndpointCard | UCollapsible, UBadge, UTable, UTabs | method: 'GET'\|'POST'\|'PATCH'\|'DELETE', path, description, access, keyAccess, body?, examples: { fetch, curl, response }, errors, sessionOnly? | none | none | Integration |
| SectionIndex | custom nav (desktop), USelectMenu "Jump to" (mobile) | sections: { id, label }[], active: string | jump | none | Integration |
| SessionOnlyGroup | UAlert-style note + list rows | routes: { method, path }[] | none | none | Integration |
| ApiKeysTable | UTable, UBadge | keys: ApiKey[], showCreatedBy: boolean | revoke | none | API keys |
| CreateApiKeyModal | UModal, UForm, UFormField, UInput | open: boolean | create | none | API keys |
| ApiKeyReveal | UModal, UAlert, UButton | key: string | done | none | API keys |
| ErrorView | custom + UButton | kind: 'not-found'\|'error' | retry | none | Not found, error boundary |
| OfflineBanner | UBanner | none (reads useOnline) | none | none | DashboardShell |
| AppBoot | USkeleton inside DashboardShell | none | none | none | First load of any verified route |
Composables behind these components: `useSession` (it also carries the display name), `useUsers` (the list and the invite action), `useProfile` (the display name draft and save), `useApi`, `useArticles` (the list cache plus actions shared by the table, cards and editor), `useArticleDraft` (dirty state, save, and the sessionStorage mirror), `useImageUpload` (XHR with progress) and `useRetryAfter` (429 countdown).
## Product component specs
Extracted from `source/RenewCMS Components Product.dc.html`; open it for the drawings.
## Components: product
These are the RenewCMS components, built from the primitives on the first page . Each one lists the Nuxt UI parts it's made from, its props, emits and slots, every state, and keyboard and responsive behavior. Components marked custom have no Nuxt UI equivalent.
### AuthShell
One centered card for every public screen. The logo sits above the title, and the form or ResultPanel goes in the slot. Footer links, the language chip and the theme select sit outside the card, so the card itself stays about one task.
Log in Welcome back. Log in to manage your articles.
default slot
Forgot password? System
Built from Custom layout component AuthShell.vue : a UPageCard variant="outline" inside a centered main , with AppLogo , and UColorModeSelect size="sm" in the footer, next to the language chip (see LanguageSwitcher). Forms inside it use UForm rather than UAuthForm , because every screen needs states (such as 403, 429 and an inline login during verification) that UAuthForm doesn't expose.
Props, slots Props title: string , description?: string . Slots default , notice (an alert above the title, for session expired or password changed), footer (links). No emits.
A11y The title is the page's h1 , and document.title becomes "Log in · RenewCMS". On route change, focus moves to the h1 . The logo is alt="RenewCMS" and isn't a link, since there's nothing public to go back to.
Responsive 400 px card from sm up, with its top 12 vh from the top. Below sm , the card loses its border and padding drops to 20 px, filling the screen on --ui-bg , and fields and buttons use xl .
### DashboardShell
The sidebar has five destinations (Home, Articles, Users, Integration, Settings), and the user menu sits at its foot. The top bar carries the breadcrumbs and page title. Collapsing the sidebar leaves a 56 px icon rail. Below lg , the sidebar becomes a slideover.
RenewCMS
Home
Articles
Integration
marta Owner
System
Light
Dark
Log out
MA marta
Articles How we price a bathroom renovation
page body
MA
Articles
Mobile top bar. The menu button opens the sidebar as a left slideover.
Built from UDashboardGroup storage="local" › UDashboardSidebar collapsible :resizable="false" :default-size="16" :collapsed-size="4" with the #header (AppLogo), #default ( UNavigationMenu orientation="vertical" :collapsed ) and #footer (UserMenu) slots. Each page is a UDashboardPanel , with a UDashboardNavbar holding UDashboardSidebarCollapse and UBreadcrumb . On mobile, UDashboardSidebarToggle appears and the sidebar's built-in slideover takes over.
UserMenu UDropdownMenu :content="{ side: 'top', align: 'start' }" on a ghost UButton block with a UAvatar and the username. The groups are: the account label; System, Light and Dark as checkbox items; then Log out. There are no profile or password items, because the API doesn't support them. Superseded: see "UserMenu (changed)" under "Added with the Users and Profile screens", which restores the System, Light and Dark items.
Props, slots No props, because the shell reads the user from the session Pinia store. The navbar reads route.meta.title and route.meta.breadcrumb . Slots navbar-right (page actions on mobile) and default . It shows the offline banner when navigator.onLine is false.
A11y The first Tab stop is a "Skip to content" link. The sidebar is a nav labeled "Main", and the current page gets aria-current="page" . In the collapsed rail, items show tooltips and keep their aria-label . The collapse button carries aria-expanded . The mobile slideover traps focus and closes after navigating.
Bend The active nav item uses a sand fill with ink text instead of Nuxt UI's primary-tinted background and left indicator. The shell is set up for this in ui.config.ts .
### PageHeader
Articles Everything you've written, online and in draft.
Connect your website New article
Articles Everything you've written, online and in draft.
New article
Built from UPageHeader :title :description , with actions in the #links slot. The ui override is { root: 'py-0 border-0', title: 'text-xl', description: 'text-sm text-muted mt-0.5' } , which brings Nuxt UI's marketing-size header down to dashboard density.
Props, slots title , description? , slot actions (the primary action goes last, so it sits on the right), slot meta (for example, a status badge next to the title in the editor).
Responsive Below md , the actions wrap under the text. The primary action goes full width at 44 px, and secondary actions move into the navbar's overflow menu.
### ArticleStatusBadge
Online
Draft
Online
Draft
Publishing…
Unpublishing…
Built from UBadge . Online: color="neutral" variant="solid" icon="i-lucide-globe" . Draft: color="neutral" variant="outline" icon="i-lucide-pencil-line" . Status is shown by the fill, the icon and the word, never by color alone.
Props online: boolean , size?: 'sm' | 'md' (sm in lists, md in the editor header), pending?: 'publish' | 'unpublish' (shows the -ing label with a spinner while the request runs). No emits, because it isn't interactive.
### ArticleTable
Search, filter tabs, a sortable table and row actions. Everything happens in the browser on the list it has already loaded. Below md , the table becomes a card list with the same data and actions.
All 6 Online 4 Drafts 2
Search by title
Title Status Created Updated
Online Draft
Built from UTabs + UInput type="search" + UTable (desktop), or a custom ArticleCardList of ULink rows (mobile), switched with useBreakpoints at md . Each row has a UDropdownMenu , and the row actions go through useArticleActions() , which shares one set of calls between the table, the list and the editor.
Props, emits Props articles: Article[] , loading: boolean , busyIds: Set<number> . Emits publish(id) , unpublish(id) , delete(article) , copy-url(id) . Search, tab and sort state lives in the URL query ( ?q=&status=&sort= ). Slots #empty , #toolbar-right .
Behavior Search is case-insensitive and ignores accents, and it matches anywhere in the title. It updates on input, debounced 120 ms. Tab counts always reflect the full list. Dates within 7 days are relative ("2 hours ago"); older ones are absolute ("18 Aug 2026"), with the full timestamp in a title attribute. Clicking a row opens the editor, except clicks on the menu.
States Loading (5 skeleton rows), empty, no results, a filter tab with nothing in it, a row busy, an action failed (toast with Retry, row reverts), and a row vanished (a 404 on any action removes the row and shows an info toast). Row actions are never optimistic: the row updates when the 200 arrives.
A11y The table has a visually hidden caption, "Articles, sorted by last updated, newest first". Each row's menu button is named "Actions for How we price a bathroom renovation". After a search, a polite live region announces "3 articles".
### EmptyState
No articles yet Write your first one. It stays a draft until you publish it. New article
No articles match "tiles" Check the spelling, or search for a shorter part of the title. Clear search
No drafts Everything you've written is online. New article
Nothing is online yet Publish a draft and it will appear on your website. Show drafts
Built from UEmpty :icon :title :description :actions variant="naked" , placed in the UTable #empty slot or on the page. The icon tile is overridden to rounded-none bg-elevated .
Props variant: 'no-articles' | 'no-results' | 'no-drafts' | 'no-online' , query?: string . Emits action . Each variant has fixed copy, and the search term is shown in quotes, cut to 40 characters.
A11y The title is an h2 . "No results" is announced through the table's live region. Clear search returns focus to the search field.
### ConfirmDialog
Delete this article? "Winter checklist for timber windows" and its 3 images will be deleted. This can't be undone.
Cancel Delete article
Delete this article? "Winter checklist for timber windows" and its 3 images will be deleted. This can't be undone.
Couldn't delete the article, so it's still there. Try again in a moment.
Cancel Deleting…
Left: default. Right: after a failed attempt (the error appears inline), with the retry running (loading).
Built from UModal opened through useOverlay().create(ConfirmDialog) . open() returns a promise that resolves once the action succeeds or the dialog is cancelled. The action runs inside the dialog, so errors show there instead of in a toast behind the overlay.
Props, emits title , description , confirmLabel , pendingLabel , tone: 'danger' | 'neutral' , action: () => Promise<void> . Emits close(confirmed: boolean) . The delete description comes from the title plus images.length , and reads "and its image" for 1 and just the title for 0.
Other uses Cancel registration: "Cancel your registration?" / "Your account will be deleted. You can register again with the same or a different email." / "Cancel registration" / "Keep my account". Leaving with unsaved changes uses the neutral tone.
A11y role="alertdialog" . Focus starts on Cancel, and Enter doesn't confirm by default. While the action runs, the dialog can't be dismissed, both buttons are disabled, and aria-busy is set. On success, focus goes to the next row, or the page heading if there isn't one.
### ArticleEditor, SaveStatus, PublishControl, CharacterCounter
The editor is made of four components that work together. The header holds the read-only title, status, SaveStatus, Save and PublishControl. A sticky toolbar sits above the writing surface, which uses the content type scale. The counter sits at the foot.
How we price a bathroom renovation Draft
Unsaved changes
Save
Save and publish
Every bathroom is different, but the way we price them isn't. We start with a visit, measure everything, and write down what stays .
What goes into the price
Stripping out and making good
Plumbing moves, if any
Details
Created 12 Mar 2026, 09:14 Updated Today, 11:02 ID 12 Images 3
Public API URL /v1/article/12 Returns 404 until the article is online.
4 318 / 10 000
Unsaved changes dirty, Save enabled
Saving… saving, Save disabled
Saved at 11:02 saved, Save disabled
Save failed Retry error, changes kept
Publish draft, saved
Unpublish online
9 240 / 10 000 body ≥ 90%
10 214 / 10 000 214 characters over. Shorten the text or remove an image. Formatting counts too. over limit, Save disabled
ArticleEditor UEditor v-model content-type="html" :extensions + UEditorToolbar :editor :items layout="fixed" . TipTap v3 StarterKit already includes Underline and Link. TextAlign handles alignment and Image handles images. Formatting is a UDropdownMenu with Paragraph and Headings 1–4 ( h1 is kept because the public site controls how the title appears). Indent and outdent call sinkListItem / liftListItem in lists. For paragraphs, a small custom Indent extension writes data-indent="1–4" , because TipTap has no paragraph indent. Clear formatting runs unsetAllMarks().clearNodes() . The editor surface gets class="article-content" .
Props, emits modelValue: string (HTML), articleId: number , disabled? . Emits update:modelValue , request-image . Exposes insertImage(src, alt) and focus() . The toolbar reflects the active state (bold is on at the cursor) using aria-pressed .
SaveStatus A custom inline span aria-live="polite" , driven by useArticleDraft() : 'clean' | 'dirty' | 'saving' | 'saved' | 'error' . Saved shows the time and switches to "Saved 5 min ago" after an hour. The error state's Retry is a UButton size="xs" variant="outline" . Ctrl/⌘ S saves. Leaving with unsaved changes triggers onBeforeRouteLeave (ConfirmDialog, neutral tone) and beforeunload . There is no autosave , so the owner controls exactly what is sent. The draft is mirrored to sessionStorage so it survives a session expiry.
PublishControl A single UButton . Draft → primary "Publish"; draft with unsaved changes → "Save and publish" (PATCH body, then PATCH publish; if the save fails, it doesn't publish); online → outline "Unpublish". Unpublishing doesn't save pending changes, so the draft edits stay pending. Toasts: "Article published", with the description "Title" is now online. ; "Article unpublished", It's a draft again and hidden from your website.
CharacterCounter A custom component in mono type. Props value: number , max: number , min?: number , warnAt?: number (default 0.9). The title uses [...str].length , so accents and emoji count as one, matching Go's validator, which counts runes. The body uses the HTML string length, which is what the API receives. The warning and error states add an icon and text, not just color. A visually hidden live region announces the change only when a threshold is crossed, not on every keystroke.
Responsive Below lg , the details panel becomes a right slideover, opened with i-lucide-panel-right . Below md , the header wraps: back, title and badge on the first row, then SaveStatus and the counter. Save and Publish move to a sticky bottom bar (44 px). The toolbar scrolls horizontally with fade edges and keeps undo, redo and the image button pinned at the start.
### ImageUploader
Opens from the editor's image button in a small modal. Files are checked in the browser before anything is sent. A JPEG, PNG or WEBP then goes through a crop-and-resize step before it uploads; an SVG skips straight to uploading. After a successful upload the image goes in at the cursor and the modal closes.
Drop an image here Choose file PNG, JPEG, WEBP, SVG · max 10 MB
idle
Drop to upload
dragging over
Crop and resize north-wall.jpg · 3.9 MB
crop, default: whole image selected, width "1600 px"
Crop and resize north-wall.jpg · 3.9 MB
crop, ratio locked to 16:9
Crop and resize north-wall.jpg · 3.9 MB
crop, crop narrower than the chosen width
north-wall.jpg 43%
Uploading 180 of 420 KB
uploading
Image added
success, closes after 600 ms
"plan.pdf" can't be used Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. Choose another file
rejected: type or size
Upload failed The server didn't accept the file. Your article hasn't changed. Try again
server error
Built from UModal title="Add an image" › UFileUpload accept="image/png,image/jpeg,image/webp,image/svg+xml" :multiple="false" layout="list" + UProgress size="xs" . Uploads go through XMLHttpRequest , because fetch has no upload progress, as FormData with the field name file . The 201 location is inserted with insertImage(location, '') .
Crop and resize A JPEG, PNG or WEBP opens on a 640px-wide modal (`md.crop`) between the file picker and the upload, replacing the "Add an image" title with "Crop and resize" and the filename/size as the description. The stage shows the image with the crop frame starting as the whole image: a rule-of-thirds grid (lines at 33.3 %/66.6 %), four 12×12 px corner handles, and a pixel-size label (mono, bottom-left, dark pill) that tracks the frame's current size. The frame is `tabindex="0"`, `aria-label="Crop area. Arrow keys move it, Shift and arrow keys resize it."`; dragging moves it, dragging a handle resizes it, arrow keys nudge it by 1 px and Shift+arrow keys resize it.
Aspect ratio is a `radiogroup` of five pills — Original, Free, 16:9, 4:3, 1:1 — plus Rotate and Reset actions above it. Width is a second `radiogroup` — Original, 1600 px (default), 1200 px, 800 px. A mono line below reads "Uploads as {width} × {height} px · about {size}", where the estimated size is shown in KB under 1 MB and in MB above it. When the chosen width is larger than the crop's own width, the output stays at the crop's size (never upscaled) and an info row explains why: "The crop is {crop width} px wide, so it's uploaded at that size. Enlarging it would make it blurry." The footer becomes "Choose another file" (back to the file picker, `data-act="upload-pick"` from `idle`) and "Upload image" (starts the upload with the cropped, resized file). SVGs never see this step: they upload as soon as they're chosen. The "in progress" state afterwards reports the resized file's size, e.g. "Uploading 180 of 420 KB".
Validation Before uploading, the uploader checks both the MIME type and the file extension, and that size ≤ 10 × 1024 × 1024 . The size error names the file size: "north-wall.tif is 14.2 MB. Images can be up to 10 MB." A server 400 shows the same message as a client-side rejection.
Props, emits articleId: number . Emits uploaded(location) , close . A 401 hands off to the global session flow. A 404 means the article was deleted, which the editor handles.
A11y, touch The drop zone is a labeled button, so Enter opens the file picker. Progress is exposed as role="progressbar" with aria-valuenow , and the result is announced. On touch screens the drop text is hidden and "Choose file" becomes the main action. Dropping a file directly onto the editor surface also opens the uploader with that file.
### CodeBlock and EndpointCard
GET /v1/article/{id}
Returns one article. Drafts return 404 to anonymous callers, the same as an id that doesn't exist.
fetch curl Response
JavaScript Copied
const res = await fetch( 'https://cms.renew.example/v1/article/12' )
if (res.status === 404 ) return notFound()
const article = await res.json()
CodeBlock This is a custom component, because Nuxt UI's prose code blocks rely on Nuxt Content and don't work in a plain Vue app. Highlighting is done ahead of time with shiki (a dual github-light / github-dark theme remapped to the semantic colors: keywords info, strings success, numbers warning, comments muted). The header shows the language label and a ghost copy UButton , which switches to "Copied" for 2 s through useClipboard . Props code , lang , label? . Long lines scroll horizontally and never wrap.
EndpointCard UCard › a method UBadge (GET is solid ink) + the path in mono + a description, with UTabs (fetch, curl, Response) showing a CodeBlock each. Props method , path , description , examples: { fetch, curl, response } . Snippets use the current location.origin .
A11y The copy button is named "Copy JavaScript example", and its result is announced. Code is a focusable pre ( tabindex="0" ) so keyboard users can scroll it. Syntax colors pass 4.5:1 on --ui-bg-elevated in both themes.
### PasswordStrengthHint
At least 8 characters not met yet
At least 8 characters met
At least 8 characters not met on submit
Passwords match reset screen only
Built from A custom ul in the password UFormField 's #help slot. Props rules: { label: string, met: boolean }[] , showErrors: boolean . There is no meter or score, because the API only requires 8 characters.
A11y The list is the field's aria-describedby . Each item has hidden text reading "met" or "not met". A change is announced once, when the rule becomes met.
### ResultPanel
Check your email If an account exists for that email, a password reset link has been sent. The link expires in 1 hour.
Back to log in
This link has expired Reset links work once, for 1 hour. Request a new one and use it within the hour.
Request a new link
Too many attempts For your security, wait a moment before trying again. You can try again in 0:42 .
Try again in 0:42 API pending
Built from A custom component that fills AuthShell's slot: an icon tile, h1 title, description and UButton block size="lg" actions. Props tone: 'success' | 'error' | 'info' , icon , title , description , actions: ButtonProps[] . Slot default for extras such as the inline login on verify.
A11y When the panel replaces a form, focus moves to its title and the panel is role="status" , or role="alert" for errors. The countdown updates every second visually but is announced only at the start and when it reaches zero.
### Error pages and offline banner
You're offline. You can keep writing, but saving and publishing will wait until you're back online.
404 Page not found The link may be mistyped, or the page may have moved. Go to Home All articles
Error Something went wrong This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server. Try again Go to Home
Built from UError isn't used because it's built for Nuxt's error.vue . Instead there's a custom ErrorView (props kind: 'not-found' | 'error' ), rendered inside UDashboardPanel when the user is logged in and inside AuthShell otherwise. The offline banner is a UBanner color="neutral" at --z-banner , shown and hidden with useOnline() . Once the connection returns it switches to "You're back online" for 3 s.
A11y The error title is the page h1 and gets focus on render. The banner is role="status" , and it can't be dismissed while offline. The offline state blocks Save and Publish and says why in their tooltip.
## Added with the Users and Profile screens
The Product components board was not regenerated for these. They are written from `source/RenewCMS Screens Back-office.dc.html`, `source/AppFrame.dc.html` and the Prototype. Their exact values come from AppFrame.
### UserMenu (changed)
The chip at the foot of the sidebar no longer logs out on click. It opens the account menu, which lists Profile, the theme choice, the language choice and Log out.
- **Chip:** 40 px high, an initials avatar (26 px, sand `#d4c5b0` with ink text), the name, and a chevrons-up-down icon. `aria-haspopup="menu"`, `aria-expanded`, title "Account". While the menu is open, or while `/profile` is the current page, it takes `--rc-sand` with `#111111` text.
- **Name:** the display name when there is one, otherwise the username. Initials come from the first letters of the first two words of the display name, uppercased. Without a display name they come from the username.
- **Desktop menu:** a 224 px popover anchored above the chip on its left edge, with `--rc-shadow-pop`. Header: the name in semibold, the email below it in muted 12 px, a bottom border. Items are 32 px: Profile (circle-user), a separator, a "Theme" section label, System, Light and Dark (14 px text, a check icon at the right edge on the current choice), a separator, a "Language" section label, English and Français (same row as the theme items, a check icon on the current one), a separator, Log out (log-out). Section labels are 11 px, weight 500, uppercase, letter-spacing .04em, `--ui-text-muted`, padded 6px 8px 2px. The current page's item is highlighted with `--ui-bg-elevated`. The theme items are radio-style: choosing one applies at once and keeps the menu open. Nuxt UI: `type: 'checkbox'` items bound to `useColorMode().preference`.
- **Mobile:** the account row in the navigation drawer opens a bottom sheet over an `--rc-overlay` scrim, with a grab handle. Header: 36 px avatar, name in 15 px semibold, email in muted text. Items are 48 px at 15 px text; the theme and language items keep the same order, section labels (padded 8px 16px 2px) and separators.
- **Language items:** a `menuitemradio` group named by the "Language" label. Each item is labelled with the language's own name (English, Français) and carries `lang="en"` / `lang="fr"`. Choosing one applies the language at once and keeps the menu open, like the theme items. See LanguageSwitcher.
- **Keyboard:** Escape or a click outside closes it. Arrow keys move between items.
- The board now draws the theme items, so the chip, menu and sheet need no visual exception. The board highlights Profile even on Home; the app highlights it only on `/profile`.
### DashboardShell (changed)
The navigation gains **Users** (users icon) between Articles and Integration, in the sidebar and in the mobile drawer. Breadcrumbs for the new pages are "Users" and "Profile". It later gains **Settings** (settings icon) after Integration, with the breadcrumb "Settings".
### UsersTable, UserCardList
Superseded by "UsersTable, UserCardList (changed)" below.
Skeleton rows appear after 150 ms of loading.
### InviteStatusBadge
A 22 px outline-less badge with a 12 px icon and the word, so status is never shown by color alone.
| Status | Icon | Fill and text |
|---|---|---|
| Accepted | circle-check | `--rc-success-soft`, `--ui-success` |
| Pending | mail | `--rc-warning-soft`, `--ui-warning` |
| Expired | clock-alert | transparent, `--ui-text-muted`, border `--ui-border-accented` |
### InviteUserModal
A UModal with one field, following the NewArticleModal pattern: title "Invite a user", the text "We'll email them a link to choose a username and password. The link expires after 3 days.", an Email field (placeholder "name@example.com", focused on open), Cancel and "Send invite". While sending, the field is disabled, the button reads "Sending…" and the dialog can't be dismissed. A field error shows under the input, a request failure shows an alert above the buttons, and the address always stays in the field. On success it closes and emits the new invite.
### ProfileCard
A bordered card with 20 px padding (16 px on mobile), a semibold 14 px title, a muted description, and its content. An optional footer strip sits below a top border on `--ui-bg-muted`, with the status text on the left and the action on the right. The Display name card uses the footer; Account and Password do not.
The Display name field uses CharacterCounter with a maximum of 60, counted in Unicode characters, in the mono style on the right of the help row. Read-only fields (username, email, password) are disabled inputs on `--ui-bg-elevated` with a lock icon at the right.
## Themed primitives
Extracted from `source/RenewCMS Components Primitives.dc.html`.
## Components: themed primitives
These are Nuxt UI components with the Ledger config applied. Every specimen reads the real --ui-* variables, so the theme switch on the right changes the whole page. The product components are on a second page .
### Button
There are five roles. A screen gets one primary button. Danger is used only for irreversible actions, and always inside a confirmation step.
New article primary
Cancel secondary
Preview ghost
Delete article danger
icon-only
Save sm 28
Save md 32
Save lg 40
Save xl 44, touch
Publish default
Publish hover
Publish focus-visible
Publish active
Publish disabled
Publishing… loading
Delete article danger hover
Cancel secondary hover
Preview ghost hover
Nuxt UI UButton . Primary color="primary" variant="solid" · secondary color="neutral" variant="outline" · ghost color="neutral" variant="ghost" · danger color="error" variant="solid" · icon-only square icon="i-lucide-ellipsis" :aria-label wrapped in UTooltip .
Loading :loading swaps the leading icon for i-lucide-loader-circle and disables the button. The label changes to the -ing form ("Publishing…") and the button width stays fixed, so the layout doesn't jump. loading-auto is used on async @click handlers.
Keyboard, ARIA Native <button> , activated with Enter or Space. Icon-only buttons need an aria-label . Loading sets aria-busy="true" . Disabled buttons stay focusable only when a tooltip explains why they're disabled.
Responsive md on desktop, xl (44 px) below md . Primary page actions go full width in sticky footers on 390 px screens.
Bend Disabled opacity is 40% instead of Nuxt UI's 75%, because a 75% black button still looks clickable. Hover darkens the button one step instead of using Nuxt UI's /75 alpha, so the label keeps its contrast.
### Input, password input, textarea
One outline style for every field. Focus adds a 2 px ink ring inside the edge. Errors turn the edge brick red and always come with a message, never color alone.
marta default, placeholder
marta hover
mar focus
ma error
owner@renew.example disabled / read-only
plaster leading icon, clear
•••••••••• password, hidden
limewash2026 password, shown
A short first paragraph. You can keep writing in the editor. textarea, autoresize 3–8 rows
Nuxt UI UInput variant="outline" color="primary" , :highlight bound to the field error so the edge turns red. The search field is icon="i-lucide-search" with a #trailing clear UButton . UTextarea autoresize :rows="3" :maxrows="8" .
Password UInput :type="shown ? 'text' : 'password'" with a #trailing ghost UButton . aria-label reads "Show password" or "Hide password", with aria-pressed and aria-controls . autocomplete is current-password on log in and new-password on register and reset. Showing the password never clears or moves focus.
States Default, hover (edge goes to muted), focus (2 px ink), filled, error, disabled, read-only (same look as disabled but selectable and focusable, used for the email on the reset screen). Fields are 16 px text below md .
Placeholder Placeholders use --ui-text-muted (5.7:1) and only show an example. The label always carries the meaning.
### Form field
A label on top, then the control, then the help text or the error in the same spot. The right-hand hint slot holds the character counter. Every field in the product is required, so none are marked.
Username 5 / 20
marta 3 to 20 characters. You'll use it to log in.
Username 2 / 20
ma Use at least 3 characters.
Email marta@renew Enter a full email address, like name@example.com.
Nuxt UI UForm :schema :state @submit + UFormField name label help , with the counter in the #hint slot. Validation runs on blur and on submit, then on every input once a field has an error, so the error clears as soon as it's fixed. The schema mirrors the API rules exactly, so a validator 400 shouldn't reach the UI.
ARIA Nuxt UI links the label and aria-describedby (help or error) and sets aria-invalid . On a failed submit, focus moves to the first invalid field. Error text sits in the help slot, so there is no layout shift.
### Checkbox, switch, select
All three are square. The Preview toggle in the editor is the only switch. The select sets sort order in the mobile article list. No screen needs a checkbox yet, but it's themed so it matches if one gets added.
Off
On
Focus
Disabled
Preview off
Preview on
Focus
Disabled
Newest updated first
Newest updated first
Oldest updated first
Nuxt UI UCheckbox , USwitch (slots base and thumb set to rounded-none ), USelect :items value-key="value" , whose content uses --shadow-popover .
Keyboard Space toggles a checkbox or switch. The select opens with Enter, Space or ↓, moves with the arrow keys and type-ahead, and closes with Esc, returning focus to the trigger. The switch uses role="switch" and aria-checked , and its visible label is its name.
### Badge
Badges show status or a count. They never replace a button. Solid ink is for the one state that matters most in a list. Soft semantic badges are for system messages.
Online
Draft
12
success
info
warning
error
GET
Online · md
Nuxt UI UBadge . Solid ink color="neutral" variant="solid" · outline color="neutral" variant="outline" · count color="neutral" variant="soft" size="sm" · semantic variant="soft" . Sizes are sm (22 px, in tables) and md (26 px, in the editor header).
ARIA Badges are text, not live regions. When a status changes, the announcement comes from the toast.
### Alert
Alerts are for messages that belong to a page and stay until the situation changes. Anything that confirms an action someone just took is a toast instead.
Your session expired Log in again to pick up where you left off.
Password changed Log in with your new password.
Almost at the length limit 9 240 of 10 000 characters used. Images and formatting count too.
Couldn't load your articles Check your connection and try again.
Try again
Nuxt UI UAlert variant="soft" :color :icon :title :description :actions . close only on alerts that are safe to dismiss (password changed, session expired). The error alert has no close button because its retry is the way out.
ARIA Info, success and warning use role="status" and error uses role="alert" . Alerts that are already there when the page loads aren't announced again. The description text uses --ui-text-toned rather than the semantic color, so long text stays readable.
### Toast
Toasts confirm that an action finished or failed. Each one has a short title and, at most, one action.
Article published "Winter checklist for timber windows" is now online.
That article no longer exists It was deleted somewhere else, so we removed it from the list.
Couldn't unpublish The server didn't respond.
Retry
Placement Bottom right on desktop, 24 px from the edges. Bottom center at full width minus 16 px on mobile, above the sticky action bar.
Duration Success and info close after 5 s. Errors with retry stay until dismissed or retried. The timer pauses on hover and on focus.
Stack At most 3 visible, newest at the bottom. A fourth pushes out the oldest non-error toast. Repeating the same action replaces its toast instead of adding a new one.
Nuxt UI UApp :toaster="{ position: 'bottom-right', duration: 5000, max: 3 }" . Toasts are raised with useToast().add({ id, title, description, color, icon, actions, duration }) , where id is the action plus the article id so a repeat replaces the old toast. Errors use duration: 0 . The progress bar is off.
ARIA The Reka toast region announces politely, and errors assertively. F8 jumps to the region. Esc closes the focused toast.
### Modal and slideover
Modals ask a question or hold a short form. Slideovers carry navigation and side panels on small screens. Both sit over a 40% ink overlay.
Leave without saving? You have changes to "How we price a bathroom renovation" that haven't been saved.
Discard changes Keep editing
Details
Created 12 Mar 2026, 09:14 Updated Today, 11:02 ID 12 Images 3
Nuxt UI UModal :title :description with the #footer slot for actions, width sm:max-w-md . Opened programmatically with useOverlay().create(ConfirmDialog) . USlideover side="right" for details, side="left" for mobile navigation (built into UDashboardSidebar ).
Button order The safe action is primary and sits on the right, where the eye lands. In destructive confirmations, the danger button is on the right and Cancel on the left (see ConfirmDialog). On mobile the buttons stack full width, with the primary on top.
Keyboard, ARIA Focus is trapped and starts on the least destructive button. Esc and the overlay close it unless it's :dismissible="false" , which is used while an action is running. Focus returns to the trigger. The title is the aria-labelledby and the description is the aria-describedby .
Responsive Below sm , modals sit 16 px from the screen edges and slideovers take the full width. Motion follows the foundations: 240 ms, and instant with reduced motion.
### Dropdown menu and tooltip
The row-action menu is the main dropdown. Delete always comes last, below a separator, in brick red. Tooltips name icon-only buttons and show their keyboard shortcut.
Edit
Unpublish
Copy API URL
Delete…
Bold Ctrl B
Nuxt UI UDropdownMenu :items :content="{ align: 'end' }" , with items grouped in nested arrays so separators appear automatically. The Delete item is color: 'error' , and its label ends in "…" because it opens a confirmation. UTooltip :text :kbds="['ctrl', 'b']" :delay-duration="400" .
Keyboard The menu opens with Enter, Space or ↓. Arrow keys and type-ahead move through it, Home and End jump to the ends, and Esc closes it and returns focus. Tooltips appear on focus as well as hover and never hold information that isn't also in the aria-label .
Touch Tooltips don't show on touch, so every icon-only control on mobile also appears in a labeled menu or has a visible label.
### Tabs
Tabs filter the article list. Each count comes from the full list, so it doesn't change while you search.
All 6
Online 4
Drafts 2
Focused
From left: selected, default, hover, focus-visible.
Nuxt UI UTabs variant="link" color="neutral" :content="false" :items v-model . The count goes in the #trailing slot as the mono sm count badge. The value syncs to ?status=online|draft so the filtered view can be linked.
Keyboard Roving tabindex: Tab enters the tab list on the selected tab, and ← and → move and activate. Each tab's accessible name includes its count, for example "Drafts, 2 articles".
Responsive Tabs stay in one row at 360 px, which is why the labels are short. Each tab is 44 px tall on touch.
### Table and pagination
Rows are 44 px with hairline separators, and only the Updated column sorts. Pagination appears only when a filter shows more than 25 articles.
Title Status Updated
How we price a bathroom renovation default 2 hours ago
Choosing lime plaster for old stone walls hover Yesterday
Before and after: the Rue Verte kitchen focus-visible 3 days ago
Winter checklist for timber windows busy 5 days ago
26–50 of 142 1 2 3 … 6
Nuxt UI UTable :data :columns v-model:sorting v-model:global-filter v-model:pagination :loading with TanStack getPaginationRowModel , and @select for row click. UPagination :total :items-per-page="25" :sibling-count="1" show-edges , active item color="primary" variant="solid" .
States Default, hover ( bg-elevated ), focus-visible (2 px inset ring), busy (a row action is running: 50% opacity, a spinner in the status cell, aria-busy , actions disabled), loading (skeleton rows), empty (EmptyState in the #empty slot).
Keyboard, ARIA Each row's title is a real link to the editor, so Tab reaches it and Enter opens it. The rest of the row is a click target for the mouse only. The sort header is a button with aria-sort . Pagination is a nav labeled "Pages", and the current page has aria-current .
### Skeleton, avatar, kbd, separator
MA marta
JV jan_vries
BO bob
Ctrl
S
Save
Skeleton USkeleton blocks shaped like the real layout: the same row heights, a badge-sized block and a date-sized block. The pulse runs at 1.6 s and stops with reduced motion. The container is aria-busy="true" and a visually hidden live region says "Loading articles". Skeletons appear only after 150 ms, so fast responses never flash.
Avatar UAvatar :alt="username" size="xs|sm" . The initials come from the username: split on anything that isn't a letter or digit, take the first letter of the first two parts, or the first two letters if there is only one part, then uppercase. Sand #d4c5b0 with ink text (11.2:1) in both themes. It's decorative ( aria-hidden ) because the username is always shown next to it.
Kbd UKbd variant="outline" , only inside tooltips and the editor's shortcut hints. The editor shortcuts are Ctrl/⌘ S to save, plus TipTap's defaults: B, I, U, Z and Shift Z.
Separator USeparator uses --ui-border by default and --ui-border-accented between groups in dense toolbars. It's decorative, with role="none" , except between menu groups.
### Color mode
Three choices: System (the default), Light and Dark. The toggle at the top of this page is the component itself, so you can try it.
Nuxt UI In the user menu: a UDropdownMenu group of three type: 'checkbox' items bound to useColorMode().preference . On auth screens: UColorModeSelect in the footer. Nuxt UI's UColorModeButton isn't used because it only toggles between light and dark and hides the system option. colorMode: true in the plugin handles storage and applying the .dark class before first paint.
ARIA Menu items use menuitemradio , with aria-checked on the current choice. The segmented variant is a radiogroup named "Theme".
### LanguageSwitcher
Two choices: English and Français. Each is always written in its own language, whatever the interface language. It appears in two places and never as a form select.
- **Account menu (signed in):** a "Language" section between the theme items and Log out. See UserMenu (changed).
- **Auth footer (signed out):** a chip beside the System theme chip, in the footer outside the card. The chip is 28 px high on desktop and 44 px on mobile, 8 px side padding, 1 px `--ui-border-accented` border, `--ui-text-toned` text, a 14 px lucide `languages` icon, then the current language's own name. `aria-haspopup="menu"`, `aria-expanded`, `aria-label` "Language: English". While open it takes `--ui-bg-elevated`. The footer sits at the right with an 8 px gap between the two chips.
- **Auth popover:** opens above the chip (bottom 36 px, right-aligned, 168 px wide), `--ui-bg`, 1 px `--ui-border`, `--rc-shadow-pop`, 4 px padding, `role="menu"` named "Language". Rows are 32 px (44 px on mobile), 14 px text, `menuitemradio` with `lang`, a 16 px check icon at the right edge on the current one. Choosing a row applies the language and closes the popover; Escape or an outside click closes it without changing anything.
- The choice persists across reloads and applies to every screen at once.
## Added with roles, invite actions and Settings
Written from `source/RenewCMS Screens Back-office.dc.html` and `source/AppFrame.dc.html`. The Product components board was not regenerated for these.
### UsersTable, UserCardList (changed)
Columns Email, Display name, Role, Invite status, Created (sorted descending, with an arrow-down icon on the header) and an unlabeled actions column. Grid `minmax(0,1.3fr) minmax(0,1fr) 84px 120px 110px 150px`, 12 px gap, 36 px header on `--ui-bg-elevated`, 48 px rows with a top border. Below md the same rows become cards with a 68 px minimum height: the email line, the display name below it when set, then role badge, status badge and "Created {date}" on one line, then the row action as a 44 px button.
The email cell is medium weight and highlighted, with a mono "you" tag on the current user's row. The display name is highlighted, or a muted `—` when empty. The row of a just-sent or just-renewed invite has `--ui-bg-elevated`. Skeleton rows have one bar per column. The username is not shown.
### RoleBadge
A 20 px badge in IBM Plex Mono 11 px medium, 7 px side padding, no icon.
| Role | Fill | Text | Border |
|---|---|---|---| |---|---|---|---|
| owner | `--ui-primary` | `--ui-bg` | `--ui-primary` | | Dialog | Centered horizontally, 140 px from the top, width 460 (560 wide forms, 640 crop), `--rc-shadow-modal` | Bottom sheet: full width, flush to the bottom, grabber (36×4, `--ui-border-accented`) | Confirmations and forms with up to about four fields |
| admin | transparent | `--ui-text-toned` | `--ui-border-accented` | | Slide-over | Right panel, full height, over a scrim (Variant form 960 wide with live preview, Media detail, Activity details 480) | Tall bottom sheet, 92% height, grabber, close ×, sticky footer. Never a full page | Only forms with a live preview, and record details. Nothing else may be a slide-over |
| Menu | Popover under the trigger | Bottom sheet with a title and a close × | Action lists (Export, row menus on mobile) |
### Row action button (Users) Dialog anatomy: header (title 16/24 weight 600, optional description in `--ui-text-muted`, close × 28 px desktop / 44 px mobile), body (padding 20 24 0, gap 16), footer (padding 20 24). Footer order: secondary on the left, primary on the right, aligned right. On mobile the buttons stack with the primary on top, 44 px high. Destructive confirms use `--ui-error` fill with `--ui-bg` text and the verb of the action ("Delete image", "Revoke key"). The scrim is `--rc-overlay`. Esc, the scrim and swipe close unless an action is in flight. In flight, the primary shows `loader-circle` and the verb in -ing form; Cancel is disabled; the dialog cannot be dismissed. Focus is trapped and returns to the trigger.
Cancel is replaced by an explicit label where "Cancel" would read as the action: "Keep invite", "Keep key", "Restart later".
Redrawn to this pattern in pass 2: variant form (mobile full page became a sheet), Activity export dialog (was centered, with a header bar and a 480 wide box), API keys dialogs (bordered box, 18 px title, 44 px buttons, 120 px offset), all Media and Variants dialogs (mobile floated with a 16 px inset; now flush sheet with grabber).
A 28 px outline button on desktop (12 px medium text, 13 px icon, 9 px side padding, `--ui-border-accented` border, `--ui-bg` fill, no wrapping), 44 px on mobile (14 px side padding, 14 px icon). Cancel invite: user-x, text `--ui-error`. Renew invite: refresh-cw, text `--ui-text-highlighted`. While busy: the label becomes "Renewing…", the icon loader-circle, opacity 50 %, disabled. Nuxt UI: `UButton variant="outline" size="xs"` with the classes above. ## Unified in pass 2
- Sidebar: ActivityFrame, MediaFrame and VariantsFrame each had their own copy (VariantsFrame's with the Settings group, MediaFrame's with a raw colour). Removed. AppFrame's is the only one. Prototype's own mobile drawer was replaced by AppFrame's drawer (`s.drawer`).
- Topbar (breadcrumb on desktop, title and lead button on mobile): three copies removed; AppFrame's is used.
- Mobile nav drawer: VariantsFrame's drawer removed; AppFrame renders one (with the Settings > Media group).
- Code block: Integration endpoints, Authentication example, error response, API keys (one-time key), base URL field and the image URL builder all use `CodeSnippet`.
- Modal geometry: AppFrame, MediaFrame, VariantsFrame, ActivityFrame (export) and ApiKeysPage now draw the same dialog and sheet.
- Toast width: 356 px desktop everywhere (was 356, 360, 372).
Still separate implementations that look the same (not merged in code, see README "Not done"): toast (5 copies), badge (`Bd` in MediaFrame and VariantsFrame), empty state (MediaFrame and VariantsFrame), modal shell (AppFrame template and the JS shells).
Cancel goes through `ConfirmDialog` with `tone: 'danger'`: title "Cancel this invite?", the description names the address, the cancel button reads "Keep invite" and the confirm button "Cancel invite", pending label "Cancelling…", and a failure shows a notice inside the dialog. ## CodeSnippet
One component for every code or copyable value. Props: `tabs` (`{ k, label, code }[]`), `active`, `copied`. A header row (min 44 px) holds the tabs and the Copy button; selected tab has a 2 px `--ui-primary` underline and `--ui-text-highlighted` text. With one tab the label is plain muted text (no tab). Copy reads "Copy", then "Copied" with `check` for 2 s (`--ui-success`), announced politely. Body is `pre`, IBM Plex Mono 12/19, scrolls horizontally, `tabindex="0"`. No syntax colouring. Background `--ui-bg-elevated`, border `--ui-border`.
### SettingsPage ## Editor: link
- **Toolbar button** (icon `link`, after the list buttons): default `--ui-text-toned`; hover `--ui-bg-elevated` with tooltip "Insert link (Ctrl+K)" (macOS: "Insert link (⌘K)"); focus-visible 2 px `--ui-primary` outline; active when the cursor is inside a link (`aria-pressed`, `--rc-sand` and `--rc-on-sand`); disabled at 40% opacity when an image is selected. 28 px desktop, 40 px mobile.
- **LinkModal**: dialog on desktop, bottom sheet on mobile. Fields "Link text" (prefilled from the selection), "URL" (http, https, mailto, relative path, #anchor), toggle "Open in new tab". Footer: Cancel left, "Insert link" or "Save link" right; "Remove link" as a text button in edit mode. Errors: "Enter the link text." and "Enter a valid URL. Use http(s), mailto, a path or #anchor."
- **Link popover** (desktop): under the link, 288 px, URL in mono on top, then "Edit", "Open", "Remove" (error colour). Opens on click or Enter on a focused link; Esc closes. **Touch**: bottom sheet with the link text, the URL and three 48 px rows "Edit link", "Open link", "Remove link". Remove needs no confirmation (undo restores).
- **Rendered link**: `--ui-text-highlighted`, 1 px underline, 3 px offset. Hover 2 px underline. Focus 2 px `--ui-primary` outline, offset 2. Selected: `--rc-sand` fill, `--rc-on-sand` text.
- **Toolbar at 390 px**: the full toolbar (22 buttons) does not fit. It scrolls sideways in one row, never wraps; a 24 px fade (`--ui-bg` to transparent) marks hidden content on each side. Buttons stay 40 px.
A page header (title "Settings", subtitle "Workspace information."), then one card holding a "Version" row. See [screens/settings.md](screens/settings.md). ## Paginator
First, previous, page numbers, next, last; "Showing 26–50 of 312"; page-size select "5 per page", "25 per page", "50 per page", "100 per page" (default 25). Mobile: previous, "Page 2 of 13", next. Hidden when there is one page. Desktop layout, one row: the summary on the left (it takes the free space), the page-size select next to it, the page controls right-aligned; Sites, Authors, Articles and Media draw it the same way. Buttons 32 px (44 px mobile), current page `--rc-sand` with `--ui-primary` border, disabled controls 40% opacity, focus ring 2 px `--ui-primary`. Page size and page live in the URL as `size` and `page`.
## Added with Media Variant for Sites and Authors (no new component): when everything fits on one page the page-size select is hidden together with the controls and the footer shows only the count line "N sites" or "N authors" in `--ui-text-muted`. Loading dims the whole pager to 40% and disables it; the summary reads "Loading page N…". Sizes are unchanged: 5, 25, 50, 100.
Exact values live in `source/MediaFrame.dc.html`. Copy and states are in [media](screens/media.md) and [media-picker](screens/media-picker.md). ## Activity export
Menu: "Export current view" (CSV, active filters, all pages) and "Export all activity…" (CSV, ignores filters; owner and admin only). Dialog: Format (read-only "CSV"; a CSV and JSON control appears when JSON ships, proposed), Date range (default "All time"). States: configure, "Preparing download…" (indeterminate bar, no percentage, Cancel left, "Preparing…" disabled right), "Download started" (Close), "Couldn't prepare the download" (Cancel, Retry), "Nothing to export" (Close), 403 "You can't export all activity" (Close). Synchronous: no job, no progress, no later notification.
| Component | Nuxt UI base | Props | Emits | Used in | ## FilterCardToggle
|---|---|---|---|---| Button with `aria-pressed`; pressed uses `--rc-sand` and `--rc-on-sand` with a `--ui-primary` border; pressing again clears. Synced with the Outcome segment and the chip.
| MediaToolbar | UTabs, UInput, USelectMenu, UButtonGroup | tab, q, sort, view, counts | update:tab, update:q, update:sort, update:view | Media |
| MediaGrid | custom (buttons in a grid) | items, selected: Set, loading | open(id), toggle(id) | Media |
| MediaTable, MediaCardList | UTable, custom list | same as MediaGrid | same as MediaGrid | Media (md and up, below md) |
| MediaBulkBar | custom, UButton | count | delete, clear | Media |
| MediaDetailPanel | USlideover | id | close, rename, delete, copy-url, review-duplicates | Media |
| MediaUploadPanel | UProgress, UButton | rows | use-existing(row), upload-anyway(row), retry(row), cancel, dismiss | Media |
| DuplicateGroups | custom cards | groups | merge(group, keepId) | Media |
| MediaThumb | custom | item, size | none | Media, picker |
| MediaBadge | UBadge | kind: 'unused'\|'duplicate'\|'used'\|'online'\|'draft', label | none | Media, picker |
| RenameDialog, MergeDialog, DeleteMediaDialog | ConfirmDialog, UForm | item(s) | done | Media |
| MediaPicker | UTabs inside ImageUploader | articleId | picked(item, alt) | Editor |
ImageUploader (changed): it gains the Upload and Library tabs and the "Already in your library" card. The remove-image confirm becomes neutral and only detaches the image from the article. ## NavItem and NavGroup
Row 32 px (44 px in the mobile drawer). Parent selected: `--rc-sand`, `--rc-on-sand`, weight 500. Child selected: parent unfilled, `--ui-text-highlighted`, weight 500, chevron-down; child filled as above. Group label "Media" 11 px uppercase `--ui-text-muted`. Children indent 19 px with a 1 px `--ui-border` rail.
## Added with Updates ## Media badges (Media library, picker, Variants board)
`Unused` (warning soft, icon unlink), `Used in N` (outline), `Duplicate` (info soft, icon copy), `N variants` (info soft, icon layers; "1 variant"; none shown at 0). The Media grid card, the list row and the mobile row show all of them. Size column and "Size" meta use the original file size only; the "with variants" size and the sort are on the Variants board (see README, Not done).
Exact values live in `source/AppFrame.dc.html` (the Settings and Home states). Copy and states are in [settings](screens/settings.md) and [home](screens/home.md). ## Other
- **PurgeDuplicatesDialog**: `UModal`, `sm:max-w-md`, sheet on mobile, cancel first, confirm `color="error"`. Props `open`, `preview`, `status` (loading, ready, purging, nothing, error, partial, conflict). Emits `purged`.
- **MediaCleanupCard**: square `USwitch`, `UInputNumber` with "days" suffix, saves on change.
- **MediaLinksCard**: rows "Manage image variants" and "Cache usage", 44 px min height.
- **StatesMatrix**: the board "States matrix: controls".
| Component | Nuxt UI base | Props | Emits | Used in | ## Sites and authors (pass 3)
|---|---|---|---|---|
| UpdatesCard | custom card, UProgress, UButton, UCollapsible | status: UpdateStatus, role | check, update, restart, copy-command | Settings | Frames: `DirectoryFrame.dc.html` (Sites, Authors, editor section content; the list, slide-over, delete-with-reassign dialog, slug field and media field are defined once there and used by both Sites and Authors); `AppFrame.dc.html` changed in place (sidebar items, Articles columns and filter, editor section and mobile details sheet, Settings card, Profile card, role-based sidebar). Existing components are reused unchanged: dialogs, toasts, empty states, skeletons, paginator, media picker.
| UpdateStatusBlock | custom (polite live region) | tone, icon, title, text, meta, pendingTag?, action? (in-block Restart, Ready only) | none | UpdatesCard |
| UpdateProgress | UProgress | label, text, value or indeterminate | none | UpdatesCard | | Component | Status | Nuxt UI | Notes |
| ReleaseNotes | UCollapsible | version, date, items | none | UpdatesCard | |---|---|---|---|
| ServerCommand | UButton, code block | line | copy | UpdatesCard (Not prepared) | | SitesTable, AuthorsTable | New | UTable; below 640 px a list of UCard | Same row heights as Users. Defined once as DirectoryList in DirectoryFrame and used by both. Read-only variant has no actions, chevron instead of the menu |
| RestartOverlay | custom full-page `role="status"` | title, text, startedAt, slow | reload | App shell | | SiteForm, AuthorForm | New | USlideover + UForm, UFormField, UInput, UTextarea, URadioGroup, USwitch | Sheet on mobile. Close with changes opens the discard UModal |
| UpdateBanner | UAlert | state: 'available'\|'ready' | go-to-settings, dismiss | Home (owner) | | SlugField | New | UInput + UButton (icon) + UBadge | Defined once in DirectoryFrame. Auto badge, pencil, "Reset to match the name". Locked read-only variant (lock icon, "Used in API paths, so it can't change.") for the site key after creation |
| UpdatePill | UBadge | none | none | DashboardShell Settings item (dot on mobile) | | SocialLinksRepeater | New | USelect + UInput + UButton, in UFormField | Max 5 rows. Placeholder follows the type |
| MediaField | New | UButton + UModal (existing picker) | Defined once in DirectoryFrame. Used for the social image and the avatar |
| AuthorViewPanel | New | USlideover, read-only | Detail for roles without author rights. Close is the only action |
| AuthorAvatar | New | UAvatar | Initials fallback, square, radius 0 |
| AuthorStack | New | UAvatarGroup max=3 | Shows "+N" |
| AuthorList | New | List of UButton, UAvatar, UBadge; sorting with useSortable | No Nuxt UI match for sortable rows. Keyboard reordering is custom |
| AuthorSelect | New | USelectMenu multiple with create-item | Footer item "Create author…" opens the dialog |
| SiteSelect | New | USelectMenu | Item slot shows name and host |
| UserSelect | New | USelectMenu | Only users without an author |
| ReassignDialog | New | UModal + USelectMenu + UAlert | Defined once in DirectoryFrame. Used for site and author deletion |
| MetaSection | New | UCollapsible | Desktop sidebar and mobile sheet. Site select for anyone who can edit the article. No "Create author…" row without author rights |
| AuthorProfileCard | New | UCard + UButton + UAlert | Profile screen. Defined once in AppFrame; every Profile frame shows it |
| CreateAuthorHint | New | UAlert closable | Dismissal stored per user |
| Default badge, Linked badge, Guest badge | New | UBadge | Sand fill, info soft fill, outline |
| Row menu with a disabled item | Existing + change | UDropdownMenu + UTooltip | The item stays focusable |
| SearchSharingSection | New | UCollapsible + UButton + USeparator | Editor sidebar and Details sheet, after Site and authors. Summary: Ready, N suggestions, Hidden from search, Checking… |
| SeoPanel | New | USlideover (desktop 960 px) / bottom sheet (mobile) + UFormField, UInput, UTextarea, USwitch, UCollapsible | Defined once in DirectoryFrame. Reuses SlugField, MediaField and the picker. Fallback chip: dashed outline, "Using …" |
| SoftCounter | New | text | Mono, "n / max". Amber (--ui-warning) outside the recommended range, never red, never blocks |
| SearchPreview | New | UCard | Desktop and Mobile toggle (UTabs, 2 items). Title cut at 60 (desktop) or 70 (mobile) characters including the template, description at 160 or 120 |
| SocialCardPreview | New | UCard | 1.91:1 image, host, title, 2-line description; fallback chips |
| SuggestionList | New | UAlert-style rows + UButton (link, ghost icon) | Calm elevated block. States: list, all good, checking. Per-article dismiss; "Show dismissed" |
| ApiPreviewDialog | New | UModal / bottom sheet + UTabs + UButton + UAlert | Formatted and Raw; Copy JSON; loading, error, draft note |
| SeoStatusChip | New | UBadge | Lightbulb + count (warning soft) or eye-off "Hidden" (outline). Articles rows and cards |
| NeedsAttentionToggle | New | UButton (pressed state) | Articles toolbar, sand when on |
| TitleTemplateField | New | UInput + live example | Sites form |
## Custom content types (pass 4)
Frames: `SchemaFrame.dc.html` (all schema, content and entry bodies; relation, permissions and import frames are Later), mounted in `AppFrame.dc.html`. Existing pieces are reused unchanged: dialogs and sheets, toasts, alerts, paginator, skeletons, empty states, SlugField, CodeSnippet, the single switch.
**AppFrame changes.** Sidebar: a "Schema" item (icon shapes) after Users, owner and admin only. A "Content" group under the main items, one row per custom type (default icon files, name truncated with the full name in the tooltip, selected row sand). Over 5 types it shows the first 5 and the selected one, then "Show all N". Mobile drawer: the group is one collapsed row "All content types" with the count; expanded it lists the types at 44 px. New props on the shell state: `content` (types), `contentOn`, `contentOpen`. The group is hidden when there are no custom types. No existing item changed.
| Component | Status | Nuxt UI | Notes |
|---|---|---|---|
| SchemaTable | New | UTable; below 640 px a list of UCard | Same pattern as SitesTable. Built-in rows add the lock and a Built-in badge |
| BuiltInBadge | New | UBadge outline + lock icon | Existing badges have no lock; same size as the Linked badge |
| FieldList | New | List of UButton rows, useSortable, UBadge | No Nuxt UI sortable list. Keyboard: the grip is a button; Space picks up, arrows move, Space drops, Esc cancels. Move up and Move down buttons on the selected row. Same approach as AuthorList |
| FieldInspector | New | UForm, UFormField, UInput, UInputNumber, UInputDate, USelect, UTextarea, USwitch | Composition only. Controls follow the field type |
| FieldTypePicker | New | URadioGroup variant="card" in a grid, icon in the label | Existing radio cards fit; only the icon and the lock state are added |
| KeyField | Existing | SlugField (UInput + UButton + UBadge) | Reused for the type key and field keys. Locked variant already exists |
| ChoicesEditor | New | UInput + UButton rows, useSortable | UInputTags can't reorder or flag a blank or duplicate per row |
| ApiPreview | New | UCollapsible + CodeSnippet + UButton | CodeSnippet is reused. Sheet on mobile |
| ChangeReview | New | UModal (sheet on mobile), UBadge, list rows | Dialog pattern unchanged. Rows reuse the UAlert-style row of SuggestionList |
| SchemaRefusal, DeleteTypeDialog | New | UModal + UAlert | Dialog pattern unchanged. Delete uses color="error" |
| ContentNavGroup | New | UNavigationMenu group "Content" (NavGroup) | Adds the 5 + selected cap, "Show all N" and the collapsed mobile row |
| EntryTable | New | UTable with column visibility | Cells per field type (FieldCell). Cards on mobile |
| ColumnPicker | New | UPopover + UCheckbox list; bottom sheet on mobile | UDropdownMenu closes on each pick and has no cap message |
| SortSelect | New | USelect + UButton icon | ID, Created and Updated only |
| FieldControl | New | UInput, UInputNumber, UInputDate, USelect, USwitch (the single toggle), MarkdownField | One switch per field type, generated from the schema |
| MarkdownField | New | UTextarea + UTabs (Write, Preview) + toolbar of UButton | The article editor is a full document editor. This is a compact one |
| FieldCell | New | text, UBadge outline (choice), icon + text (yes or no) | Renders one value per type, with the empty dash |
| EntryForm | New | UForm generated from the schema | Provenance footer, schema-changed alert |
| RelationPicker (later) | New | URadioGroup card + USelect | Sentence preview is text |
| RelationField (later) | New | UInputMenu multiple | Chips and search |
| PermissionsGrid (later) | New | UTable + UCheckbox; UCard list on mobile | |
| SchemaImportDialog (later) | New | UModal + UFileUpload | |
## Media field (pass 5)
Frames: `SchemaFrame.dc.html` (`MCtl`) and `MediaFrame.dc.html` (picker in field mode, usage). Full spec: `media-field-changes.md`.
| Component | Status | Nuxt UI | Notes |
|---|---|---|---|
| MediaFieldControl | New | UFormField, UButton outline and ghost, UModal (desktop) or bottom UDrawer (mobile) for the picker, UTooltip, UIcon (image, image-off, unlink, image-plus). The thumbnail is a plain button | Props label, required, modelValue (image id or null), image (filename, width, height, size, or missing), disabled, error. Emits update:modelValue. States: empty, filled, hover, focus, error, missing, saving, read-only |
| FieldCell (media) | Extended | Thumbnail 40 px square, dash when empty, dashed square with unlink when the image is gone | Row height 48 px, mobile card thumbnail 32 px |
| MediaPicker (field mode) | Extended | Existing picker | Title "Choose an image", "Use this image", no alt text, no variant select, "Current" badge, optional "No image" row |
## Roles and permissions (new)
| Component | Used for | Nuxt UI mapping |
|---|---|---|
| ScopeControl (tri-state) | None / Own / All in grid cells, mobile cards and general rows | URadioGroup variant="table" orientation="horizontal" size="xs", three items, in a grid cell. Selected item: sand fill, weight 600 and an underline |
| OnOffCell | Create column and boolean rows | USwitch with visible On/Off label |
| PermissionGrid | Content permissions | Custom grid (role="grid") of ScopeControl cells; header buttons use UDropdownMenu for bulk actions |
| PresetPills | Read only, Write own, Full content | UButton variant="outline" size="xs", pressed state via aria-pressed |
| PermissionGroup | Collapsible group with "x of y enabled" | UCollapsible; header UButton variant="ghost" |
| OwnerOnlyTag | Locked owner-only items | UBadge variant="subtle" with lucide lock icon |
| RoleBadge | Users, roles, API keys | UBadge variant="outline" with role icon (crown, shield, pencil, user-round) |
| RolePreview | What this role can do | Card on desktop (UCard), USlideover side="bottom" on mobile |
| RolePicker | Change role, invite, reassign on delete | URadioGroup variant="card" with description; USelectMenu for the reassign field; items disabled with a reason in description |
| SaveBar | Unsaved changes | Sticky container with UButton color="neutral" variant="outline" and UButton color="primary" |
| ReadOnlyBanner | Read-only pages | UAlert color="info" icon="i-lucide-lock" variant="soft" |
| ViewOnlyMark | Own scope rows | Text with lucide lock icon and UTooltip |
Accessibility of the tri-state cell:
| Item | Pattern |
|---|---|
| Structure | The grid is role="grid". Each cell contains a role="radiogroup" named "{Type}: {Action}" (for example "Events: Update") with three role="radio" items None, Own, All. Create is a role="switch" named "Events: Create". |
| Keyboard | Tab moves between cells (one stop per cell). Inside a group, Left and Right (or Up and Down) move and select, Home and End jump to None and All. Arrow keys also move the roving tabindex. Space selects. Header bulk buttons open a menu with the same three values. |
| Announcement | Selected item is aria-checked="true". A polite live region announces changes: "Events, Update, set to Own." Disabled items are aria-disabled and described by the reason ("You can only grant what your role has."). |
| Not by color alone | Selected segment: sand fill plus weight 600 plus a 2 px underline in --ui-primary. Locked: lock icon. Changed cell: square marker. Mixed: text badge. |
| Targets | 26 px segments on desktop, 44 px on mobile. Focus: 2 px --ui-primary outline, offset 2 px. |
+85 -165
View File
@@ -1,167 +1,87 @@
# Foundations # Foundations
Token source files (copy them into `web/src/theme/` in the Nuxt UI foundation slice, which then becomes the live source): Tokens only. No raw hex or rgb in any frame. The one exception is the brand mark SVG (`#111` and `#fff` fills). Radius is 0 everywhere (no `border-radius` is declared in any frame).
- `source/tokens/renewcms.css`: `@theme`, `:root`, `.dark` and the `.article-content` scale ## New tokens (do not exist in the app yet)
- `source/tokens/ui.config.ts`: Nuxt UI color aliases, icons and component `ui` overrides `tokens/renewcms.css` defines no `--rc-*` token. The frames use the ones below. They are **new**. Add them to the app's CSS, light values in `:root` and dark values in `.dark`. No `--ui-*` token is changed.
- `source/tokens/vite.config.ts`: the `@nuxt/ui/vite` plugin setup
- `source/tokens/tokens.json`: the same values as data | Name | Light | Dark | Used by |
- `source/brand/favicon.svg`: the mark, dark-aware |---|---|---|---|
| `--rc-sand` | #e8dfd1 | #d4c5b0 | Selected nav item (parent and child), selected table row and media card, current paginator page, pressed filter card, avatars, account chip, "In this article" tag, drag-drop fill |
Extracted from `source/RenewCMS Foundations.dc.html`: | `--rc-on-sand` | #111111 | #111111 | Text and icons on `--rc-sand`. Never use `--ui-text-muted` or white on sand |
| `--rc-success-soft` | #eaefea | #1f241f | Success badge, success alert and toast icon ground |
## Ledger foundations | `--rc-info-soft` | #ebeef0 | #202428 | Info badge, info alert, "variants" badge, duplicate badge |
| `--rc-warning-soft` | #f0ede6 | #2a251e | Warning badge and alert, "Unused" badge |
Everything below is generated from one set of files: tokens/renewcms.css (Tailwind @theme , :root , .dark , article content styles), tokens/ui.config.ts (Nuxt UI colors, icons and component overrides) and tokens/vite.config.ts . The tables on this page read those files, so they can't drift. | `--rc-error-soft` | #f3eae9 | #2c201f | Error badge and alert, failed-actions card |
| `--rc-overlay` | rgb(10 10 10 / 0.4) | rgb(0 0 0 / 0.6) | Dialog, sheet and slide-over scrim; crop mask |
### Logo | `--rc-shadow-pop` | 0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06) | 0 4px 16px rgb(0 0 0 / 0.5) | Menus, popovers, toasts. Same as `--shadow-popover` |
| `--rc-shadow-modal` | 0 16px 48px rgb(17 17 17 / 0.18) | 0 16px 48px rgb(0 0 0 / 0.6) | Dialogs, sheets, slide-overs. Same as `--shadow-modal` |
The mark is a white R on a black square, drawn on a 16-pixel grid so it stays sharp as a favicon. It keeps the same geometry at every size. In dark mode the black square gets a 1 px #444 edge so it doesn't disappear into the background. The wordmark pairs the mark with RenewCMS in Plex Sans Semibold, with the text set at 0.6× the mark's height. | `--rc-canvas` | #ecebe8 | #1c1c1c | Design boards only. Not for the app |
RenewCMS Implementers may alias `--rc-shadow-pop` and `--rc-shadow-modal` to the existing `--shadow-popover` and `--shadow-modal`. The Nuxt UI `UAlert`/`UBadge` `soft` variants can map to the four `-soft` tokens.
16 ## Contrast (computed, AA: text 4.5:1, UI components 3:1)
Light | dark. Computed with the WCAG relative luminance formula from the token values above and the `--ui-*` values in the board root.
32
| Pair | Light | Dark | Result |
64 |---|---|---|---|
| Success, info, warning, error on their `-soft` ground (badges, alerts) | 6.7 / 6.9 / 7.1 / 7.3 | 8.7 / 8.4 / 8.1 / 8.1 | Pass |
128, app icon source for 180 / 512 | Neutral badge: `--ui-text-toned` on `--ui-bg-elevated` | 8.7 | 10.9 | Pass |
| Chip, selected row, current page: `--rc-on-sand` on `--rc-sand` | 14.3 | 11.2 | Pass |
Articles · RenewCMS | Neutral chip: `--ui-text-highlighted` on `--ui-bg-accented` | 14.9 | 15.2 | Pass |
| Toast title on `--ui-bg` | 18.9 | 18.1 | Pass |
RenewCMS | Toast body `--ui-text-toned` / `--ui-text-muted` on `--ui-bg` | 9.7 / 5.7 | 11.8 / 6.6 | Pass |
| Banner (offline): `--ui-text-inverted` on `--ui-bg-inverted` | 18.9 | 18.1 | Pass |
16 | Table text `--ui-text` on `--ui-bg`; on hover `--ui-bg-elevated` | 15.9; 14.2 | 14.9; 13.7 | Pass |
| Table header and hover meta: `--ui-text-muted` on `--ui-bg-elevated` | 5.1 | 6.1 | Pass |
32 | Tab selected / unselected / count | 18.9 / 9.7 / 5.7 | 18.1 / 11.8 / 6.6 | Pass |
| Segmented: selected `--ui-bg` on `--ui-primary`; unselected `--ui-text-toned` on `--ui-bg` | 18.9; 9.7 | 18.1; 11.8 | Pass |
64 | Placeholder `--ui-text-muted` on `--ui-bg` | 5.7 | 6.6 | Pass |
| `--ui-text-muted` on `--rc-sand` | 4.3 | 1.7 | **Fails.** Never place muted text on sand. Frames use `--rc-on-sand` |
128 | `--ui-text-dimmed` on `--ui-bg` | 2.8 | 3.3 | **Fails.** Not used for text. Allowed for disabled icons and decorative strokes |
| Disabled controls at 40% opacity (label on `--ui-bg`) | 2.6 | 3.7 | Exempt (inactive component). Never carry information only in a disabled label |
Articles · RenewCMS | Disabled table row at 55% opacity | not computed | not computed | Exempt (inactive) |
| UI: `--ui-border-accented` on `--ui-bg` (input, checkbox, segmented, secondary button borders) | 3.6 | 3.3 | Pass (3:1) |
Favicon file: brand/favicon.svg . The 1 px edge switches on with prefers-color-scheme: dark . Keep clear space of at least half the mark's height around it. The R is never recolored or placed on sand. | UI: focus ring `--ui-primary` on `--ui-bg` | 18.9 | 18.1 | Pass |
| UI: `--rc-sand` fill against `--ui-bg` | 1.3 | 11.2 | **Fails in light** as the only indicator |
### Color scales | UI: `--ui-border` on `--ui-bg` | 1.3 | 1.5 | Decorative only (card and table lines). Never the only boundary of a control |
Seven Tailwind color scales, each mapped to a Nuxt UI alias. The ink, chalk and sand scales are built on your brand values. The four semantic scales are new and deliberately muted: evenly spaced oklch lightness steps with low chroma, so they sit comfortably next to ink and sand. Fixes applied (token swaps):
- Current page in the paginator: border changed from `--rc-sand` to `--ui-primary` (18.9 / 18.1) so the state does not rely on a 1.3:1 fill.
### Theme variables - Selected media cards and rows keep `--ui-primary` outline with the sand fill (already so).
- Selected nav items still rely on the sand fill plus weight 500 and `aria-current="page"`. The fill alone is 1.3:1 in light; the weight change and the programmatic state carry the information. This is accepted and recorded here, not "fixed".
Every --ui-* variable Nuxt UI reads, for both themes. Surfaces are white. The #fafafa brand background is the page canvas behind them. The theme follows the system setting until the owner picks Light or Dark from the user menu, and that choice is stored by Nuxt UI's color mode. - Rule added: `--ui-text-muted` is never used on `--rc-sand`.
Not computed: hover and active backgrounds of every control against every text colour; the states-matrix uses combinations already listed above.
Variable Used for Light Dark
## One value per role (spacing, radius, type, icons)
### Contrast checks Sweep of the frames (`AppFrame`, `ActivityFrame`, `MediaFrame`, `VariantsFrame`, `IntegrationPage`, `IntegrationImages`, `ApiKeysPage`, `CodeSnippet`, `AuthCard`). "Found" is what was drawn before pass 2.
Pair Needs Light Dark | Role | Found | Value now (desktop / mobile) |
|---|---|---|
### Typography | Page padding | 32 px (Home, Users, Settings, Integration, API keys), 24 px (Media, Activity, Variants); mobile 20 16 or 16 | 24 px / 16 px. Extra bottom padding only to clear a fixed bar (88 or 96 px) |
| Section gap on a page | 20, 28 (Home, Settings), 16 elsewhere | 16 px |
The interface uses IBM Plex Sans in three weights: 400, 500 and 600. IDs, paths, counters and code are set in IBM Plex Mono. Sizes replace Tailwind's text-* defaults. On screens narrower than md , form fields use 16 px so iOS doesn't zoom in when a field gets focus. | Card padding | 16 px and 20 px | 16 px |
| Card inner gap | 8, 12, 14, 16 | 12 px (6 px between a label and its control, 8 px between buttons) |
Before and after: the Rue Verte kitchen | Banner and alert padding | 10 12, 12 14 | 12 14 |
| Radius | none declared | 0 |
GET /v1/article/12 · 8 412 / 10 000 | Page title | 28/36 (Home, Integration, API keys), 24/32 (Variants), 20/28 (rest) | 20/28, weight 600, same on mobile |
| Section heading (reference pages) | 18/26 | 18/26 |
font-mono · 13px / 20px · 400 Code, paths, IDs, counters | Card title | 14/22, 15/22, 15/24 | 15/24, weight 600 |
| Sub heading | 14/22 | 14/22 |
Article content uses a separate scale in Source Serif 4, applied through the .article-content class. The editor surface and the preview both use it, so what the owner writes looks like what a public site would roughly show. The measure is capped at 42 rem, about 70 characters per line. | Dialog title | 16/24 | 16/24 |
| Stat figure | 28/36 (Home), 24/32 (Activity), 22/36 (Variants mobile) | 24/32, mobile 22/32 |
Choosing lime plaster for old stone walls | Error and 404 page title | 24/32 | 24/32 (unchanged) |
| Editor article title | 18/26 | 18/26 (own role) |
Cement traps moisture in solid walls. Lime lets the stone breathe, which is why we strip it back on almost every farmhouse we take on . | Body text | 13/20 | 13/20 |
| Small and meta text | 12/18 | 12/18 |
What we use | Icon in text and buttons | 16 (91), 14 (87), 12 (20), 13 (1), 18, 20, 22, 28 | 16 px. 14 px inside compact controls and badges (12 px in badges). 20 px in the mobile top bar. 18 px in mobile list rows and sheet rows. The 13 px icon became 14. Auth card (22) and the empty-state glyph (28) stay as illustration sizes |
| Control height (button, input, select) | 28, 32, 40 (inputs in API keys, Integration, Images) | 32 px / 44 px. Inline row buttons 28 px on desktop, 44 px on mobile. The 40 px inputs became 32 / 44 |
Hot-mixed lime for the base coat | Dialog | top offset 120 and 140, centered (export), padding 20 | width 460 (560 wide, 640 crop), 140 px from the top, header 20 24 0, body 20 24 0, footer 20 24 |
| Table row | 40, 48, 56 | 48 px; media rows 56 px (thumbnail); mobile 56 px |
Hair or fibre for the scratch coat
## Pairings
A thin lime putty finish - Selected rows use `--rc-sand` with `--rc-on-sand` text and icon in both themes. Hover, focus-visible and active on a selected row keep sand. Focus adds a 2 px `--ui-primary` outline.
- Child selected: the parent row is not filled. It uses `--ui-text-highlighted`, weight 500, chevron down.
Drying time - Focus-visible: 2 px `--ui-primary`, offset 2 px (inset -2 px for tabs, segmented controls and rows).
- Disabled: 40% opacity (table rows 55%).
It sets slowly, and that is the point. - Hover, active and the rest: board "States matrix: controls" (`StatesMatrix.dc.html`). Hover and active tokens: primary button hover `--ui-text`, active `--ui-text-toned`; secondary and ghost hover `--ui-bg-elevated`, active `--ui-bg-accented`; destructive hover opacity 0.85, active 0.75; input hover border `--ui-text-muted`, focus border `--ui-primary`.
Before you start
Strip loose material back to sound stone.
Wet the wall the evening before.
Article image
The north wall after the second coat.
Element md and up Below md, spacing
### Spacing, radius and density
Spacing uses Tailwind's 4 px base unit unchanged. Radius is 0 everywhere: setting --ui-radius: 0rem flattens every component that derives its corners from it, and rounded-none overrides cover the few that hard-code rounded-full . The density is compact on desktop. On touch screens, every tap target is at least 44 px.
Control Height and use
size="sm" 28 px · table row actions, toolbar buttons in the editor
size="md" 32 px · default on desktop: buttons, inputs, selects, tabs
size="lg" 40 px · auth forms, primary actions in dialogs
size="xl" 44 px · everything below md, via a responsive size prop
Table row 44 px on desktop, 64 px cards on mobile
Gutters 16 px on mobile, 24 px on desktop; sidebar 224 px, collapses to 56 px
### Borders and elevation
Most surfaces are flat and separated by hairlines. Shadows appear only on things that float above the page.
Hairline 1 px --ui-border . Cards, table edges, sidebar and top bar dividers. Decorative, so no contrast requirement.
Search by title
Control edge 1 px --ui-border-accented . Inputs, selects, checkboxes, outline buttons. 3.6:1 on white.
New article
Focus ring 2 px --ui-primary outline with a 2 px offset, shown only on :focus-visible . Nuxt UI's default, kept.
Edit
Unpublish
Popover --shadow-popover . Dropdown menus, selects, toasts. In dark mode it becomes a darker shadow plus the hairline.
Modal --shadow-modal over a 40% chalk-950 overlay. Modals and slideovers.
Layer z Contents
Breakpoint Min What changes
### Motion
Motion is there to show where something came from or that something is working. It is short and never decorative.
120 ms --duration-fast · ease-standard Hover and pressed colors, focus ring, the counter turning to warning, the switch thumb.
180 ms --duration-base · ease-enter / ease-exit Dropdowns and tooltips (fade plus a 4 px shift), the tab indicator, toasts sliding in, SaveStatus crossfades.
240 ms --duration-slow · ease-enter / ease-exit Modals (fade plus scale from 0.98), slideovers (slide from the edge), sidebar collapse.
Never animates Route changes, table rows sorting or filtering, numbers counting up, the logo, editor text, and anything that runs on page load.
Reduced motion With prefers-reduced-motion: reduce , every transition and animation completes instantly, skeletons stop pulsing and become flat blocks, and toasts appear without sliding. The one exception is the spinner on the automatic verification screen. It keeps turning, slowed to 1.6 s per rotation, because it is the only sign that work is happening.
### Icons
Lucide through Iconify, using Nuxt UI's i-lucide-* classes. Icons are 16 px next to 13–14 px text and 20 px on mobile top bars. They are always paired with a visible label or an aria-label . Each icon maps to one meaning: Eye is only ever show password, and Preview uses Book Open.
### Where Nuxt UI defaults bend
### Files
Ready to paste into the repository. They follow its code style: tabs, no semicolons, single quotes and no comments. The CSS goes in src/assets/main.css and the config in src/theme/ui.config.ts . Add class="isolate" to the app root, as Nuxt UI requires.
+121
View File
@@ -0,0 +1,121 @@
# Media field type: change list for implementation
Board: `RenewCMS Screens Schema.dc.html`. Frames changed: `SchemaFrame.dc.html`, `MediaFrame.dc.html`. Spec data: `schema-spec.js` (states) and `media-field-tables.js` (tables, appended to the board). No new tokens, no new colors, radius 0.
## 1. Behaviour
- New field type `media`, name "Media", icon `lucide:image`, description "One image from your media library".
- Stored value: id of a library image, or null. The file is never copied. The field always shows the current library image.
- Options: label, key, required. No default, unique, min, max or choices. The inspector hides them.
- Required: the entry can't be saved while the field is empty (422 invalid_entry, rule required).
- A saved field's type never changes. Key and type are locked after the first save (existing rule).
- Adding a required media field to a type with entries is refused, because a media field can't have a default. The review step shows this before the server is asked.
- Image deleted while entries point at it: the entry keeps the id. The control shows the missing state. Saving is refused only if the field is required.
- Entries count as uses of an image in Media. Deleting a used image is allowed after a warning.
- Variants: the editor preview uses the original. No variant select in the field. Nothing about variants in the builder.
- Galleries (several images) are out of scope and not drawn.
## 2. New component: MediaFieldControl
Props: label, required, modelValue (image id or null), image (filename, width, height, size, or `missing: true`), disabled, error. Emits `update:modelValue`. Opens the picker.
Nuxt UI v4: `UFormField` (label, help, error, required), `UButton` outline ("Pick an image", "Change image", "Choose another image"), `UButton` ghost ("Remove", "Clear"), `UModal` on desktop and bottom `UDrawer` on mobile for the picker, `UTooltip` for the list placeholder and the badge, `UIcon` lucide:image, image-off, unlink, image-plus. The thumbnail is a plain `button`.
States and measurements (SchemaFrame `MCtl`):
| State | Drawing |
|---|---|
| Empty | Dashed box (1px dashed `--ui-border-accented`), padding 12, 40 px icon square (`--ui-bg-elevated`), "No image selected.", button "Pick an image" (icon image-plus). Mobile: stacked, 44 px button |
| Filled | Solid 1px `--ui-border-accented` box, padding 12. Thumbnail 160 × 120 (mobile: full width, 16:9). Filename weight 500, then "1600 × 900 · 240 KB" 12/18 muted. Buttons "Change image" (outline, icon image) and "Remove" (ghost, icon x). Help line "One image from your library." |
| Hover (desktop) | Border `--ui-text-muted`. Bottom bar 28 px, `--ui-bg-inverted` with `--ui-text-inverted`, icon image 14 and "Change image". No hover on mobile |
| Focus | 2 px `--ui-primary` outline, offset 2, on the thumbnail button |
| Error | Box border `--ui-error` plus inset 1px `--ui-error`, error line with circle-alert 14: "Pick an image for Thumbnail." Focus ring on the first button (Pick an image, or Choose another image) |
| Missing | Dashed 160 × 120 (mobile 16:9) placeholder with image-off 24. Text "This image is no longer in the library" (weight 500). Help: optional "You can still save. The entry keeps the link until you change it." / required "Thumbnail is required. Choose another image to save." Buttons "Choose another image" (outline) and "Clear" (ghost) |
| Saving | Thumbnail at 60%, buttons at 40%, all form fields disabled, Save reads "Saving…" |
| Read-only (fields changed) | Same as saving, plus warning alert "The fields of Events changed" and button "Reload form" |
Label is the field label, plus " *" when required (existing form rule; "(optional)" is not used).
## 3. Per screen
### 3.1 Schema list (group 1), state "Custom type: read-only view"
Fields table of a custom type (Label, Key, Type, Rules) in the built-in table style. Thumbnail row: image icon, key `thumbnail`, type "Media", rules "One image from the library". Header "Events" with badge "Read only" (lock). Alert: "You can read these fields here" / "Only an owner or admin can change a content type." The built-in Media type in Schema is unchanged. Decision needed: no route shows a custom type read-only today.
### 3.2 Schema builder (group 2), 3 states
- "Media field selected": saved Thumbnail row selected (index 7). Inspector: Label, Key (locked, "Used in the API, so it can't change."), Type tiles (seven, Media selected, others dimmed with "Can't change after saving"), Required switch ("Required" / "An entry can't be saved with this field empty."). Hidden for media: Unique values, Default value, limits, choices. Hint (info-soft block, info icon): "Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version."
- "New media field, seven types": new row with "New" badge, key editable ("The name used in the API. Starts with a letter. Lowercase letters, digits and underscores."), type tiles all enabled. State chip "Unsaved changes".
- "Media field, required": row shows the existing Required marker.
- Field row: grip, 28 px icon square (image), label, key (mono), badges. Mobile: inspector in the existing 92% sheet (title "Field: Thumbnail", footer "Done").
- Type tile grid now has seven tiles (3 columns desktop inspector, 2 in sheets).
### 3.3 Review (group 3), 2 states
Dialog "Review changes to Events" / "Apply them to save the content type." Summary "Add 1 field" / "Events has 12 entries. Each one is checked against the new fields when you apply." The info bar in the builder behind reads "12 entries use this content type…".
- Refused: row "Add Thumbnail", warning flag "Can't be applied", text "Media, required. A media field can't have a default, so the 12 existing entries would have no image. Make Thumbnail optional to add it." Note "Back to editing keeps your other changes." Buttons "Back to editing", "Apply changes" (disabled). This departs from the existing review (Apply is never disabled): decision.
- Clean: row "Add Thumbnail", "Media, optional. 12 entries are unaffected." Apply enabled.
### 3.4 Field types (group 4), state "Media"
Per-type frame like the others: header icon image, "Media", "One image from your media library", `type: "media"`. Columns: inspector options, entry form control (filled with hover, then empty), list cell (Filled thumbnail, Empty "–", Image missing), errors by rule, filter (later).
Errors by rule: required 422 "Pick an image for Thumbnail."; type 422 "Thumbnail must be an image from the library."; min, max, options, unique: "Not used for media." Filter widget: "Thumbnail: has an image" with Any / Has an image / No image, tagged Later.
### 3.5 Entry list (group 5), 3 states
Columns shown: Title, Thumbnail, Starts on, Category (user choice). Header "Thumbnail", no sort icon, column 80 px. Cell: 40 px square thumbnail; "–" when empty; dashed 40 px square with unlink icon, accessible name "Image no longer in the library", when the image is gone. Row height stays 48 px (thumbnail fits with 4 px above and below). Mobile card: 32 px thumbnail leads the card (empty: "–", missing: dashed square), card min height 68 px. Columns menu lists Thumbnail with the image icon ("4 of 6", "Choose up to 6 fields to show. 4 selected." on mobile). Tooltip on the missing placeholder (desktop): "Image no longer in the library".
### 3.6 Entry editor (group 6), 10 states
Control sits after Description, at the end of the form (schema order; see conflicts). States: empty optional, empty required, filled, hover, focus, validation error (status chip "Not saved"), image no longer in the library, missing + required (error), saving, read-only. The board scrolls the form so the control is visible in the frame; the real form is not scrolled.
### 3.7 Picker (group 10), 8 states, MediaFrame field mode
Existing picker (Upload and Library tabs, single selection, search "Search by filename", "Newest first · 48 images", Load more, 4 columns desktop and 3 on mobile, square tiles). Differences:
- Title "Choose an image".
- Primary "Use this image" (disabled until a tile is chosen). When "No image" is chosen it reads "Remove image".
- No alt text field, no variant select. Selected strip shows thumbnail, filename, "dimensions · size".
- Current image tile badge "Current" (replaces "In this article").
- Optional fields only: row "No image" / "Leave this field empty." at the top of the Library tab. Selected: sand fill, primary border, check.
- Empty: "Your library is empty" / "Images you upload appear here, ready to use in any article or entry." / "Upload an image".
- Failed: "Couldn't load your library" / "Check your connection and try again." / "Try again".
- Loading: skeleton tiles after 150 ms.
- Upload tab: unchanged flow (crop, resize); the new image lands in the library and is selected.
- Desktop dialog 640 wide, mobile bottom sheet.
### 3.8 Media library (group 11), 4 states
- Badge "Used in N" = articles + entries. The tooltip (hover or focus) reads "Used in entries: 3" and, if articles also use it, "Used in articles: 2" on the first line. Example: bathroom-tiles.png, 3 entries, "Used in 3".
- List view: same badge in the list row and mobile card.
- Detail panel (lime-plaster.jpg, 2 articles, 5 entries): preview is shorter when the image has entries; existing "Used in 2 articles" list; new section "Used in entries" with up to 3 rows (type name muted, entry title as link, arrow-up-right) and "and 2 more". When there are entries the empty-articles line reads "No article shows it." (not "safe to delete").
- Delete: title "Delete an image that's in use?" (existing in-use dialog), body `"lime-plaster.jpg" is used in 5 entries and 2 articles. Those entry fields will show "This image is no longer in the library", and the articles will show a broken image. This can't be undone.` with the entries and articles lists; "Cancel" and destructive "Delete image".
## 4. Copy added (exact)
Media · One image from your media library · Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version. · No image selected. · Pick an image · Change image · Remove · Choose another image · Clear · One image from your library. · This image is no longer in the library · You can still save. The entry keeps the link until you change it. · Thumbnail is required. Choose another image to save. · Pick an image for Thumbnail. · Thumbnail must be an image from the library. · Not used for media. · Image no longer in the library · Choose an image · Use this image · Remove image · Current · No image · Leave this field empty. · Used in entries · and 2 more · Used in entries: 3 · Can't be applied · Media, required. A media field can't have a default, so the 12 existing entries would have no image. Make Thumbnail optional to add it. · Media, optional. 12 entries are unaffected. · The Thumbnail field was changed after you opened this form. Reload the form to continue. What you typed here can't be saved against the old fields.
Sentence case, "image" never "asset", buttons are verbs.
## 5. API and backend
- Schema API accepts type `media` with label, key, required. Value: image id or null.
- 422 invalid_entry: required → "Pick an image for Thumbnail."; type (id not in the library on a changed value) → "Thumbnail must be an image from the library."
- Entry and list responses include, per media field, filename, width, height, size and a thumbnail URL, or `missing: true`.
- GET /v1/media/{id}/usage returns articles and entries (content type name, key, entry id, title), a total and the first few items. GET /v1/media/ list items carry the combined use count.
- DELETE /v1/media/{id} stays allowed for used images. Entries keep the id.
- A new required media field on a type with entries is refused (schema_incompatible). The client shows the refusal first.
HTTP to copy (picker and field), from states.md: 400 "That request wasn't valid. Check the fields and try again."; 401/403 global; 404 "That item no longer exists."; 409 "This changed somewhere else. Reload and try again."; 413 "That file is too large."; 429 "Too many requests. Try again in a few minutes."; 500/network "Something went wrong on our side. Nothing was changed. Try again in a moment."
## 6. Accessibility
- Control is `role="group"` labelled by the field label. Required: `aria-required` on the main button.
- Names: "Pick an image for Thumbnail"; thumbnail "Change image, lime-plaster-workshop.jpg, 1600 by 900, 240 KB"; "Remove image from Thumbnail"; "Clear Thumbnail"; missing placeholder `role="img"` "Image no longer in the library".
- Error: error text has an id. The main button gets `aria-invalid="true"` and `aria-describedby` (error id, then help id). Focus moves to the first invalid control on a refused save.
- Order: label, thumbnail, Change image, Remove, help or error. The thumbnail duplicates Change image; use `tabindex="-1"` on it if one stop is preferred.
- One polite live region: "Image selected: lime-plaster-workshop.jpg", "Image removed from Thumbnail.", "Thumbnail cleared."
- Focus: Use this image → Change image. Remove or Clear → Pick an image. Cancel or Esc → the opener.
- Picker: tiles are buttons with `aria-pressed`, names "stone-steps.jpg, 310 KB, Current". "No image" has `aria-pressed`. "Use this image" uses `aria-disabled`.
- Contrast: Current badge and selected rows use `--rc-on-sand`; hover bar 18.9 (inverted pair). 44 px targets on mobile. No hover on mobile.
## 7. Conflicts to decide
1. Row height: design is 48 px (56 px media rows, 68 px cards), not 32 px. Drawn at 48 and 68.
2. Thumbnail position: existing form has Description last. Drawn after Description (end of form).
3. "Used in N" counted articles. Now articles + entries.
4. Delete title: existing in-use title kept; "Delete this image?" stays for unused images.
5. "Clear" vs glossary "Remove".
6. "1600 × 900 · 240 KB" (design separator) instead of "1600 x 900 - 240 KB".
7. Review step disables Apply for the certain refusal; existing review never does.
8. Picker button "Use this image" vs article picker "Insert image"; "Remove image" when No image is chosen is my addition.
9. "Pick an image" also on optional empty fields.
10. No read-only route for custom types.
11. Two tab stops for the same picker (thumbnail and Change image).
12. Disabled opacity: form 60%, foundations 40%.
13. The picker frame shows a stand-in entry form, not the real editor.
## 8. Not done
README.md, states.md and the screens/*.md files are not updated with the new counts (group 2 +3, 3 +2, 4 +1, 5 +3, 6 +10, 1 +1, new groups 10 (8) and 11 (4): 32 states). Light/dark and mobile frames were checked by reading the DOM, not state by state on screen.
+73 -24
View File
@@ -1,27 +1,76 @@
# Route map # Routes
A global `router.beforeEach` guard reads `meta.auth`. On the first navigation it resolves the session with `GET /v1/auth/me` and caches the result in the session store. `useApi` turns any 401 into `/login?redirect=<path>&reason=expired` and any 403 from an unverified account into `/register/pending`. | Route | Screen | Query | API |
|---|---|---|---|
| /settings | Settings (Version, Media card, Media cleanup, Retention) | none | GET/PATCH /v1/media/purge-settings, GET/PATCH /v1/settings |
| /settings/media/variants | Image variants (admin) | none | GET/PATCH/DELETE /v1/image-variants, POST /v1/image-variants/{name}/purge |
| /settings/media/cache | Cache (admin) | none | GET /v1/image-variants/cache, POST /v1/image-variants/cache/purge {scope: all|unused|variant|image} |
| /media | Media library | page, size, q, filter, sort, view | GET /v1/media/?page=&size=&q=&filter=&variants=&sort= (page, size; total in the `X-Total-Count` header) |
| /media (detail) | Variants tab | none | GET /v1/media/{id}/variants, POST and DELETE /v1/media/{id}/variants/{name} |
| /articles | Articles list | page, size, tab, q | GET /v1/article/?page=&size=&q=&status= (page, size; total in the `X-Total-Count` header; array body unchanged) |
| /activity | Activity | q, actor, category, outcome, security, range, from, to, page, size | GET /v1/activity (page, size), GET /v1/activity/export (shipped, CSV) |
| /integration | Integration | none | none |
| /settings/sites | Sites (owner only; other roles get a 403 page and no sidebar entry): list, add and edit slide-over, delete with reassignment. The key is read-only after creation | none | GET/POST /v1/site/, PATCH/DELETE /v1/site/{id} (DELETE takes `reassign_to`) |
| /authors | Authors, all roles. Owner and admin: list, search, add and edit slide-over, delete with reassignment. Other roles: read-only list and detail | q, page, size | GET/POST /v1/author/, PATCH/DELETE /v1/author/{id}, POST /v1/author/me |
| /articles | Articles list gains Authors column; Site column and filter when more than one site | site | GET /v1/article/?site={key} |
| /article/:id/edit | Editor gains the "Site and authors" section (Site select and authors, any role that can edit the article) | none | PATCH /v1/article/{id} {site_id, author_ids[]} |
| /profile | Profile gains the "Author profile" card, all roles | none | POST /v1/author/me |
| /integration/api-keys | API keys | none | GET/POST /v1/api-key, DELETE /v1/api-key/{id} |
| /schema | Schema list, custom and built-in types (owner or admin; others get a 403 page and no sidebar entry). Available now | none | GET /v1/content-type |
| /schema/new | Schema builder, new type (owner or admin). Available now | none | POST /v1/content-type |
| /schema/:key | Schema builder for a custom type, read-only view for a built-in (owner or admin). Review step on save when the type has entries. Available now | none | GET/PUT/DELETE /v1/content-type/{key} |
| /content/:key | Entry list (any verified user). Available now | page, size, sort | GET /v1/content-type/{key}, GET /v1/content-type/{key}/entry?page=&size=&sort= |
| /content/:key/new | New entry (any verified user). Available now | none | POST /v1/content-type/{key}/entry |
| /content/:key/:id | Entry editor (any verified user). Available now | none | GET/PUT/DELETE /v1/content-type/{key}/entry/{id} |
| /settings/permissions | Retired. 301 to /settings/roles (see Roles and permissions below) | none | not built |
| Path | Layout | Auth | Screen | API calls | ## Pagination in the URL (Articles, Media)
|---|---|---|---|---| `/articles?page=2&size=50` and `/media?page=7&size=50`. Parameter names follow the repo (activity already uses `page` and `size`). Allowed sizes 5, 25, 50, 100. Defaults `page=1`, `size=25`, omitted from the URL when default. Changing search, tab, filter or size resets `page` to 1. `page` above the last page redirects (replace) to the last page and shows "That page doesn't exist. Showing the last page." An invalid `size` falls back to 25.
| `/` | none | any | Redirect | none; goes to /home or /login | **API:** `GET /v1/article/` accepts `page` (default 1) and `size` (5, 25, 50, 100; default 25), as `GET /v1/activity` does. The body stays a JSON array so existing consumers keep working; the total comes back in `X-Total-Count` (the response shape is to be confirmed with the specifier). Anonymous callers only see online articles, and the total counts only those. Invalid `page` or `size` answers 400, the same as Activity.
| `/login` | AuthShell | public | LoginPage | GET /v1/auth/registration · POST /v1/auth/login |
| `/register` | AuthShell | public | RegisterPage | POST /v1/auth/register |
| `/register/pending` | AuthShell | session | PendingPage | DELETE /v1/auth/register · POST /v1/auth/logout |
| `/register/validate` | AuthShell | public | VerifyPage | POST /v1/auth/verify (+ POST /login inline on 401) |
| `/register/reset/request` | AuthShell | public | ResetRequestPage | POST /v1/auth/password-reset |
| `/register/reset/validate` | AuthShell | public | ResetConfirmPage | POST /v1/auth/password-reset/confirm |
| `/home` | DashboardShell | verified | HomePage | GET /v1/article/ · GET /v1/system/update (owner, for the banner) |
| `/article` | DashboardShell | verified | ArticlesPage | GET /v1/article/ · PATCH /{id}/publish\|unpublish · DELETE /{id} |
| `/article/new` | DashboardShell | verified | ArticlesPage + NewArticleModal | POST /v1/article/ |
| `/article/:id/edit` | DashboardShell | verified | EditorPage | GET /{id} · PATCH /{id} · PATCH /{id}/publish\|unpublish · GET /v1/media/ · POST /v1/media/ · DELETE /{id} |
| `/media` | DashboardShell | verified | MediaPage | GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage |
| `/integration` | DashboardShell | verified | IntegrationPage | none |
| `/integration/api-keys` | DashboardShell | verified | ApiKeysPage + CreateApiKeyModal | GET /v1/api-key · POST /v1/api-key · DELETE /v1/api-key/{id} |
| `/users` | DashboardShell | verified | UsersPage + InviteUserModal | GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id · POST /v1/user/invite/:id/renew |
| `/settings` | DashboardShell | verified | SettingsPage | GET /v1/settings · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart |
| `/profile` | DashboardShell | verified | ProfilePage | GET /v1/auth/me · PATCH /v1/user/me · POST /v1/auth/password-reset |
| `/invite/accept` | AuthShell | public | AcceptInvitePage | GET /v1/auth/invite?c=* · POST /v1/auth/invite/accept* |
| `/:pathMatch(.*)*` | either | any | NotFoundPage | none |
Auth values: `public` needs no session, `session` needs a logged-in account (verified or not), `verified` needs a verified account, `any` applies to redirects and the catch-all. **Media API:** `GET /v1/media/` takes the same `page` and `size`, with the same 400 and the total in `X-Total-Count`; the array body stays. The earlier `cursor` and `limit` parameters are replaced by them.
**Page size memory:** the size chosen on Articles, Activity or Media is remembered for the user and used when the URL has no `size`; an explicit `size` in the URL wins.
**SPA:** never shows that 400. A `size` outside 5, 25, 50, 100 or a `page` that is not a positive integer is replaced in the URL by the default (`history.replace`), silently. A `page` past the last page is replaced by the last page and the notice "That page doesn't exist. Showing the last page." appears.
## Activity pagination
`page` and `size`, allowed sizes 5, 25, 50, 100, default 25 (as implemented).
## Activity export
`GET /v1/activity/export`: implemented. CSV only, synchronous (one streamed download, no job, no progress, no later notification). Accepts the list filters q, actor, category, outcome, security, range, from, to. Owner and admin only; others get 403. "Export current view" calls it with the active filters. "Export all activity" calls it with no filters and an optional `from`/`to` range. JSON (`format=json`) is **proposed**: new backend feature.
## Public image request
`/media/photo.png?v=<variant-name>`. Unknown or disabled variant: 400 `{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "<name>" }`. Any other query parameter: 400 `{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "<parameter>" }`. Missing source image: 404.
## Sites and authors in the URL
`/articles?site=journal` filters by site key; it combines with `page`, `size`, `tab` and `q`, and resets `page` to 1 when it changes. An unknown key is dropped from the URL with no error. `/authors?q=camille&page=2&size=25` follows the same pagination rules as Articles. Deep links: `/authors?edit={slug}` and `/settings/sites?edit={key}` open the slide-over for owner and admin (authors) and the owner (sites); an unknown value shows the info toast "That author no longer exists" or "That site no longer exists". For other roles `/authors?edit={slug}` opens the read-only detail, and `/settings/sites` and its `?edit=` link show the 403 page "You don't have access to Sites".
Sites pagination: `/settings/sites?q=&page=&size=` with the same rules as Articles (sizes 5, 25, 50, 100; default 25; defaults omitted; search and size changes reset to page 1; a page past the end falls back to the last page). API: GET /v1/site/?q=&page=&size=, total in `X-Total-Count` (design assumption). Authors: `/authors?q=&page=&size=` as above.
## SEO
No new page routes. The panel and dialog are overlays on `/article/:id/edit`: `?panel=seo` (optionally `&field=seo_title|seo_description|slug|cover|authors|body` for the suggestion jump) and `?dialog=seo-api`. `/articles?seo=suggestions` is the Needs attention filter. API: GET /v1/article/{id}/seo, GET /v1/site/{key}/sitemap?page=&size=, PATCH /v1/article/{id} (new fields), PATCH /v1/site/{id} {site_name, title_template, default_description, social_image_id, default_robots}, GET /v1/article/?seo=ready|suggestions|hidden. 401 without a session; 403 owner_only on site SEO fields; 409 slug_taken {suggestion} on PATCH slug; 400 on invalid canonical_url or title_template.
## Custom content types in the URL
`/content/events?page=2&size=50&sort=created_at:desc`. `page` and `size` follow Articles (sizes 5, 25, 50, 100; default 25). `sort` is one parameter, `<id|created_at|updated_at>:<asc|desc>`, default `updated_at:desc`. Defaults are omitted from the URL; a size or sort change resets `page` to 1; invalid values are replaced silently by the default; a `page` past the end falls back to the last page. The response carries `total`, `page`, `size` and `sort`: use them for the summary ("Showing 1–5 of 37"), the pager and the "37 entries use this content type" line of the review step. No filter or search on custom fields yet. Auth: `/schema*` owner or admin (403 page otherwise), `/content*` any verified user (unverified accounts are redirected to /register/pending). Unknown type: 404 content_type_not_found page. Unknown entry: 404 entry_not_found page.
## Roles and permissions
| Route | Purpose | Access |
|---|---|---|
| /settings/roles | Roles list | Roles: view |
| /settings/roles/new | New role | Roles: manage |
| /settings/roles/:id | Role editor (read-only without manage) | Roles: view |
| /settings/permissions | Retired. 301 to /settings/roles | n/a |
API: GET/POST /v1/role · GET/PUT/DELETE /v1/role/{id} · POST /v1/role/{id}/reassign · PUT /v1/user/{id}/role · POST /v1/site/transfer-ownership · GET /v1/me · GET /v1/permission. Errors: 403 missing_permission, 403 role_locked, 409 role_has_members, 409 role_name_taken, 409 role_conflict.
Needs backend:
| Item | Detail |
|---|---|
| Role storage | Table role (id, name, description, built_in, locked, version) and role_permission (role_id, key, scope none|own|all). One role_id on user. Built-ins seeded and non-deletable. |
| Endpoints | GET/POST /v1/role · GET/PUT/DELETE /v1/role/{id} · POST /v1/role/{id}/reassign · PUT /v1/user/{id}/role · POST /v1/site/transfer-ownership · GET /v1/me (role and permissions) · GET /v1/permission (catalogue) |
| created_by | Add created_by (user id) to articles and entries so Own scope can be checked on read, update, delete and publish. Backfill from existing author or activity data. |
| Enforcement | Server checks every call against role permissions and the no-escalation rules. API keys resolve the creator's current role on each request. |
| Version field | role.version for the conflict state (409 role_conflict). |
| Activity events | role.created, role.updated (with a diff of added and removed permissions), role.deleted, user.role_changed, site.ownership_transferred. |
+26 -103
View File
@@ -1,114 +1,37 @@
# Accept invite # Accept invite
- **Route:** `/invite/accept?c=CODE` Route: `/invite/accept?c=CODE`. API: GET /v1/auth/invite?c= (pending) · POST /v1/auth/invite/accept (pending).
- **API:** GET /v1/auth/invite?c=CODE (pending) · POST /v1/auth/invite/accept (pending)
- **Visual reference:** `source/RenewCMS Screens Auth.dc.html`, group "Accept invite" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
Opened from the invitation email. The link carries only the code, so the invited email is looked up on load and shown read-only. Username and password follow the Register rules, including PasswordStrengthHint, with no confirm field. On success the person is logged in and sent to /home with a toast. The account is already verified, because the invite went to their email, so it skips the pending and verify screens. Opened from the invitation email. The link carries only the code, so the invited email is looked up on load and shown read-only. Username and password follow the Register rules, including PasswordStrengthHint. On success the person is logged in and sent to /home with a toast; they are already verified because the invite went to their email.
Neither endpoint exists yet: Visual reference: `RenewCMS Screens Auth.dc.html`, groups "Accept invite". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
- `GET /v1/auth/invite?c=CODE` returns `{ email }`, or 404 or 410. ## Copy per state (generated from the board source, pass 2)
- `POST /v1/auth/invite/accept` takes `{ code, username, password }`. It returns 200 with the session cookie, 409 for a taken username, or 404 or 410 as above.
Registration stays closed to everyone else: [register.md](register.md) "Registration closed" still applies to `/register`. The board does not draw the case of a logged-in person opening the link; it is not specified. Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Auth.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
## States ### Base copy
- Accept your invite
- Choose a username and password to finish setting up your account.
- Accept invite
- Already have an account?
- Log in
### Checking the link ### Accept invite (group "Accept invite", 10 states)
**Trigger:** On load, while the invite is looked up. Route: `/invite/accept?c=CODE`. API: GET /v1/auth/invite?c= (pending) · POST /v1/auth/invite/accept (pending).
- Full-card spinner: "Checking your invite…" Opened from the invitation email. The link carries only the code, so the invited email is looked up on load and shown read-only. Username and password follow the Register rules, including PasswordStrengthHint. On success the person is logged in and sent to /home with a toast; they are already verified because the invite went to their email.
### Default | # | State | Trigger | Copy |
|---|---|---|---|
**Trigger:** The code is valid and unused. | 1 | Checking the link | On load, while the invite is looked up. | "Checking your invite…" |
| 2 | Default | The code is valid and unused. | |
- Heading: **Accept your invite** | 3 | Validation errors | Checked on blur and on submit, using the same rules as Register. Nothing is sent. | "Use 3 to 20 characters." |
- Description: "Choose a username and password to finish setting up your account." | 4 | Filled, valid | Every rule is met and the checklist item turns green. | |
- Field `Email`: placeholder "you@example.com"; value "lea.martin@renew.example"; read-only; help "From your invitation." | 5 | Submitting | Fields and button are disabled. | "Creating account…" |
- Field `Username`: placeholder "e.g. lea"; counter "0 / 20"; help "Letters and numbers, 3 to 20 characters. You'll use it to log in." | 6 | Username taken (409) | Another user has that username. The value is kept and the field gets focus. | "That username is taken. Try another." |
- Field `Password`: password with show/hide; checklist "At least 8 characters" (unmet) | 7 | Invalid or used link (404) | The code is wrong, was already accepted, or is missing from the URL. No form is shown. | "This invite link doesn't work" / "It may be incomplete, or it may already have been used. If you already set up your account, log in instead." / "Open the link from your email again, or copy the full address into your browser." / "Go to log in" |
- Submit button: "Accept invite" | 8 | Expired link (410) | The invite is older than 3 days. | "This invite has expired" / "Invites work once, for 3 days. Ask the person who invited you to send a new one." / "Go to log in" |
- Footer: Already have an account? [Log in] | 9 | Server error | A 500 or no response. The values are kept. | "Couldn't create the account" / "Something went wrong on the server. Try again in a moment." |
| 10 | Success | 200 sets the session cookie → /home after 800 ms, with a toast. No verification step. | "Your account is ready" / "Taking you to your dashboard…" / "Welcome to RenewCMS" / "You're logged in as lea." |
### Validation errors
**Trigger:** Checked on blur and on submit, using the same rules as Register. Nothing is sent.
- Heading: **Accept your invite**
- Description: "Choose a username and password to finish setting up your account."
- Field `Email`: placeholder "you@example.com"; value "lea.martin@renew.example"; read-only; help "From your invitation."
- Field `Username`: placeholder "e.g. lea"; value "ma"; counter "2 / 20" in error; **error "Use 3 to 20 characters."**
- Field `Password`: value "lime"; password with show/hide; checklist "At least 8 characters" (err)
- Submit button: "Accept invite"
- Footer: Already have an account? [Log in]
### Filled, valid
**Trigger:** Every rule is met and the checklist item turns green.
- Heading: **Accept your invite**
- Description: "Choose a username and password to finish setting up your account."
- Field `Email`: placeholder "you@example.com"; value "lea.martin@renew.example"; read-only; help "From your invitation."
- Field `Username`: placeholder "e.g. lea"; value "lea"; counter "3 / 20"; help "Letters and numbers, 3 to 20 characters. You'll use it to log in."
- Field `Password`: value "limewash2026"; password with show/hide; password shown; checklist "At least 8 characters" (met)
- Submit button: "Accept invite"
- Footer: Already have an account? [Log in]
### Submitting
**Trigger:** Fields and button are disabled.
- Heading: **Accept your invite**
- Description: "Choose a username and password to finish setting up your account."
- Field `Email`: placeholder "you@example.com"; value "lea.martin@renew.example"; read-only; help "From your invitation."
- Field `Username`: placeholder "e.g. lea"; value "lea"; counter "3 / 20"; help "Letters and numbers, 3 to 20 characters. You'll use it to log in."
- Field `Password`: value "limewash2026"; password with show/hide; checklist "At least 8 characters" (met)
- Submit button: "Creating account…", loading
- Footer: Already have an account? [Log in]
### Username taken (409)
**Trigger:** Another user has that username. The value is kept and the field gets focus.
- Heading: **Accept your invite**
- Description: "Choose a username and password to finish setting up your account."
- Field `Email`: placeholder "you@example.com"; value "lea.martin@renew.example"; read-only; help "From your invitation."
- Field `Username`: placeholder "e.g. lea"; value "marta"; counter "5 / 20"; focused; **error "That username is taken. Try another."**
- Field `Password`: value "limewash2026"; password with show/hide; checklist "At least 8 characters" (met)
- Submit button: "Accept invite"
- Footer: Already have an account? [Log in]
### Invalid or used link (404)
**Trigger:** The code is wrong, was already accepted, or is missing from the URL. No form is shown.
- ResultPanel (error, link-2-off): **This invite link doesn't work**; text "It may be incomplete, or it may already have been used. If you already set up your account, log in instead."; note "Open the link from your email again, or copy the full address into your browser."; actions "Go to log in" (primary)
### Expired link (410)
**Trigger:** The invite is older than 3 days.
- ResultPanel (error, clock): **This invite has expired**; text "Invites work once, for 3 days. Ask the person who invited you to send a new one."; actions "Go to log in" (secondary)
### Server error
**Trigger:** A 500 or no response. The values are kept.
- Alert (error, circle-x): **Couldn't create the account** "Something went wrong on the server. Try again in a moment."
- Heading: **Accept your invite**
- Description: "Choose a username and password to finish setting up your account."
- Field `Email`: placeholder "you@example.com"; value "lea.martin@renew.example"; read-only; help "From your invitation."
- Field `Username`: placeholder "e.g. lea"; value "lea"; counter "3 / 20"; help "Letters and numbers, 3 to 20 characters. You'll use it to log in."
- Field `Password`: value "limewash2026"; password with show/hide; checklist "At least 8 characters" (met)
- Submit button: "Accept invite"
- Footer: Already have an account? [Log in]
### Success
**Trigger:** 200 sets the session cookie → /home after 800 ms, with a toast. No verification step.
- ResultPanel (success, circle-check): **Your account is ready**; text "Taking you to your dashboard…"
- Toast (circle-check): **Welcome to RenewCMS** "You're logged in as lea."
+95
View File
@@ -0,0 +1,95 @@
# Activity
Route: `/activity`, `Row click`. API: GET /v1/activity · GET /v1/activity/summary · GET /v1/activity?outcome=failed · ?type=security · GET /v1/activity/export (shipped: CSV only, synchronous streamed download, owner and admin only, 403 otherwise). Same filters as the list: q, actor, category, outcome, security, range, from, to. JSON is proposed. · Uses the item from GET /v1/activity · GET /v1/activity (server scoped to the user).
New route in the sidebar after Users. Owner and admin see everything; regular users get "My activity". Copy, component mapping and API calls are at the bottom of this board.
Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Activity", "Activity: filter cards", "Activity: export", "Activity details", "My activity". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
## Copy per state (generated from the board source, pass 2)
Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
### Activity, owner and admin (group "Activity", 9 states)
Route: `/activity`. API: GET /v1/activity · GET /v1/activity/summary.
New route in the sidebar after Users. Owner and admin see everything; regular users get "My activity". Copy, component mapping and API calls are at the bottom of this board.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Default, large dataset | Newest first, day separators, a security row, 1,284 events across 52 pages. Hover on the first row shows the exact time. | |
| 2 | Loading | Skeleton strip and table while both requests are in flight. | |
| 3 | Zero-value summary strip | Quiet period: Failed and Security show a calm 0. | |
| 4 | Filtered, card applied | Failed actions card clicked, chips and Clear all. Mobile shows the count on Filters. | |
| 5 | Mobile filters sheet | Filters open as a bottom sheet (desktop uses the inline bar above). | "Custom" |
| 6 | Empty, no activity yet | New site. Export is disabled. | |
| 7 | Empty, filters match nothing | Offers Clear filters. | "Media" / "Failed" |
| 8 | Error with retry | Request failed. The summary strip still shows if its call succeeded. | |
| 9 | Forbidden (403) | API returned 403 for this role or filter. | |
### Activity: failed-action cards as toggles (group "Activity: filter cards", 10 states)
Route: `/activity`. API: GET /v1/activity?outcome=failed · ?type=security.
The Failed actions and Security events cards are toggle buttons with aria-pressed. Pressing one applies its filter and shows it in the chip row; pressing the active card again removes it. Three controls always agree: the card, the chip with its × and "Clear all". Removing the filter with any of them returns the card to its default look, removes the chip and sets the Outcome segment back to All. The active card uses --rc-sand with --rc-on-sand; the hint line changes from "Filtering. Click to clear" to "Click to clear this filter" on hover.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Default, no filter | Both cards unpressed, no chips. | |
| 2 | Card hover | Pointer over Failed actions. Background --ui-bg-elevated, hint "Click to filter the list". | |
| 3 | Card focus-visible | Keyboard focus on the card: 2 px --ui-primary outline, 2 px offset. Enter or Space toggles. | |
| 4 | Failed card active | aria-pressed="true". Outcome chip, Outcome segment on Failed, list reduced to failed events. | |
| 5 | Active card hover | Hint reads "Click to clear this filter" with an × icon. | |
| 6 | Security card active | Chip "Type: Security events". The Outcome segment stays on All. | |
| 7 | Chip × hover | Dismissing the chip also unpresses the card. | |
| 8 | Clear all hover | Clears the chips, the card and every other filter together. | |
| 9 | Clear all focus-visible | Keyboard focus on Clear all. | |
| 10 | Card plus other filters | Failed card with Category and Dates. Unpressing the card removes only its chip. | "Authentication" / "30 days" |
### Activity: export menu and dialogs (group "Activity: export", 15 states)
Route: `/activity`. API: GET /v1/activity/export (shipped: CSV only, synchronous streamed download, owner and admin only, 403 otherwise). Same filters as the list: q, actor, category, outcome, security, range, from, to. JSON is proposed..
Export is one menu button with two items. Export current view: the same endpoint with the active filters, across all pages. Export all activity: the same endpoint with no filters and an optional date range (from, to), default all time. There is no server job, progress or later notification. On mobile the menu and dialog are bottom sheets. Dialogs: secondary left, primary right.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Menu open | Export pressed. Both items available. Owners and admins only; other roles never see the button. | |
| 2 | Menu, item hover | Pointer over Export all activity. | |
| 3 | Menu, filtered view | Failed card applied. The first item states the matching count. | |
| 4 | Menu, nothing to export | Filters match nothing. Export current view is disabled with the reason. | "Media" / "Failed" |
| 5 | Menu, no permission | The role was lowered while the page was open. Item disabled with the reason; the API answers 403. | |
| 6 | Export button focus-visible | 2 px --ui-primary outline, 2 px offset. Enter or ArrowDown opens the menu; Esc closes it. | |
| 7 | Dialog: configure, CSV only | Export all activity…. CSV is the only format. Date range defaults to All time. | |
| 8 | Dialog: configure, CSV or JSON (proposed) | Needs the JSON backend feature. Format control appears only when it ships. | |
| 9 | Dialog: preparing download | Request in flight. Indeterminate bar, no percentage: the server streams one file and reports no progress. | |
| 10 | Dialog: download started | The browser received the file. Only Close is offered. | |
| 11 | Dialog: failed | 500 or network failure. Retry restarts the export. | |
| 12 | Dialog: nothing to export | The chosen date range has no events. Only Close. | |
| 13 | Dialog: not allowed (403) | Owner or admin only. | |
| 14 | Toast: current view | The response started streaming and the browser saved the file. | "Download started" / "activity-2026-10-06.csv is downloading." |
| 15 | Toast: export all | Dialog closed after the download started. | "Download started" / "activity-all-2026-10-06.csv is downloading." |
### Activity: details slide-over and bottom sheet (group "Activity details", 6 states)
Route: `Row click`. API: Uses the item from GET /v1/activity.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Edit with a diff | Title change and "Body edited (+312 characters)". No body text. | |
| 2 | Settings diff, long text | Long values clamp to two lines with Show more. | |
| 3 | Failed action, very long target title | Failure reason shown, target title wraps. | |
| 4 | Deleted target, no diff | Struck-through target with a Deleted badge. | |
| 5 | System-initiated, no human actor | Scheduler with a System badge. IP and browser not applicable. | |
| 6 | Security event, failed login | Actor not signed in. Restrained shield marker. | |
### My activity, regular user (group "My activity", 3 states)
Route: `/activity`. API: GET /v1/activity (server scoped to the user).
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Default | No summary strip, no actor filter, no Export. Title becomes My activity. | |
| 2 | Empty | The user has not done anything yet. | |
| 3 | Details | Same slide-over, own event. | |
+74 -95
View File
@@ -1,122 +1,101 @@
# Articles # Articles
- **Route:** `/article` Route: `/article`, `/articles?page=2&size=50`. API: GET /v1/article/ · PATCH …/publish · PATCH …/unpublish · DELETE /v1/article/{id} · GET /v1/article/?page=&size=&q=&status= (page, size; total in the X-Total-Count header; invalid values are normalised in the URL, never shown as an error).
- **API:** GET /v1/article/ · PATCH …/publish · PATCH …/unpublish · DELETE /v1/article/{id}
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Articles" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
Rows update when the server responds, not optimistically. Search, tab and sort are stored in the query string. Rows update when the server responds, not optimistically. Search, tab and sort are stored in the query string.
## States Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Articles", "Articles: pagination". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
### Default ## Copy per state (generated from the board source, pass 2)
**Trigger:** The list loaded, sorted by last updated. Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
- Breadcrumbs: Articles (mobile title "Articles") ### Fixed copy in the frame
- Articles
- Everything you've written, online and in draft.
- New article
- Retry
- Title
- Status
- Created
- Updated
- Online
- Draft
- Edit
- Copy API URL
- Delete…
- Previous
- Next
### Row menu open ### Articles (group "Articles", 14 states)
**Trigger:** The ellipsis button, or Enter on the focused menu button. Route: `/article`. API: GET /v1/article/ · PATCH …/publish · PATCH …/unpublish · DELETE /v1/article/{id}.
- Breadcrumbs: Articles (mobile title "Articles") Rows update when the server responds, not optimistically. Search, tab and sort are stored in the query string.
- Row "Winter checklist for timber windows": menu true, online false, updated "5 days ago"
### Loading | # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Default | The list loaded, sorted by last updated. | |
| 2 | Row menu open | The ellipsis button, or Enter on the focused menu button. | |
| 3 | Loading | The first load, or a retry. | |
| 4 | Empty | The list is empty. | "No articles yet" / "Write your first one. It stays a draft until you publish it." / "New article" |
| 5 | No results | Search matched nothing. The tab counts still show the full list. | "No articles match "tiles"" / "Check the spelling, or search for a shorter part of the title." / "Clear search" |
| 6 | No drafts | Drafts tab with nothing in it. The "Online" tab has an equivalent "Nothing is online yet" state. | "Everything you've written is online." / "New article" |
| 7 | Action in progress | Publish chosen from the row menu. That row is disabled. | "Publishing…" |
| 8 | Published | 200: the row updates in place and a toast confirms. Clock-sensitive: shows a relative time or a countdown. | "Just now" / "Article published" / ""Winter checklist for timber windows" is now online." |
| 9 | Action failed | A 500 or network error. The row stays as it was, and the toast stays until retried or dismissed. | "Couldn't unpublish" / "The server didn't respond. Nothing changed." / "Retry" |
| 10 | Row vanished (404) | Any row action gets a 404. | "That article no longer exists" / "It was deleted somewhere else, so we removed it from the list." |
| 11 | Delete: confirm | Delete… from the row menu. | |
| 12 | Delete: in progress | DELETE in flight. The dialog can't be dismissed. | |
| 13 | Delete: failed | 500, a partial failure where the article is kept. The error shows inside the dialog. | "Couldn't delete the article, so it's still there. Try again in a moment." |
| 14 | Deleted | 204: the row is removed and focus moves to the next row. This is also where a delete from the editor ends up. | "Article deleted" / ""Winter checklist for timber windows" and its images are gone." |
**Trigger:** The first load, or a retry. ### Articles: pagination (group "Articles: pagination", 11 states)
- Breadcrumbs: Articles (mobile title "Articles") Route: `/articles?page=2&size=50`. API: GET /v1/article/?page=&size=&q=&status= (page, size; total in the X-Total-Count header; invalid values are normalised in the URL, never shown as an error).
- List: loading skeleton
### Empty Page and page size live in the URL as page and size. Defaults (page 1, size 25) are omitted. Allowed sizes 5, 25, 50, 100. Search, tab or sort changes reset to page 1. Rows equal the page size; the frames draw at most 10 rows. The paginator hides when everything fits on one page; the summary stays. Mobile shows Previous, "Page N of M" and Next. Keyboard: Tab reaches page size, first, previous, each page, next, last; Enter or Space activates; the current page has aria-current="page"; ArrowDown opens the page-size list, Enter selects, Esc closes.
**Trigger:** The list is empty. | # | State | Trigger | Copy |
|---|---|---|---|
| 1 | First page | /articles. Previous and First are disabled. Summary "Showing 1–25 of 312". | |
| 2 | Middle page | /articles?page=7. Current page uses --rc-sand and --rc-on-sand; ellipses mark skipped ranges. | |
| 3 | Last page | /articles?page=13. Next and Last are disabled. Summary "Showing 301–312 of 312". | |
| 4 | Single page | Total fits in one page. Paginator hidden; summary "Showing 1–6 of 6" and the page-size select stay. | |
| 5 | Loading next page | Next was pressed. Skeleton rows keep the table height, controls are disabled, summary reads "Loading page 7…". | |
| 6 | Error loading a page | 500 or network failure on page change. Previous rows stay; Retry sits in the notice. | "Couldn't load page 7. Showing page 6." |
| 7 | Page out of range | /articles?page=99 is replaced with ?page=13 and this notice is shown. | "That page doesn't exist. Showing the last page." |
| 8 | Search resets to page 1 | Searching "plaster" from page 7 goes to /articles?q=plaster (page omitted). Same for tab, sort and size changes. | |
| 9 | Empty after search | Zero results. Paginator and summary are hidden. | "No articles match "tiles"" / "Check the spelling, or search for a shorter part of the title." / "Clear search" |
| 10 | Page size menu | The select opens upward. 5, 25, 50 or 100; the chosen size is saved as size and the page resets to 1. | |
| 11 | Focus-visible | Keyboard focus on Next: 2 px --ui-primary outline, 2 px offset. Same ring on every paginator control. | |
- Breadcrumbs: Articles (mobile title "Articles") ## Changed in pass 3: authors, site column and site filter
- Tab counts (All, Online, Drafts): 0, 0, 0
- EmptyState (file-text): **No articles yet** "Write your first one. It stays a draft until you publish it.", action "New article" (primary)
### No results ### Articles: sites and authors (group "Articles: sites and authors", 3 states)
**Trigger:** Search matched nothing. The tab counts still show the full list. Route: `/articles?site=journal`. API: GET /v1/article/?site={key}&page=&size=&q=&status=.
- Breadcrumbs: Articles (mobile title "Articles") Every Articles frame has the Authors column (avatars, at most 3 plus "+N"; "—" when none; on mobile avatars with the first name). When more than one site exists, a Site column and a Site filter appear, read-only text for every role; with one site they are not rendered.
- Search query: "tiles"
- EmptyState (search): **No articles match "tiles"** "Check the spelling, or search for a shorter part of the title.", action "Clear search"
### No drafts | # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Several sites | More than one site exists. | Authors · Site · All sites · Renew · Journal · Projects |
| 2 | Site filter open | The Site select in the toolbar. Choosing a site stores ?site=key and resets the page to 1. | All sites · Renew · Journal · Projects · Archive |
| 3 | No articles on a site | The chosen site has no articles. | No articles on "Archive" · Move articles here from the editor, or choose another site. · Show all sites |
**Trigger:** Drafts tab with nothing in it. The "Online" tab has an equivalent "Nothing is online yet" state. All existing Articles frames show the Authors column (avatars, at most 3 plus "+N", "—" when none).
- Breadcrumbs: Articles (mobile title "Articles") ## Articles: search status (SEO round)
- Active tab index: 2 (0 All, 1 Online, 2 Drafts)
- Tab counts (All, Online, Drafts): 4, 4, 0
- EmptyState (pencil-line): **No drafts** "Everything you've written is online.", action "New article"
### Action in progress Route: `/articles?seo=suggestions`. API: GET /v1/article/?seo=suggestions&page=&size= (items carry seo_status and seo_suggestions).
**Trigger:** Publish chosen from the row menu. That row is disabled. A quiet indicator in the Status cell on desktop and under the title on mobile cards: a lightbulb chip with a count when there are suggestions, an eye-off chip "Hidden" when noindex is on, nothing when the article is ready. The toolbar gets a "Needs attention" toggle with a count next to the search field. Tabs and their counts are unchanged. On a single-site install nothing changes: the Site filter and column stay hidden and the toggle works the same, over all articles.
- Breadcrumbs: Articles (mobile title "Articles") | # | State | Trigger | Copy |
- Row "Winter checklist for timber windows": busy "Publishing…", online false, updated "5 days ago" |---|---|---|---|
| 1 | Status chips | Row 2 has 2 suggestions, row 3 is hidden, row 4 has 3, row 5 has 1. | Needs attention · 3 · Hidden · 2 suggestions (tooltip) · Hidden from search engines (tooltip) |
| 2 | Needs attention on | The toggle is pressed. The list shows only articles with suggestions. Hidden-only articles are not included. | Needs attention · 3 |
| 3 | Needs attention: none | Nothing to show with the toggle on. | Nothing needs attention · Every article has a clean check. Suggestions are optional, so this is just a bonus. · Show all articles |
### Published Filter: "Needs attention" sets `?seo=suggestions` and resets `page` to 1. Tab counts do not change. Single-site installs behave the same; the Site filter stays hidden.
**Trigger:** 200: the row updates in place and a toast confirms.
- Breadcrumbs: Articles (mobile title "Articles")
- Toast (success, circle-check): **Article published** ""Winter checklist for timber windows" is now online."
### Action failed
**Trigger:** A 500 or network error. The row stays as it was, and the toast stays until retried or dismissed.
- Breadcrumbs: Articles (mobile title "Articles")
- Toast (error, circle-x): **Couldn't unpublish** "The server didn't respond. Nothing changed." with action "Retry"
### Row vanished (404)
**Trigger:** Any row action gets a 404.
- Breadcrumbs: Articles (mobile title "Articles")
- Toast (info, info): **That article no longer exists** "It was deleted somewhere else, so we removed it from the list."
- Tab counts (All, Online, Drafts): 5, 4, 1
### Delete: confirm
**Trigger:** Delete… from the row menu.
- Breadcrumbs: Articles (mobile title "Articles")
- Modal: **Delete this article?**
- text ""Winter checklist for timber windows" will be deleted. Its images stay in your library. This can't be undone."
- cancel "Cancel"
- confirm "Delete article" (danger)
### Delete: in progress
**Trigger:** DELETE in flight. The dialog can't be dismissed.
- Breadcrumbs: Articles (mobile title "Articles")
- Modal: **Delete this article?**
- text ""Winter checklist for timber windows" will be deleted. Its images stay in your library. This can't be undone."
- cancel "Cancel"
- confirm "Deleting…" (danger), loading, not dismissible
### Delete: failed
**Trigger:** 500, a partial failure where the article is kept. The error shows inside the dialog.
- Breadcrumbs: Articles (mobile title "Articles")
- Modal: **Delete this article?**
- text ""Winter checklist for timber windows" will be deleted. Its images stay in your library. This can't be undone."
- Alert: "Couldn't delete the article, so it's still there. Try again in a moment."
- cancel "Cancel"
- confirm "Delete article" (danger)
### Deleted
**Trigger:** 204: the row is removed and focus moves to the next row. This is also where a delete from the editor ends up.
- Breadcrumbs: Articles (mobile title "Articles")
- Toast (success, circle-check): **Article deleted** ""Winter checklist for timber windows" is gone."
- Tab counts (All, Online, Drafts): 5, 4, 1
+82
View File
@@ -0,0 +1,82 @@
# Authors
Route: `/authors`. API: GET /v1/author/ · POST /v1/author/ · PATCH /v1/author/{id} · DELETE /v1/author/{id}?reassign_to={id|none} · POST /v1/author/me.
An author is a public byline, not an account. It may be linked to one CMS user; guest writers have no link. The slug is globally unique, generated from the name and editable. An article has zero or more authors in a set order; the first is the primary byline. Zero authors is allowed.
**Roles.** Owner and admin can create, edit and delete any author. Other roles see the Authors screen read-only: the list and a read-only detail, with no Add author button, no row menu, no slide-over form and no delete. They can pick authors in the article editor. Any role can use "Create my author profile" and edit their own linked author: the form opens with the linked user locked and has no delete action.
**Shared pieces.** The list, slide-over, delete-with-reassign dialog, slug field and media field are defined once in `DirectoryFrame.dc.html` and referenced by both Authors and Sites. The media picker is drawn once, in "Authors: form". The server-fallback 409 dialog is drawn once, in "Authors: delete".
Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Authors", "Authors: form", "Authors: delete". Every state is drawn in light and dark (theme switch) at 1440×900 and 390×844. Copy is exact. Strings in a Copy cell are separated by " · " and listed in reading order.
### Authors (group "Authors", 8 states)
Sidebar entry between Media and Users, visible to every role. Owner and admin create, edit and delete any author. Other roles see the list and the detail read-only. Columns: avatar and name, slug, account (Linked or Guest), article count. Search by name or slug. Long names truncate in the row and show in full on hover. Above 25 authors the list uses the same paginator as Articles; its states are drawn in "Authors: pagination". List, panel and dialogs come from DirectoryFrame and are shared with Sites.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Default | Owner or admin. The list loaded, newest first. | Authors · The names readers see on your articles. An author doesn't need an account. · Add author · Search by name or slug · Author · Slug · Account · Articles · Linked · Guest · 9 articles · 1 article · No articles |
| 2 | Loading | The first load, or a retry. | |
| 3 | Error | 500 or network failure. | Couldn't load authors · Check your connection and try again. · Try again |
| 4 | Empty, first run | Zero authors. Toolbar hidden, one call to action. Other roles see the same text without the button. | No authors yet · Authors are the names readers see on articles. They don't need an account. · Add author |
| 5 | No results | Search matched nothing. | No authors match "zoe" · Check the spelling, or search for a shorter part of the name. · Clear search |
| 6 | Row menu | Owner or admin. The ellipsis button. Mobile: bottom sheet. | Edit · Delete… |
| 7 | Read-only list (other roles) | Any role except owner and admin. No Add author button, no row menu and no bottom button on mobile. Search still works. A row opens the read-only detail. | Authors · The names readers see on your articles. Only an owner or admin can change authors. · Search by name or slug · Author · Slug · Account · Articles |
| 8 | Read-only detail | A row was opened by a role without author rights. Same panel width as the form, no fields, Close as the only action. The account email is not shown. | Inès Duarte · Linked · 9 articles · Bio · Website · Only an owner or admin can change this author. · Close |
### Authors: add and edit (group "Authors: form", 11 states)
Owner and admin. A slide-over, because the form has a live preview (the byline). Fields: avatar (media picker), name, slug (auto, editable), bio with counter, website, social links (X, LinkedIn, GitHub, Mastodon, Custom; up to 5), linked user (only users without an author). Slug field, media field and picker are the shared ones from DirectoryFrame. Saving shows "Saving…" in the button, as in the other forms.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Add: empty | "Add author". Owner or admin. | Add author · Avatar (optional) · Choose image · Name · e.g. Camille Roche · Slug · Generated from the name · Bio (optional) · A sentence or two about the author. · 0 / 280 · Website (optional) · https://example.com · Social links (optional) · Add link · 0 of 5 · Linked user (optional) · No linked user · Link a CMS user if this author has an account. Guest writers need no link. · Byline preview · Author name · The bio appears here. · Cancel · Add author |
| 2 | Slug from the name | Typing a name fills the slug. Auto badge and "Edit slug" pencil. | camille-roche · Auto · Generated from the name. Used in public author URLs and API paths. Unique across all authors. |
| 3 | Slug collision | 409 on submit, or on blur after a check. | The slug "camille-roche" is already used by another author. · Use camille-roche-2 · Reset to match the name |
| 4 | Validation | On submit or blur. Nothing is sent. | Enter a name. · Shorten the bio to 280 characters. · 291 / 280 · Enter a full URL starting with https://. |
| 5 | Social links | Add link adds a row with a type select and a URL. One link is invalid. | X · LinkedIn · GitHub · Mastodon · Custom · https://x.com/username · https://www.linkedin.com/in/username · https://github.com/username · https://mastodon.social/@username · https://example.com · Enter a full URL starting with https://. · You can add up to 5 links. |
| 6 | Linked user: choose | The select is open. Only users without an author. When none are left it reads "Every user already has an author." | Choose a user · Users without an author · Marta Alves · marta@renew.example · Rui Santos · rui@renew.example |
| 7 | Edit: linked author | Edit on an author linked to a user. | Edit Inès Duarte · Linked user (optional) · ines.duarte@renew.example · Unlink · New articles by this user start with this author. · Save changes |
| 8 | Avatar: picker | "Choose image". The existing media picker; shown as a square. The site social image uses this same picker. | Choose an image · The avatar for Camille Roche. · Shown as a square. Choose an image with the face or logo near the middle. · Use image |
| 9 | Save failed | 500 or network failure. | Couldn't save the author. Your changes are still here. Try again in a moment. |
| 10 | Unlink: confirm | "Unlink" next to the linked user, owner or admin. On 200 the field returns to "No linked user" and a toast reads "User unlinked" · "Inès Duarte is now a guest author." | Unlink Inès Duarte? · Inès Duarte will stay on their articles. New articles by ines.duarte@renew.example will no longer start with this author. · Cancel · Unlink user |
| 11 | Re-link | "Link user" on a guest author in the row menu or the form. Same list as above. | Link Camille Roche to a user? · Only users without an author are listed. · Cancel · Link user |
### Authors: delete (group "Authors: delete", 6 states)
Owner and admin. Reassign: the dialog asks who gets the articles, with "Nobody" as the last option, and shows the count in the button. Zero authors are allowed, so Nobody is always valid. The 409 dialog is the server fallback and is drawn once.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Reassign (recommended) | "Delete…" on an author with articles, owner or admin. The button reads "Reassigning…" while the request runs. On 204 a toast reads "Author deleted" · "3 articles now credit Inès Duarte." A linked author also shows "Linked to ines.duarte@renew.example. The user account isn't affected." | Delete this author? · "Camille Roche" will be deleted and is credited on 3 articles. This can't be undone. · Credit these articles to · Inès Duarte · Where an article has several authors, Camille Roche is removed and the others stay. · Cancel · Delete and reassign 3 articles |
| 2 | Choose who | The select is open. The deleted author is not listed. | Nobody · Remove the byline |
| 3 | Nobody | "Nobody" chosen. | The articles keep any other authors. Articles with only this author will have no byline. |
| 4 | Failed (shared by site and author delete) | 500 or network failure. Both delete dialogs use this notice. | Couldn't delete the author · Nothing was changed. Try again in a moment. |
| 5 | Server fallback (409) | Fallback only. The dialog above always sends reassign_to, so this appears only when the server refuses a stale request because articles still credit the author. Drawn once, not as a flow. | Can't delete this author · "Camille Roche" is credited on 3 articles. Choose another author to credit instead, or remove Camille Roche from those articles first. · Close · Reassign articles |
| 6 | No articles | An author with zero articles: plain confirm. | Delete this author? · "Léa Fontaine" will be deleted. No articles credit this author. This can't be undone. · Delete author |
### Authors: pagination (group "Authors: pagination", 10 states)
Route: `/authors?page=3&size=50`. API: GET /v1/author/?q=&page=&size= (total in `X-Total-Count`, array body unchanged; `q` already exists). Owner, admin and read-only roles see the same pager. Drawn with 128 authors (6 pages) and generated rows.
**URL.** `/authors?page=2&size=50`. `page` and `size` are the parameter names (as Articles and Activity). `size` is one of 5, 25, 50, 100; default 25. Defaults are omitted from the URL (`page=1`, `size=25`). Combined with search: `/authors?q=mar&page=2`. A search change or a size change resets `page` to 1 and drops it from the URL. An invalid `size` or `page` (not a positive integer) is replaced silently by the default with `history.replace`. A `page` past the last page falls back to the last page: the URL is replaced by `?page=<last>` and the info notice "That page doesn't exist. Showing the last page." is shown, as on Articles. Browser Back returns to the previous page of results.
**Pager.** The same component as Articles and Media, in the same footer position (below the list, summary on the left, page-size select next to it, right-aligned controls; mobile: summary and select on one row, then Previous, "Page X of Y", Next). Nothing new is drawn. Sizes are the sizes of the existing pattern: 5, 25, 50, 100 (default 25). API: total in `X-Total-Count`, array body unchanged. Keyboard and aria as Articles. The only difference from Articles: on a single page the page-size select is hidden too and the footer holds only the count line "N authors" (Articles keeps its summary and select). Existing frames of this screen that show a short list (the "Default" frames above) don't draw the count line yet; it appears whenever the list has loaded.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | First page | /authors. Previous and First are disabled (40% opacity, aria-disabled). Mobile: Previous disabled. | Showing 1–25 of 128 · 25 per page · Page 1 of 6 |
| 2 | Middle page | /authors?page=3. Current page uses --rc-sand and --rc-on-sand with a --ui-primary border; ellipses mark skipped ranges once there are more than 7 pages. | Showing 51–75 of 128 · Page 3 of 6 |
| 3 | Last page | /authors?page=6. Next and Last are disabled. | Showing 126–128 of 128 · Page 6 of 6 |
| 4 | Single page | Everything fits on one page (6 authors). The pager and the page-size select are hidden; the count line stays in the footer position. Zero results use state 8 instead. | 6 authors |
| 5 | Page size menu | The select opens upward. Sizes are the Articles sizes: 5, 25, 50, 100; default 25. The chosen size is saved as size and the page resets to 1. | 5 per page · 25 per page · 50 per page · 100 per page |
| 6 | Loading a page | Next, Previous, a page number or a size change was used. The rows are replaced by the existing skeleton rows (8 desktop, 6 mobile). The pager stays and is disabled (40% opacity, not focusable); the summary reads "Loading page 4…" and the page number shown is still 3. | Loading page 4… |
| 7 | Error loading a page | 500 or network failure on a page change. The rows of page 3 stay, the pager keeps "Page 3", and the inline error with Retry sits above the pager. Retry repeats the request for page 4. | Couldn't load page 4. Showing page 3. · Check your connection and try again. · Retry |
| 8 | Empty after search | A search matched nothing. The existing empty state replaces the list; no pager, no count. Clear search returns to /authors and page 1. | No authors match "zoe" · Check the spelling, or search for a shorter part of the name. · Clear search |
| 9 | Search resets to page 1 | Searching "mar" from page 5 goes to /authors?q=mar (page omitted). Any search change, and any size change, resets the page to 1. | Showing 1–25 of 31 |
| 10 | Long labels on mobile | A large total on a 390 px screen. The summary and the page-size select share one row and wrap onto two if needed; Previous, the "Page 12 of 52" label and Next stay on one row (the two buttons flex, the label never wraps). Totals over 999 use thousands separators in both labels. | Showing 276–300 of 1,284 · Page 12 of 52 |
## SEO round
No change to the Authors screen. Name, profile URL (website) and social links feed `authors[].name`, `authors[].url`, `authors[].same_as` and the JSON-LD author (`sameAs`) in GET /v1/article/{id}/seo. They are visible in the editor's "What the frontend gets" dialog.
+17 -9
View File
@@ -1,16 +1,24 @@
# App boot # Boot
- **Route:** `any route, first load` Route: `any route, first load`. API: GET /v1/auth/me (pending).
- **API:** GET /v1/auth/me (pending)
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Boot" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
While the session is checked, the shell renders with skeletons, never a spinner. The possible outcomes are: 200 with a verified user, which renders the route; 200 with an unverified user, which goes to /register/pending; and 401, which goes to /login?redirect=<path>. Public routes skip this check. While the session is checked, the shell renders with skeletons, never a spinner. The possible outcomes are: 200 with a verified user, which renders the route; 200 with an unverified user, which goes to /register/pending; and 401, which goes to /login?redirect=<path>. Public routes skip this check.
## States Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Boot". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
### Checking session ## Copy per state (generated from the board source, pass 2)
**Trigger:** The first paint on any back-office URL, and usually visible for well under a second. Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
- Breadcrumbs: Home (mobile title "Home") ### Fixed copy in the frame
- boot: true - API pending · GET /v1/auth/me
### App boot (group "Boot", 1 states)
Route: `any route, first load`. API: GET /v1/auth/me (pending).
While the session is checked, the shell renders with skeletons, never a spinner. The possible outcomes are: 200 with a verified user, which renders the route; 200 with an unverified user, which goes to /register/pending; and 401, which goes to /login?redirect=<path>. Public routes skip this check.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Checking session | The first paint on any back-office URL, and usually visible for well under a second. | |
+26
View File
@@ -0,0 +1,26 @@
# Content
Route: `/content/:key`. API: GET /v1/content-type/{key} · GET /v1/content-type/{key}/entry?page=&size=&sort=. Auth: any verified user.
Visual reference: `RenewCMS Screens Schema.dc.html`, groups "Content and entry list". Frames: `SchemaFrame.dc.html` mounted in `AppFrame.dc.html` (shell, sidebar, topbar, drawer). Every state is drawn in light and dark (theme switch) at 1440×900 (shown at 50%) and 390×844 (shown at 75%). Copy is exact. Strings in a Copy cell are separated by " · " and listed in reading order. Nothing is clock-sensitive.
### 5 Content navigation and entry list (group "5 Content and entry list", 9 states)
Route: `/content/:key`. API: GET /v1/content-type/{key}, GET /v1/content-type/{key}/entry. Auth: Any verified user.
The sidebar gets a "Content" group, one item per custom type (a default icon: types have no icon in the API). With more than 5 types it shows the first 5 and the selected one, then "Show all N"; long names truncate with the full name in a tooltip. On mobile the group is one collapsed row in the drawer. List: ID, up to 6 chosen field columns (default: first 4), Created, Updated, Updated by. Sort: ID, Created, Updated, either direction (default Updated, newest first). Page size 5, 25, 50, 100 (default 25). No filter or search. URL: /content/events?page=2&size=50&sort=created_at:desc (defaults omitted). The response carries total, page, size and sort; the summary, the pager and the review dialog use them.
| # | State | Trigger | Copy | Scope |
|---|---|---|---|---|
| 1 | Populated | /content/events. 7 entries, one page: no pager, only the count. | Content · Events · events · 7 fields · New entry · Sort by · Last updated · Columns · Edit content type · ID · Title · Starts on · Ends on · Online · Created · Updated · Updated by · 7 entries | Available now |
| 2 | Paginated, 5 per page | /content/events?size=5. 37 entries. | Showing 1–5 of 37 · 5 per page · Page 1 of 8 (mobile) | Available now |
| 3 | Columns menu | The Columns button. Popover on desktop, sheet on mobile. Created and updated always show. | Columns · 4 of 6 · Choose up to 6 fields to show. 4 selected. · Created and updated dates always show · Reset · Done | Available now |
| 4 | Many types in the sidebar | 14 custom types. Long names truncate. Mobile shows the drawer with the group expanded. | Content · Frequently asked questions and answers (truncated) · Show all 14 · All content types 14 (mobile, collapsed) | Available now |
| 5 | Loading | First load or retry. | (skeleton) | Available now |
| 6 | Empty for this type | Total is 0. The hint points to New entry. | No entries yet · Use New entry to add the first event. Each entry follows the fields of this content type. · New entry | Available now |
| 7 | Error | 500 or network failure. | Couldn't load entries · GET /v1/content-type/events/entry failed. Check your connection and try again. · Try again | Available now |
| 8 | Forbidden (403) | The API answers 403. Unverified accounts are redirected to /register/pending before this. | You don't have access to Events · Your account can't open this content right now. If you think this is a mistake, ask an owner or an admin. · Go to Home | Available now |
| 9 | Type not found | 404 content_type_not_found on /content/unknown. | Content type not found · It may have been deleted, or the address may be mistyped. · Go to Home | Available now |
## Media field (pass 5)
Spec, states, exact copy, measurements, backend needs and accessibility: `media-field-changes.md`, section 3.5. Board groups: 5 Content and entry list.
+172 -214
View File
@@ -1,219 +1,177 @@
# Editor # Editor
- **Route:** `/article/:id/edit` Route: `/article/:id/edit`. API: GET /v1/article/{id} · PATCH /v1/article/{id} · PATCH …/publish|unpublish · POST …/image · DELETE.
- **API:** GET /v1/article/{id} · PATCH /v1/article/{id} · PATCH …/publish|unpublish · POST …/image · DELETE
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Editor" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
On mobile, Save and Publish sit in a sticky bottom bar and the details panel opens as a slideover. A session expiry mid-edit keeps the draft in sessionStorage and goes to /login?redirect=/article/12/edit. After logging in, the editor restores the draft and shows "Unsaved changes". On mobile, Save and Publish sit in a sticky bottom bar and the details panel opens as a slideover. A session expiry mid-edit keeps the draft in sessionStorage and goes to /login?redirect=/article/12/edit. After logging in, the editor restores the draft and shows "Unsaved changes".
## States Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Editor". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
### Loading ## Copy per state (generated from the board source, pass 2)
**Trigger:** GET in flight. Skeletons for the title, content and details. Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article") ### Fixed copy in the frame
- Editor: status "draft", save "saved", loading true - This article doesn't exist
- Mobile: leading arrow-left button, trailing ellipsis - It may have been deleted, or the link is wrong.
- All articles
### Saved - Online
- Draft
**Trigger:** Just opened or just saved. Save is disabled. - Retry
- Preview
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article") - Save
- Editor: status "draft", save "saved" - Delete
- Mobile: leading arrow-left button, trailing ellipsis - Every bathroom is different, but the way we price them isn't. We start with a visit, measure everything, and
- write down what stays
### Unsaved changes - We also check the
- timber windows
**Trigger:** Any edit. Publish becomes "Save and publish". Leaving asks for confirmation. - /articles/winter-checklist
- Edit
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article") - Open
- Editor: status "draft", save "dirty" - Remove
- Mobile: leading arrow-left button, trailing ellipsis - before any tiling starts.
- What goes into the price
### Saving - Stripping out and making good
- Plumbing moves, if any
**Trigger:** Save, or Ctrl/⌘ S. - Tiles, fittings and a day for silicone
- /static/uploadedImages/3f9c…e1.jpg
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article") - We don't add a margin on materials you choose yourself.
- Editor: status "draft", save "saving" - Details
- Mobile: leading arrow-left button, trailing ellipsis - Created
- 12 Mar 2026, 09:14
### Save failed - Updated
- Today, 11:02
**Trigger:** 500 or network failure. The changes are kept and Retry resends them. - ID
- Images
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article") - Public API URL
- Editor: status "draft", save "error" - /v1/article/12
- Mobile: leading arrow-left button, trailing ellipsis - Title
- Titles can't be changed after an article is created. To use a different title, create a new article and delete this one.
### Near the limit
### Editor (group "Editor", 41 states)
**Trigger:** 90% of 10 000 HTML characters.
Route: `/article/:id/edit`. API: GET /v1/article/{id} · PATCH /v1/article/{id} · PATCH …/publish|unpublish · POST …/image · DELETE.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Editor: status "draft", save "dirty", len 9240 On mobile, Save and Publish sit in a sticky bottom bar and the details panel opens as a slideover. A session expiry mid-edit keeps the draft in sessionStorage and goes to /login?redirect=/article/12/edit. After logging in, the editor restores the draft and shows "Unsaved changes".
- Mobile: leading arrow-left button, trailing ellipsis
| # | State | Trigger | Copy |
### Too long |---|---|---|---|
| 1 | Loading | GET in flight. Skeletons for the title, content and details. | |
**Trigger:** Over 10 000. Save and Publish are disabled until it's shorter. | 2 | Saved | Just opened or just saved. Save is disabled. | |
| 3 | Unsaved changes | Any edit. Publish becomes "Save and publish". Leaving asks for confirmation. | |
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article") | 4 | Saving | Save, or Ctrl/⌘ S. | |
- Editor: status "draft", save "dirty", len 10214 | 5 | Save failed | 500 or network failure. The changes are kept and Retry resends them. | |
- Mobile: leading arrow-left button, trailing ellipsis | 6 | Very long article | No length limit. The count stays neutral and correct at 120,000 characters. Save and Publish stay enabled. | |
| 7 | Very long article: saving | Save with large content. The status shows the spinner and the page stays editable. | "Saving 120,000 characters. This can take a few seconds." |
### Preview | 8 | Very long article: saved | 200. Status reads Saved with the time. The count is unchanged. | |
| 9 | Very long article: save failed | 500, 413 or network failure. Nothing is lost; Retry resends the same content. | "Couldn't save. Your text is kept. Retry." |
**Trigger:** The Preview switch. Shows the title and body in the content scale, the way a public site might. | 10 | Preview | The Preview switch. Shows the title and body in the content scale, the way a public site might. | |
| 11 | Link: create from selection | Ctrl/⌘ K or the link button with text selected. Link text is prefilled from the selection; URL has focus. | "Link text" / "timber windows" / "URL" |
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article") | 12 | Link: create with no selection | Nothing selected. Link text is empty and has focus. | "Link text" / "What the reader sees" / "URL" |
- Editor: status "draft", save "saved", preview true | 13 | Link: edit existing | Edit in the link popover, or Ctrl/⌘ K inside a link. Prefilled; "Remove link" is offered. | "Edit link" / "Save link" / "Link text" / "timber windows" / "URL" |
- Mobile: leading arrow-left button, trailing ellipsis | 14 | Link: invalid URL | On submit or blur. Nothing is inserted. | "Link text" / "timber windows" / "URL" / "Enter a valid URL. Use http(s), mailto, a path or #anchor." |
| 15 | Link: empty text | On submit with empty link text. | "Link text" / "Enter the link text." / "What the reader sees" / "URL" |
### Upload: choose | 16 | Link: long URL | The field truncates in the middle of the line; the full value is available by scrolling the field and in the title attribute. | "Edit link" / "Save link" / "Link text" / "planning guidance" / "URL" |
| 17 | Link button: default | Toolbar button, icon link, after the list buttons. Desktop 28 px, mobile 40 px. Text colour --ui-text-toned. The toolbar scrolls sideways on mobile (see "Toolbar overflow"). | |
**Trigger:** The image button in the toolbar, or a file dropped on the editor. | 18 | Link button: hover and tooltip | Pointer or keyboard focus after 400 ms. Tooltip "Insert link (Ctrl+K)"; on macOS it reads "Insert link (⌘K)". Touch has no tooltip. | |
| 19 | Link button: focus-visible | 2 px --ui-primary outline, 1 px offset inside the toolbar. Enter or Space opens the link dialog. | |
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article") | 20 | Link button: active (cursor in a link) | The cursor or selection is inside a link: aria-pressed true, --rc-sand fill and --rc-on-sand icon. Pressing it opens the dialog in edit mode. | |
- Modal: **Add an image** | 21 | Link button: disabled | An image is selected in the editor. The button is at 40% opacity, aria-disabled, no tooltip. Ctrl/⌘ K does nothing. | |
- upload state: state "idle" | 22 | Link popover and sheet | Click a link, or Enter on a focused link. Desktop: popover under the link with the URL and Edit, Open and Remove. Mobile (touch): bottom sheet with the link text, the URL and Edit link, Open link, Remove link at 48 px. Esc or a tap outside closes it. Remove asks for no confirmation; undo restores the link. | |
- cancel "Cancel" | 23 | Rendered link: default | Same as the published page: --ui-text-highlighted, 1 px underline, 3 px offset. | |
- Editor: status "draft", save "dirty" | 24 | Rendered link: hover | Underline thickens to 2 px. | |
- Mobile: leading arrow-left button, trailing ellipsis | 25 | Rendered link: focus | Tab or arrow keys into the link: 2 px --ui-primary outline, 2 px offset. | |
| 26 | Rendered link: selected | Text selected across the link: --rc-sand fill, --rc-on-sand text. | |
### Upload: crop and resize | 27 | Toolbar overflow (390 px) | The full toolbar does not fit at 390 px. It scrolls sideways in one row (no wrap); a 24 or 32 px fade marks each hidden side. This frame is scrolled to the end, so the link, remove-formatting and Image buttons are visible. Desktop is unchanged. | |
| 28 | Upload: choose | The image button in the toolbar, or a file dropped on the editor. | "Add an image" / "Cancel" |
**Trigger:** A JPEG, PNG or WEBP passed the checks, and nothing is sent yet. The crop starts as the whole image and the width defaults to 1600 px. SVGs skip this step. | 29 | Upload: crop and resize | A JPEG, PNG or WEBP passed the checks, and nothing is sent yet. The crop starts as the whole image and the width defaults to 1600 px. SVGs skip this step. | |
| 30 | Upload: crop to a ratio | 16:9 chosen. The frame keeps the ratio while it's dragged or resized. Arrow keys move it; Shift and arrow keys resize it. | |
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article") | 31 | Upload: crop narrower than the width | The crop is smaller than the chosen width, so it keeps its own size. | |
- Modal: **Crop and resize** "north-wall.jpg · 3.9 MB" | 32 | Upload: in progress | Upload image was pressed. The cropped, resized file is uploading. Closing the dialog cancels the upload. | "Add an image" / "Cancel upload" |
- crop: ratio "Original", width "1600 px" | 33 | Upload: rejected | Checked in the browser, so nothing is sent. | "Add an image" / "Cancel" / "Choose another file" |
- cancel "Choose another file" | 34 | Upload: inserted | 201: the image goes in at the cursor, and the article now has unsaved changes. | "Image added" / "Save to keep it in the article." |
- confirm "Upload image" | 35 | Publishing | "Save and publish": the PATCH body, then the publish request. | "Saving and publishing…" |
- Editor: status "draft", save "dirty" | 36 | Published | 200: the badge flips, Unpublish is offered and a toast confirms. | "Article published" / ""How we price a bathroom renovation" is now online." |
- Mobile: leading arrow-left button, trailing ellipsis | 37 | Unpublishing | Unpublish doesn't save pending edits. | "Unpublishing…" |
| 38 | Delete: confirm | Delete… from the overflow menu. After a 204, it goes to /article with the "Article deleted" toast. | "Delete this article?" / ""How we price a bathroom renovation" and its 3 images will be deleted. This can't be undone." / "Cancel" / "Delete article" |
### Upload: crop to a ratio | 39 | Leave with unsaved changes | Back, a nav link or closing the tab. | "Leave without saving?" / "Your changes to "How we price a bathroom renovation" haven't been saved." / "Discard changes" / "Keep editing" |
| 40 | Offline | The connection dropped. Writing continues, and Save and Publish wait. | |
**Trigger:** 16:9 chosen. The frame keeps the ratio while it's dragged or resized. Arrow keys move it; Shift and arrow keys resize it. | 41 | Not found (404) | Unknown id, or the article was deleted elsewhere. | |
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article") ## Changed in pass 3: site and authors
- Modal: **Crop and resize** "north-wall.jpg · 3.9 MB"
- crop: ratio "16:9", width "1600 px" Every existing Editor frame that has the details sidebar shows the "Site and authors" section collapsed, as the SPA renders it: one summary line ("Inès Duarte") because the install has one site and the article has an author, so there is no Site select. In the Loading frame the header is shown with a skeleton summary. The Not found frame has no editor, so it has no section. On mobile the section lives in the Details sheet, closed by default, so the mobile editor frames do not show it until the sheet is opened. The group below holds only the states specific to the feature.
- cancel "Choose another file"
- confirm "Upload image" ### Editor: site and authors (group "Editor: site and authors", 16 states)
- Editor: status "draft", save "dirty"
- Mobile: leading arrow-left button, trailing ellipsis Route: `/article/:id/edit`. API: PATCH /v1/article/{id} {site_id, author_ids[]} · GET /v1/site/ · GET /v1/author/ · POST /v1/author/.
### Upload: crop narrower than the width States specific to this feature. The collapsed default (one site, the article has an author) and the Loading and Not found states are drawn in the existing Editor frames, which show the section in the same place; they are not repeated here. The section is open when the install has more than one site or the article has no authors, and collapsed to one summary line otherwise; the choice is remembered per browser. On mobile it is closed by default. With one site the Site select is not rendered. Anyone who can edit the article can set its site and pick authors; "Create author…", the Create author dialog and its "This is me" checkbox are for owner and admin only. For any other role, the "Create my author profile" hint (state 4) creates the profile directly with POST /v1/author/me, with no dialog, then shows the author in the list with a toast. Authors are a reorderable list; the first is the primary byline.
**Trigger:** The crop is smaller than the chosen width, so it keeps its own size. | # | State | Trigger | Copy |
|---|---|---|---|
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article") | 1 | Open: one site, one author | The section was opened. No Site select. | Authors · Add an author |
- Modal: **Crop and resize** "north-wall.jpg · 3.9 MB" | 2 | Open: several sites | Site select above the authors, with the canonical host as helper text. | Site · Journal · journal.renew.example · Canonical host: journal.renew.example |
- crop: ratio "1:1", width "1600 px" | 3 | Site select open | The select is open. Options show name and host; the default site is first. | Renew · renew.example · Journal · journal.renew.example · Projects · Archive |
- note: "The crop is 640 px wide, so it's uploaded at that size. Enlarging it would make it blurry." | 4 | No authors, with hint | The article has no authors and the user has no author profile. The hint is dismissible and does not return once dismissed. "Create my author profile" creates the profile directly for every role (POST /v1/author/me, no dialog), then the author appears in the list and a toast confirms. | You don't have an author profile yet · Create one to be credited on your articles. · Create my author profile · No authors. This article will have no byline. |
- cancel "Choose another file" | 5 | Many authors | Four authors. The first shows "Primary". Each row has a drag handle and a remove button. Long names truncate with an ellipsis and carry the full name in title and aria-label. | Primary · Drag to reorder. The first author is the primary byline. · Dr. Alexandra Konstantinopoulou-Whitfield |
- confirm "Upload image" | 6 | Add author: open | The Add an author combobox is open. Authors already on the article are checked and dimmed. "Create author…" is shown only to owner and admin. | Search authors · Create author… |
- Editor: status "draft", save "dirty" | 7 | Add author: no match | Typing a name that matches nobody. "Create author…" is shown only to owner and admin. | No author matches "Zoe Lambert". · Create author "Zoe Lambert"… |
- Mobile: leading arrow-left button, trailing ellipsis | 8 | Add author: open (no create) | A role without author rights. The combobox lists the authors with no "Create author…" row, because that row is shown only to owner and admin. | Search authors |
| 9 | Add author: no match (no create) | A role without author rights types a name that matches nobody. Only the message shows; there is no create link, because "Create author…" is shown only to owner and admin. | No author matches "Zoe Lambert". |
### Upload: in progress | 10 | Add author: error | GET /v1/author/ failed. The article keeps its authors. | Couldn't load authors · Check your connection and try again. · Try again |
| 11 | Reorder: keyboard | Space or Enter on the handle grabs the row. Arrow keys move it. | Jonas Pereira grabbed. Position 2 of 3. Use the up and down arrow keys to move, Space to drop, Escape to cancel. |
**Trigger:** Upload image was pressed. The cropped, resized file is uploading. Closing the dialog cancels the upload. | 12 | Reorder: pointer | Dragging a row. The row lifts and a line shows the drop position. | |
| 13 | Create author: dialog | "Create author…" in the dropdown, owner and admin only. The writer stays in the editor. | Create author · Adds a byline for this article. You can add a bio and avatar later in Authors. · Name · Slug · Zoe Lambert · zoe-lambert · Cancel · Create author |
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article") | 14 | Create author: for me | Owner and admin only. When the user has no author profile, a checkbox links the new author to them. | This is me · Links the author to marta@renew.example. |
- Modal: **Add an image** | 15 | Create author: slug taken | The slug exists. The dialog suggests a free one. | The slug "zoe-lambert" is already used by another author. · Use zoe-lambert-2 |
- upload state: state "uploading" ("Uploading 180 of 420 KB") | 16 | Create author: failed | 500 or network failure. | Couldn't create the author · The article is unchanged. Try again in a moment. |
- cancel "Cancel upload"
- Editor: status "draft", save "dirty" ## Editor: search and sharing (SEO round)
- Mobile: leading arrow-left button, trailing ellipsis
Route: `/article/:id/edit`. API: PATCH /v1/article/{id} {slug, excerpt, seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex, seo_dismissed[]}.
### Upload: rejected
RenewCMS is headless: it never renders pages or adds meta tags. This section helps the writer fill in SEO data and shows what the frontend will receive. Everything is optional and each empty field shows its fallback. Placement: a "Search and sharing" section under "Site and authors" in the sidebar and in the mobile Details sheet. Collapsed by default with a one-line summary (Ready, N suggestions, Hidden from search), remembered per browser. The fields and live previews open in a slide-over (desktop, 960 px) or a 92% sheet over the Details sheet (mobile). Anyone who can edit the article can edit these fields. Suggestions never block saving or publishing: publishing is allowed regardless. Counters are soft guides and turn amber, never red, outside the recommended range. Light and dark follow the theme switch above.
**Trigger:** Checked in the browser, so nothing is sent.
| # | State | Trigger | Copy |
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article") |---|---|---|---|
- Modal: **Add an image** | 1 | Collapsed: ready | The default. Nothing to suggest. | Search and sharing · Ready |
- upload state: state "rejected" | 2 | Collapsed: suggestions | Summary in the warning colour. No count badge elsewhere. | Search and sharing · 2 suggestions |
- cancel "Cancel" | 3 | Collapsed: hidden | noindex is on. | Search and sharing · Hidden from search |
- confirm "Choose another file" | 4 | Open: ready | Address, the all-good message and two actions. | Address · renew.example/how-we-price-a-bathroom-renovation · All good · Nothing to suggest for this article. · Edit search and sharing · What the frontend gets |
- Editor: status "draft", save "dirty" | 5 | Open: suggestions | At most three suggestions here, then a count. Each links to its field. | The meta title is 74 characters. Search engines may cut it off after about 60. · Go to meta title · The cover image has no alt text. Add a short description of it. · Go to cover image · The body has no headings. H2 headings help readers scan the article. · Go to body · +1 more in Search and sharing |
- Mobile: leading arrow-left button, trailing ellipsis | 6 | Open: checking | While the checks run after the editor loads. The rest of the section stays usable. | Checking this article… |
| 7 | Open: hidden | A line explains the flag. | Hidden from search engines. This only sets a flag in the API. |
### Upload: inserted | 8 | Open: other site | The address uses the article's site. | journal.renew.example/how-we-price-a-bathroom-renovation |
| 9 | Panel: defaults | A writer who changed nothing. Every value is a fallback and says so. | Search and sharing · URL slug · Generated from the title until you edit it. · Meta title · Using article title · Recommended 30 to 60 characters. This is a guide, not a limit. · Meta description · Using first sentence of the body · Recommended 70 to 160 characters. · Excerpt · A short summary. It is also sent as the article teaser. · Cover image · Using the site social image (north-wall.jpg). · Social sharing · Optional · Uses the meta values · Advanced · Canonical URL: automatic · Hide from search engines · Sets noindex in the API. It doesn't change who can read the article. · Search result · Social card · Suggestions · What the frontend gets · Cancel · Save changes |
**Trigger:** 201: the image goes in at the cursor, and the article now has unsaved changes. | 10 | Panel: filled in | Own values everywhere. The fallback chips are gone and the suggestion list shows All good. | All good · Nothing to suggest for this article. |
| 11 | Panel: outside the soft ranges | Counters turn amber. The preview shows the cut-off. Nothing is blocked. | The meta title is 74 characters. Search engines may cut it off after about 60. · The description is 41 characters. About 70 to 160 reads best in results. · Cut off here, the way a search engine would. |
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article") | 12 | Panel: slug edited | The Auto badge is gone. "Reset to match the title" brings it back. | Reset to match the title |
- Toast (success, circle-check): **Image added** "Save to keep it in the article." | 13 | Panel: slug taken | Checked on blur and on save. Nothing is saved until a free slug is chosen. | The slug "bathroom-costs" is already used on Renew. · Use bathroom-costs-2 |
- Editor: status "draft", save "dirty", image true | 14 | Panel: cover without alt | Alt text is required before the cover counts as ready. It is a suggestion, not an error. | Alt text · Needed before the cover counts as ready. Sent as image alt in the API. · Describe the image for people who can't see it · The cover image has no alt text. Add a short description of it. |
- Mobile: leading arrow-left button, trailing ellipsis | 15 | Panel: choose cover | The shared Media picker. | Choose a cover image · The cover for this article. · Use image |
| 16 | Panel: social sharing open | Optional overrides. Each falls back to the meta values. | Social sharing · Optional · Social title · Using meta title · Social description · Using meta description · Social image · Using the cover image. |
### Publishing | 17 | Panel: social customised | The card shows the overrides, so the Using chips disappear. | Customised |
| 18 | Panel: advanced, invalid canonical | Collapsed by default. The address must be absolute. | Advanced · Canonical URL · Only change this if the same content lives at another address. · Enter a full address starting with https:// |
**Trigger:** "Save and publish": the PATCH body, then the publish request. | 19 | Panel: hidden from search | The switch only sets a flag in the API. | Hide from search engines · Sets noindex in the API. It doesn't change who can read the article. |
| 20 | Panel: mobile search preview | The Desktop and Mobile toggle changes the width and where the title is cut. | Search result · Desktop · Mobile |
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article") | 21 | Panel: checking | Checks run on open and after each pause in typing. | Checking this article… |
- Editor: status "draft", save "saving", pending "Saving and publishing…" | 22 | Panel: saving | Fields are disabled while the request runs. | Saving… |
- Mobile: leading arrow-left button, trailing ellipsis | 23 | Panel: save failed | 500 or network failure. The values stay. | Couldn't save search and sharing. Your changes are still here. Try again in a moment. |
| 24 | Saved | The panel closes. The summary updates. | Search and sharing saved · Your search and sharing settings are up to date. |
### Published
Mobile: the section sits under "Site and authors" in the Details sheet. "Edit search and sharing" opens a 92% sheet on top of the Details sheet, with suggestions first, then fields, then the two previews. Publishing is allowed regardless of suggestions; Save and Publish are never disabled by them.
**Trigger:** 200: the badge flips, Unpublish is offered and a toast confirms.
## Editor: what the frontend gets (SEO round)
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Toast (success, circle-check): **Article published** ""How we price a bathroom renovation" is now online." Route: `/article/:id/edit`. API: GET /v1/article/{id}/seo.
- Editor: status "online", save "saved"
- Mobile: leading arrow-left button, trailing ellipsis A read-only dialog (desktop, 720 px) or bottom sheet (mobile) with the resolved SEO object exactly as the API returns it. Opened from the sidebar section or the panel. Each value that came from a fallback carries a chip. Formatted shows a compact key and value list, Raw shows the JSON. Copy JSON copies the raw object. Author name, profile URL and social links come from the Authors screen and surface in authors[] and in the JSON-LD author, so Authors itself needs no change.
### Unpublishing | # | State | Trigger | Copy |
|---|---|---|---|
**Trigger:** Unpublish doesn't save pending edits. | 1 | Formatted | Default view. | What the frontend gets · The SEO data returned by GET /v1/article/12/seo, read only. RenewCMS doesn't render pages or add meta tags: your frontend does. · Formatted · Raw JSON · Copy JSON · Close |
| 2 | Raw JSON | The JSON as the API returns it. | Raw JSON |
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article") | 3 | Copied | The button reads Copied for 2 s and this is announced. | Copied |
- Editor: status "online", save "saved", pending "Unpublishing…" | 4 | Draft article | Drafts answer 404 to anonymous callers. | This article is a draft. The API returns this data only to signed-in callers until it is published. |
- Mobile: leading arrow-left button, trailing ellipsis | 5 | Loading | Copy is disabled. | – |
| 6 | Error | GET failed. Nothing in the article changes. | Couldn't load the SEO data · GET /v1/article/12/seo failed. Your article is unchanged. Try again in a moment. · Try again |
### Delete: confirm
**Trigger:** Delete… from the overflow menu. After a 204, it goes to /article with the "Article deleted" toast.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Modal: **Delete this article?**
- text ""How we price a bathroom renovation" will be deleted. Its images stay in your library. This can't be undone."
- cancel "Cancel"
- confirm "Delete article" (danger)
- Editor: status "draft", save "saved"
- Mobile: leading arrow-left button, trailing ellipsis
### Leave with unsaved changes
**Trigger:** Back, a nav link or closing the tab.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Modal: **Leave without saving?**
- text "Your changes to "How we price a bathroom renovation" haven't been saved."
- cancel "Discard changes"
- confirm "Keep editing"
- Editor: status "draft", save "dirty"
- Mobile: leading arrow-left button, trailing ellipsis
### Offline
**Trigger:** The connection dropped. Writing continues, and Save and Publish wait.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Editor: status "draft", save "offline"
- offline: true
- Mobile: leading arrow-left button, trailing ellipsis
### Not found (404)
**Trigger:** Unknown id, or the article was deleted elsewhere.
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Editor: status "draft", save "saved", notfound true
- Mobile: leading arrow-left button, trailing ellipsis
+30
View File
@@ -0,0 +1,30 @@
# Entry editor
Route: `/content/:key/new`, `/content/:key/:id`. API: POST /v1/content-type/{key}/entry · GET/PUT/DELETE /v1/content-type/{key}/entry/{id}. Auth: any verified user.
Visual reference: `RenewCMS Screens Schema.dc.html`, groups "Entry editor". Frames: `SchemaFrame.dc.html` mounted in `AppFrame.dc.html` (shell, sidebar, topbar, drawer). Every state is drawn in light and dark (theme switch) at 1440×900 (shown at 50%) and 390×844 (shown at 75%). Copy is exact. Strings in a Copy cell are separated by " · " and listed in reading order. Nothing is clock-sensitive.
### 6 Entry editor (group "6 Entry editor", 13 states)
Route: `/content/:key/new, /content/:key/:id`. API: POST /v1/content-type/{key}/entry, GET/PUT/DELETE /v1/content-type/{key}/entry/{id}. Auth: Any verified user.
The form is generated from the schema, one control per field in schema order. Required fields carry * and the line "Fields marked * are required." Defaults are pre-filled on create and tagged Default. Saving replaces the whole entry.
| # | State | Trigger | Copy | Scope |
|---|---|---|---|---|
| 1 | New entry | /content/events/new. Online is pre-filled from its default. | New entry · Events · Fields marked * are required. · Default · Each entry needs a different title. · Between 1 and 5000. · Choose an option · Pick a date · Save · Cancel · New entry · Not saved yet | Available now |
| 2 | Editing | /content/events/42. Save is disabled until something changes. | Lime plaster workshop · Events · Entry 42 · Saved · Delete · Cancel · Save · Created by · Updated by · Inès Duarte · 12 Oct 2026, 09:14 · Marta Alves · 15 Oct 2026, 11:02 | Available now |
| 3 | Unsaved changes | Any edit. | Unsaved changes · 32 / 80 | Available now |
| 4 | Markdown preview | The Preview tab of a rich text field. | Write · Preview | Available now |
| 5 | Saving | PUT or POST in flight. Form dims. | Saving… | Available now |
| 6 | Saved | 200. | Saved at 11:02 · Entry saved · "Lime plaster workshop" is up to date. | Available now |
| 7 | Save failed | 500 or network failure. Values stay. | Save failed · Couldn't save the entry · Something went wrong on our side. Your changes are still here. Try again in a moment. · Try again | Available now |
| 8 | Required fields empty | 422 invalid_entry: title: required, starts_on: required. The first field with an error takes focus. | Fill in Title. · Pick a date for Starts on. · Not saved | Available now |
| 9 | Rule errors | 422 invalid_entry for max, type, max and options. One rule per field is shown at a time. | Title can be up to 80 characters. · 91 / 80 · Ends on must be a date, like 2026-11-21. · Capacity must be at most 5000. · Choose one of: Talk, Workshop or Meetup. · Not saved | Available now |
| 10 | Duplicate unique value (409) | 409 invalid_entry: title: unique. | Not saved · Another entry already uses this title. Change it and save again. · Another entry already uses this Title. Enter a different one. | Available now |
| 11 | Entry not found | 404 entry_not_found, for example after a delete elsewhere. | Entry not found · It may have been deleted by someone else. · Back to Events | Available now |
| 12 | Schema changed since load | 422 invalid_entry: room: required, or unknown_field. The form is read-only until reloaded. | The fields of Events changed · A field was removed and a new required field, Room, was added after you opened this form. Reload the form to continue. What you typed here can't be saved against the old fields. · Reload form | Available now |
| 13 | Delete entry: confirm | Delete. | Delete this entry? · "Lime plaster workshop" will be deleted from Events. This can't be undone. · Cancel · Delete entry | Available now |
## Media field (pass 5)
Spec, states, exact copy, measurements, backend needs and accessibility: `media-field-changes.md`, section 3.6. Board groups: 6 Entry editor.
+20 -56
View File
@@ -1,68 +1,32 @@
# Forgot password # Forgot password
- **Route:** `/register/reset/request` Route: `/register/reset/request`. API: POST /v1/auth/password-reset.
- **API:** POST /v1/auth/password-reset
- **Visual reference:** `source/RenewCMS Screens Auth.dc.html`, group "Forgot password" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
The sent state reads the same whether or not the email exists, so the page doesn't reveal which emails have accounts. The sent state reads the same whether or not the email exists, so the page doesn't reveal which emails have accounts.
## States Visual reference: `RenewCMS Screens Auth.dc.html`, groups "Forgot password". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
### Default ## Copy per state (generated from the board source, pass 2)
**Trigger:** From "Forgot password?" on log in. Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Auth.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
- Heading: **Reset your password** ### Base copy
- Description: "Enter the email for your account and we'll send you a link to choose a new password." - Reset your password
- Field `Email`: placeholder "you@example.com" - Enter the email for your account and we'll send you a link to choose a new password.
- Submit button: "Send reset link" - Send reset link
- Below the card: [Back to log in] - Back to log in
### Validation error ### Forgot password (group "Forgot password", 6 states)
**Trigger:** Invalid email on submit. Nothing is sent. Route: `/register/reset/request`. API: POST /v1/auth/password-reset.
- Heading: **Reset your password** The sent state reads the same whether or not the email exists, so the page doesn't reveal which emails have accounts.
- Description: "Enter the email for your account and we'll send you a link to choose a new password."
- Field `Email`: placeholder "you@example.com"; value "marta@renew"; **error "Enter a full email address, like name@example.com."**
- Submit button: "Send reset link"
- Below the card: [Back to log in]
### Submitting | # | State | Trigger | Copy |
|---|---|---|---|
**Trigger:** Request in flight. | 1 | Default | From "Forgot password?" on log in. | |
| 2 | Validation error | Invalid email on submit. Nothing is sent. | "marta@renew" / "Enter a full email address, like name@example.com." |
- Heading: **Reset your password** | 3 | Submitting | Request in flight. | "marta@renew.example" / "Sending…" |
- Description: "Enter the email for your account and we'll send you a link to choose a new password." | 4 | Sent | Any 200, whether or not the account exists. | "Check your email" / "If an account exists for that email, a password reset link has been sent." / "The link expires in 1 hour." / "Back to log in" |
- Field `Email`: placeholder "you@example.com"; value "marta@renew.example" | 5 | Too many attempts (429) | Rate limit with Retry-After. | "Too many requests" / "Wait a moment before asking for another link." / "marta@renew.example" / "Try again in 0:58" |
- Submit button: "Sending…", loading | 6 | Server error | 500 or no response. | "Couldn't send the link" / "Check your connection and try again." / "marta@renew.example" |
- Below the card: [Back to log in]
### Sent
**Trigger:** Any 200, whether or not the account exists.
- ResultPanel (success, mail-check): **Check your email**; text "If an account exists for that email, a password reset link has been sent."; note "The link expires in 1 hour."; actions "Back to log in" (secondary)
### Too many attempts (429)
**Trigger:** Rate limit with Retry-After.
- Relied on an API marked pending in the design; that API now exists.
- Alert (info, timer): **Too many requests** "Wait a moment before asking for another link."
- Heading: **Reset your password**
- Description: "Enter the email for your account and we'll send you a link to choose a new password."
- Field `Email`: placeholder "you@example.com"; value "marta@renew.example"
- Submit button: "Try again in 0:58", disabled
- Below the card: [Back to log in]
### Server error
**Trigger:** 500 or no response.
- Alert (error, circle-x): **Couldn't send the link** "Check your connection and try again."
- Heading: **Reset your password**
- Description: "Enter the email for your account and we'll send you a link to choose a new password."
- Field `Email`: placeholder "you@example.com"; value "marta@renew.example"
- Submit button: "Send reset link"
- Below the card: [Back to log in]
+13 -28
View File
@@ -1,39 +1,24 @@
# Global states # Global states
- **Route:** `any route` Route: `any route`. API: .
- **API:**
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Global" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
A 401 on any back-office call redirects to /login?redirect=<path>&reason=expired (see "Session expired" on the auth board). A 403 from an unverified account redirects to /register/pending. A 429 appears only on log in and password reset. Toasts sit bottom right on desktop and bottom center on mobile, at most 3 at once. Success and info last 5 s; errors stay until dismissed. A 401 on any back-office call redirects to /login?redirect=<path>&reason=expired (see "Session expired" on the auth board). A 403 from an unverified account redirects to /register/pending. A 429 appears only on log in and password reset. Toasts sit bottom right on desktop and bottom center on mobile, at most 3 at once. Success and info last 5 s; errors stay until dismissed.
## States Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Global". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
### 404 inside the shell ## Copy per state (generated from the board source, pass 2)
**Trigger:** An unknown route while logged in. Logged out, the same content appears inside AuthShell. Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
- Breadcrumbs: Not found (mobile title "Not found") ### Global states (group "Global", 4 states)
- err: {"code":"404","title":"Page not found","text":"The link may be mistyped, or the page may have moved.","primary":"Go to Home","secondary":"All articles"}
### Generic error Route: `any route`. API: none.
**Trigger:** An unexpected error while rendering a route. A 401 on any back-office call redirects to /login?redirect=<path>&reason=expired (see "Session expired" on the auth board). A 403 from an unverified account redirects to /register/pending. A 429 appears only on log in and password reset. Toasts sit bottom right on desktop and bottom center on mobile, at most 3 at once. Success and info last 5 s; errors stay until dismissed.
- Breadcrumbs: Home (mobile title "Home") | # | State | Trigger | Copy |
- err: {"code":"Error","title":"Something went wrong","text":"This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.","primary":"Try again","secondary":"Go to Home"} |---|---|---|---|
| 1 | 404 inside the shell | An unknown route while logged in. Logged out, the same content appears inside AuthShell. | "Not found" / "Page not found" / "The link may be mistyped, or the page may have moved." / "Go to Home" / "All articles" |
### Offline banner | 2 | Generic error | An unexpected error while rendering a route. | "Home" / "Error" / "Something went wrong" / "This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server." / "Try again" / "Go to Home" |
| 3 | Offline banner | navigator.onLine is false. The banner is shown on every back-office page. | |
**Trigger:** navigator.onLine is false. The banner is shown on every back-office page. | 4 | Toast stack | Three at most, newest at the bottom. A fourth pushes out the oldest non-error toast. | "Article published" / ""Winter checklist for timber windows" is now online." / "That article no longer exists" / "It was deleted somewhere else, so we removed it from the list." / "Couldn't unpublish" / "The server didn't respond. Nothing changed." / "Retry" |
- Breadcrumbs: Articles (mobile title "Articles")
- offline: true
### Toast stack
**Trigger:** Three at most, newest at the bottom. A fourth pushes out the oldest non-error toast.
- Breadcrumbs: Articles (mobile title "Articles")
- Toast (success, circle-check): **Article published** ""Winter checklist for timber windows" is now online."
- Toast (info, info): **That article no longer exists** "It was deleted somewhere else, so we removed it from the list."
- Toast (error, circle-x): **Couldn't unpublish** "The server didn't respond. Nothing changed." with action "Retry"
+38 -45
View File
@@ -1,56 +1,49 @@
# Home # Home
- **Route:** `/home` Route: `/home`. API: GET /v1/article/ · GET /v1/activity?size=5.
- **API:** GET /v1/article/
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Home" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
The greeting reads "Good morning, {name}", where the name is the display name when the user has saved one and the username otherwise. The counts come from the list, so there's no separate endpoint. The five most recently updated articles link to the editor. The counts come from the list, so there's no separate endpoint. The five most recently updated articles link to the editor. Below them, a Recent activity section shows the last 5 events (owner and admin) or the user's own (regular users), and links to Activity.
## States Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Home". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
### Default ## Copy per state (generated from the board source, pass 2)
**Trigger:** The articles loaded. Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
- Breadcrumbs: Home (mobile title "Home") ### Fixed copy in the frame
- Subtitle: "Here's where things stand on your site." - New article
- Stats: 6 articles, 4 online, 2 drafts - Couldn't load your articles
- Recent: the 5 most recently updated articles, with status - Check your connection and try again.
- Try again
- Write your first article
- It starts as a draft, so nothing appears on your site until you publish it.
- Recently updated
- All articles
- Online
- Draft
- View all
- Couldn't load recent activity
- No activity yet
- Failed
- Connect your website
- Your site reads published articles from RenewCMS. Share this guide with whoever builds it.
- Open the guide
### Loading ### Home (group "Home", 10 states)
**Trigger:** The list request is in flight. Skeletons appear after 150 ms. Route: `/home`. API: GET /v1/article/ · GET /v1/activity?size=5.
- Breadcrumbs: Home (mobile title "Home") The counts come from the list, so there's no separate endpoint. The five most recently updated articles link to the editor. Below them, a Recent activity section shows the last 5 events (owner and admin) or the user's own (regular users), and links to Activity.
- Subtitle: "Here's where things stand on your site."
- Stats and recent list: skeletons
### Empty, first run | # | State | Trigger | Copy |
|---|---|---|---|
**Trigger:** Zero articles: a single call to action, and no stats. | 1 | Default | The articles loaded. | "Here's where things stand on your site." |
| 2 | Loading | The list request is in flight. Skeletons appear after 150 ms. | "Here's where things stand on your site." |
- Breadcrumbs: Home (mobile title "Home") | 3 | Empty, first run | Zero articles: a single call to action, and no stats. | "Welcome to RenewCMS." |
- Subtitle: "Welcome to RenewCMS." | 4 | Error | A 500 or network failure on the list request. | "Here's where things stand on your site." |
- First-run empty state instead of stats and recent list | 5 | Update available (owner) | The owner only. A quiet banner above the content, and an "Update" pill on the Settings item (a dot on the menu icon on mobile, read as "Update available"). Dismissing hides the banner until the next version; the pill stays. Admins see neither. | "Here's where things stand on your site." / "Version 2.3.0 is available" / "Update it from Settings. It takes about 2 minutes and the site stays online." |
| 6 | Update ready to install (owner) | The download is checked. The same banner changes its copy, and a dismiss comes back on the next visit because the update is waiting. | "Here's where things stand on your site." / "Version 2.3.0 is ready to install" / "Restart from Settings to finish. The site is unavailable for about 10 seconds." |
### Error | 7 | Recent activity: loading | The activity request is in flight. Five skeleton rows. | "Here's where things stand on your site." |
| 8 | Recent activity: error | A failed request shows an inline alert in the section only. The rest of Home is unaffected. | "Here's where things stand on your site." |
**Trigger:** A 500 or network failure on the list request. | 9 | Recent activity: empty | No events yet. | "Here's where things stand on your site." |
| 10 | Regular user | Own events only, titled "Your recent activity". The sidebar has no Users, Integration or Settings. | "Here's where things stand on your site." |
- Breadcrumbs: Home (mobile title "Home")
- Subtitle: "Here's where things stand on your site."
- Error state for the list request
### Update available (owner)
**Trigger:** The owner only. A quiet banner above the content, and an "Update" pill on the Settings sidebar item (a dot on the menu icon on mobile, read as "Update available"). Dismissing hides the banner until the next version; the pill stays. Admins see neither.
- Breadcrumbs: Home (mobile title "Home")
- Subtitle: "Here's where things stand on your site."
- Banner: `role="region"` labelled "Update available", info tone, `circle-arrow-up` icon, title "Version 2.3.0 is available", text "Update it from Settings. It takes about 2 minutes and the site stays online.", button "Go to Settings", and a dismiss button
### Update ready to install (owner)
**Trigger:** The download is checked. The same banner changes its copy, and a dismiss comes back on the next visit because the update is waiting.
- Banner: success tone, `circle-check` icon, title "Version 2.3.0 is ready to install", text "Restart from Settings to finish. The site is unavailable for about 10 seconds.", button "Go to Settings"
+85
View File
@@ -0,0 +1,85 @@
# Image variants
Route: `/settings/media/variants`, `Slide-over on desktop · full page on mobile`, `/settings/media/cache`, `/settings · /settings/media/variants · /settings/media/cache`. API: GET /v1/image-variants · PATCH /v1/image-variants/{name} · DELETE /v1/image-variants/{name} · POST /v1/image-variants/{name}/purge · POST /v1/image-variants · PUT /v1/image-variants/{name} · GET /v1/image-variants/cache · POST /v1/image-variants/cache/purge {scope: all|unused|variant|image}.
Admin-only page under Settings → Media, with two tabs: Variants and Cache. Desktop shows a table; mobile stacks each variant as a card. The status switch always pairs with an icon and the words Enabled or Disabled. The reference card "How requests are handled" sits under the table (section 7 of the brief).
Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Variants list", "Add edit variant", "Cache panel", "Settings navigation". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
## Copy per state (generated from the board source, pass 2)
Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
### 1. Settings → Media → Variants (group "Variants list", 12 states)
Route: `/settings/media/variants`. API: GET /v1/image-variants · PATCH /v1/image-variants/{name} · DELETE /v1/image-variants/{name} · POST /v1/image-variants/{name}/purge.
Admin-only page under Settings → Media, with two tabs: Variants and Cache. Desktop shows a table; mobile stacks each variant as a card. The status switch always pairs with an icon and the words Enabled or Disabled. The reference card "How requests are handled" sits under the table (section 7 of the brief).
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Loaded | Six variants, one of them disabled (print). | |
| 2 | Loading | Skeleton rows appear after 150 ms. | |
| 3 | Empty | No variants. Two templates pre-fill the Add variant form. | |
| 4 | Error loading | A 500 or network failure on the list request. | |
| 5 | Forbidden | A signed-in user who is not an admin opens the URL. | |
| 6 | Row menu | The ellipsis button on a row. | |
| 7 | Row action in progress | Purge cache was confirmed on hero. The row is dimmed and its controls are disabled. | |
| 8 | Purge confirm | Purge cache in the row menu. | "purgeVariant" |
| 9 | Delete confirm | Delete in the row menu. | "deleteVariant" |
| 10 | Delete in progress | Delete was confirmed. The button shows a spinner. | "deleteVariant" |
| 11 | Entry: sidebar | From any page, Settings expands in the sidebar to Media: Variants and Cache. Admin only. On mobile the menu button opens it as a drawer. | |
| 12 | Request reference | Mobile scrolled to the reference card. On desktop it is visible below the table. | |
### 2. Add / edit variant (group "Add edit variant", 11 states)
Route: `Slide-over on desktop · full page on mobile`. API: POST /v1/image-variants · PUT /v1/image-variants/{name}.
Form on the left, live preview on the right (stacked on mobile). The preview uses a sample image with a before/after divider. The divider is a slider: arrow keys move it by 1%, Shift+arrow by 10%, Home and End jump to the ends. Name is locked in edit mode. The edit warning is always visible in edit mode. Preview images are placeholders.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Create: pristine | Add variant just opened. Save is disabled. | "Original" |
| 2 | Create: dirty | Valid values. The divider has keyboard focus. Mobile is scrolled to the preview. | |
| 3 | Field errors | On blur and on submit. Name format, width above 8000, quality above 100. | "Hero banner" / "WebP" / "Use lowercase letters, numbers and dashes, 2 to 32 characters." / "Enter a value from 1 to 8000." / "Enter a value from 1 to 100." |
| 4 | No dimension | Width and height are both empty on submit. | "WebP" / "Set a width, a height, or both." |
| 5 | Name already taken | POST returns 409. | "18 KB" / "A variant named “thumb” already exists. Choose another name." |
| 6 | Saving | Request in flight. Fields and buttons are disabled. | |
| 7 | Save error | A 500 or network failure. Values are kept. | |
| 8 | Edit: pristine | Opened from Edit. Name is locked. Save is disabled. | |
| 9 | Edit: dirty | Quality changed from 82 to 70. | "71 KB" |
| 10 | Unsaved changes | Close, Cancel or Escape while dirty. | "71 KB" |
| 11 | PNG: no quality | Quality is hidden when the format is PNG. | "PNG" / "310 KB" |
### 5. Settings → Media → Cache (group "Cache panel", 10 states)
Route: `/settings/media/cache`. API: GET /v1/image-variants/cache · POST /v1/image-variants/cache/purge {scope: all|unused|variant|image}.
Stat tiles, a per-variant bar list (bar length is the disk size relative to the largest variant) and the ten images with the most variant disk usage. Purge all needs the word "purge" typed. Purge unused removes files of enabled variants not requested in 30 days.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Loaded | Default view. | |
| 2 | Loading | Tiles and lists show skeletons. | |
| 3 | Empty cache | Nothing generated. Purge buttons are disabled. | |
| 4 | Purging | Progress for a purge in progress. Content is dimmed. | |
| 5 | Purge done | The cache is empty and a toast reports the result. | "Purged 1,284 files, freed 212 MB" |
| 6 | Purge failed | Part of the purge did not complete. | "Purge didn't finish" / "640 of 1,284 files were removed. Try again." / "Try again" |
| 7 | Error loading | A 500 or network failure. | |
| 8 | Purge all: confirm | The word is not typed yet. The button is disabled. | "purgeAll" |
| 9 | Purge all: confirm, typed | The input matches "purge". | "purgeAllOk" |
| 10 | Purge unused: confirm | Variants of enabled variants not requested for 30 days. | "purgeUnused" |
### Settings navigation: parent and child selected (group "Settings navigation", 5 states)
Route: `/settings · /settings/media/variants · /settings/media/cache`. API: none.
One sidebar for the whole Settings area. Settings is the parent row and expands to a Media group with Variants and Cache. Parent selected: the Settings row uses --rc-sand with --rc-on-sand. Child selected: the Settings row is not filled (--ui-text-highlighted, weight 500, chevron down) and only the child row uses --rc-sand with --rc-on-sand. Use the theme switch for dark. Hover, focus-visible and active on a selected row keep --rc-sand and --rc-on-sand; see foundations.md.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | N1 · Parent selected | /settings. Settings row filled, Media group visible, children unselected. | |
| 2 | N2 · Child selected: Variants | /settings/media/variants. Settings row unfilled and bold, Variants filled. | |
| 3 | N3 · Child selected: Cache | /settings/media/cache. Settings row unfilled and bold, Cache filled. | |
| 4 | N4 · Mobile drawer, parent selected | Menu button on /settings. Drawer rows are 44 px tall. | "Settings" |
| 5 | N5 · Mobile drawer, child selected | Menu button on /settings/media/variants. | "Settings" / "Media" / "Variants" |
+40 -30
View File
@@ -2,7 +2,7 @@
Route: `/integration/api-keys`. Component: `ApiKeysPage.dc.html`, mounted by `AppFrame`. Route: `/integration/api-keys`. Component: `ApiKeysPage.dc.html`, mounted by `AppFrame`.
- **Visual reference:** `source/RenewCMS Screens API keys.dc.html`, group "API keys", drawn by `source/ApiKeysPage.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle). Visual reference: `RenewCMS Screens API keys.dc.html`.
## API ## API
`POST /v1/api-key` · `GET /v1/api-key` · `DELETE /v1/api-key/{id}`. Access: Session only. An API key cannot manage keys; with a key these routes answer 403 "This action needs a real session." `POST /v1/api-key` · `GET /v1/api-key` · `DELETE /v1/api-key/{id}`. Access: Session only. An API key cannot manage keys; with a key these routes answer 403 "This action needs a real session."
@@ -16,7 +16,7 @@ Under Integration, as a sub-page linked from the Authentication section ("Manage
- Columns: Name, Key (prefix, mono), Status (active, expired, revoked), Created by (owners only), Created, Last used, Expires, Revoke. Owners see all keys; admins see their own. - Columns: Name, Key (prefix, mono), Status (active, expired, revoked), Created by (owners only), Created, Last used, Expires, Revoke. Owners see all keys; admins see their own.
- Create key modal: Name (required, up to 60, counter), Expiry date (optional, must be in the future). Errors: "Enter a name." / "Use 60 characters or fewer." / "Choose a date in the future." - Create key modal: Name (required, up to 60, counter), Expiry date (optional, must be in the future). Errors: "Enter a name." / "Use 60 characters or fewer." / "Choose a date in the future."
- The Expiry field is a native date input. The displayed format follows the browser's locale, so there is no custom format. The board draws one example date only. - The Expiry field is a native date input. The displayed format follows the browser's locale, so there is no custom format. The board draws one example date only.
- One-time reveal: warning "You won't be able to see this key again.", monospace key, Copy / Copied, "Closing this window discards the key from the screen. Copy it first." Button "Done". - One-time reveal: warning "You won't be able to see this key again.", the key in a CodeSnippet with one tab ("API key") and Copy / Copied, "Closing this window discards the key from the screen. Copy it first." Button "Done".
- Revoke: "Revoke this key?" / ""Nightly media import" (rcms_Mq7b…) will stop working right away. Requests that use it answer 401. This can't be undone." Buttons "Keep key", "Revoke key". Toast "Key revoked". - Revoke: "Revoke this key?" / ""Nightly media import" (rcms_Mq7b…) will stop working right away. Requests that use it answer 401. This can't be undone." Buttons "Keep key", "Revoke key". Toast "Key revoked".
- Empty: "No API keys yet" / "Create a key to call the API from a script or service." Error: "Couldn't load your API keys" / "Check your connection and try again." - Empty: "No API keys yet" / "Create a key to call the API from a script or service." Error: "Couldn't load your API keys" / "Check your connection and try again."
- Note: "Resetting a user's password revokes all of their keys." - Note: "Resetting a user's password revokes all of their keys."
@@ -25,34 +25,44 @@ Under Integration, as a sub-page linked from the Authentication section ("Manage
On mobile the "Create key" button is pinned to the bottom of the viewport (44 px high, full width, on a bar with a top border). It shows in Owner list, Admin own keys only, Loading, Empty, Error and Revoked, and is hidden behind every modal state. In Empty it replaces the inline button. The content has bottom padding so nothing sits under the bar, and the toast sits above it. Desktop keeps "Create key" in the page header (and in the Empty card). On mobile the "Create key" button is pinned to the bottom of the viewport (44 px high, full width, on a bar with a top border). It shows in Owner list, Admin own keys only, Loading, Empty, Error and Revoked, and is hidden behind every modal state. In Empty it replaces the inline button. The content has bottom padding so nothing sits under the bar, and the toast sits above it. Desktop keeps "Create key" in the page header (and in the Empty card).
## States ## States
Names are matched exactly by the visual-check script. Names are matched exactly by the visual-check script.
- Owner: list
### Owner: list - Admin: own keys only
- Loading: the list request is in flight. Skeleton rows appear only after 150 ms.
### Admin: own keys only - Empty
- Error: a 500 or network failure on the list request. If a key is created while this state is showing, the list reloads, the new key appears and the error alert is cleared.
### Loading - Create key
the list request is in flight. Skeleton rows appear only after 150 ms. - Create key: validation
- Create key: name too long
### Empty - One-time reveal
- One-time reveal: copied
### Error - Revoke: confirm
a 500 or network failure on the list request. If a key is created while this state is showing, the list reloads, the new key appears and the error alert is cleared. - Revoked
### Create key
### Create key: validation
### Create key: name too long
### One-time reveal
### One-time reveal: copied
### Revoke: confirm
### Revoked
## Component inventory (Nuxt UI v4) ## Component inventory (Nuxt UI v4)
`UTable`, `UBadge`, `UModal` with `UForm`/`UFormField`/`UInput` (type date), `UAlert` color warning, `UButton`, `useClipboard`. `UTable`, `UBadge`, `UModal` (dialog on desktop, bottom sheet on mobile, footer secondary left and primary right) with `UForm`/`UFormField`/`UInput` (type date), `UAlert` color warning, `UButton`, CodeSnippet for the key.
## Copy per state (generated from the board source, pass 2)
Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens API keys.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
### API keys (group "API keys", 12 states)
Route: `/integration/api-keys`. API: GET /v1/api-key · POST /v1/api-key · DELETE /v1/api-key/{id} (session only).
Recommendation: place it under Integration, as a sub-page linked from the Authentication section and from the sidebar item. Keys exist to call the API, so they belong next to the reference. Settings is owner-only today, but admins can also create keys. The screen is session only: an API key cannot manage keys. Key name: required, up to 60 characters. Expiry: optional, in the future. A key is shown in full once; afterwards only its prefix is visible.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Owner: list | Owners see every user's keys and a "Created by" column. Expired keys keep their Revoke action. | "Integration" / "API keys" |
| 2 | Admin: own keys only | Admins see only the keys they created, with no "Created by" column. | "Integration" / "API keys" |
| 3 | Loading | The list request is in flight. Skeleton rows appear after 150 ms. | "Integration" / "API keys" |
| 4 | Empty | No keys yet. | "Integration" / "API keys" |
| 5 | Error | A 500 or network failure on the list request. | "Integration" / "API keys" |
| 6 | Create key | "Create key" in the page header, or the bottom action on mobile. Name is required and limited to 60 characters; expiry is optional. | "Integration" / "API keys" |
| 7 | Create key: validation | On submit or blur. Nothing is sent. The name is empty and the expiry is in the past. | "Integration" / "API keys" |
| 8 | Create key: name too long | Over 60 characters. Checked as you type. | "Integration" / "API keys" |
| 9 | One-time reveal | 201: the full key is shown once. Closing the dialog discards it from the screen. | "Integration" / "API keys" |
| 10 | One-time reveal: copied | Copy was pressed. The button reads "Copied" for 2 s and this is announced. | "Integration" / "API keys" |
| 11 | Revoke: confirm | Revoke on an active or expired row. | "Integration" / "API keys" |
| 12 | Revoked | 204: the key stays in the list, greyed, with no Revoke action. A toast confirms. | "Integration" / "API keys" |
+147 -29
View File
@@ -2,7 +2,7 @@
Route: `/integration`. Component: `IntegrationPage.dc.html`, mounted by `AppFrame`. The API keys sub-page is in `screens/integration-api-keys.md`. Route: `/integration`. Component: `IntegrationPage.dc.html`, mounted by `AppFrame`. The API keys sub-page is in `screens/integration-api-keys.md`.
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Integration", drawn by `source/IntegrationPage.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle). Visual reference: `RenewCMS Screens Back-office.dc.html`, group "Integration".
## Navigation decision ## Navigation decision
Sticky section index on the right (desktop), "Jump to" select (mobile). A reference is read in one scroll and searched with the browser, so tabs would hide content and break find-in-page and deep links; the index keeps one page with anchors and shows position. Sticky section index on the right (desktop), "Jump to" select (mobile). A reference is read in one scroll and searched with the browser, so tabs would hide content and break find-in-page and deep links; the index keeps one page with anchors and shows position.
@@ -10,55 +10,40 @@ Sticky section index on the right (desktop), "Jump to" select (mobile). A refere
## Page copy ## Page copy
- Header: breadcrumb "Integration", title "API reference", description "Everything a developer needs to build on RenewCMS." - Header: breadcrumb "Integration", title "API reference", description "Everything a developer needs to build on RenewCMS."
- Intro: "RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office." - Intro: "RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office."
- Base URL: label "Base URL", button "Copy" / "Copied" (2 s, announced). - Base URL: label "Base URL", a CodeSnippet with one tab ("Base URL") and the button "Copy" / "Copied" (2 s, announced).
- Authentication, card "Public": "No key needed. Two read-only routes, and only online articles are returned." `GET /v1/article/`, `GET /v1/article/{id}`. - Authentication, card "Public": "No key needed. Two read-only routes, and only online articles are returned." `GET /v1/article/`, `GET /v1/article/{id}`.
- Card "API key": "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator." CodeBlock tabs curl, fetch (GET /v1/auth/me). "Keys start with `rcms_` and are shown in full once, when you create them." Link "Manage API keys". - Card "API key": "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator." CodeSnippet tabs curl, fetch (GET /v1/auth/me). "Keys start with `rcms_` and are shown in full once, when you create them." Link "Manage API keys".
- Note 1: "A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified." - Note 1: "A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified."
- Note 2: "Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header." - Note 2: "Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header."
- Groups: Articles (7), Media (6), Users and invites (6), Settings (1), Session only (12 routes, 3 sub-groups; every `/v1/system` route is in this group). Session-only banner: "These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session."" - Groups, in index order: Authentication, Articles (7), Search and sharing (2), Content types (5), Entries (5), Built-in types (2), Media (10), Sites and authors (2), Images and variants, Users and invites (6), Settings (1), Session only (35 routes in 6 sub-groups: System, Sign in and account, API key management, Sites and authors writes, Image variants, Activity; every `/v1/system` route is in this group), Errors, Notes. Session-only banner: "These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session.""
- Errors: "Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain." Table 400, 401, 403, 404, 429, 500 with the real messages below. Sample: a status line plus the text body (`HTTP/1.1 403 Forbidden`, `Content-Type: text/plain`, blank line, `This action needs a real session.`). - Errors: "Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain." Table 400, 401, 403, 404, 429, 500 with the real messages below. The 403 row has a note line under it: "Site SEO defaults are owner only. Article SEO fields need only the right to edit the article." (12/18, `--ui-text-muted`, in the cause column, 2 px under the cause text). It is the only row with a note; rows below it move down by the wrapped note height (two 18 px lines on desktop, three on mobile, plus 2 px). Sample: a status line plus the text body (`HTTP/1.1 403 Forbidden`, `Content-Type: text/plain`, blank line, `This action needs a real session.`).
- Notes: image URL prefix, CORS (existing copy), and "Article routes accept `?format=markdown`, `?format=html` or `?format=json` to choose how `body` is returned. With `json`, `body` is the stored JSON structure. Articles have `body_format: markdown` by default." - Notes: image URL prefix, CORS (existing copy), and "Article routes accept `?format=markdown`, `?format=html` or `?format=json` to choose how `body` is returned. With `json`, `body` is the stored JSON structure. Articles have `body_format: markdown` by default."
## EndpointCard states ## EndpointCard states
- Collapsed (44 px row): method badge, mono path, one-line description, chips, chevron. Desktop is one line; mobile is three (method and path, description, chips). - Collapsed (44 px row): method badge, mono path, one-line description, chips, chevron. Desktop is one line; mobile is three (method and path, description, chips).
- Expanded: "Request body" table (Name, Type, Required, Rule; "No request body." when empty), tabs fetch / curl / Response with one CodeBlock and Copy, then "Errors": status chip plus one-line caption (also its `title` tooltip). - Expanded: "Request body" table (Name, Type, Required, Rule; "No request body." when empty), tabs fetch / curl / Response in one CodeSnippet with Copy, then "Errors": status chip plus one-line caption (also its `title` tooltip).
- Session only: dashed border, muted fill, lock icon, "Session only" chip, no method colour, no expand. - Session only: dashed border, muted fill, lock icon, "Session only" chip, no method colour, no expand.
- Chips: access "Public", "Verified user", "Owner only"; key "API key OK" (success soft) or "Session only" (dashed, muted). - Chips: access "Public", "Verified user", "Owner only"; key "API key OK" (success soft) or "Session only" (dashed, muted).
- Method badges: GET `--ui-bg-inverted`, POST success soft, PATCH warning soft, DELETE error soft. Existing tokens only. - Method badges: GET `--ui-bg-inverted`, POST success soft, PATCH warning soft, DELETE error soft. Existing tokens only.
## States ## States
Desktop 1440×900, mobile 390×844, light and dark. Names are matched exactly by the visual-check script. Desktop 1440×900, mobile 390×844, light and dark.
1 Default · 2 Media group, POST /v1/media/ expanded with curl tab · 3a Base URL copied · 3b CodeSnippet copied · 4 Authentication · 5 Session only · 6 Errors · 7 Navigation (index active item Media; mobile select open).
### 1. Default
### 2. Group scrolled, one card expanded
### 3a. Copied: base URL
### 3b. Copied: CodeBlock
### 4. Authentication
### 5. Session only
### 6. Errors
### 7. Navigation
## Error messages ## Error messages
Plain text, one sentence. Used in the table and in the per-endpoint error captions. Plain text, one sentence. Used in the table and in the per-endpoint error captions.
- 400: "The request cannot be processed due to a mismatch in the format of the body." / "The expiry must be a date in the future." - 400: "The request cannot be processed due to a mismatch in the format of the body." / "The expiry must be a date in the future." Image requests (`/media/photo.png?v=`) answer 400 with a JSON body (`status`, `error`, `message`, `detail`) in two cases: `{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "unknown" }` for an unknown or disabled variant, and `{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "width" }` for any other query parameter (`width`, `height`, `format`, `quality`). Both are rows of the "Responses" table in "Images and variants".
- 401: "You must be logged in to access this resource." - 401: "You must be logged in to access this resource."
- 403: "This action needs a real session." (session-only routes called with a key) / "Only the owner can do this." / "You must verify your account to access this resource." - 403: "This action needs a real session." (session-only routes called with a key) / "Only the owner can do this." / "You must verify your account to access this resource." Note under the 403 row: "Site SEO defaults are owner only. Article SEO fields need only the right to edit the article." The inline parenthetical that said the same thing in the row's cause text was removed to avoid saying it twice.
- 404: "Article not found" / "Image not found" / "Invite not found" / "API key not found" - 404: "Article not found" / "Image not found" / "Invite not found" / "API key not found" / `site_not_found` (GET /v1/site/{key}/sitemap only; body is the code, not a sentence)
- 429: "Too many requests. Please try again later." A per-key limit also sends `Retry-After`. - 429: "Too many requests. Please try again later." A per-key limit also sends `Retry-After`.
- 500: "Internal server error" - 500: "Internal server error"
- 409, only as examples under their endpoints: `POST /v1/user/invite` "This address already has an account."; `POST /v1/media/merge` "Some of the images to merge changed. Refresh and try again." There is no 409 or 422 row in the table. - 409, only as examples under their endpoints: `POST /v1/user/invite` "This address already has an account."; `POST /v1/media/merge` "Some of the images to merge changed. Refresh and try again." There is no 409 or 422 row in the table.
- 413 and 415 on `POST /v1/media/` keep their earlier plain captions; no real message is known. - 413 and 415 on `POST /v1/media/` keep their earlier plain captions; no real message is known.
## Request bodies ## Request bodies
- `POST /v1/article/`: `title` (string, required, 3 to 200 characters), `body` (string, required, max 10000). - `POST /v1/article/`: `title` (string, required, 3 to 200 characters), `body` (string, optional, no length limit).
- `PATCH /v1/article/{id}`: `body` (string, required, max 10000). - `PATCH /v1/article/{id}`: `body` (string, optional, no length limit).
- `PATCH /v1/media/{id}` (rename): `filename` (string, required). - `PATCH /v1/media/{id}` (rename): `filename` (string, required).
- `POST /v1/media/merge`: `keep_id` (number, required, > 0), `merge_ids` (number[], required, at least 1, each > 0). - `POST /v1/media/merge`: `keep_id` (number, required, > 0), `merge_ids` (number[], required, at least 1, each > 0).
- `POST /v1/media/`: multipart/form-data, 1 file field (shown as "multipart/form-data, 1 file field"). - `POST /v1/media/`: multipart/form-data, 1 file field (shown as "multipart/form-data, 1 file field").
@@ -67,7 +52,7 @@ Plain text, one sentence. Used in the table and in the per-endpoint error captio
- `POST /v1/system/update/restart`: optional body `{ "kind": "update" | "restart" }`. - `POST /v1/system/update/restart`: optional body `{ "kind": "update" | "restart" }`.
## Component inventory and Nuxt UI v4 ## Component inventory and Nuxt UI v4
- EndpointCard (extended): `UCollapsible` (trigger is a button with aria-expanded), `UBadge` (method, chips, status), `UTable` (body fields), `UTabs` (fetch, curl, Response), CodeBlock. - EndpointCard (extended): `UCollapsible` (trigger is a button with aria-expanded), `UBadge` (method, chips, status), `UTable` (body fields), CodeSnippet (fetch, curl, Response). CodeSnippet is the only code block in the product: Integration, Integration images, API keys (one-time key) all use it. Selected tab: 2 px `--ui-primary` underline. Syntax colouring was dropped so there is one rendering.
- SectionIndex (new): custom `<nav>` of anchor buttons with `aria-current`; sticky, scroll-spy. Mobile: `USelectMenu` labelled "Jump to". - SectionIndex (new): custom `<nav>` of anchor buttons with `aria-current`; sticky, scroll-spy. Mobile: `USelectMenu` labelled "Jump to".
- SessionOnlyGroup (new): `UAlert`-style info note plus list rows; no new colour. - SessionOnlyGroup (new): `UAlert`-style info note plus list rows; no new colour.
- ErrorTable: `UTable`. Info notes: existing note style (or `UAlert` variant `naked`). - ErrorTable: `UTable`. Info notes: existing note style (or `UAlert` variant `naked`).
@@ -80,3 +65,136 @@ Plain text, one sentence. Used in the table and in the per-endpoint error captio
## Tokens ## Tokens
No new tokens. Foundations check: colours, radius (0) and spacing come from existing `--ui-*` and `--rc-*` values; every control has a 2 px primary focus ring; index, select and cards are keyboard operable. Open question: the board hard-codes a few pixel sizes, as the other screens do. No new tokens. Foundations check: colours, radius (0) and spacing come from existing `--ui-*` and `--rc-*` values; every control has a 2 px primary focus ring; index, select and cards are keyboard operable. Open question: the board hard-codes a few pixel sizes, as the other screens do.
## Copy per state (generated from the board source, pass 2)
Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
### Integration: API reference (group "Integration", 8 states)
Route: `/integration`. API: none (static). Replaces "Connect your website"..
Navigation: a sticky section index on the right on desktop, and a "Jump to" select on mobile. A long reference is read in one scroll and searched with the browser, so tabs would hide content and break find-in-page; the index keeps every group on one page, links to anchors and shows where you are. Written for a developer. The base URL is window.location.origin. The API keys screen has its own board.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | 1. Default | Opened from the sidebar or the Home card. Every endpoint card is collapsed. Desktop shows the sticky index on the right; mobile shows the "Jump to" select above the content. | "Integration" |
| 2 | 2. Group scrolled, one card expanded | POST /v1/media/ opened in the Media group: multipart body with 1 file field, curl tab shown. Only one example block is visible per card. Other cards stay collapsed. | "Integration" |
| 3 | 3a. Copied: base URL | The base URL was copied. The button reads "Copied" for 2 s and this is announced. | "Integration" |
| 4 | 3b. Copied: CodeBlock | The Copy button in the API key example after a click. Back to "Copy" after 2 s. | "Integration" |
| 5 | 4. Authentication | The API key card with its example, the "Manage API keys" link (goes to the API keys board) and the two notes. | "Integration" |
| 6 | 5. Session only | Routes API keys cannot call. Dashed, muted and locked, so they read as unavailable and not as an error. | "Integration" |
| 7 | 6. Errors | Status table and the plain-text error response (status line plus a one-sentence body). Key-specific causes are in the 401, 403 and 429 rows. | "Integration" |
| 8 | 7. Navigation | Desktop: the sticky index with the active item (Media). Mobile: the "Jump to" select open. Keyboard: the index buttons and the select are in the tab order, Enter jumps, Escape closes the list. | "Integration" |
### Integration: images and ?v= (group "Integration: images", 9 states)
Route: `/integration#images`. API: GET /v1/image-variants (existing).
New section "Images and variants" on the Integration page, after Media and before Errors. It explains the ?v=<variant-name> parameter, shows one CodeSnippet with curl, HTML and JavaScript tabs, a table of enabled variants, a URL builder and the response codes. When there are no variants, admins see a link to /settings/media/variants and other users see plain information. A removed variant is not replaced by the original: the request returns 400 with the body shown in the builder.
| # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Many variants | Twelve enabled variants. The table scrolls at 264 px. On mobile the table and snippet scroll horizontally. | |
| 2 | One variant | A single enabled variant. The snippets use its name. | |
| 3 | No variants, admin | Empty state with a link to /settings/media/variants. | |
| 4 | No variants, not an admin | Plain information, no link. | |
| 5 | Variant removed | The builder holds "hero", which was deleted. Warning with the exact 400 body. | |
| 6 | Loading | Skeleton blocks while variants load. Snippets are hidden until the list is known. | |
| 7 | Error loading | 500 or network failure. Snippets still render with the placeholder name. | |
| 8 | Snippet copied | Copy pressed on the snippet. The label reads Copied for 2 s and is announced politely. | |
| 9 | URL builder copied | Copy pressed on the built URL. | |
## Pass 2 notes
- The base URL field and the image URL builder output are CodeSnippets with a single tab.
- Page padding 24 px (16 px on mobile), page title 20/28, section headings 18/26, card titles 15/24, sub headings 14/22 (foundations.md).
## Search and sharing (SEO round)
New group "Search and sharing" after Articles, in the sidebar index and the mobile Jump to select. Two cards in the existing endpoint style (collapsed, method badge, auth badge "Public", example, 401 and 404 rows, copy buttons):
- GET /v1/article/{id}/seo: the resolved SEO object. Fields: title, description, canonical_url, robots, og{type,title,description,url,site_name,image,image_alt,locale}, twitter{card,title,description,image,image_alt}, image{url,alt,source}, authors[{name,url,same_as}], published_at, modified_at, fallbacks, json_ld. Drafts answer 404 to anonymous callers.
- GET /v1/site/{key}/sitemap?page=&size=: yes, shown per site by key. Items: { url, modified_at, noindex }. Total in X-Total-Count. Sizes 25, 50, 100, 500, default 100. Errors: 400 "The request cannot be processed due to a mismatch in the format of the body." (same text as the other routes; bad `page` or `size`), 401, 404 `site_not_found` (unknown site key; this is the body the API returns, shown as a code and not rewritten as prose).
PATCH /v1/article/{id} lists the new optional fields (slug, excerpt, seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex, seo_dismissed). The 403 row of the HTTP status table adds: site SEO defaults are owner only; article SEO fields need only the right to edit the article.
## Search (jump-to palette)
Board: group "Integration", states 8a to 8h, desktop 1440×900 and mobile 390×844, light and dark. Search never hides or filters the page. It only jumps, so find-in-page and deep links keep working.
### Behaviour
- Trigger, desktop: a field-style button in the page header, right of the title block, 320×36. Placeholder "Search the API reference…", "/" key hint. Mobile: 44×44 icon button right of "Jump to", inside the sticky bar. Opens with "/" (when focus is not in an input), Ctrl+K and Cmd+K. Both shortcuts are announced with aria-keyshortcuts.
- Palette: modal, 720 px wide, 96 px from the top, on the existing overlay. Mobile: full-screen sheet with a "Cancel" button (44 px) in the input row. Focus goes to the input; on close it returns to the trigger.
- Groups: "Endpoints" (max 6 rows, then "N more endpoints. Keep typing to narrow the list."), then "Sections". Empty query: one group "Suggested" with 5 rows.
- Match: every space-separated term must appear (case-insensitive substring) in method, path, description or access chip, or in a request field name, or in an error code or message. Rows matched only in a field or error show a second line ("Body field", "Query parameter" or "Error") with the match. Terms are highlighted with --rc-sand / --rc-on-sand. Rows matched in the path or description sort first.
- Selecting: Enter or click closes the palette, scrolls to the card (or section heading), expands the card, moves focus to the card button, and flashes the row (--rc-sand fill, 2 px --ui-primary ring, 1.5 s, then the open-card fill). With prefers-reduced-motion the flash is a static ring with no fade. The index active item updates through the existing scroll-spy. The URL hash becomes #ep-<id> so the result can be shared.
- Keys: Up and Down move (wrap), Enter opens, Esc closes, Home and End go to the first and last row. Typing resets the selection to the first row.
- Roles: input role="combobox" with aria-expanded, aria-controls, aria-autocomplete="list", aria-activedescendant; list role="listbox"; groups role="group" with aria-labelledby; rows role="option" with aria-selected and a full aria-label (method, path, description, access). A polite live region announces the count.
### Row anatomy
- Endpoint: method badge (existing colours), mono path 13/500, one-line description (ellipsis), access chip. Session-only: dashed muted badge, 400 weight path, dashed chip with lock icon, text --ui-text-muted.
- Section: icon in the badge column (hash, or lock for Session only), title 500, muted description.
- Selected: --ui-bg-elevated with a 2 px --ui-primary bar on the left (same as the section index active item). Hover: --ui-bg-elevated.
- Mobile rows: 56 px min height, badge and path on line 1, chip then description on line 2.
### Copy per state
| # | State | Copy |
|---|---|---|
| 8a | Trigger focused | Placeholder "Search the API reference…"; key hint "/"; aria-label "Search the API reference" |
| 8b | Open, empty | Input placeholder "Search the API reference…"; group "Suggested": Authentication ("Public access and API keys"), POST /v1/media/ ("Upload an image (multipart)." Verified user), Errors ("Status codes and messages"), GET /v1/article/ ("List articles, one page at a time." Public), PATCH /v1/user/me ("Update my profile." Verified user); footer "↑↓ Move", "↵ Open", "Esc Close", count "5 suggestions"; live "5 suggestions" |
| 8c | Typing "media" | Groups "Endpoints" (6 media routes) and "Sections" (Media, "10 routes"); count and live "17 results, showing 7" |
| 8d | "404", third row selected | Endpoints list the six first 404 routes with an "Error" line, for example "404 Article not found"; Sections: Errors ("Status codes and messages"); "N more endpoints. Keep typing to narrow the list."; count "22 results"; live "22 results, showing 7" |
| 8e | "keep_id" | POST /v1/media/merge, "Merge identical copies into one image.", Verified user, line "Body field keep_id"; "1 result" |
| 8f | "login" | POST /v1/auth/login, "Log in", chip "Session only" (lock); Section "Session only", "35 routes, not available to API keys"; "2 results" |
| 8g | No results | No match for "xyz". Try a path, a method or a status code like 404. Count "0 results"; live "No results" |
| 8h | After selecting | Page only: POST /v1/media/merge expanded and highlighted; index and Jump to read "Media" |
### Nuxt UI v4 mapping
- Trigger: UButton variant="outline" color="neutral" with a UIcon and UKbd (desktop); UButton icon-only (mobile). Shortcut: defineShortcuts({ '/': open, meta_k: open, ctrl_k: open }).
- Palette: UModal (fullscreen on mobile via the fullscreen prop) wrapping UCommandPalette with :groups, v-model:search-term, placeholder, :close (mobile "Cancel"). Fuse options off for the endpoint group (our own multi-field match), highlight via the #item-label and #item-description slots. UCommandPalette provides the combobox and listbox roles, aria-activedescendant and arrow-key handling.
- Rows: custom #item slot with UBadge (method, access chip), mono path, UIcon (lock, hash). Footer: #footer slot with UKbd for ↑ ↓ ↵ Esc. Empty: #empty slot. Live region: a visually hidden element with aria-live="polite".
- Flash: a CSS class on the EndpointCard row driven by a ref; uses var(--rc-sand) and var(--ui-primary) only.
### Assumptions to confirm
- Search is client-side over a static index built from the same data as the cards (method, path, first sentence of the description, access, request fields, error codes and messages). It does not search code samples, prose or response examples.
- Matching is substring per term, not fuzzy ("mdia" does not match "media").
- The palette shows the first sentence of each endpoint description, not the full text.
- Endpoints are capped at 6 rows with a count line; sections are never capped.
- Recent searches are left out. Open question: store the last 3 queries in localStorage?
- "/" is ignored while a text field has focus. Cmd+K on this route does not clash with any back-office shortcut (to confirm).
- The search trigger scrolls away with the header on desktop. Open question: keep it sticky next to the section index?
- Highlight uses --rc-sand on --rc-on-sand. On the selected row (--ui-bg-elevated) the two tones are close; the dark text keeps contrast. No new token is needed.
- The 1.5 s flash duration and the #ep-<id> hash are proposals.
## Custom content types round
Board: group "Integration", states 9a to 9j. Source: `IntegrationPage.dc.html`, `IntegrationSearch.dc.html`. Every route an API key can call is now documented.
### New groups
- Content types (`ctypes`): description "Define your own kind of content, like events or recipes. An owner or admin creates a type made of 1 to 50 ordered fields; the type then has its own entry routes." A "Field types" table (Type, Value in an entry, Rules) and a sample field object sit above the routes (anchor `ct-fields`).
- `GET /v1/content-type/` and `GET /v1/content-type/{key}`: Verified user. 404 `content_type_not_found`.
- `POST /v1/content-type/`: Owner or admin. Body `key` (2 to 40, lowercase letters, digits, hyphens), `name` (max 100), `icon` (optional), `fields` (1 to 50). 409 `content_type_key_taken`.
- `PUT /v1/content-type/{key}`: Owner or admin. Body `name`, `icon`, `fields`. 409 `schema_incompatible`.
- `DELETE /v1/content-type/{key}`: Owner or admin. 204. 409 `content_type_has_entries`, 409 `content_type_referenced`.
- Entries (`entries`): `GET` and `POST /v1/content-type/{key}/entry`, `GET`, `PUT` and `DELETE /v1/content-type/{key}/entry/{id}`. Verified user. List query `page`, `size` (5, 25, 50, 100), `sort` (`id`, `created_at`, `updated_at`, then `:asc` or `:desc`); the response is `{ items, total, page, size }`, not a bare array. Body `data` (object keyed by field key). `media` in a response is keyed by field key. 409 and 422 bodies are both `invalid_entry: <reason>`; 409 only when a unique field clashes. Id 400: "The server expects the ID to be in the format of an unsigned 32-bit integer (uint32)."
- Built-in types (`builtin`): `GET /v1/builtin-type/` and `/{key}`, Owner or admin. 404 `builtin_type_not_found`.
- Sites and authors (`sites`): `GET /v1/site/` and `GET /v1/author/` (paged, `{ items, total, page, size }`), Verified user.
- Media adds `GET` and `PATCH /v1/media/purge-settings` (`auto_purge_enabled`, `auto_purge_interval_days` 1 to 365; response also `last_purge_at`, `last_purge_merged`), `GET /v1/media/purge-preview` and `POST /v1/media/purge` (optional `fingerprint` from the preview). Owner or admin.
- PUT uses the PATCH badge colours.
### Access and errors
- 403 rows: "Only owners and admins can do this." (owner-or-admin routes) and "Only the owner can do this.", both listed in the 403 row; the existing note stays.
- 404 adds `content_type_not_found`, `entry_not_found`, `builtin_type_not_found`. 409 and 422 are real rows now (earlier text said there is no such row). Code-style text is the literal body.
- Authentication copy says a key can also manage content types and entries, and owner or admin routes answer 403 to a key whose creator is neither.
### Search
The index covers the new endpoints, body fields (`data`, and the events sample keys such as `capacity`) and error codes. "entry" also matches "entries". "N more endpoints" is singular for one.
### States 9a to 9j
9a create a content type, curl tab · 9b entries list, Response tab · 9c entry replace with the 409 and 422 errors · 9d Errors table · 9e Session only, 6 sub-groups · 9f search "entry" · 9g search "capacity" (Body field) · 9h search "schema_incompatible" (Error) · 9i search "content type" (Sections) · 9j navigation with scroll-spy on Content types. 8c now announces "17 results, showing 7" and 8d "22 results, showing 7" (the copy table above predates this; the board is right).
### Final round
- `POST /v1/media/purge` and the built-in type samples now match the API (no `icon`; keys `article`, `author`, `site`, `media`, `user`). Built-in types are read-only through the API.
- `GET /v1/media/` takes `page` (default 1) and `size` (5, 25, 50, 100; default 25), returns a JSON array and sends the total in `X-Total-Count`; 400 "Invalid page or size."
- Counts: Articles 7, Search and sharing 2, Content types 5, Entries 5, Built-in types 2, Media 10, Sites and authors 2, Users and invites 6, Settings 1, Session only 35. Search index: 75 endpoints (40 documented, 35 session-only).
- Announced: 8c "17 results, showing 7", 8d "22 results, showing 7", 8f "2 results", 9f "8 results, showing 7", 9g "2 results", 9h "1 result", 9i "12 results, showing 8".
- Illustrative only: built-in sample fields, purge-preview sample, the reason after `invalid_entry:`, session-only row labels, entry `created_by`/`updated_by`, `cover` media data.
- Not confirmed by the design tool: dark mode, the search palette on mobile and the Session only list on mobile. `task visual:check` is the gate.
- `POST /v1/content-type/` and `POST …/entry` answer 201; POST cards show only the JSON body.
+22 -95
View File
@@ -1,107 +1,34 @@
# Log in # Log in
- **Route:** `/login` Route: `/login`. API: GET /v1/auth/registration (pending) · POST /v1/auth/login.
- **API:** GET /v1/auth/registration (pending) · POST /v1/auth/login
- **Visual reference:** `source/RenewCMS Screens Auth.dc.html`, group "Log in" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
After logging in, you go back to the page you originally asked for (?redirect=), or to /home. An unverified account goes to /register/pending. "Create the owner account" appears only when registration is open. After logging in, you go back to the page you originally asked for (?redirect=), or to /home. An unverified account goes to /register/pending. "Create the owner account" appears only when registration is open.
## States Visual reference: `RenewCMS Screens Auth.dc.html`, groups "Log in". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
### Default ## Copy per state (generated from the board source, pass 2)
**Trigger:** Registration is closed, which is the usual case. Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Auth.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
- Heading: **Log in** ### Base copy
- Description: "Welcome back. Log in to manage your articles." - Log in
- Field `Username`: placeholder "Your username" - Welcome back. Log in to manage your articles.
- Field `Password`: password with show/hide; link "Forgot password?" - Log in
- Submit button: "Log in"
### Fresh install ### Log in (group "Log in", 9 states)
**Trigger:** GET /v1/auth/registration returns { open: true }. Route: `/login`. API: GET /v1/auth/registration (pending) · POST /v1/auth/login.
- Relied on an API marked pending in the design; that API now exists. After logging in, you go back to the page you originally asked for (?redirect=), or to /home. An unverified account goes to /register/pending. "Create the owner account" appears only when registration is open.
- Heading: **Log in**
- Description: "Welcome back. Log in to manage your articles."
- Field `Username`: placeholder "Your username"
- Field `Password`: password with show/hide; link "Forgot password?"
- Submit button: "Log in"
- Footer: First time here? [Create the owner account]
### Validation errors | # | State | Trigger | Copy |
|---|---|---|---|
**Trigger:** Submit with a short username and an empty password. Nothing is sent. | 1 | Default | Registration is closed, which is the usual case. | |
| 2 | Fresh install | GET /v1/auth/registration returns { open: true }. | "First time here?" / "Create the owner account" |
- Heading: **Log in** | 3 | Validation errors | Submit with a short username and an empty password. Nothing is sent. | "Use at least 3 characters." / "Enter your password." |
- Description: "Welcome back. Log in to manage your articles." | 4 | Submitting | The request is in flight. Fields and button are disabled. | "Logging in…" |
- Field `Username`: placeholder "Your username"; value "ma"; **error "Use at least 3 characters."** | 5 | Wrong credentials (403) | The API doesn't say which field is wrong, so neither does the message. The password is cleared and gets focus. | "That username and password don't match" / "Check both and try again. Passwords are case-sensitive." |
- Field `Password`: password with show/hide; link "Forgot password?"; **error "Enter your password."** | 6 | Too many attempts (429) | Retry-After is counted down. The button re-enables at zero. | "Too many attempts" / "For your security, wait a moment before trying again." / "Try again in 0:42" |
- Submit button: "Log in" | 7 | Session expired | Redirected from a 401 with ?redirect=/article/12/edit&reason=expired. Unsaved edits are kept in the browser. | "Your session expired" / "Log in again to pick up where you left off. Unsaved changes are kept on this device." |
| 8 | Password changed | Arrived from a successful reset with ?reason=password-changed. | "Log in with your new password." |
### Submitting | 9 | Server or network error | A 500 or no response. The fields keep their values. | "Couldn't reach RenewCMS" / "Check your connection and try again." |
**Trigger:** The request is in flight. Fields and button are disabled.
- Heading: **Log in**
- Description: "Welcome back. Log in to manage your articles."
- Field `Username`: placeholder "Your username"; value "marta"
- Field `Password`: value "limewash2026"; password with show/hide; link "Forgot password?"
- Submit button: "Logging in…", loading
### Wrong credentials (403)
**Trigger:** The API doesn't say which field is wrong, so neither does the message. The password is cleared and gets focus.
- Alert (error, circle-x): **That username and password don't match** "Check both and try again. Passwords are case-sensitive."
- Heading: **Log in**
- Description: "Welcome back. Log in to manage your articles."
- Field `Username`: placeholder "Your username"; value "marta"
- Field `Password`: password with show/hide; focused; link "Forgot password?"
- Submit button: "Log in"
### Too many attempts (429)
**Trigger:** Retry-After is counted down. The button re-enables at zero.
- Relied on an API marked pending in the design; that API now exists.
- Alert (info, timer): **Too many attempts** "For your security, wait a moment before trying again."
- Heading: **Log in**
- Description: "Welcome back. Log in to manage your articles."
- Field `Username`: placeholder "Your username"; value "marta"
- Field `Password`: value "limewash"; password with show/hide; link "Forgot password?"
- Submit button: "Try again in 0:42", disabled
### Session expired
**Trigger:** Redirected from a 401 with ?redirect=/article/12/edit&reason=expired. Unsaved edits are kept in the browser.
- Alert (info, clock): **Your session expired** "Log in again to pick up where you left off. Unsaved changes are kept on this device."
- Heading: **Log in**
- Description: "Welcome back. Log in to manage your articles."
- Field `Username`: placeholder "Your username"; value "marta"
- Field `Password`: password with show/hide; focused; link "Forgot password?"
- Submit button: "Log in"
### Password changed
**Trigger:** Arrived from a successful reset with ?reason=password-changed.
- Alert (success, circle-check): **Password changed** "Log in with your new password."
- Heading: **Log in**
- Description: "Welcome back. Log in to manage your articles."
- Field `Username`: placeholder "Your username"
- Field `Password`: password with show/hide; link "Forgot password?"
- Submit button: "Log in"
### Server or network error
**Trigger:** A 500 or no response. The fields keep their values.
- Alert (error, circle-x): **Couldn't reach RenewCMS** "Check your connection and try again."
- Heading: **Log in**
- Description: "Welcome back. Log in to manage your articles."
- Field `Username`: placeholder "Your username"; value "marta"
- Field `Password`: value "limewash2026"; password with show/hide; link "Forgot password?"
- Submit button: "Log in"
+35 -41
View File
@@ -1,56 +1,50 @@
# Media picker (editor) # Media picker
- **Route:** `/article/:id/edit` (inside the editor's existing "Add an image" dialog) Route: `/article/:id/edit`, `/articles/{id}/edit`. API: GET /v1/media/?q=&limit=&cursor= · POST /v1/media/ (same as the Media page) · Inserted URL: /media/{file}?v={name}.
- **API:** GET /v1/media/?q=&limit=&cursor= · POST /v1/media/ (same as the Media page)
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Media picker", drawn by `source/MediaFrame.dc.html`.
The existing "Add an image" dialog gains two tabs, Upload and Library. The Upload tab is the current flow unchanged, so it still offers crop and resize, and a new image lands in the library and is inserted as before. The Library tab opens on the Library tab when the library has images and on Upload when it is empty. Images load 24 at a time with "Load more". Newest first, and images already in this article carry "In this article". Alt text is set here and lives only in the article. The dialog is 640 px wide on desktop and a bottom sheet on mobile. The existing "Add an image" dialog gains two tabs, Upload and Library. The Upload tab is the current flow unchanged, so it still offers crop and resize, and a new image lands in the library and is inserted as before. The Library tab opens on the Library tab when the library has images and on Upload when it is empty. Images load 24 at a time with "Load more". Newest first, and images already in this article carry "In this article". Alt text is set here and lives only in the article. The dialog is 640 px wide on desktop and a bottom sheet on mobile.
This supersedes the upload-only "Add an image" dialog described in [editor](editor.md), and the "Remove this image?" confirmation from `features/editor_image_removal.feature`. Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Media picker", "Editor picker". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
## Dialog ## Copy per state (generated from the board source, pass 2)
Title "Add an image", `x` to close, 640 px wide on desktop (bottom sheet on mobile). Two tabs under the title: **Upload** (`upload` icon) and **Library** (`images` icon). The dialog opens on Library when the library has images and on Upload when it is empty; dropping a file onto the editor opens Upload with the file provided, as before. Footer: "Cancel", and on Library a primary "Insert image", disabled until an image is selected. Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
**Upload tab.** The current flow unchanged (drop zone "Drop an image here, or choose a file", help "PNG, JPEG, WEBP, SVG · max 10 MB", crop and resize, rejected, uploading and failed states). Two changes: the new image lands in the library and is inserted as before, and a chosen file whose content matches an existing image sends nothing and shows an info-bordered card: thumbnail, "Already in your library", `north-wall.jpg matches an image uploaded on 12 Sep 2026 by Marta Alves.`, with "Use existing" (primary) and "Upload anyway". "Use existing" goes to the alt text step for the existing image; "Upload anyway" continues to crop and upload. ### Picker in the editor (group "Media picker", 13 states)
**Library tab.** Search field ("Search by filename"), the line "Newest first · 48 images", a grid of square tiles (4 columns on desktop, 3 on mobile; scrolls inside the dialog) with the filename under each, and "Load more" below (24 images per page). Tiles are buttons labelled with filename, size and use; Enter or Space selects (single selection). A tile of an image already in this article carries a sand "In this article" tag. The selected tile gets a primary outline and a check. Once one is selected, the grid shrinks and a panel appears below it: 56 px thumbnail, filename, `dimensions · size`, and a field "Alt text (optional)" with placeholder "e.g. North wall of the kitchen before plastering" and help "Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration." Alt text lives only in the article, never on the image. Route: `/article/:id/edit`. API: GET /v1/media/?q=&limit=&cursor= · POST /v1/media/ (same as the Media page).
| Library state | Copy | The existing "Add an image" dialog gains two tabs, Upload and Library. The Upload tab is the current flow unchanged, so it still offers crop and resize, and a new image lands in the library and is inserted as before. The Library tab opens on the Library tab when the library has images and on Upload when it is empty. Images load 24 at a time with "Load more". Newest first, and images already in this article carry "In this article". Alt text is set here and lives only in the article. The dialog is 640 px wide on desktop and a bottom sheet on mobile.
|---|---|
| Empty | `images` icon, "Your library is empty" / "Images you upload appear here, ready to use in any article." / "Upload an image" (switches to Upload) |
| No results | `search` icon, `No images match "wall"` / "Check the spelling, or search for a shorter part of the filename." / "Clear search" |
| Failed | error alert "Couldn't load your library" / "Check your connection and try again." / "Try again" |
| Loading more | button reads "Loading…" with a loader, skeleton tiles at the end |
Insert (either tab): the image goes in at the cursor with the chosen alt text (empty alt means decorative), the draft becomes "Unsaved changes", and a toast appears: `circle-check`, "Image added" / "Save to keep it in the article." The dialog then closes. | # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Upload tab | The image button in the toolbar. Identical to the current "Add an image" content; only the tabs are new. Rejected, uploading and failed states are unchanged. | "Upload" |
| 2 | Upload tab: already in your library | A chosen file has the same content as an existing image. Nothing is sent. "Use existing" inserts the existing image (the alt text step follows); "Upload anyway" continues to crop and upload as usual. | "Upload" |
| 3 | Library: loading | The Library tab was opened, or a search changed. Skeleton tiles after 150 ms. | |
| 4 | Library: nothing selected | Newest first. Tiles are buttons labelled with filename, size and use, and Enter or Space selects. Images already in this article carry "In this article". "Insert image" is disabled. | |
| 5 | Library: selected | One image selected (single selection). Below the grid: thumbnail, filename, dimensions and size, and an optional alt text field. "Insert image" turns on. | |
| 6 | Library: alt text entered | Alt text is optional. Empty alt text is inserted as a decorative image. It is not saved on the image. | "A kitchen with new oak worktops and white tiles" |
| 7 | Library: loading more | "Load more" pressed. The button shows progress and new skeleton tiles appear at the end. Focus stays on the button until the new tiles arrive, then moves to the first new tile. | |
| 8 | Library: empty | Zero images. "Upload an image" switches to the Upload tab. | |
| 9 | Library: no results | Search matched nothing. "Clear search" resets it. | |
| 10 | Library: failed to load | A 500 or network failure. The error shows in the dialog and Try again repeats the request. A 429 uses the same countdown alert as the Media page, and a 401 is handled globally. | |
| 11 | Inserted | Insert image from either tab. The image goes in at the cursor with the chosen alt text, and the article now has unsaved changes. | "Image added" / "Save to keep it in the article." |
| 12 | Remove image: confirm | Remove image on an image in the article. It now only removes it from this article, and the copy says the file stays in the library. The confirm button is neutral, not red, because nothing is deleted. Title: "Remove this image from the article?" | |
| 13 | Removed | Confirmed. The image leaves the article body, which now has unsaved changes. | "Image removed" / "It's still in your library." |
## Removing an image from the article ### 6. Editor image picker (group "Editor picker", 5 states)
The "Remove image" button on a selected image in the editor (and Backspace or Delete on it) now opens a neutral (not red) confirm, because nothing is deleted: Route: `/articles/{id}/edit`. API: Inserted URL: /media/{file}?v={name}.
- Title: "Remove this image from the article?" The Library tab of the picker gets a Variant select. Options are Original plus the enabled variants, each with its dimensions and format. Once inserted, a block whose variant was deleted or disabled shows an inline warning with a way to fix it. Disabled variants do not appear in the select.
- Description: "It's removed from this article only. The file stays in your library and in any other article that uses it."
- Body: thumbnail, filename, "Stays in the library · 3.9 MB"
- Buttons: "Cancel" / "Remove image"
- Done: the image leaves the body, the draft becomes "Unsaved changes", and a toast appears: `circle-check`, "Image removed" / "It's still in your library."
No API call is made. Removing is a body edit only. | # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Variant select | An image is selected. The URL line shows what will be inserted. | "Articles" / "How we price a bathroom renovation" / "Edit article" |
| 2 | Select open | Original plus five enabled variants. | "Articles" / "How we price a bathroom renovation" / "Edit article" |
| 3 | No variants defined | Only Original is offered. | "Articles" / "How we price a bathroom renovation" / "Edit article" |
| 4 | Variant deleted | The block keeps its URL. Pick another variant opens the select. | "Articles" / "How we price a bathroom renovation" / "Edit article" / "Variant 'hero' no longer exists." |
| 5 | Variant disabled | Same block, different cause. Proposed copy. | "Articles" / "How we price a bathroom renovation" / "Edit article" / "Variant 'hero' is disabled." |
## States ## Media field (pass 5)
Spec, states, exact copy, measurements, backend needs and accessibility: `media-field-changes.md`, section 3.7. Board groups: 10 Media picker for a field.
| State | Trigger and behaviour |
|---|---|
| Upload tab | The image button in the toolbar. Identical to the current "Add an image" content; only the tabs are new. Rejected, uploading and failed states are unchanged. |
| Upload tab: already in your library | A chosen file has the same content as an existing image. Nothing is sent. "Use existing" inserts the existing image (the alt text step follows); "Upload anyway" continues to crop and upload as usual. |
| Library: loading | The Library tab was opened, or a search changed. Skeleton tiles after 150 ms. |
| Library: nothing selected | Newest first. Tiles are buttons labelled with filename, size and use, and Enter or Space selects. Images already in this article carry "In this article". "Insert image" is disabled. |
| Library: selected | One image selected (single selection). Below the grid: thumbnail, filename, dimensions and size, and an optional alt text field. "Insert image" turns on. |
| Library: alt text entered | Alt text is optional. Empty alt text is inserted as a decorative image. It is not saved on the image. |
| Library: loading more | "Load more" pressed. The button shows progress and new skeleton tiles appear at the end. Focus stays on the button until the new tiles arrive, then moves to the first new tile. |
| Library: empty | Zero images. "Upload an image" switches to the Upload tab. |
| Library: no results | Search matched nothing. "Clear search" resets it. |
| Library: failed to load | A 500 or network failure. The error shows in the dialog and Try again repeats the request. A 429 uses the same countdown alert as the Media page, and a 401 is handled globally. |
| Inserted | Insert image from either tab. The image goes in at the cursor with the chosen alt text, and the article now has unsaved changes. |
| Remove image: confirm | Remove image on an image in the article. It now only removes it from this article, and the copy says the file stays in the library. The confirm button is neutral, not red, because nothing is deleted. Title: "Remove this image from the article?" |
| Removed | Confirmed. The image leaves the article body, which now has unsaved changes. |
+94 -135
View File
@@ -1,159 +1,118 @@
# Media # Media
- **Route:** `/media` (verified users) Route: `/media`, `/media?page=7&size=50`, `/media (detail panel)`, `/media?filter=duplicates`. API: GET /v1/media/?page=&size=&q=&filter= (total in `X-Total-Count`) · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage · GET /v1/media?variants=with|without&sort=total_size · GET /v1/media/{id}/variants · POST /v1/media/{id}/variants/{name} · DELETE /v1/media/{id}/variants/{name} · owner and admin only.
- **API:** GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Media", drawn by `source/MediaFrame.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
The library of every uploaded image. It replaces "images belong to one article": an image belongs to the library and can be used in any number of articles. Deleting an article never deletes images. The sidebar gains **Media** (`images` icon) between Articles and Users, breadcrumb "Media".
New route for verified users, in the sidebar between Articles and Users with the images icon. Images belong to the library, not to an article: one image can be used in many articles, and deleting an article never deletes images. Uploads from this page are not cropped or resized, and the file is stored as chosen. "Used in" is read from article bodies each time the list loads. Duplicates are images with identical content, whatever their filenames. Alt text is not stored on the image; it is set when inserting into an article. Search, filter, sort and view are stored in the query string. Keyboard: grid items are buttons in one tab stop each; Enter opens the detail panel, Space toggles selection, and Escape closes the panel or clears the selection. Selection changes are announced ("north-wall.jpg selected, 1 selected"). New route for verified users, in the sidebar between Articles and Users with the images icon. Images belong to the library, not to an article: one image can be used in many articles, and deleting an article never deletes images. Uploads from this page are not cropped or resized, and the file is stored as chosen. "Used in" is read from article bodies each time the list loads. Duplicates are images with identical content, whatever their filenames. Alt text is not stored on the image; it is set when inserting into an article. Search, filter, sort and view are stored in the query string. Keyboard: grid items are buttons in one tab stop each; Enter opens the detail panel, Space toggles selection, and Escape closes the panel or clears the selection. Selection changes are announced ("north-wall.jpg selected, 1 selected").
## Layout Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Media", "Media library", "Image detail", "Media duplicates entry". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
Header: title "Media", subtitle "Every image you've uploaded, ready to use in any article.", primary "Upload" button (`upload` icon) on the right. On mobile the button is a full-width 48 px button pinned to the bottom (it turns into "Try again in 0:42" while rate limited, and is replaced by the bulk bar while items are selected). ## Copy per state (generated from the board source, pass 2)
Toolbar: tabs **All**, **Unused** and **Duplicates** (count badge of duplicate groups, only while groups exist), then search ("Search by filename"), sort and the grid/list toggle. Sort options: Newest (default), Oldest, Name, Largest. Below md the toolbar stacks: search, tabs, then sort and toggle. Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
Grid (default): 5 columns on desktop, 2 on mobile, 16 px gap (10 px on mobile). Each card is one button: 4:3 thumbnail (checkerboard behind transparent PNG and SVG), filename (medium, ellipsis), `size · dimensions` (SVG dimensions read "Vector"), then badges. The accessible name reads "north-wall.jpg, 3.9 MB, used in 2 articles" or "…, unused". A "Select all" row with the image count sits above the grid. ### Media (group "Media", 52 states)
| Badge | Look | Route: `/media`. API: GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage.
|---|---|
| Unused | warning-soft fill, `unlink` icon |
| Duplicate | info-soft fill, `copy` icon |
| Used in N | outline, toned text |
List view: table from md up, columns Filename (with 40 px thumbnail and Duplicate badge), Dimensions, Size, Type, Used in (underlined count, opens the detail panel), Uploaded by, Uploaded (sorted, `arrow-down`); 56 px rows, 36 px header, first column is the checkbox. Below md it is a card list with a 44 px checkbox column, a 56 px thumbnail, filename, `size · dimensions · type`, badges plus `uploader · date`, and a trailing `chevron-right`. New route for verified users, in the sidebar between Articles and Users with the images icon. Images belong to the library, not to an article: one image can be used in many articles, and deleting an article never deletes images. Uploads from this page are not cropped or resized, and the file is stored as chosen. "Used in" is read from article bodies each time the list loads. Duplicates are images with identical content, whatever their filenames. Alt text is not stored on the image; it is set when inserting into an article. Search, filter, sort and view are stored in the query string. Keyboard: grid items are buttons in one tab stop each; Enter opens the detail panel, Space toggles selection, and Escape closes the panel or clears the selection. Selection changes are announced ("north-wall.jpg selected, 1 selected").
Selection: selected items take the sand fill (`--rc-sand`) with a primary outline. The checkbox shows on hover, on keyboard focus, on mobile, and whenever anything is selected. The bulk bar is sticky at the bottom (400 px wide on desktop, full width above the bottom edge on mobile): inverted fill, "3 selected", a danger "Delete" button (`trash-2`) and an inverted "Clear". | # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Grid, many images | Default view. 12 images, newest first. Each item is a button labelled like "north-wall.jpg, 3.9 MB, used in 2 articles". Badges: "Unused" (warm), "Duplicate" (info), "Used in N" (outline). The Duplicates tab carries a count of groups only while duplicates exist. Below the grid: "Showing 1–12 of 12" and the page-size select (same paginator as Articles). | |
| 2 | Grid, few images | Three images. Cards keep their size instead of stretching. Checkerboard shows behind the SVG. | |
| 3 | List view | The list icon in the toggle. A table from md up (Filename, Dimensions, Size, Type, Used in, Uploaded by, Uploaded) and a card list below md. The "Used in" count opens the article list in the detail panel. | |
| 4 | Loading | The first load, a retry, or a change of search or filter. Skeleton cards appear after 150 ms. | |
| 5 | Empty library, first run | Zero images. Toolbar and tabs are hidden; one call to action. Copy: "No images yet" / "Upload your first image. You can use it in any article." / "Upload an image". | |
| 6 | No results | Search or filter matched nothing. "Clear filters" resets the search and goes back to All. | |
| 7 | Hover, focus and keyboard | Hover (second item) darkens the border, adds the pop shadow and reveals the checkbox. Keyboard focus (third item) shows the 2 px primary ring with a 2 px offset and reveals the checkbox. Tab moves between items, Enter opens the detail panel, Space toggles selection, and arrow keys move focus across the grid. | "aria-label: kitchen-before.webp, 1.1 MB, used in 1 article" |
| 8 | Selection and bulk bar | Checkbox on any item, or Space. Selected items take the sand fill with a primary outline, and Select all shows a partial state. The bar is sticky at the bottom (above the bottom edge on mobile, replacing the Upload button). Announced: "3 selected". Escape or Clear empties it. | |
| 9 | Bulk delete: confirm | Delete in the bulk bar. The dialog says how many of the selection are in use and lists them. The confirm button carries the count. | |
| 10 | Bulk delete: in progress | Requests in flight. The dialog can't be dismissed. | |
| 11 | Bulk delete: partly failed | Some requests failed. Deleted images leave the grid, failed ones stay selected, and the toast stays until retried or dismissed. | "Couldn't delete 1 image" / "2 images were deleted. window-frame.jpg is still in your library. Try again." / "Retry" |
| 12 | Bulk delete: done | 204 for all. Focus moves to the next item. | "3 images deleted" / "They're gone from your library." |
| 13 | Detail panel: in use | Click or Enter on an item. Right-hand slideover on desktop, full screen on mobile with a back arrow. Actions: Copy URL, Rename, Delete. "Used in 2 articles" lists title, Online or Draft, and links to the editor. Focus returns to the item on close. | |
| 14 | Detail panel: unused | Used in nothing. The section reads "Not used in any article" and says the image is safe to delete. | |
| 15 | Detail panel: duplicate | The image has identical copies. An info alert links to the duplicates view. | |
| 16 | Copy URL | Copy URL. Clipboard write succeeded. Success toasts last 5 s. | "URL copied" / "Paste it wherever you need the image address." |
| 17 | Rename: dialog | Rename in the panel. The field holds the name without its extension, and the .jpg ending is fixed. The description says the URL stays the same. | |
| 18 | Rename: saving | PATCH in flight. Field and buttons disabled, dialog can't be dismissed. | |
| 19 | Rename: validation | Empty or only symbols, on submit or blur. Nothing is sent. | "Enter a filename." |
| 20 | Rename: failed | 500 or network failure. The error shows inside the dialog. | "Couldn't rename the image. Your text is still here. Try again in a moment." |
| 21 | Rename: done | 200: the panel title and list update in place. | "Image renamed" / "It's now "north-wall-01.jpg". The URL is the same." |
| 22 | Delete: unused image | Delete in the panel. Plain confirm. Says what happens and that it can't be undone. | |
| 23 | Delete: image in use | Stronger confirm. Lists the affected articles and states that they will show a broken image. | "delUsed" |
| 24 | Delete: in progress | DELETE in flight. The dialog can't be dismissed. | "delUsed" |
| 25 | Delete: failed | 500 or network failure. The error shows inside the dialog, as in the article delete. | "Couldn't delete the image, so it's still there. Try again in a moment." |
| 26 | Delete: done | 204: the panel closes, the item leaves the grid and focus moves to the next item. | "Image deleted" / ""window-frame.jpg" is gone from your library." |
| 27 | Image already gone (404) | Any action on an image that was deleted elsewhere. The panel closes and the item leaves the list. | "That image no longer exists" / "It was deleted somewhere else, so we removed it from the library." |
| 28 | Upload: drag over the page | Files dragged onto the page. A dashed drop area covers the content. Dropping starts the upload straight away; there is no crop step on this page. | |
| 29 | Upload: several files, mixed | Upload button or drop, several files. Each file has its own row and progress. Files upload two at a time. A row can end as added, "Already in your library", rejected in the browser, or failed. Rejected and failed rows stay until dismissed. | "2.2 MB" / "2.6 MB" / "1.1 of 2.6 MB" / "1.9 MB" |
| 30 | Upload: summary | All rows settled. The summary counts each outcome. "Use existing" selects the existing image and drops the row; "Upload anyway" stores a second copy, which then appears under Duplicates. "Try again" retries one file. Announced when it finishes. | "2.2 MB" / "2.6 MB" / "Upload finished" / "5 images" / "2 uploaded · 1 already in your library · 2 need attention" |
| 31 | Upload: rejected files (413, 400) | Type and size are checked in the browser first, so nothing is sent. A server 413 or 400 for a file that got through shows the same row with its own message. | "210 KB" / "notes.pdf isn't an image" / "Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image." / "3.0 MB" / "broken.png couldn't be read" / "The server couldn't read this image. Save it again as a PNG or JPEG, then try again." / "Nothing was uploaded" / "3 files were rejected" |
| 32 | Upload: too many requests (429) | Rate limited. Retry-After is counted down in the button. It re-enables at zero. Already-added files are kept. Clock-sensitive: shows a relative time or a countdown. | |
| 33 | Duplicates: groups | The Duplicates tab. Groups of identical images side by side, each with its uploader, upload date and "Used in N articles". The suggested copy to keep is the most used, then the oldest. | |
| 34 | Purge duplicates: confirm | "Purge duplicates" in the toolbar, next to the sort menu (in the stacked toolbar on mobile). Opens the same dialog as Settings; every other state is on the Media cleanup board. | |
| 35 | Duplicates: no purge button (member) | Verified users who are not owner or admin. The "Purge duplicates" button is hidden. "Merge into this one" is unchanged. | |
| 36 | Duplicates: empty | No groups. The Duplicates tab loses its count badge. | |
| 37 | Merge: confirm | "Merge into this one" on floorboards.jpg. The dialog says articles using the other copies will switch to this image and the other copies will be deleted, with the article count. | |
| 38 | Merge: in progress | Request in flight. The dialog can't be dismissed. | |
| 39 | Merge: failed | 500 or network failure. Nothing is switched or deleted. | "Couldn't merge the copies. Nothing was changed. Try again in a moment." |
| 40 | Merge: conflict (409) | A copy was deleted, or a new copy appeared, since the page loaded. | "One of these images changed, so nothing was merged. The list is up to date now. Check it and try again." |
| 41 | Merge: done | 200: the group leaves the list. When it was the last one, "No duplicates" shows. | "Copies merged" / "2 copies merged. 5 articles updated." |
| 42 | List failed to load | A 500 or network failure on the list request. The alert replaces the images; Try again repeats the request. | |
| 43 | Offline | navigator.onLine is false. Loaded images stay browsable. Upload, Rename, Delete and Merge are disabled, and the banner says why. | |
| 44 | Pagination: first page | /media. Previous and First are disabled. Summary "Showing 1–25 of 312". The paginator sits under the grid or list and uses the Articles controls: 5, 25, 50 or 100 per page, default 25; page and size live in the URL as page and size (/media?page=7&size=50); search, tab, sort, view and size changes reset to page 1. API: GET /v1/media/?page=&size=&q=&filter= with the total in X-Total-Count. | |
| 45 | Pagination: middle page | /media?page=7. Current page uses --rc-sand and --rc-on-sand; ellipses mark skipped ranges. | |
| 46 | Pagination: last page | /media?page=13. Next and Last are disabled. Summary "Showing 301–312 of 312". | |
| 47 | Pagination: list view | Same paginator under the table. On mobile: Previous, "Page 2 of 13", Next, with the 44 px targets. | |
| 48 | Pagination: loading next page | Next was pressed. Skeleton cards keep the grid height, controls are disabled, summary reads "Loading page 7…". | |
| 49 | Pagination: error loading a page | 500 or network failure on page change. Previous images stay; Retry sits in the notice. | "Couldn't load page 7. Showing page 6." |
| 50 | Pagination: page out of range | /media?page=99 is replaced with ?page=13 and this notice is shown. | "That page doesn't exist. Showing the last page." |
| 51 | Pagination: page size menu | The select opens upward. 5, 25, 50 or 100; the chosen size is saved as size and the page resets to 1. | |
| 52 | Pagination: focus-visible | Keyboard focus on Next: 2 px --ui-primary outline, 2 px offset. Same ring on every paginator control. | |
Detail panel: right-hand slideover 460 px wide on desktop (`x` to close), full screen on mobile with an `arrow-left`. Title is the filename. Body: 16:10 preview (4:3 on mobile), actions Copy URL (`link`), Rename (`pencil`), Delete (`trash-2`, error text), an info alert for duplicates, a metadata list (Type, Size, Dimensions, Uploaded, Uploaded by, URL in mono), then the "Used in" section. ### 3. Media library (group "Media library", 6 states)
Detail panel copy: Route: `/media`. API: GET /v1/media?variants=with|without&sort=total_size.
| Where | Copy | The existing grid with three additions: a "N variants" badge on cards that have cached files, an All / With variants / Without filter, and the sort option "Largest total size (original + variants)". When sorted by total size, the meta line shows the total.
|---|---|
| Duplicate alert | "Identical to 2 other images" / "floorboards-copy.jpg and IMG_2208.jpg have the same content." / action "Review duplicates" (`arrow-right`) |
| Used | "Used in 2 articles", then one row per article: title (underlined link to the editor), badge "Online" (`globe`, inverted) or "Draft" (`pencil-line`, outline), `arrow-up-right`. Footnote: "Counted from the text of your articles each time you open this page." |
| Not used | "Not used in any article" / "This image is safe to delete. No article shows it." |
## Dialogs | # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Grid with badges | Default view. Images with no cached variants have no badge. | "New: badge" |
| 2 | Sort menu | The sort select is open. The new option is last. | "New: sort option" |
| 3 | Filter: With variants, sorted by total size | The meta line becomes total · original + variants. | "With variants" |
| 4 | Filter: Without variants | Images no variant has been generated for. | "Without" |
| 5 | Filter: nothing to show | With variants, and the cache is empty. | "With variants" |
| 6 | Delete confirm | Delete on an image that has cached variants. | "deleteImage" |
All dialogs use the existing ConfirmDialog look (`UModal`, `sm:max-w-md`, bottom sheet on mobile, cancel first). While a request runs the confirm button shows a `loader-circle`, both buttons are disabled and the dialog cannot be dismissed. A failure shows an error alert at the top of the dialog body and the dialog stays open. ### 4. Image detail: Variants tab (group "Image detail", 8 states)
| Dialog | Title | Description | Body | Cancel / Confirm (pending) | Route: `/media (detail panel)`. API: GET /v1/media/{id}/variants · POST /v1/media/{id}/variants/{name} · DELETE /v1/media/{id}/variants/{name}.
|---|---|---|---|---|
| Rename | "Rename image" | "Only the filename changes. The URL stays the same, so articles that use this image keep working." | Field "Filename" with the extension fixed as a mono suffix (`.jpg`); help "Letters, numbers, dashes and underscores. The .jpg ending stays."; error "Enter a filename." on empty or only symbols | "Cancel" / "Rename" ("Renaming…") |
| Delete, unused | "Delete this image?" | ""window-frame.jpg" will be deleted from your library. No article uses it. This can't be undone." | none | "Cancel" / "Delete image" ("Deleting…"), danger |
| Delete, in use | "Delete an image that's in use?" | ""north-wall.jpg" is used in 2 articles. If you delete it, they will show a broken image. This can't be undone." | list of affected articles with Online or Draft badges | "Cancel" / "Delete image" ("Deleting…"), danger |
| Bulk delete | "Delete 3 images?" | "2 of them are used in 4 articles, which will show a broken image. The other one isn't used anywhere. This can't be undone." | one row per image: filename and "Used in N" or "Unused" | "Cancel" / "Delete 3 images" ("Deleting…"), danger |
| Merge | "Merge into floorboards.jpg?" | "Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 articles will be updated. This can't be undone." | the kept image: thumbnail, filename, "Kept · used in 4 articles now" | "Cancel" / "Merge 2 copies" ("Merging…"), danger |
Dialog errors: rename "Couldn't rename the image. Your text is still here. Try again in a moment."; delete "Couldn't delete the image, so it's still there. Try again in a moment."; merge (500) "Couldn't merge the copies. Nothing was changed. Try again in a moment."; merge (409) "One of these images changed, so nothing was merged. The list is up to date now. Check it and try again." The detail panel gets a Variants tab with one row per enabled variant. On desktop the row actions are icon buttons with tooltips and accessible names; on mobile they are labeled buttons on their own line. Only actions that apply are shown: generated rows offer Copy URL, Preview and Purge; others offer Copy URL and Generate now. The tab shows output dimensions as they will be generated, capped at the source size.
## Upload panel | # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Partly generated | Four of five generated. Saving is shown against the original. | |
| 2 | Nothing generated yet | No cached files for this image. | |
| 3 | Generating | Generate all or Generate now was pressed. Rows show a spinner. | |
| 4 | Generation failed | The row shows the cause and Retry. Both causes are shown together for reference. | "Unsupported source format" / "Image too large" |
| 5 | No variants defined | There are no enabled variants. | |
| 6 | Source is an SVG | Variants aren't generated for vector images. | "SVG images are served as they are." |
| 7 | Source is an animated GIF | Same rule as SVG. | "Animated GIFs are served as they are." |
| 8 | Preview dialog | Preview on a generated row. Both at actual size. | |
A panel above the images while files are in flight and until dismissed. Header: "Uploading 6 images" with "2 of 6 done" and "Cancel all" (after it settles: "Upload finished" / "5 images" and "Dismiss"; if nothing was added: "Nothing was uploaded"). Files upload two at a time, one row each: 40 px thumbnail, filename, size, then per status: ### Media: Duplicates tab entry point (group "Media duplicates entry", 2 states)
| Status | Row | Route: `/media?filter=duplicates`. API: owner and admin only.
|---|---|
| Waiting | "Waiting" |
| Uploading | "Uploading 1.1 of 2.6 MB" with a 4 px progress bar and a cancel `x` |
| Added | `circle-check` "Added to your library" |
| Duplicate | "Already in your library" / `Same as "floorboards.jpg", uploaded 14 Jun 2026 by Inès Duarte.` / "Use existing" and "Upload anyway" |
| Rejected | `file-x` title, then "Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image." Titles: `scan.tif is 14.2 MB` (too large), `notes.pdf isn't an image` (type), `broken.png couldn't be read` with "The server couldn't read this image. Save it again as a PNG or JPEG, then try again." (400) |
| Failed | `circle-x` "Upload failed" / "The server didn't accept the file. Nothing was added to your library." / "Try again" (`rotate-cw`) |
Summary line when settled: "2 uploaded · 1 already in your library · 2 need attention". Type and size are checked in the browser before anything is sent (PNG, JPEG, WEBP, SVG; 10 MB), by MIME type and by extension, exactly as the editor's uploader does. Dropping files onto the page shows a dashed primary-bordered area "Drop images to upload" / "PNG, JPEG, WEBP, SVG · max 10 MB each". A secondary "Purge duplicates" button (icon merge) in the toolbar next to the sort menu, and in the stacked toolbar on mobile. The dialog and the member state are listed with the Media states above. Per-group "Merge into this one" is unchanged.
## Duplicates view | # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Duplicates tab with the button | Owner or admin. | |
| 2 | Duplicates tab: offline | The button is disabled with the existing banner. | |
Groups stacked with 16 px gap. Each group is a bordered card: header "Group 1" and "3 identical images · 2.4 MB each · 3600 × 2400"; one column per copy (stacked on mobile) with thumbnail, filename, "Uploaded 14 Jun 2026 by Inès Duarte", underlined "Used in 4 articles", and a "Merge into this one" button (`merge` icon). The suggested copy has the sand fill, a primary outline, a "Suggested" tag and a primary button; the others are outline. Footer: "Suggested: the most used copy, then the oldest. Choosing another copy switches articles to it." Empty: `copy` icon, "No duplicates" / "Every image in your library is different." / "Back to all images". ## Media field (pass 5)
Spec, states, exact copy, measurements, backend needs and accessibility: `media-field-changes.md`, section 3.8. Board groups: 11 Media library usage.
## Other copy
| Where | Copy |
|---|---|
| Empty library | `images` icon, "No images yet" / "Upload your first image. You can use it in any article." / primary "Upload an image"; under it "PNG, JPEG, WEBP, SVG · max 10 MB each" |
| No results | `search` icon, `No images match "wall"` / "Nothing matches in Unused. Check the spelling, or clear the filters." / "Clear filters" |
| List error | error alert "Couldn't load your images" / "Check your connection and try again." / "Try again" |
| Rate limited | info alert `timer` "Too many uploads" / "For your security, wait a moment before uploading again."; the Upload button reads "Try again in 0:42" and is disabled |
| Offline banner | `wifi-off` "You're offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection." |
Toasts (success 5 s unless noted):
| Trigger | Icon, title, description |
|---|---|
| Copy URL | `circle-check` "URL copied" / "Paste it wherever you need the image address." |
| Rename | `circle-check` "Image renamed" / `It's now "north-wall-01.jpg". The URL is the same.` |
| Delete | `circle-check` "Image deleted" / `"window-frame.jpg" is gone from your library.` |
| Bulk delete | `circle-check` "3 images deleted" / "They're gone from your library." |
| Bulk delete, partial | error, stays until retried or dismissed: `circle-x` "Couldn't delete 1 image" / "2 images were deleted. window-frame.jpg is still in your library. Try again." / "Retry" |
| Merge | `circle-check` "Copies merged" / "2 copies merged. 5 articles updated." |
| 404 | info `info` "That image no longer exists" / "It was deleted somewhere else, so we removed it from the library." |
## Status to copy
| Status | Where | Copy |
|---|---|---|
| 200 / 201 | Upload, rename, merge | Upload: row reads "Added to your library". Rename and merge: toasts above. |
| 204 | Delete, bulk delete | Toasts above. |
| 400 | Upload row | "broken.png couldn't be read" / "The server couldn't read this image. Save it again as a PNG or JPEG, then try again." In a dialog: "Something in the form isn't valid. Check the highlighted fields." |
| 401 | any | Global: /login?redirect=/media&reason=expired. No local copy. |
| 403 | any, unverified | Global: /register/pending. |
| 404 | rename, delete, merge, panel | The info toast above; the panel or dialog closes. |
| 409 | merge | Dialog alert above. |
| 413 | upload row | "scan.tif is 14.2 MB" / "Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image." |
| 415 | upload row | "notes.pdf isn't an image" / same description. |
| 429 | upload | Rate-limited alert above, counting down Retry-After. |
| 500 / network | list, upload row, rename, delete, merge | The copy for each place above. |
| Offline | whole page | Banner above. |
## Keyboard and accessibility
Grid items are buttons, one tab stop each. Enter opens the detail panel, Space toggles selection, arrow keys move focus across the grid, Escape closes the panel or clears the selection. Focus returns to the item when the panel closes, and moves to the next item after a delete. Selection changes are announced ("north-wall.jpg selected, 1 selected"), as are upload completion and the merge result. Search, filter, sort and view live in the query string.
## States
| State | Trigger and behaviour |
|---|---|
| Grid, many images | Default view. 12 images, newest first. Each item is a button labelled like "north-wall.jpg, 3.9 MB, used in 2 articles". Badges: "Unused" (warm), "Duplicate" (info), "Used in N" (outline). The Duplicates tab carries a count of groups only while duplicates exist. |
| Grid, few images | Three images. Cards keep their size instead of stretching. Checkerboard shows behind the SVG. |
| List view | The list icon in the toggle. A table from md up (Filename, Dimensions, Size, Type, Used in, Uploaded by, Uploaded) and a card list below md. The "Used in" count opens the article list in the detail panel. |
| Loading | The first load, a retry, or a change of search or filter. Skeleton cards appear after 150 ms. |
| Empty library, first run | Zero images. Toolbar and tabs are hidden; one call to action. Copy: "No images yet" / "Upload your first image. You can use it in any article." / "Upload an image". |
| No results | Search or filter matched nothing. "Clear filters" resets the search and goes back to All. |
| Hover, focus and keyboard | Hover (second item) darkens the border, adds the pop shadow and reveals the checkbox. Keyboard focus (third item) shows the 2 px primary ring with a 2 px offset and reveals the checkbox. Tab moves between items, Enter opens the detail panel, Space toggles selection, and arrow keys move focus across the grid. |
| Selection and bulk bar | Checkbox on any item, or Space. Selected items take the sand fill with a primary outline, and Select all shows a partial state. The bar is sticky at the bottom (above the bottom edge on mobile, replacing the Upload button). Announced: "3 selected". Escape or Clear empties it. |
| Bulk delete: confirm | Delete in the bulk bar. The dialog says how many of the selection are in use and lists them. The confirm button carries the count. |
| Bulk delete: in progress | Requests in flight. The dialog can't be dismissed. |
| Bulk delete: partly failed | Some requests failed. Deleted images leave the grid, failed ones stay selected, and the toast stays until retried or dismissed. |
| Bulk delete: done | 204 for all. Focus moves to the next item. |
| Detail panel: in use | Click or Enter on an item. Right-hand slideover on desktop, full screen on mobile with a back arrow. Actions: Copy URL, Rename, Delete. "Used in 2 articles" lists title, Online or Draft, and links to the editor. Focus returns to the item on close. |
| Detail panel: unused | Used in nothing. The section reads "Not used in any article" and says the image is safe to delete. |
| Detail panel: duplicate | The image has identical copies. An info alert links to the duplicates view. |
| Copy URL | Copy URL. Clipboard write succeeded. Success toasts last 5 s. |
| Rename: dialog | Rename in the panel. The field holds the name without its extension, and the .jpg ending is fixed. The description says the URL stays the same. |
| Rename: saving | PATCH in flight. Field and buttons disabled, dialog can't be dismissed. |
| Rename: validation | Empty or only symbols, on submit or blur. Nothing is sent. |
| Rename: failed | 500 or network failure. The error shows inside the dialog. |
| Rename: done | 200: the panel title and list update in place. |
| Delete: unused image | Delete in the panel. Plain confirm. Says what happens and that it can't be undone. |
| Delete: image in use | Stronger confirm. Lists the affected articles and states that they will show a broken image. |
| Delete: in progress | DELETE in flight. The dialog can't be dismissed. |
| Delete: failed | 500 or network failure. The error shows inside the dialog, as in the article delete. |
| Delete: done | 204: the panel closes, the item leaves the grid and focus moves to the next item. |
| Image already gone (404) | Any action on an image that was deleted elsewhere. The panel closes and the item leaves the list. |
| Upload: drag over the page | Files dragged onto the page. A dashed drop area covers the content. Dropping starts the upload straight away; there is no crop step on this page. |
| Upload: several files, mixed | Upload button or drop, several files. Each file has its own row and progress. Files upload two at a time. A row can end as added, "Already in your library", rejected in the browser, or failed. Rejected and failed rows stay until dismissed. |
| Upload: summary | All rows settled. The summary counts each outcome. "Use existing" selects the existing image and drops the row; "Upload anyway" stores a second copy, which then appears under Duplicates. "Try again" retries one file. Announced when it finishes. |
| Upload: rejected files (413, 400) | Type and size are checked in the browser first, so nothing is sent. A server 413 or 400 for a file that got through shows the same row with its own message. |
| Upload: too many requests (429) | Rate limited. Retry-After is counted down in the button. It re-enables at zero. Already-added files are kept. |
| Duplicates: groups | The Duplicates tab. Groups of identical images side by side, each with its uploader, upload date and "Used in N articles". The suggested copy to keep is the most used, then the oldest. |
| Duplicates: empty | No groups. The Duplicates tab loses its count badge. |
| Merge: confirm | "Merge into this one" on floorboards.jpg. The dialog says articles using the other copies will switch to this image and the other copies will be deleted, with the article count. |
| Merge: in progress | Request in flight. The dialog can't be dismissed. |
| Merge: failed | 500 or network failure. Nothing is switched or deleted. |
| Merge: conflict (409) | A copy was deleted, or a new copy appeared, since the page loaded. |
| Merge: done | 200: the group leaves the list. When it was the last one, "No duplicates" shows. |
| List failed to load | A 500 or network failure on the list request. The alert replaces the images; Try again repeats the request. |
| Offline | navigator.onLine is false. Loaded images stay browsable. Upload, Rename, Delete and Merge are disabled, and the banner says why. |
+15 -61
View File
@@ -1,71 +1,25 @@
# New article # New article
- **Route:** `modal from anywhere · /article/new` Route: `modal from anywhere · /article/new`. API: POST /v1/article/.
- **API:** POST /v1/article/
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "New article" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
Recommendation: a modal. It needs two fields, and keeping the page underneath means cancelling costs nothing. /article/new opens the same modal over the article list, so it can be linked to and the back button closes it. A successful create always makes a draft and goes to the editor. Recommendation: a modal. Title is the only field. The body is not asked for here; it is written in the editor, which opens straight after. Keeping the page underneath means cancelling costs nothing. /article/new opens the same modal over the article list, so it can be linked to and the back button closes it. A successful create always makes a draft and goes to the editor.
## States Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "New article". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
### Default ## Copy per state (generated from the board source, pass 2)
**Trigger:** "New article" from Home, Articles or the empty state. Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
- Breadcrumbs: Articles (mobile title "Articles") ### New article (group "New article", 5 states)
- Modal: **New article**
- text "It's saved as a draft. Titles can't be changed later, so check it now."
- field `Title`: placeholder "e.g. Choosing tiles for a small bathroom"; focused; counter "0 / 200"; help "3 to 200 characters."
- field `First paragraph`: placeholder "Start writing. You'll continue in the editor."; textarea; help "You can format it and add images in the editor."
- cancel "Cancel"
- confirm "Create draft"
### Validation Route: `modal from anywhere · /article/new`. API: POST /v1/article/.
**Trigger:** On submit or blur. Nothing is sent. Recommendation: a modal. Title is the only field. The body is not asked for here; it is written in the editor, which opens straight after. Keeping the page underneath means cancelling costs nothing. /article/new opens the same modal over the article list, so it can be linked to and the back button closes it. A successful create always makes a draft and goes to the editor.
- Breadcrumbs: Articles (mobile title "Articles") | # | State | Trigger | Copy |
- Modal: **New article** |---|---|---|---|
- text "It's saved as a draft. Titles can't be changed later, so check it now." | 1 | Default | "New article" from Home, Articles or the empty state. | |
- field `Title`: value "Ti"; counter "2 / 200" in error; **error "Use at least 3 characters."** | 2 | Validation | On submit or blur. Nothing is sent. | "Use at least 3 characters." |
- field `First paragraph`: textarea; **error "Add a first paragraph. It can be a single sentence."** | 3 | Submitting | Request in flight. Fields are disabled and the dialog can't be dismissed. | "Creating…" / "Choosing tiles for a small bathroom" |
- cancel "Cancel" | 4 | Title rejected (400) | A server 400 that got past the browser checks. The raw error isn't shown. | "Something in the form isn't valid. Check the highlighted fields." / "Choosing tiles for a small bathroom" / "Check this title and try again." |
- confirm "Create draft" | 5 | Error | 500 or network failure. | "Couldn't create the article. Your text is still here. Try again in a moment." / "Choosing tiles for a small bathroom" |
### Submitting
**Trigger:** Request in flight. Fields are disabled and the dialog can't be dismissed.
- Breadcrumbs: Articles (mobile title "Articles")
- Modal: **New article**
- text "It's saved as a draft. Titles can't be changed later, so check it now."
- field `Title`: value "Choosing tiles for a small bathroom"; counter "35 / 200"
- field `First paragraph`: value "Small rooms show every grout line, so we start with the floor."; textarea
- cancel "Cancel"
- confirm "Creating…", loading, not dismissible
### Title rejected (400)
**Trigger:** A server 400 that got past the browser checks. The raw error isn't shown.
- Breadcrumbs: Articles (mobile title "Articles")
- Modal: **New article**
- text "It's saved as a draft. Titles can't be changed later, so check it now."
- field `Title`: value "Choosing tiles for a small bathroom"; counter "35 / 200"; **error "Check this title and try again."**
- field `First paragraph`: value "Small rooms show every grout line, so we start with the floor."; textarea
- Alert: "Something in the form isn't valid. Check the highlighted fields."
- cancel "Cancel"
- confirm "Create draft"
### Error
**Trigger:** 500 or network failure.
- Breadcrumbs: Articles (mobile title "Articles")
- Modal: **New article**
- text "It's saved as a draft. Titles can't be changed later, so check it now."
- field `Title`: value "Choosing tiles for a small bathroom"; counter "35 / 200"
- field `First paragraph`: value "Small rooms show every grout line, so we start with the floor."; textarea
- Alert: "Couldn't create the article. Your text is still here. Try again in a moment."
- cancel "Cancel"
- confirm "Create draft"
+16 -51
View File
@@ -1,61 +1,26 @@
# Check your email # Pending
- **Route:** `/register/pending` Route: `/register/pending`. API: DELETE /v1/auth/register · POST /v1/auth/logout.
- **API:** DELETE /v1/auth/register · POST /v1/auth/logout
- **Visual reference:** `source/RenewCMS Screens Auth.dc.html`, group "Pending" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
Any back-office route visited by an unverified account lands here. There's no way to resend the email because the API doesn't offer one, so the note explains the way out. Any back-office route visited by an unverified account lands here. There's no way to resend the email because the API doesn't offer one, so the note explains the way out.
## States Visual reference: `RenewCMS Screens Auth.dc.html`, groups "Pending". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
### Default ## Copy per state (generated from the board source, pass 2)
**Trigger:** Straight after registering, when the email is known from the form. Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Auth.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
- ResultPanel (success, mail-check): **Check your email**; text "We sent a verification link to marta@renew.example. It expires in 2 hours."; note "Opened it on another device? That works too; you'll log in there first. Can't find it? Check your spam folder. If the link has expired, cancel your registration and start again."; actions "Log out" (secondary), "Cancel registration" (secondary) ### Check your email (group "Pending", 6 states)
### Email unknown Route: `/register/pending`. API: DELETE /v1/auth/register · POST /v1/auth/logout.
**Trigger:** Arrived after logging in as an unverified user. Any back-office route visited by an unverified account lands here. There's no way to resend the email because the API doesn't offer one, so the note explains the way out.
- ResultPanel (success, mail-check): **Check your email**; text "We sent a verification link to the email you registered with. It expires in 2 hours."; note "Opened it on another device? That works too; you'll log in there first. Can't find it? Check your spam folder. If the link has expired, cancel your registration and start again."; actions "Log out" (secondary), "Cancel registration" (secondary) | # | State | Trigger | Copy |
|---|---|---|---|
### Confirm cancel | 1 | Default | Straight after registering, when the email is known from the form. | "marta@renew.example" |
| 2 | Email unknown | Arrived after logging in as an unverified user. | |
**Trigger:** "Cancel registration" opens the destructive ConfirmDialog. | 3 | Confirm cancel | "Cancel registration" opens the destructive ConfirmDialog. | "marta@renew.example" |
| 4 | Cancelling | DELETE in flight. The dialog can't be dismissed. | "marta@renew.example" |
- ResultPanel (success, mail-check): **Check your email**; text "We sent a verification link to marta@renew.example. It expires in 2 hours."; note "Opened it on another device? That works too; you'll log in there first. Can't find it? Check your spam folder. If the link has expired, cancel your registration and start again."; actions "Log out" (secondary), "Cancel registration" (secondary) | 5 | Cancelled | 200 → /register, with a toast. | "Registration cancelled" / "You can create the owner account again." |
- ConfirmDialog: **Cancel your registration?** | 6 | Already verified (403) | Verified on another device. Goes to /home; shown here as the toast over the destination. | "You're verified" / "Taking you to your dashboard…" / "Your account is already verified" / "There's nothing to cancel." |
- text "Your account will be deleted. You can register again with the same or a different email."
- cancel "Keep my account"
- confirm "Cancel registration"
### Cancelling
**Trigger:** DELETE in flight. The dialog can't be dismissed.
- ResultPanel (success, mail-check): **Check your email**; text "We sent a verification link to marta@renew.example. It expires in 2 hours."; note "Opened it on another device? That works too; you'll log in there first. Can't find it? Check your spam folder. If the link has expired, cancel your registration and start again."; actions "Log out" (secondary), "Cancel registration" (secondary)
- ConfirmDialog: **Cancel your registration?**
- text "Your account will be deleted. You can register again with the same or a different email."
- cancel "Keep my account"
- confirm "Cancelling…"
### Cancelled
**Trigger:** 200 → /register, with a toast.
- Heading: **Create the owner account**
- Description: "This creates the single admin account for this site. Once it's verified, registration closes."
- Field `Username`: placeholder "e.g. marta"; counter "0 / 20"; help "Letters and numbers, 3 to 20 characters. You'll use it to log in."
- Field `Email`: placeholder "you@example.com"; help "We'll send a verification link here."
- Field `Password`: password with show/hide; checklist "At least 8 characters" (unmet)
- Submit button: "Create account"
- Footer: Already set up? [Log in]
- Toast (circle-check): **Registration cancelled** "You can create the owner account again."
### Already verified (403)
**Trigger:** Verified on another device. Goes to /home; shown here as the toast over the destination.
- ResultPanel (success, shield-check): **You're verified**; text "Taking you to your dashboard…"
- Toast (info): **Your account is already verified** "There's nothing to cancel."
+49 -166
View File
@@ -1,183 +1,66 @@
# Profile # Profile
- **Route:** `/profile` Route: `/profile`. API: GET /v1/auth/me · PATCH /v1/user/me (pending) · POST /v1/auth/password-reset.
- **API:** GET /v1/auth/me · PATCH /v1/user/me (pending) · POST /v1/auth/password-reset
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Profile" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
Opened from the account menu at the bottom of the sidebar. Username, email and password are read-only. The display name is optional, trimmed, and up to 60 characters. Opened from the account menu at the bottom of the sidebar. Username, email and password are read-only. The display name is optional, trimmed, and up to 60 characters. Once saved it replaces the username everywhere a person is named: the sidebar, the account menu, the Home greeting, the Users list (with the username beside it) and the emails RenewCMS sends, such as "Marta Alves invited you to RenewCMS". Saving it empty goes back to the username. The reset link uses the same flow as "Forgot password" and ends on the auth board's "Choose a new password" screen.
Once saved it replaces the username everywhere a person is named: Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Profile". Frames: `AppFrame.dc.html` (shell, sidebar, topbar, drawer) plus the screen frame where listed in components.md.
- the sidebar chip and the account menu (name, and the avatar initials) ## Copy per state (generated from the board source, pass 2)
- the Home greeting ("Good morning, Marta Alves")
- the Display name column of the Users list
- the emails RenewCMS sends, such as "Marta Alves invited you to RenewCMS"
Saving it empty goes back to the username. Logging in still uses the username. Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
The reset link uses the same flow as "Forgot password" and ends on the auth board's "Choose a new password" screen ([reset-password.md](reset-password.md)). ### Fixed copy in the frame
- Profile
- Your account on this RenewCMS workspace.
- Display name
- Shown instead of your username across the back-office and in the emails RenewCMS sends. Leave it empty to use your username.
- (optional)
- Account
- Set when your account was created. You log in with your username, even after adding a display name.
- Username
- marta
- Email
- marta@renew.example
- Password
- To change it, we'll email a reset link to marta@renew.example. The link works once, within 1 hour.
## The page ### Profile (group "Profile", 13 states)
Three cards under the title "Profile" and the subtitle "Your account on this RenewCMS workspace.", 720 px wide at most. Route: `/profile`. API: GET /v1/auth/me · PATCH /v1/user/me (pending) · POST /v1/auth/password-reset.
| Card | Content | Opened from the account menu at the bottom of the sidebar. Username, email and password are read-only. The display name is optional, trimmed, and up to 60 characters. Once saved it replaces the username everywhere a person is named: the sidebar, the account menu, the Home greeting, the Users list (with the username beside it) and the emails RenewCMS sends, such as "Marta Alves invited you to RenewCMS". Saving it empty goes back to the username. The reset link uses the same flow as "Forgot password" and ends on the auth board's "Choose a new password" screen.
|---|---|
| Display name | Title "Display name". Text "Shown instead of your username across the back-office and in the emails RenewCMS sends. Leave it empty to use your username." One input, label "Display name (optional)", placeholder "e.g. Marta Alves", with a live counter "n / 60" in mono on the right of the help row. A footer strip holds a status line on the left and the Save button on the right. |
| Account | Title "Account". Text "Set when your account was created. You log in with your username, even after adding a display name." Two disabled inputs with a lock icon: Username and Email (two columns on desktop, one on mobile). |
| Password | Title "Password". Text "To change it, we'll email a reset link to marta@renew.example. The link works once, within 1 hour." A disabled input showing "••••••••••••" beside the button "Send reset link" (mail icon). A note appears under them once a request has been made. |
**Footer status line** (12 px): "Unsaved changes" in the warning color while dirty and not saving. Otherwise "You appear as “Marta Alves”." when a display name is saved, or "You appear as “marta”, your username." when it isn't. | # | State | Trigger | Copy |
|---|---|---|---|
| 1 | Account menu | The account chip at the bottom of the sidebar. On mobile, the account row in the navigation drawer opens it as a sheet. Escape or a click outside closes it. | "Here's where things stand on your site." |
| 2 | Loading | Opened directly by URL before /auth/me has returned. From the menu, the cached user renders straight away. | |
| 3 | Default, no display name | The usual first visit. The username is used everywhere. | |
| 4 | Editing | Typing in the field. Save turns on, and leaving the page asks for confirmation. | |
| 5 | Too long | Over 60 characters. Checked as you type, and Save stays off. | "Marta Alves, Head of Renovations, Brussels and Wallonia office" / "Use 60 characters or fewer." |
| 6 | Saving | Save, or Enter in the field. The field is disabled. | |
| 7 | Saved | 200: the sidebar and account menu switch to the display name at once, and a toast confirms. | "Display name saved" / "You now appear as Marta Alves across RenewCMS and in emails." |
| 8 | Save failed | 500 or network failure. The typed value is kept. | "Couldn't save your display name. Your text is still here. Try again in a moment." |
| 9 | Reset: sending | "Send reset link". No confirmation, since it only sends an email. | |
| 10 | Reset: sent | 200. The message stays until the page is left. "Send again" sends a new link and the old one stops working. | |
| 11 | Reset: too many requests (429) | Rate limited. The button is disabled for 5 minutes. | |
| 12 | Reset: failed | 500 or network failure. The button can be pressed again. | |
| 13 | Display names in the Users list | The Display name column shows the name when one is set and a dash when not. Your own row follows your saved name. | |
**Save button:** full opacity when the value changed and is valid, or while saving. 40 % opacity and inert otherwise. Enter in the field saves. ## Changed in pass 3: author profile
**Counting:** characters are counted as Unicode characters, not bytes or UTF-16 units. The counter turns red over 60. Every existing Profile frame has the "Author profile" card between Account and Password, in its default state ("Create my author profile"). The card is defined once, in `AppFrame.dc.html`.
**Leaving the page** with unsaved changes asks for confirmation. ### Profile: author profile (group "Profile: author profile", 5 states)
**Mobile:** cards use 16 px padding, inputs are 44 px, the two account inputs stack, and the password input and its button stack. Route: `/profile`. API: POST /v1/author/me · GET /v1/auth/me (author field).
## The account menu The "Author profile" card sits between Account and Password in every Profile frame, defined once in AppFrame. The existing Profile frames show its default state ("Create my author profile"). All roles can create their own profile and edit their own linked author. Only the states specific to the card are drawn here.
Opened from the account chip at the bottom of the sidebar (chevrons-up-down icon; `aria-haspopup="menu"`, `aria-expanded`). The chip takes the sand fill with ink text while the menu is open, and while `/profile` is the current page. | # | State | Trigger | Copy |
|---|---|---|---|
Desktop: a 224 px popover above the chip, aligned to its left. Mobile: the account row in the navigation drawer opens it as a bottom sheet with a grab handle. Escape or a click outside closes both. | 1 | No author profile | The user has no linked author. | Author profile · Your public byline on articles. It's separate from your account. · You don't have an author profile yet. Create one to be credited on your own articles. · Create my author profile · Uses your display name, Marta Alves. You can change it afterwards. |
| 2 | Error | 500 or network failure. | Couldn't create your author profile · Try again in a moment. |
| Part | Content | | 3 | Created | 201: the card shows the author. The author is named from the display name and linked to the user. | Author profile created · Marta Alves is now your byline. · Edit author · Linked to your account. New articles start with this author. |
|---|---| | 4 | Created, slug adjusted | The slug was taken, so the server added a number. No error is shown. | Your slug is marta-alves-2 · marta-alves was already used by another author. You can change it in Edit author. |
| Header | The name in semibold (display name, or the username), and the email in muted 12 px (14 px on mobile, with a 36 px initials avatar beside them) | | 5 | Edit own author | Edit author on the card, by a role without author rights. The form opens with the linked user locked and no Unlink. There is no delete action in the form or on this screen. | Edit Marta Alves · Linked user · marta@renew.example · This is your author profile. Only an owner or admin can change the link. · Save changes |
| Item | Profile (circle-user icon), highlighted while on `/profile` |
| Separator | |
| Label | "Theme": 11 px, weight 500, uppercase, letter-spacing .04em, muted |
| Items | System, Light, Dark: a radio-style group, the current choice carries a check icon at the right edge; choosing one applies the theme at once and keeps the menu open |
| Separator | |
| Label | "Language", styled like "Theme" |
| Items | English, Français (each in its own language, with `lang`): a radio-style group with a check on the current one; choosing one applies the language at once and keeps the menu open |
| Separator | |
| Item | Log out (log-out icon) |
Avatar initials come from the first letters of the first two words of the display name, uppercased ("Marta Alves" gives MA). Without a display name they come from the username.
The board's menu now carries System, Light and Dark, matching the shipped UserMenu. The theme choice stays in the account menu; the Profile page does not repeat it. Items are 32 px on desktop and 48 px in the mobile sheet.
## States
### Account menu
**Trigger:** The account chip at the bottom of the sidebar. On mobile, the account row in the navigation drawer opens it as a sheet. Escape or a click outside closes it.
- Breadcrumbs: Home (mobile title "Home")
- Page behind the menu: Home, default
- Menu open, as described above, with the Theme group (System, Light, Dark) and the Language group (English, Français) between Profile and Log out. The board shows Light and English checked
### Loading
**Trigger:** Opened directly by URL before /auth/me has returned. From the menu, the cached user renders straight away.
- Breadcrumbs: Profile (mobile title "Profile")
- Three skeleton cards (one input, two inputs, one input)
### Default, no display name
**Trigger:** The usual first visit. The username is used everywhere.
- Breadcrumbs: Profile (mobile title "Profile")
- Display name: empty, placeholder "e.g. Marta Alves", counter "0 / 60"
- Footer: "You appear as “marta”, your username."
- Save: 40 % opacity
### Editing
**Trigger:** Typing in the field. Save turns on, and leaving the page asks for confirmation.
- Breadcrumbs: Profile (mobile title "Profile")
- Display name: "Marta Alves"; focused; counter "11 / 60"
- Footer: "Unsaved changes" (warning)
- Save: enabled
### Too long
**Trigger:** Over 60 characters. Checked as you type, and Save stays off.
- Breadcrumbs: Profile (mobile title "Profile")
- Display name: "Marta Alves, Head of Renovations, Brussels and Wallonia office"; focused; counter red
- **Error "Use 60 characters or fewer."**
- Footer: "Unsaved changes" (warning)
- Save: 40 % opacity
### Saving
**Trigger:** Save, or Enter in the field. The field is disabled.
- Breadcrumbs: Profile (mobile title "Profile")
- Display name: "Marta Alves", disabled, dimmed
- Save: "Saving…" with a loader
### Saved
**Trigger:** 200: the sidebar and account menu switch to the display name at once, and a toast confirms.
- Breadcrumbs: Profile (mobile title "Profile")
- Sidebar chip: "Marta Alves", initials MA
- Display name: "Marta Alves"; counter "11 / 60"
- Footer: "You appear as “Marta Alves”."
- Toast (success, circle-check): **Display name saved** "You now appear as Marta Alves across RenewCMS and in emails."
### Save failed
**Trigger:** 500 or network failure. The typed value is kept.
- Breadcrumbs: Profile (mobile title "Profile")
- Alert (error) in the Display name card: "Couldn't save your display name. Your text is still here. Try again in a moment."
- Display name: "Marta Alves"
- Footer: "Unsaved changes" (warning)
- Save: enabled
### Reset: sending
**Trigger:** "Send reset link". No confirmation, since it only sends an email.
- Breadcrumbs: Profile (mobile title "Profile")
- Button: "Sending…" with a loader
- Sidebar chip shows the display name "Marta Alves"
### Reset: sent
**Trigger:** 200. The message stays until the page is left. "Send again" sends a new link and the old one stops working.
- Breadcrumbs: Profile (mobile title "Profile")
- Button: "Send again" (mail)
- Note (success, mail-check): **Reset link sent** "Check marta@renew.example. The link works once, within 1 hour."
### Reset: too many requests (429)
**Trigger:** Rate limited. The button is disabled for 5 minutes.
- Breadcrumbs: Profile (mobile title "Profile")
- Button: "Send reset link", 40 % opacity, disabled
- Note (warning, clock-alert): **Too many requests** "You've asked for several links recently. Wait a few minutes, then try again."
### Reset: failed
**Trigger:** 500 or network failure. The button can be pressed again.
- Breadcrumbs: Profile (mobile title "Profile")
- Button: "Send reset link"
- Note (error, circle-x): **Couldn't send the link** "Check your connection and try again."
### Display names in the Users list
**Trigger:** The Display name column of the Users list shows the name when one is set and a dash when not. Your own row follows your saved name. This state needs the Users page ([users.md](users.md)).
- Breadcrumbs: Users (mobile title "Users")
- Signed-in user's display name: "Marta Alves" (sidebar chip shows it too)
- Row `marta@renew.example` (you): display name "Marta Alves", role owner, Accepted
- Row `ines.duarte@renew.example`: display name "Inès Duarte", role admin, Accepted
- Row `jonas@renew.example`: display name "—" (muted), role admin, Accepted
## Notes for implementation
- `PATCH /v1/user/me` is marked pending by the design: it does not exist yet. `GET /v1/auth/me` has to start returning the display name.
- The Password card sends the reset email to the account's own address through the existing `POST /v1/auth/password-reset`. The link ends on the reset confirm screen, and a successful reset invalidates the current session like any other reset.
- "Disabled for 5 minutes" is the design's number. The real window comes from the `Retry-After` header, as on the login and reset screens.
- Resolved: the theme choice (System, Light, Dark) stays in the account menu, as the board now draws it. The board still draws Profile highlighted on Home; the app highlights it only on `/profile`.
Loaded 100 of 1700 files, more files were not shown because too many files have changed in this diff. Show more