Compare commits

...
154 Commits
Author SHA1 Message Date
Florian Sylvain ed0686085a docs: release notes for v3.7.2-beta 2026-10-30 21:38:08 +01:00
Florian Sylvain f3fcdf9ce9 fix: added items-center to the item class of the role select 2026-10-30 21:24:52 +01:00
Florian Sylvain 018fc98295 docs: release notes for v3.7.1-beta 2026-10-30 21:08:47 +01:00
Florian Sylvain 03f5d54bb6 fix: contrast on the selected role card 2026-10-30 21:07:21 +01:00
Florian Sylvain 39ff84e967 fix: user can edit its own author from author list page 2026-10-30 20:48:46 +01:00
Florian Sylvain f084ccb66f fix: create author button height in article editor 2026-10-30 20:47:52 +01:00
Florian Sylvain c92d8740ed docs: consolidate design change logs and refresh stale docs 2026-10-30 20:40:24 +01:00
Florian Sylvain c050db78d2 docs: release notes for v3.7.0-beta 2026-10-30 20:20:51 +01:00
Florian Sylvain 10a66cd979 feat: change email, export my data, delete account (with data anonymization) 2026-10-30 20:19:07 +01:00
Florian Sylvain bb191c389a chore: gdpr features design groundwork 2026-10-29 23:24:15 +01:00
Florian Sylvain c6d49db95e chore: updated reference design to app changes and fixes 2026-10-29 18:07:06 +01:00
Florian Sylvain 8cdd70bd81 fix: french buttons labels overflowing 2026-10-29 17:16:19 +01:00
Florian Sylvain b40a792d02 fix: hide date separators when date picker field idle, to prevent overlapping labels 2026-10-29 17:14:02 +01:00
Florian Sylvain 7a66318a73 fix: sticky footer on role editor 2026-10-29 17:11:40 +01:00
Florian Sylvain 89da2f6b14 fix: "used in" media badge color when selected readabiltiy issue 2026-10-29 17:09:17 +01:00
Florian Sylvain fda2793888 fix: add details to media panel when image is used in author or site 2026-10-29 17:05:25 +01:00
Florian Sylvain 63c0aec444 fix: visual issues (footer floating, table elements overflowing each others) 2026-10-29 16:57:39 +01:00
Florian Sylvain b39a41be88 refac: DELETE /v1/user/{id}?expected_role_id=N, user.remove audit event, 409 target_role_not_found, GetAll returns its error, Own-scope visibility filter moved into repos, JSON tags removed from use-case types 2026-10-29 16:28:25 +01:00
Florian Sylvain 5d6f378fce refac: stale test entries, failing web tests, Postgre backup bug, Hardcoded MySQL table count, Stale Postgre connection in test, own-scope visibility filter, GetAll ignoring its error, JSON tags in use-case types, 31 copies of DESKTOP_QUERY, ProfileView endpoint paths 2026-10-28 17:54:10 +01:00
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
fsylvain 1e5f324579 release: v2.8.2-beta Security headers and clearer log in errors 2026-10-05 16:24:12 +02:00
fsylvain 1d4f4bd2cc fix: missing security headers on every response, including the SPA and uploaded images 2026-10-05 16:19:37 +02:00
fsylvain d8f171b8cc fix: more complete log in username error messages 2026-10-05 16:04:24 +02:00
fsylvain 8ad3941f6d release: v2.8.1-beta Docker deployment 2026-10-05 04:56:12 +02:00
fsylvain d275ba3c14 feat: complete dockerization (optional) 2026-10-05 04:52:49 +02:00
fsylvain 3e01c29657 release: v2.8.0-beta API keys and API reference 2026-10-05 02:08:26 +02:00
fsylvain acd392c29d feat: integration page (PART 3/3) 2026-10-05 02:03:24 +02:00
fsylvain 514a082c96 feat(SPA): screen to manage API keys (PART 2/3) 2026-10-05 00:05:12 +02:00
fsylvain 844842fdae feat: integration v2 and API key PART 1/3 2026-10-04 21:56:14 +02:00
fsylvain 7e9bd9ba30 feat: articles format option with markdown new default 2026-10-04 19:04:12 +02:00
fsylvain 68401d66e0 refac: revamp the update flow in settings so the user has better visibility that it has to restart when update downloaded 2026-10-04 17:42:46 +02:00
fsylvain daf497576f fix: widened the theme selector in auth shell 2026-10-04 17:10:49 +02:00
fsylvain 0490a4c494 fix: language buttons real design / conformance 2026-10-04 17:08:43 +02:00
fsylvain 2c8f39273c release: v2.6.2-beta English and French interface 2026-10-03 02:08:52 +02:00
fsylvain 721311a643 feat(SPA): i18n internationzalization, english and french 2026-10-03 01:53:49 +02:00
fsylvain 7f1e8e572d docs: deployement guide with new ui update settings 2026-10-02 17:11:04 +02:00
fsylvain 02a0a36dc3 release: v2.6.0-beta signed updates and browser restart 2026-10-02 16:56:04 +02:00
fsylvain 5a430c08f4 chore: ai fest 2026-10-02 16:44:04 +02:00
fsylvain 54e8fd5fed feat: restart to apply update, privileged helper, rollback 2026-10-01 21:36:40 +02:00
fsylvain 70460e4170 feat: update button (download, verify, stage) 2026-10-01 18:13:45 +02:00
fsylvain 834493a4f5 feat: signed releases, release feed, notification (read-only) 2026-10-01 15:01:53 +02:00
fsylvain a49eec909c chore: groundwork for update from settings page 2026-10-01 06:04:02 +02:00
1953 changed files with 301843 additions and 9203 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"
+17
View File
@@ -0,0 +1,17 @@
.git
.gitignore
.dockerignore
.env
docker.env
tmp/
bin/
data/
node_modules
web/node_modules
internal/infrastructure/assets/dist
.task/
.claude/
.idea
.vscode
*.db
*.exe
+27
View File
@@ -14,3 +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
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
+12 -1
View File
@@ -4,7 +4,7 @@
# DB # DB
*.db *.db
data/ /data/
# Build # Build
bin bin
@@ -12,6 +12,7 @@ dist/
# Environment # Environment
.env .env
docker.env
# Generated files # Generated files
tmp/ tmp/
@@ -20,5 +21,15 @@ build-errors.log
uploadedImages/ uploadedImages/
*.exe *.exe
# AI fest
.claude/ .claude/
CLAUDE.md CLAUDE.md
.codex/
.agents/
AGENTS.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)
+35
View File
@@ -0,0 +1,35 @@
FROM oven/bun:1-alpine AS web
WORKDIR /src/web
COPY web/package.json web/bun.lock ./
RUN bun install --frozen-lockfile
COPY web/ ./
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.3-alpine AS server
ARG VERSION=dev
ENV CGO_ENABLED=0
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
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
FROM alpine:3.22
RUN addgroup -S -g 10001 renewcms \
&& adduser -S -u 10001 -G renewcms -H -s /sbin/nologin renewcms \
&& mkdir /data \
&& chown renewcms:renewcms /data
COPY --from=server /out/renewcms /usr/local/bin/renewcms
ENV RENEWCMS_CONTAINER=true
USER renewcms
WORKDIR /data
VOLUME /data
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["wget", "-qO-", "http://127.0.0.1:8080/healthz"]
ENTRYPOINT ["/usr/local/bin/renewcms"]
+22 -8
View File
@@ -15,6 +15,20 @@ cp .env.example .env # fill in PUBLIC_URL, JWT_SECRET and SMTP_*
Data is stored in `./data/` by default. For a real server (systemd, HTTPS, backups), see **[docs/deployment.md](docs/deployment.md)**. Data is stored in `./data/` by default. For a real server (systemd, HTTPS, backups), see **[docs/deployment.md](docs/deployment.md)**.
### Run with Docker
```sh
cp docker.env.example docker.env # fill in PUBLIC_URL, JWT_SECRET and SMTP_*
docker compose pull
docker compose up -d # http://localhost:8080
```
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
On Linux with systemd, install the helper manually once, then follow [the browser-update setup guide](docs/deployment.md#browser-updates). It covers finding the actual database directory, installing the helper, testing a restart and troubleshooting. Other platforms use manual upgrades.
### Develop ### Develop
Requires Go 1.27+, Node.js 22, [Bun](https://bun.sh) and [Task](https://taskfile.dev). Requires Go 1.27+, Node.js 22, [Bun](https://bun.sh) and [Task](https://taskfile.dev).
@@ -31,13 +45,13 @@ task dev # API with hot reload + Vite HMR → http://localhost:51
|---|---| |---|---|
| [docs/development.md](docs/development.md) | Dev setup, how Vite and the API fit together, tests, lint, `task` commands | | [docs/development.md](docs/development.md) | Dev setup, how Vite and the API fit together, tests, lint, `task` commands |
| [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/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 and known limitations |
| [docs/image-variants.md](docs/image-variants.md) | Image variants and their cache |
## TODOs | [docs/release-notes.md](docs/release-notes.md) | Release notes format, the published releases and how to publish one |
- Cancel button when email validation pending or expired
+293 -7
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,26 +233,63 @@ 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
release: build:mcp:
desc: Cross-compile production binaries for all release targets → bin/ desc: MCP server (stdio) for this machine → bin/{{.BINARY}}-mcp
deps: [build:web]
cmds: cmds:
- go build -trimpath -ldflags "{{.LDFLAGS}}" -o bin/{{.BINARY}}-mcp{{exeExt}} ./cmd/mcp
release:
desc: Cross-compile and sign production binaries for all release targets → bin/ (needs RELEASE_SIGNING_KEY)
cmds:
- task: release:check
- task: build:web
- task: build:llms
- for: { var: RELEASE_TARGETS } - for: { var: RELEASE_TARGETS }
task: build:target task: build:target
vars: vars:
GOOS: '{{index (splitList "/" .ITEM) 0}}' GOOS: '{{index (splitList "/" .ITEM) 0}}'
GOARCH: '{{index (splitList "/" .ITEM) 1}}' GOARCH: '{{index (splitList "/" .ITEM) 1}}'
- go run ./cmd/releasesign checksums bin --match "{{.BINARY}}_{{.VERSION}}_*"
- go run ./cmd/releasesign sign bin/checksums.txt
- go run ./cmd/releasesign verify bin/checksums.txt
release:check:
internal: true
cmds:
- cmd: 'test -n "$RELEASE_SIGNING_KEY" || { echo "RELEASE_SIGNING_KEY is not set. Point it at the private release key file (see task release:keygen)." >&2; exit 1; }'
silent: true
- cmd: 'test -f "$RELEASE_SIGNING_KEY" || { echo "RELEASE_SIGNING_KEY points to a missing file: $RELEASE_SIGNING_KEY" >&2; exit 1; }'
silent: true
- mkdir -p tmp
- echo probe > tmp/release-probe.txt
- defer: rm -f tmp/release-probe.txt tmp/release-probe.txt.sig
- go run ./cmd/releasesign sign tmp/release-probe.txt
release:keygen:
desc: One-time release key pair. Usage, task release:keygen -- <private key path outside the repository>
cmds:
- cmd: 'test -n "{{.CLI_ARGS}}" || { echo "usage: task release:keygen -- <private key path outside the repository>" >&2; exit 1; }'
silent: true
- go run ./cmd/releasesign keygen {{.CLI_ARGS}}
build:target: build:target:
internal: true internal: true
@@ -147,6 +307,132 @@ tasks:
cmds: cmds:
- ./bin/{{.BINARY}}{{exeExt}} - ./bin/{{.BINARY}}{{exeExt}}
docker:build:
desc: Build the Docker image tagged with the git describe version and latest
cmds:
- docker build --build-arg VERSION={{.VERSION}} -t renewcms:{{.VERSION}} -t renewcms:latest .
docker:run:
desc: Run the image once on a named volume, reading docker.env, on http://localhost:8080
cmds:
- docker run --rm --name renewcms -p 8080:8080 --env-file docker.env -e DB_FILE=/data/renewcms.db -e UPLOAD_DIR=/data/uploads -v renewcms-data:/data --stop-timeout 15 renewcms:latest
docker:up:
desc: Build from the local sources and start the Compose service in the background
env:
VERSION: '{{.VERSION}}'
cmds:
- docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
docker:pull:
desc: Pull the published image (RENEWCMS_IMAGE, default the beta tag)
cmds:
- docker compose pull
docker:upgrade:
desc: Pull the published image and recreate the Compose service on it
cmds:
- docker compose pull
- docker compose up -d
docker:down:
desc: Stop and remove the Compose service (the data volume is kept)
cmds:
- docker compose down
docker:logs:
desc: Follow the Compose service logs
cmds:
- docker compose logs -f renewcms
docker:publish:
desc: 'Build and push the multi-arch image to the registry. Usage, task docker:publish [-- --dry-run]'
vars:
REGISTRY_IMAGE:
sh: echo "${REGISTRY_IMAGE-git.floriansylvain.fr/fsylvain/renewcms}"
cmds:
- cmd: |
IMAGE='{{.REGISTRY_IMAGE}}'
V='{{.VERSION}}'
ARGS='{{.CLI_ARGS}}'
fail() { echo "docker:publish refused: $1" >&2; exit 1; }
DRY=no
case "$ARGS" in
"") ;;
"--dry-run") DRY=yes ;;
*) fail "unknown argument '$ARGS' (only --dry-run is accepted)" ;;
esac
[ -n "$IMAGE" ] || fail "REGISTRY_IMAGE is empty"
case "$V" in
v*) ;;
*) fail "VERSION '$V' must be an exact tag vX.Y.Z or vX.Y.Z-<pre>" ;;
esac
R="${V#v}"
CORE="${R%%-*}"
PRE=""
[ "$CORE" = "$R" ] || PRE="${R#*-}"
MAJOR="${CORE%%.*}"
REST="${CORE#*.}"
MINOR="${REST%%.*}"
PATCH="${REST#*.}"
for N in "$MAJOR" "$MINOR" "$PATCH"; do
case "$N" in
""|*[!0-9]*) fail "VERSION '$V' must be an exact tag vX.Y.Z or vX.Y.Z-<pre>" ;;
esac
done
[ "$CORE" = "$MAJOR.$MINOR.$PATCH" ] || fail "VERSION '$V' must be an exact tag vX.Y.Z or vX.Y.Z-<pre>"
if [ "$R" != "$CORE" ]; then
case "$PRE" in
""|*[!0-9A-Za-z.-]*) fail "VERSION '$V' has an invalid pre-release part" ;;
esac
PRENAME="${PRE%%[!A-Za-z]*}"
[ -n "$PRENAME" ] || fail "VERSION '$V' pre-release must start with letters, for example -beta or -rc.1"
fi
if [ "$DRY" = no ]; then
[ -z "$(git status --porcelain)" ] || fail "the work tree has uncommitted changes"
[ "$(git describe --tags --exact-match 2>/dev/null)" = "$V" ] || fail "VERSION '$V' is not an exact git tag on HEAD (an override is accepted only with --dry-run)"
fi
TAGS="$V $R"
if [ -n "$PRE" ]; then
TAGS="$TAGS $PRENAME"
else
TAGS="$TAGS $MAJOR.$MINOR latest"
fi
ARGS_TAGS=""
LIST=""
for T in $TAGS; do
ARGS_TAGS="$ARGS_TAGS -t $IMAGE:$T"
LIST="$LIST, $T"
done
echo "image: $IMAGE"
echo "tags: ${LIST#, }"
if [ "$DRY" = yes ]; then
echo "dry run: building for the host platform only, loading locally, pushing nothing"
docker buildx build --load --build-arg VERSION="$V" $ARGS_TAGS .
exit 0
fi
BUILDER="$(docker buildx inspect 2>&1)" || fail "docker buildx is not available or no builder is selected (see docs/docker.md, Maintainer: publishing)"
STORE="$(docker info 2>&1)"
case "$BUILDER" in
*linux/amd64*) ;;
*) fail "the selected buildx builder does not support linux/amd64 (see docs/docker.md, Maintainer: publishing)" ;;
esac
case "$BUILDER" in
*linux/arm64*) ;;
*) fail "the selected buildx builder does not support linux/arm64: enable QEMU/binfmt (see docs/docker.md, Maintainer: publishing)" ;;
esac
case "$BUILDER" in
*"Driver:"*"docker-container"*|*"Driver:"*"kubernetes"*|*"Driver:"*"remote"*) ;;
*)
case "$STORE" in
*io.containerd.snapshotter*) ;;
*) fail "the selected builder uses the plain docker driver without the containerd image store, which cannot build multi-platform images: run 'docker buildx create --use' (see docs/docker.md, Maintainer: publishing)" ;;
esac
;;
esac
docker buildx build --platform linux/amd64,linux/arm64 --build-arg VERSION="$V" $ARGS_TAGS --push .
silent: true
clean: clean:
desc: Remove build output, the built SPA and tmp/ desc: Remove build output, the built SPA and tmp/
cmds: cmds:
+494 -62
View File
@@ -1,33 +1,95 @@
package api package api
import ( import (
"RenewCMS/api/pkg/authtoken"
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"
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/persistence/models" "RenewCMS/internal/infrastructure/markdown"
"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/usage"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"fmt" "context"
"crypto/ed25519"
"log"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"github.com/glebarez/sqlite" "sync"
"github.com/go-chi/jwtauth/v5" "time"
"gorm.io/gorm"
) )
type UseCases struct { type UseCases struct {
Token *jwtauth.JWTAuth apiKeyAuthOnce sync.Once
apiKeyAuthMiddleware func(http.Handler) http.Handler
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
@@ -36,119 +98,489 @@ type UseCases struct {
RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase
ResetPasswordUseCase *useCases.ResetPasswordUseCase ResetPasswordUseCase *useCases.ResetPasswordUseCase
UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase
RequestEmailChangeUseCase *useCases.RequestEmailChangeUseCase
ResendEmailChangeUseCase *useCases.ResendEmailChangeUseCase
CancelEmailChangeUseCase *useCases.CancelEmailChangeUseCase
ConfirmEmailChangeUseCase *useCases.ConfirmEmailChangeUseCase
ListUsersUseCase *useCases.ListUsersUseCase ListUsersUseCase *useCases.ListUsersUseCase
CreateInviteUseCase *useCases.CreateInviteUseCase CreateInviteUseCase *useCases.CreateInviteUseCase
CancelInviteUseCase *useCases.CancelInviteUseCase CancelInviteUseCase *useCases.CancelInviteUseCase
RenewInviteUseCase *useCases.RenewInviteUseCase RenewInviteUseCase *useCases.RenewInviteUseCase
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
ChangeUserRoleUseCase *useCases.ChangeUserRoleUseCase
RemoveUserUseCase *useCases.RemoveUserUseCase
ExportUserDataUseCase *useCases.ExportUserDataUseCase
DeleteOwnAccountUseCase *useCases.DeleteOwnAccountUseCase
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
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
RequestRestartUseCase *useCases.RequestRestartUseCase
RecordRunningInfoUseCase *useCases.RecordRunningInfoUseCase
CreateAPIKeyUseCase *useCases.CreateAPIKeyUseCase
ListAPIKeysUseCase *useCases.ListAPIKeysUseCase
RevokeAPIKeyUseCase *useCases.RevokeAPIKeyUseCase
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
UserEraser domainUser.Eraser
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
SessionRepo domainSession.Repository
PurgeSettingsRepo domainPurge.Repository
AuditRepo domainAudit.Repository
AuditSettingsRepo domainAudit.SettingsRepository
MailRepo domainMail.Repository MailRepo domainMail.Repository
ReleaseFeed domainUpdate.ReleaseFeed
UpdateStore domainUpdate.StatusStore
UpdateJobs domainUpdate.JobStore
UpdateHelper domainUpdate.HelperDetector
AssetFetcher domainUpdate.AssetFetcher
SignatureVerifier domainUpdate.SignatureVerifier
StagedRunner domainUpdate.StagedBinaryRunner
DatabaseBackup domainUpdate.DatabaseBackup
UpdateResults domainUpdate.ResultStore
RestartRequests domainUpdate.RestartRequestStore
RunningInfo domainUpdate.RunningInfoStore
} }
const sqliteBusyTimeoutMs = 5000 const (
updateStatusFileName = "update-status.json"
updateJobFileName = "update-job.json"
updateHelperFileName = "update-helper.json"
updatesDirName = "updates"
backupsDirName = "backups"
func SQLiteDSN(dbName string) string { maxConcurrentDataExports = 2
return fmt.Sprintf("%s?_pragma=busy_timeout(%d)", dbName, sqliteBusyTimeoutMs) )
}
func getDb(dbName string, uploadDir string) *gorm.DB { func imageStorageConfig(settings config.Settings) storage.Config {
if err := os.MkdirAll(filepath.Dir(dbName), os.ModePerm); err != nil { if settings.Storage.Driver == config.StorageDriverS3 {
panic("Unable to create necessary subdirectories: " + err.Error()) 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,
},
} }
db, err := gorm.Open(sqlite.Open(SQLiteDSN(dbName)), &gorm.Config{}) }
return storage.Config{Driver: storage.DriverLocal, Location: settings.UploadDir}
}
func openImageStorage(settings config.Settings) domainImage.Storage {
imageStorage, err := storage.Open(imageStorageConfig(settings))
if err != nil {
panic("Unable to open the image storage: " + err.Error())
}
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{}); 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 getRepositories(db *gorm.DB, settings config.Settings) *Repositories { func releasePublicKey() ed25519.PublicKey {
key, err := update.EmbeddedPublicKey()
if err != nil {
panic("Unable to read the embedded release key: " + err.Error())
}
return key
}
func getRepositories(connection *database.Connection, settings config.Settings) *Repositories {
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), UserEraser: persistence.NewAccountEraser(db),
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),
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),
UpdateStore: update.NewJSONStatusStore(filepath.Join(dataDir, updateStatusFileName)),
UpdateJobs: update.NewJSONJobStore(filepath.Join(dataDir, updateJobFileName)),
UpdateHelper: update.NewContainerHelperDetector(update.NewFileHelperDetector(filepath.Join(dataDir, updateHelperFileName)), settings.Container),
AssetFetcher: update.NewHTTPAssetFetcher(settings.UpdateFeedURL),
SignatureVerifier: update.NewEd25519SignatureVerifier(releasePublicKey()),
StagedRunner: update.NewExecStagedBinaryRunner(),
DatabaseBackup: connection.NewBackup(filepath.Join(dataDir, backupsDirName), time.Now),
UpdateResults: update.NewJSONResultStore(filepath.Join(dataDir, domainUpdate.ResultFileName)),
RestartRequests: update.NewJSONRestartRequestStore(filepath.Join(dataDir, domainUpdate.RestartRequestFileName)),
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()
mailDispatcher := mailer.NewAsyncDispatcher()
sessionRevocation := useCases.NewSessionRevocationUseCase(repos.SessionRepo, time.Now)
renderer := markdown.NewConverter()
keyGenerator := hashing.NewSHA256KeyGenerator()
recorder := auditing.NewRecorder(repos.AuditRepo, time.Now, log.Default())
mediaUsage := useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo)
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), ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo), RetentionScheduler: scheduler.NewSystemRetentionRunner(useCases.NewEnforceRetentionUseCase(repos.UserRepo, repos.InviteRepo, repos.SessionRepo), log.Default()),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo), Token: authtoken.New([]byte(settings.JWTSecret), authtoken.WithRevocationStore(sessionRevocation)),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo), CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo), GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ImageRepo),
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), ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder),
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo), UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, recorder),
RequestEmailChangeUseCase: useCases.NewRequestEmailChangeUseCase(repos.UserRepo, hasher, repos.MailRepo, mailDispatcher, settings.PublicURL, time.Now, recorder),
ResendEmailChangeUseCase: useCases.NewResendEmailChangeUseCase(repos.UserRepo, hasher, repos.MailRepo, mailDispatcher, settings.PublicURL, time.Now, recorder),
CancelEmailChangeUseCase: useCases.NewCancelEmailChangeUseCase(repos.UserRepo, recorder),
ConfirmEmailChangeUseCase: useCases.NewConfirmEmailChangeUseCase(repos.UserRepo, hasher, time.Now, 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),
RemoveUserUseCase: useCases.NewRemoveUserUseCase(repos.UserRepo, repos.RoleRepo, repos.ContentTypeRepo, recorder),
ExportUserDataUseCase: useCases.NewExportUserDataUseCase(repos.UserRepo, repos.RoleRepo, repos.AuthorRepo, repos.APIKeyRepo, repos.ArticleRepo, repos.EntryRepo, repos.ContentTypeRepo, repos.ImageRepo, repos.AuditRepo, time.Now, recorder, maxConcurrentDataExports),
DeleteOwnAccountUseCase: useCases.NewDeleteOwnAccountUseCase(repos.UserRepo, repos.RoleRepo, hasher, repos.UserEraser, sessionRevocation, repos.MailRepo, mailDispatcher, settings.PublicURL, recorder, repos.AuthorRepo, repos.ImageRepo, mediaUsage, imageStorage, repos.VariantFileRepo),
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: mediaUsage,
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),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now, recorder),
StartUpdateDownloadUseCase: startUpdateDownloadUseCase(repos, settings, recorder),
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),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now, recorder),
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now),
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, time.Now, recorder),
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, recorder domainAudit.Recorder) *useCases.StartUpdateDownloadUseCase {
return useCases.NewStartUpdateDownloadUseCase(useCases.StartUpdateDownloadDependencies{
Store: repos.UpdateStore,
Jobs: repos.UpdateJobs,
Helper: repos.UpdateHelper,
Results: repos.UpdateResults,
Requests: repos.RestartRequests,
Fetcher: repos.AssetFetcher,
Verifier: repos.SignatureVerifier,
Runner: repos.StagedRunner,
Backup: repos.DatabaseBackup,
Staging: update.NewOSStagingArea(filepath.Join(settings.DataDir(), updatesDirName)),
Logger: log.Default(),
CurrentVersion: settings.Version,
Platform: domainUpdate.Platform{OS: runtime.GOOS, Arch: runtime.GOARCH},
FeedURL: settings.UpdateFeedURL,
Now: time.Now,
Recorder: recorder,
})
}
func backfillImageMetadata(imageRepo domainImage.Repository, imageStorage domainImage.Storage, settings config.Settings) error {
run := func(readable domainImage.Storage) error {
return useCases.NewBackfillImageMetadataUseCase(imageRepo, readable).Run()
}
if settings.Storage.Driver == config.StorageDriverS3 {
return storage.RunBackfillReportingReads(imageStorage, log.Default(), run)
}
return run(imageStorage)
}
func InitContainer(settings config.Settings) (*UseCases, func() error) { func InitContainer(settings config.Settings) (*UseCases, func() error) {
db := getDb(settings.DBFile, settings.UploadDir) imageStorage := openImageStorage(settings)
repos := getRepositories(db, settings) connection := openDatabase(settings)
return getUseCases(repos, settings), closeDb(db) repos := getRepositories(connection, settings)
if err := backfillImageMetadata(repos.ImageRepo, imageStorage, settings); err != nil {
panic("Failed to backfill image metadata: " + err.Error())
}
container := getUseCases(repos, imageStorage, settings)
return container, container.runUsageTracker(connection.Close)
} }
func closeDb(db *gorm.DB) func() error { 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 { return func() error {
sqlDb, err := db.DB() stop()
if err != nil { <-stopped
return err return closeDatabase()
}
return sqlDb.Close()
} }
} }
type BrowserUpdateCommands struct {
Enable *useCases.EnableBrowserUpdatesUseCase
Disable *useCases.DisableBrowserUpdatesUseCase
}
func InitBrowserUpdateCommands() *BrowserUpdateCommands {
units := update.NewSystemdUnits()
dependencies := useCases.ManageBrowserUpdatesDependencies{
Probe: update.NewSystemMachineProbe(),
Locator: units,
Units: units,
Manager: update.NewSystemctlControl(domainUpdate.ServiceUnitName),
Status: update.NewJSONHelperStatusWriter(),
Now: time.Now,
}
return &BrowserUpdateCommands{
Enable: useCases.NewEnableBrowserUpdatesUseCase(dependencies),
Disable: useCases.NewDisableBrowserUpdatesUseCase(dependencies),
}
}
func InitApplyUpdateCommand(options domainUpdate.InstallOptions) *useCases.ApplyRestartRequestUseCase {
layout := domainUpdate.HelperLayout{DataDir: options.DataDir, BinaryPath: options.Binary}
files := update.NewNoFollowFilesystem()
return useCases.NewApplyRestartRequestUseCase(useCases.ApplyRestartRequestDependencies{
Requests: update.NewFSRestartRequestStore(files, layout.RestartRequestPath()),
Results: update.NewFSResultStore(files, layout.ResultPath()),
Running: update.NewJSONRunningInfoStore(filepath.Join(options.DataDir, domainUpdate.RunningInfoFileName)),
Files: files,
Service: update.NewSystemctlControl(options.Service),
Health: update.NewHTTPHealthChecker(options.Port),
Verifier: update.NewEd25519SignatureVerifier(releasePublicKey()),
Clock: update.NewSystemClock(),
Logger: log.Default(),
Layout: layout,
Platform: domainUpdate.Platform{OS: runtime.GOOS, Arch: runtime.GOARCH},
})
}
+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)
}
+181
View File
@@ -0,0 +1,181 @@
package apikey
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
)
type Handler struct {
CreateUseCase *useCases.CreateAPIKeyUseCase
ListUseCase *useCases.ListAPIKeysUseCase
RevokeUseCase *useCases.RevokeAPIKeyUseCase
}
type CreateAPIKeyRequest struct {
Name string `json:"name" validate:"required,max=60"`
ExpiresAt *time.Time `json:"expires_at"`
}
type createdKeyResponse struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
Key string `json:"key"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at"`
}
type keyListItem struct {
ID uint32 `json:"id"`
UserID uint32 `json:"user_id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
Status domain.Status `json:"status"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at"`
ExpiresAt *time.Time `json:"expires_at"`
RevokedAt *time.Time `json:"revoked_at"`
CreatedByName *string `json:"created_by_name,omitempty"`
}
func NewHandler(create *useCases.CreateAPIKeyUseCase, list *useCases.ListAPIKeysUseCase, revoke *useCases.RevokeAPIKeyUseCase) *Handler {
return &Handler{CreateUseCase: create, ListUseCase: list, RevokeUseCase: revoke}
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var request CreateAPIKeyRequest
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
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
created, err := h.CreateUseCase.CreateAPIKey(r.Context(), useCases.CreateAPIKeyCommand{
UserID: userId,
Name: request.Name,
ExpiresAt: request.ExpiresAt,
})
if err != nil {
if errors.Is(err, domain.ErrExpiryNotInFuture) {
http.Error(w, apperr.APIKeyExpiryErrorMessage, http.StatusBadRequest)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
body, err := json.Marshal(createdKeyResponse{
ID: created.Key.ID,
Name: created.Key.Name,
Prefix: created.Key.Prefix,
Key: created.RawKey,
CreatedAt: created.Key.CreatedAt,
ExpiresAt: created.Key.ExpiresAt,
})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(body)
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
entries, err := h.ListUseCase.ListAPIKeys(userId, access.Grants(r))
if err != nil {
if access.WriteIfMissingPermission(w, err) {
return
}
if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
items := make([]keyListItem, 0, len(entries))
for _, entry := range entries {
key := entry.Key
item := keyListItem{
ID: key.ID,
UserID: key.UserID,
Name: key.Name,
Prefix: key.Prefix,
Status: entry.Status,
CreatedAt: key.CreatedAt,
LastUsedAt: key.LastUsedAt,
ExpiresAt: key.ExpiresAt,
RevokedAt: key.RevokedAt,
}
if entry.ListsCreator {
item.CreatedByName = &entry.CreatorName
}
items = append(items, item)
}
body, err := json.Marshal(items)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
if err := h.RevokeUseCase.RevokeAPIKey(r.Context(), userId, uint32(id), access.Grants(r)); err != nil {
switch {
case access.WriteIfMissingPermission(w, err):
case errors.Is(err, domain.ErrAPIKeyNotFound):
http.Error(w, apperr.APIKeyNotFoundErrorMessage, http.StatusNotFound)
case errors.Is(err, user.ErrUserNotFound):
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
w.WriteHeader(http.StatusNoContent)
}
+442 -54
View File
@@ -1,19 +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"
"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 {
@@ -22,25 +31,146 @@ 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 baseArticleResponse struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Body json.RawMessage `json:"body"`
BodyFormat domain.BodyFormat `json:"body_format"`
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"`
UpdatedAt time.Time `json:"updated_at"`
}
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)
if format != domain.BodyFormatJSON {
encoded, err := json.Marshal(article.Body)
if err != nil {
return baseArticleResponse{}, err
}
body = encoded
}
authors := article.Authors
if authors == nil {
authors = []author.Byline{}
}
return baseArticleResponse{
ID: article.ID,
Title: article.Title,
Body: body,
BodyFormat: format,
IsOnline: article.IsOnline,
SiteID: article.SiteID,
CreatedBy: creatorFor(article, viewer),
Authors: authors,
CreatedAt: article.CreatedAt,
UpdatedAt: article.UpdatedAt,
}, nil
}
func toArticleResponse(article domain.Article, format domain.BodyFormat, viewer access.Caller) (articleResponse, error) {
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 {
return nil, err
}
return json.Marshal(response)
}
func parseBodyFormat(w http.ResponseWriter, r *http.Request) (domain.BodyFormat, bool) {
format, err := domain.ParseBodyFormat(r.URL.Query().Get("format"))
if err != nil {
http.Error(w, apperr.ArticleBodyFormatErrorMessage, http.StatusBadRequest)
return "", false
}
return format, true
}
func parseArticleId(r *http.Request) (uint32, error) { func parseArticleId(r *http.Request) (uint32, error) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32) id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
return uint32(id), err return uint32(id), err
@@ -53,12 +183,12 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
return return
} }
var article domain.Article format, ok := parseBodyFormat(w, r)
if h.callerIsVerified(r) { if !ok {
article, err = h.GetUseCase.GetArticle(id) return
} else {
article, err = h.GetUseCase.GetOnlineArticle(id)
} }
article, err := h.GetUseCase.GetArticle(id, format, access.CallerOf(r))
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)
@@ -68,7 +198,7 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
return return
} }
articleJson, err := json.Marshal(article) 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
@@ -80,6 +210,11 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) { func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
format, ok := parseBodyFormat(w, r)
if !ok {
return
}
var localArticle PostArticle var localArticle PostArticle
if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil { if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
@@ -91,46 +226,98 @@ 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,
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
} }
articleJson, err := json.Marshal(createdArticle) writeArticleJsonResponse(w, r, http.StatusCreated, createdArticle, format)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
if _, err := w.Write(articleJson); err != nil {
log.Printf("Failed to write response: %v", err)
}
} }
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, article domain.Article) { func writeKnownArticleError(w http.ResponseWriter, err error) bool {
articleJson, err := json.Marshal(article) 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
} }
if status != http.StatusOK {
w.WriteHeader(status)
}
if _, err := w.Write(articleJson); err != nil { if _, err := w.Write(articleJson); err != nil {
log.Printf("Failed to write response: %v", err) log.Printf("Failed to write response: %v", err)
} }
@@ -143,17 +330,113 @@ func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline b
return return
} }
updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(id, isOnline) format, ok := parseBodyFormat(w, r)
if !ok {
return
}
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, updatedArticle) 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) {
@@ -163,24 +446,46 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
return return
} }
var localArticle UpdateArticleBody format, ok := parseBodyFormat(w, r)
if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil { if !ok {
return
}
request, ok := readUpdateArticleBody(r)
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) 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, updatedArticle) 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) {
@@ -191,34 +496,114 @@ 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 )
}
currentUser, err := h.GetUserUseCase.GetUser(userId) 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"`
}
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) {
var articles []domain.Article format, ok := parseBodyFormat(w, r)
var err error if !ok {
if h.callerIsVerified(r) { return
articles, err = h.ListUseCase.ListAllArticles()
} else {
articles, err = h.ListUseCase.ListOnlineArticles()
} }
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.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
} }
articlesJson, err := json.Marshal(articles) responses := make([]listItemResponse, 0, len(page.Items))
for _, article := range page.Items {
response, err := toListItemResponse(article, page.SEO[article.ID], format, access.CallerOf(r))
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
responses = append(responses, response)
}
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
@@ -236,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)
} }
+151
View File
@@ -0,0 +1,151 @@
package auth
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
)
type EmailChangeHandler struct {
RequestUseCase *useCases.RequestEmailChangeUseCase
ResendUseCase *useCases.ResendEmailChangeUseCase
CancelUseCase *useCases.CancelEmailChangeUseCase
ConfirmUseCase *useCases.ConfirmEmailChangeUseCase
}
type RequestEmailChangeCredentials struct {
NewEmail string `json:"new_email" validate:"required,max=255,inviteemail"`
CurrentPassword string `json:"current_password" validate:"required"`
}
type ConfirmEmailChangeCredentials struct {
Code string `json:"code" validate:"required"`
}
func NewEmailChangeHandler(request *useCases.RequestEmailChangeUseCase, resend *useCases.ResendEmailChangeUseCase, cancel *useCases.CancelEmailChangeUseCase, confirm *useCases.ConfirmEmailChangeUseCase) *EmailChangeHandler {
return &EmailChangeHandler{
RequestUseCase: request,
ResendUseCase: resend,
CancelUseCase: cancel,
ConfirmUseCase: confirm,
}
}
func (h *EmailChangeHandler) Request(w http.ResponseWriter, r *http.Request) {
var credentials RequestEmailChangeCredentials
if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(credentials); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
pending, err := h.RequestUseCase.RequestEmailChange(r.Context(), useCases.RequestEmailChangeCommand{
UserID: userId,
NewEmail: credentials.NewEmail,
CurrentPassword: credentials.CurrentPassword,
})
if err != nil {
writeEmailChangeError(w, err)
return
}
writePendingEmail(w, pending)
}
func (h *EmailChangeHandler) Resend(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
pending, err := h.ResendUseCase.ResendEmailChange(r.Context(), userId)
if err != nil {
writeEmailChangeError(w, err)
return
}
writePendingEmail(w, pending)
}
func (h *EmailChangeHandler) Cancel(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
if err := h.CancelUseCase.CancelEmailChange(r.Context(), userId); err != nil {
writeEmailChangeError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *EmailChangeHandler) Confirm(w http.ResponseWriter, r *http.Request) {
var credentials ConfirmEmailChangeCredentials
if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(credentials); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
email, err := h.ConfirmUseCase.ConfirmEmailChange(r.Context(), userId, credentials.Code)
if err != nil {
writeEmailChangeError(w, err)
return
}
body, err := json.Marshal(map[string]string{"email": email})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
_, _ = w.Write(body)
}
func writePendingEmail(w http.ResponseWriter, pending string) {
body, err := json.Marshal(map[string]string{"pending_email": pending})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
_, _ = w.Write(body)
}
func writeEmailChangeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, user.ErrUserNotFound):
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
case errors.Is(err, user.ErrWrongPassword):
http.Error(w, apperr.WrongPasswordErrorCode, http.StatusForbidden)
case errors.Is(err, user.ErrSameEmail):
http.Error(w, apperr.SameEmailErrorCode, http.StatusBadRequest)
case errors.Is(err, user.ErrNoPendingEmailChange):
http.Error(w, apperr.NoPendingEmailChangeErrorCode, http.StatusNotFound)
case errors.Is(err, user.ErrInvalidEmailChangeCode):
http.Error(w, apperr.InvalidEmailChangeCodeErrorCode, http.StatusForbidden)
case errors.Is(err, user.ErrEmailUnavailable):
http.Error(w, apperr.EmailUnavailableErrorCode, http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+29 -21
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,
@@ -41,28 +40,21 @@ 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, authtoken.ExpiredCookie(isProduction))
Name: "jwt",
Value: "",
Expires: time.Now(),
MaxAge: -1,
Secure: isProduction,
HttpOnly: true,
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 +79,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 +105,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 +115,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
} }
@@ -134,13 +130,25 @@ func (h *LoginHandler) Register(w http.ResponseWriter, r *http.Request) {
} }
func (h *LoginHandler) RegistrationStatus(w http.ResponseWriter, _ *http.Request) { func (h *LoginHandler) RegistrationStatus(w http.ResponseWriter, _ *http.Request) {
body, _ := json.Marshal(map[string]bool{"open": h.CreateUseCase.IsRegistrationOpen()}) open, err := h.CreateUseCase.IsRegistrationOpen()
if err != nil {
http.Error(w, apperr.RegistrationFailedErrorMessage, http.StatusInternalServerError)
return
}
body, _ := json.Marshal(map[string]bool{"open": open})
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
_, _ = 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)
} }
+61 -2
View File
@@ -3,7 +3,10 @@ package auth
import ( import (
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/payload"
"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 +16,58 @@ 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 payload.RoleRef `json:"role"`
Permissions []role.Grant `json:"permissions"`
RoleAssignedBy *payload.RoleAssigner `json:"role_assigned_by"`
}
type meResponse struct {
publicuser.PublicUser
PendingEmail *string `json:"pending_email"`
Author *meAuthor `json:"author"`
Access meAccess `json:"access"`
}
func toMeAccess(access useCases.Access) meAccess {
return meAccess{
Role: payload.RoleRefOf(useCases.NewRoleRef(access.Role)),
Permissions: access.Permissions,
RoleAssignedBy: payload.RoleAssignerPtrOf(access.RoleAssignedBy),
}
}
func toMeAuthor(found *author.Author) *meAuthor {
if found == nil {
return nil
}
return &meAuthor{Byline: found.Byline(), ArticleCount: found.ArticleCount}
}
func pendingEmailOf(account user.User) *string {
if account.PendingEmail == "" {
return nil
}
return &account.PendingEmail
}
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 +85,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), PendingEmail: pendingEmailOf(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,69 @@
package ownership
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/payload"
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(payload.OwnershipTransferOf(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)
}
+251
View File
@@ -0,0 +1,251 @@
package role
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/payload"
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.ErrReassignTargetNotFound, http.StatusConflict, apperr.ReassignTargetNotFoundErrorMessage},
{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, payload.RoleViewsOf(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, payload.RoleViewOf(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, payload.RoleViewOf(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, payload.RoleViewOf(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, payload.RoleViewOf(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 {
+139
View File
@@ -0,0 +1,139 @@
package update
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/role"
domain "RenewCMS/internal/domain/update"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
)
const restartBodyLimitBytes = 1024
type Handler struct {
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
RequestRestartUseCase *useCases.RequestRestartUseCase
}
type restartRequest struct {
Kind *string `json:"kind" validate:"omitempty,oneof=update restart"`
}
func NewHandler(getStatus *useCases.GetUpdateStatusUseCase, checkForUpdate *useCases.CheckForUpdateUseCase, startDownload *useCases.StartUpdateDownloadUseCase, requestRestart *useCases.RequestRestartUseCase) *Handler {
return &Handler{
GetUpdateStatusUseCase: getStatus,
CheckForUpdateUseCase: checkForUpdate,
StartUpdateDownloadUseCase: startDownload,
RequestRestartUseCase: requestRestart,
}
}
func (h *Handler) GetStatus(w http.ResponseWriter, r *http.Request) {
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(canApply(r)))
}
func (h *Handler) Check(w http.ResponseWriter, r *http.Request) {
if _, err := h.CheckForUpdateUseCase.Check(r.Context()); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(canApply(r)))
}
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
status, err := h.StartUpdateDownloadUseCase.Start(r.Context())
if err != nil {
writeDownloadError(w, err)
return
}
writeStatus(w, http.StatusAccepted, status)
}
func (h *Handler) Restart(w http.ResponseWriter, r *http.Request) {
kind, ok := decodeRestartKind(w, r)
if !ok {
return
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
status, err := h.RequestRestartUseCase.Request(r.Context(), userId, kind)
if err != nil {
writeRestartError(w, err)
return
}
writeStatus(w, http.StatusAccepted, status)
}
func decodeRestartKind(w http.ResponseWriter, r *http.Request) (string, bool) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, restartBodyLimitBytes))
if err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return "", false
}
if strings.TrimSpace(string(body)) == "" {
return "", true
}
var request restartRequest
if err := json.Unmarshal(body, &request); err != nil || validator.Validate.Struct(request) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return "", false
}
if request.Kind == nil {
return "", true
}
return *request.Kind, true
}
func canApply(r *http.Request) bool {
return access.Grants(r).Holds(role.KeySystemApplyUpdates)
}
func writeDownloadError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domain.ErrNotPrepared):
http.Error(w, apperr.UpdateNotPreparedErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrJobInProgress), errors.Is(err, domain.ErrRestartPending):
http.Error(w, apperr.UpdateAlreadyRunningErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrNothingToUpdate):
http.Error(w, apperr.NothingToUpdateErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrPreflightRefused):
http.Error(w, apperr.UpdateRefusedErrorMessage, http.StatusUnprocessableEntity)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func writeRestartError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domain.ErrNotPrepared):
http.Error(w, apperr.UpdateNotPreparedErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrJobInProgress), errors.Is(err, domain.ErrRestartPending):
http.Error(w, apperr.UpdateAlreadyRunningErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrRestartKindMismatch):
http.Error(w, apperr.UpdateRestartMismatchErrorMessage, http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func writeStatus(w http.ResponseWriter, code int, status domain.UpdateStatus) {
body, err := json.Marshal(status)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(code)
_, _ = w.Write(body)
}
+81
View File
@@ -0,0 +1,81 @@
package user
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/authtoken"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"time"
)
type DeleteAccountHandler struct {
DeleteUseCase *useCases.DeleteOwnAccountUseCase
IsProduction bool
}
type DeleteAccountRequest struct {
CurrentPassword string `json:"current_password" validate:"required"`
Confirmation string `json:"confirmation" validate:"required,eq=DELETE"`
}
func NewDeleteAccountHandler(deleteAccount *useCases.DeleteOwnAccountUseCase, isProduction bool) *DeleteAccountHandler {
return &DeleteAccountHandler{DeleteUseCase: deleteAccount, IsProduction: isProduction}
}
func (h *DeleteAccountHandler) Delete(w http.ResponseWriter, r *http.Request) {
var request DeleteAccountRequest
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
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
command := useCases.DeleteOwnAccountCommand{UserID: userId, CurrentPassword: request.CurrentPassword}
command.SessionID, command.SessionExpiresAt = sessionOf(r)
if err := h.DeleteUseCase.Delete(r.Context(), command); err != nil {
writeDeleteAccountError(w, err)
return
}
http.SetCookie(w, authtoken.ExpiredCookie(h.IsProduction))
w.WriteHeader(http.StatusNoContent)
}
func sessionOf(r *http.Request) (string, time.Time) {
tokenClaims, err := authtoken.FromContext(r.Context())
if err != nil {
return "", time.Time{}
}
id, _ := tokenClaims["jti"].(string)
expiry, _ := tokenClaims["exp"].(float64)
return id, time.Unix(int64(expiry), 0)
}
func writeDeleteAccountError(w http.ResponseWriter, err error) {
var lastAdmin domain.LastAdminError
switch {
case errors.Is(err, domain.ErrUserNotFound):
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
case errors.Is(err, domain.ErrWrongPassword):
http.Error(w, apperr.WrongPasswordErrorCode, http.StatusForbidden)
case errors.As(err, &lastAdmin):
w.Header().Set(apperr.LastRoleHeader, lastAdmin.Role)
http.Error(w, apperr.LastAdminErrorCode, http.StatusConflict)
case errors.Is(err, domain.ErrAccountNotDeletable):
http.Error(w, apperr.AccountNotDeletableErrorCode, http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+44
View File
@@ -0,0 +1,44 @@
package user
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"net/http"
"strconv"
)
type ExportHandler struct {
ExportUseCase *useCases.ExportUserDataUseCase
}
func NewExportHandler(export *useCases.ExportUserDataUseCase) *ExportHandler {
return &ExportHandler{ExportUseCase: export}
}
func (h *ExportHandler) Export(w http.ResponseWriter, r *http.Request) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
archive, err := h.ExportUseCase.Export(r.Context(), userId)
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "Service unavailable", http.StatusServiceUnavailable)
return
}
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Disposition", "attachment; filename=\""+archive.Filename+"\"")
w.Header().Set("Content-Length", strconv.Itoa(len(archive.Content)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(archive.Content)
}
+41 -19
View File
@@ -1,8 +1,10 @@
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"
"RenewCMS/api/pkg/payload"
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"
@@ -25,6 +27,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 +35,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 payload.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 +44,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 *payload.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 +55,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 *payload.RoleRef `json:"role"`
RoleAssignedBy *payload.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 +88,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 +102,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: payload.RoleRefOf(entry.Role),
CreatedAt: entry.CreatedAt, CreatedAt: entry.CreatedAt,
ExpiresAt: entry.ExpiresAt, ExpiresAt: entry.ExpiresAt,
}) })
@@ -124,7 +133,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 +153,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 +162,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 +180,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 +200,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: payload.RoleRefPtrOf(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)
}
+62
View File
@@ -0,0 +1,62 @@
package user
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/role"
domain "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
type RemoveHandler struct {
RemoveUserUseCase *useCases.RemoveUserUseCase
}
func NewRemoveHandler(removeUser *useCases.RemoveUserUseCase) *RemoveHandler {
return &RemoveHandler{RemoveUserUseCase: removeUser}
}
func (h *RemoveHandler) Remove(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
}
expectedRoleID, err := strconv.ParseUint(r.URL.Query().Get("expected_role_id"), 10, 32)
if err != nil || expectedRoleID == 0 {
http.Error(w, apperr.InvalidRoleErrorMessage, http.StatusBadRequest)
return
}
err = h.RemoveUserUseCase.Remove(r.Context(), useCases.RemoveUserCommand{
Caller: access.CallerOf(r),
TargetID: uint32(targetID),
ExpectedRoleID: uint32(expectedRoleID),
})
if err != nil {
writeRemoveError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func writeRemoveError(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)
}
}
+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)
}
}
+52
View File
@@ -0,0 +1,52 @@
package user
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/payload"
"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: payload.RoleRefPtrOf(entry.Role),
RoleAssignedBy: payload.RoleAssignerPtrOf(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)
+106
View File
@@ -0,0 +1,106 @@
package middleware
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/authtoken"
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
)
const bearerScheme = "bearer "
type apiKeyIdContextKey struct{}
type apiKeyNameContextKey struct{}
func bearerAPIKey(r *http.Request) (string, bool) {
header := r.Header.Get("Authorization")
if len(header) < len(bearerScheme) || !strings.EqualFold(header[:len(bearerScheme)], bearerScheme) {
return "", false
}
credential := strings.TrimSpace(header[len(bearerScheme):])
return credential, strings.HasPrefix(credential, apikey.RawKeyPrefix)
}
func apiKeyIdFrom(r *http.Request) (uint32, bool) {
id, ok := r.Context().Value(apiKeyIdContextKey{}).(uint32)
return id, ok
}
func apiKeyNameFrom(r *http.Request) string {
name, _ := r.Context().Value(apiKeyNameContextKey{}).(string)
return name
}
func RequireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, isKey := bearerAPIKey(r); isKey {
http.Error(w, apperr.SessionRequiredErrorMessage, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
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 {
limiter := newKeyedRateLimiter(maxRequests, window, apiKeyRateLimitKey)
return func(next http.Handler) http.Handler {
limited := limiter(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rawKey, isKey := bearerAPIKey(r)
if !isKey {
next.ServeHTTP(w, r)
return
}
authenticated, err := authenticate.AuthenticateAPIKey(rawKey)
if err != nil {
writeAPIKeyError(w, err)
return
}
ctx := authtoken.NewContext(r.Context(), sessionClaimsFor(authenticated), nil)
ctx = context.WithValue(ctx, apiKeyIdContextKey{}, authenticated.Key.ID)
ctx = context.WithValue(ctx, apiKeyNameContextKey{}, authenticated.Key.Name)
limited.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func writeAPIKeyError(w http.ResponseWriter, err error) {
if errors.Is(err, apikey.ErrAPIKeyInvalid) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
func sessionClaimsFor(authenticated useCases.AuthenticatedAPIKey) authtoken.Claims {
return authtoken.Claims{
"user_id": float64(authenticated.User.ID),
"token_version": float64(authenticated.User.TokenVersion),
}
}
func apiKeyRateLimitKey(r *http.Request) string {
id, _ := apiKeyIdFrom(r)
return strconv.FormatUint(uint64(id), 10)
}
+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)))
})
}
}
+47 -11
View File
@@ -2,6 +2,7 @@ package middleware
import ( import (
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"net/http" "net/http"
"strconv" "strconv"
"sync" "sync"
@@ -13,13 +14,36 @@ 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 {
var mu sync.Mutex return newKeyedRateLimiter(maxAttempts, window, connectionHostKey)
windows := make(map[string]*rateLimitWindow) }
func connectionHostKey(r *http.Request) string {
return hostOf(r.RemoteAddr)
}
func NewUserRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler {
return newKeyedRateLimiter(maxAttempts, window, userRateLimitKey)
}
func userRateLimitKey(r *http.Request) string {
userId, _ := claims.UserId(r)
return strconv.FormatUint(uint64(userId), 10)
}
func newKeyedRateLimiter(maxAttempts int, window time.Duration, keyOf func(*http.Request) string) func(http.Handler) http.Handler {
counters := &rateLimitCounters{window: window, 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, r.RemoteAddr, 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)
@@ -30,24 +54,36 @@ func NewRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) ht
} }
} }
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)
})
}
+33
View File
@@ -0,0 +1,33 @@
package middleware
import "net/http"
const contentSecurityPolicy = "default-src 'self'; " +
"script-src 'self' 'wasm-unsafe-eval'; " +
"style-src 'self' 'unsafe-inline'; " +
"font-src 'self'; " +
"img-src 'self' data: blob: https:; " +
"connect-src 'self'; " +
"object-src 'none'; " +
"base-uri 'self'; " +
"form-action 'self'; " +
"frame-ancestors 'none'"
const strictTransportSecurity = "max-age=31536000"
func SecurityHeaders(hsts bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", contentSecurityPolicy)
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("Cross-Origin-Opener-Policy", "same-origin")
if hsts {
h.Set("Strict-Transport-Security", strictTransportSecurity)
}
next.ServeHTTP(w, r)
})
}
}
+90
View File
@@ -0,0 +1,90 @@
package middleware
import (
"RenewCMS/api/pkg/apperr"
"net/http"
"strconv"
"sync"
"time"
)
type successSlots struct {
mu sync.Mutex
window time.Duration
now func() time.Time
taken map[string]time.Time
}
func NewUserSuccessLimiter(window time.Duration) func(http.Handler) http.Handler {
return NewUserSuccessLimiterAt(window, time.Now)
}
func NewUserSuccessLimiterAt(window time.Duration, now func() time.Time) func(http.Handler) http.Handler {
slots := &successSlots{window: window, now: now, taken: make(map[string]time.Time)}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := userRateLimitKey(r)
claimed, retryAfter, granted := slots.claim(key)
if !granted {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
http.Error(w, apperr.TooManyRequestsErrorMessage, http.StatusTooManyRequests)
return
}
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
succeeded := false
defer func() {
if !succeeded {
slots.release(key, claimed)
}
}()
next.ServeHTTP(recorder, r)
succeeded = recorder.status < http.StatusBadRequest
})
}
}
func (s *successSlots) claim(key string) (time.Time, int, bool) {
s.mu.Lock()
defer s.mu.Unlock()
now := s.now()
s.sweepExpired(now)
if claimedAt, held := s.taken[key]; held {
return time.Time{}, retryAfterSeconds(s.window - now.Sub(claimedAt)), false
}
s.taken[key] = now
return now, 0, true
}
func (s *successSlots) release(key string, claimedAt time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
if current, held := s.taken[key]; held && current.Equal(claimedAt) {
delete(s.taken, key)
}
}
func (s *successSlots) sweepExpired(now time.Time) {
for key, claimedAt := range s.taken {
if now.Sub(claimedAt) >= s.window {
delete(s.taken, key)
}
}
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(status int) {
s.status = status
s.ResponseWriter.WriteHeader(status)
}
func (s *statusRecorder) Unwrap() http.ResponseWriter {
return s.ResponseWriter
}
+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
}
+108 -1
View File
@@ -16,16 +16,123 @@ 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."
const MediaMergeRequestErrorMessage = "Choose an image to keep and at least one other image to merge into it." const MediaMergeRequestErrorMessage = "Choose an image to keep and at least one other image to merge into it."
const UpdateNotPreparedErrorMessage = "This server cannot update itself yet."
const UpdateAlreadyRunningErrorMessage = "An update is already running or waiting to be installed."
const NothingToUpdateErrorMessage = "There is nothing to update."
const UpdateRefusedErrorMessage = "This update cannot be downloaded."
const UpdateRestartMismatchErrorMessage = "The update state changed. Reload the page and try again."
const ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html, json."
const APIKeyNotFoundErrorMessage = "API key not found"
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 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 ReassignTargetNotFoundErrorMessage = "target_role_not_found"
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"
const WrongPasswordErrorCode = "wrong_password"
const SameEmailErrorCode = "same_email"
const NoPendingEmailChangeErrorCode = "no_pending_email_change"
const InvalidEmailChangeCodeErrorCode = "invalid_email_change_code"
const EmailUnavailableErrorCode = "email_unavailable"
const LastAdminErrorCode = "last_admin"
const AccountNotDeletableErrorCode = "account_not_deletable"
const LastRoleHeader = "X-Last-Role"
+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)
}
+19
View File
@@ -0,0 +1,19 @@
package authtoken
import (
"net/http"
"time"
)
func ExpiredCookie(secure bool) *http.Cookie {
return &http.Cookie{
Name: CookieName,
Value: "",
Expires: time.Now(),
MaxAge: -1,
Secure: secure,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Path: "/",
}
}
+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
} }
+91
View File
@@ -0,0 +1,91 @@
package payload
import (
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases"
"time"
)
type RoleRef struct {
ID uint32 `json:"id"`
Name string `json:"name"`
BuiltIn bool `json:"built_in"`
Locked bool `json:"locked"`
}
type RoleAssigner struct {
ID uint32 `json:"id"`
Name string `json:"name"`
}
type RoleEditor struct {
ID uint32 `json:"id"`
Name string `json:"name"`
}
type RoleView struct {
role.Role
Members int `json:"members"`
UpdatedAt *time.Time `json:"updated_at"`
UpdatedBy *RoleEditor `json:"updated_by"`
}
type OwnershipSuccessor struct {
ID uint32 `json:"id"`
Name string `json:"name"`
}
type OwnershipPredecessor struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Role RoleRef `json:"role"`
}
type OwnershipTransfer struct {
Owner OwnershipSuccessor `json:"owner"`
FormerOwner OwnershipPredecessor `json:"former_owner"`
}
func RoleRefOf(ref useCases.RoleRef) RoleRef {
return RoleRef(ref)
}
func RoleRefPtrOf(ref *useCases.RoleRef) *RoleRef {
if ref == nil {
return nil
}
converted := RoleRefOf(*ref)
return &converted
}
func RoleAssignerPtrOf(assigner *useCases.RoleAssigner) *RoleAssigner {
if assigner == nil {
return nil
}
converted := RoleAssigner(*assigner)
return &converted
}
func RoleViewOf(view useCases.RoleView) RoleView {
converted := RoleView{Role: view.Role, Members: view.Members, UpdatedAt: view.UpdatedAt}
if view.UpdatedBy != nil {
editor := RoleEditor(*view.UpdatedBy)
converted.UpdatedBy = &editor
}
return converted
}
func RoleViewsOf(views []useCases.RoleView) []RoleView {
converted := make([]RoleView, 0, len(views))
for _, view := range views {
converted = append(converted, RoleViewOf(view))
}
return converted
}
func OwnershipTransferOf(transfer useCases.OwnershipTransfer) OwnershipTransfer {
return OwnershipTransfer{
Owner: OwnershipSuccessor(transfer.Owner),
FormerOwner: OwnershipPredecessor{ID: transfer.FormerOwner.ID, Name: transfer.FormerOwner.Name, Role: RoleRefOf(transfer.FormerOwner.Role)},
}
}
-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 {
+479 -49
View File
@@ -1,28 +1,46 @@
package api package api
import ( import (
"RenewCMS/api/handlers/activity"
"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/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"
"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),
}))
} }
} }
@@ -31,35 +49,85 @@ func GetHelloWorld(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(msg) _, _ = w.Write(msg)
} }
func NewArticleRouter(container *UseCases) http.Handler { const (
r := chi.NewRouter() apiKeyMaxRequests = 120
apiKeyWindow = time.Minute
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase) emailChangeMaxRequests = 5
emailChangeWindow = 15 * time.Minute
dataExportWindow = 15 * time.Minute
accountDeleteMaxRequests = 5
accountDeleteWindow = 15 * time.Minute
)
func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler {
c.apiKeyAuthOnce.Do(func() {
c.apiKeyAuthMiddleware = middleware.APIKeyAuth(c.AuthenticateAPIKeyUseCase, apiKeyMaxRequests, apiKeyWindow)
})
return c.apiKeyAuthMiddleware
}
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.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
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)
emailChangeHandler := auth.NewEmailChangeHandler(container.RequestEmailChangeUseCase, container.ResendEmailChangeUseCase, container.CancelEmailChangeUseCase, container.ConfirmEmailChangeUseCase)
acceptInviteHandler := auth.NewAcceptInviteHandler(container.GetInviteUseCase, container.AcceptInviteUseCase, container.Token, isProduction) acceptInviteHandler := auth.NewAcceptInviteHandler(container.GetInviteUseCase, container.AcceptInviteUseCase, container.Token, isProduction)
r.Post("/register", loginHandler.Register) r.Post("/register", loginHandler.Register)
@@ -83,67 +151,423 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
r.Post("/invite/accept", acceptInviteHandler.AcceptInvite) r.Post("/invite/accept", acceptInviteHandler.AcceptInvite)
}) })
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token)) r.Use(middleware.RequireSession)
r.Use(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Verifier(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.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.Delete("/email/change", emailChangeHandler.Cancel)
r.Post("/email/confirm", emailChangeHandler.Confirm)
r.Group(func(r chi.Router) {
r.Use(middleware.NewUserRateLimiter(emailChangeMaxRequests, emailChangeWindow))
r.Post("/email/change", emailChangeHandler.Request)
r.Post("/email/resend", emailChangeHandler.Resend)
})
})
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.Get("/me", meHandler.Me) r.Get("/me", meHandler.Me)
}) })
return r return r
} }
func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler { func NewUserRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
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)
removeHandler := user.NewRemoveHandler(container.RemoveUserUseCase)
exportHandler := user.NewExportHandler(container.ExportUserDataUseCase)
deleteAccountHandler := user.NewDeleteAccountHandler(container.DeleteOwnAccountUseCase, isProduction)
r.Use(jwtauth.Verifier(container.Token)) r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
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.With(middleware.NewUserSuccessLimiter(dataExportWindow)).Get("/me/export", exportHandler.Export)
})
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(middleware.NewUserRateLimiter(accountDeleteMaxRequests, accountDeleteWindow))
r.Delete("/me", deleteAccountHandler.Delete)
})
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)
})
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.KeyUsersRemove))
r.Delete("/{id}", removeHandler.Remove)
})
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(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
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 return r
} }
func NewMediaRouter(container *UseCases) http.Handler { func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase) siteHandler := site.NewHandler(container.ListSitesUseCase, container.CreateSiteUseCase, container.UpdateSiteUseCase, container.SetDefaultSiteUseCase, container.DeleteSiteUseCase)
r.Use(jwtauth.Verifier(container.Token)) sitemapHandler := site.NewSitemapHandler(container.ListSitemapUseCase)
r.Use(jwtauth.Authenticator(container.Token))
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.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", mediaHandler.List) r.Get("/", siteHandler.List)
r.Post("/", mediaHandler.Upload) })
r.Post("/merge", mediaHandler.Merge) r.Group(func(r chi.Router) {
r.Patch("/{id}", mediaHandler.Rename) r.Use(middleware.RequireSession)
r.Delete("/{id}", mediaHandler.Delete) r.Use(authtoken.Verifier(container.Token))
r.Get("/{id}/usage", mediaHandler.Usage) 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
}
const (
updateCheckMaxAttempts = 5
updateCheckWindow = time.Minute
updateDownloadMaxAttempts = 5
updateDownloadWindow = time.Minute
)
func NewSystemRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.RequestRestartUseCase)
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.With(container.require(role.KeySettingsView)).Get("/update", updateHandler.GetStatus)
r.Group(func(r chi.Router) {
r.Use(container.require(role.KeySystemApplyUpdates))
r.Use(middleware.NewUserRateLimiter(updateCheckMaxAttempts, updateCheckWindow))
r.Post("/update/check", updateHandler.Check)
})
r.Group(func(r chi.Router) {
r.Use(container.require(role.KeySystemApplyUpdates))
r.Use(middleware.NewUserRateLimiter(updateDownloadMaxAttempts, updateDownloadWindow))
r.Post("/update/download", updateHandler.Download)
})
r.Group(func(r chi.Router) {
r.Use(container.require(role.KeySystemApplyUpdates))
r.Post("/update/restart", updateHandler.Restart)
})
return r
}
func NewAPIKeyRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
apiKeyHandler := apikey.NewHandler(container.CreateAPIKeyUseCase, container.ListAPIKeysUseCase, container.RevokeAPIKeyUseCase)
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.With(container.require(role.KeyAPIKeysCreate)).Post("/", apiKeyHandler.Create)
r.With(container.require(role.KeyAPIKeysView)).Get("/", apiKeyHandler.List)
r.With(container.require(role.KeyAPIKeysRevoke)).Delete("/{id}", apiKeyHandler.Revoke)
return r
}
func NewPermissionRouter(container *UseCases) http.Handler {
r := chi.NewRouter()
permissionHandler := permission.NewHandler(container.ListPermissionsUseCase)
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("/", permissionHandler.List)
return r
}
func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler {
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
} }
@@ -156,12 +580,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.IsProduction(), 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("/role", NewRoleRouter(container, settings.TrustedProxies))
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
@@ -186,14 +620,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", "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 {
@@ -201,11 +628,14 @@ func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
frontend := InitFrontendRoutes(settings.Environment) frontend := InitFrontendRoutes(settings.Environment)
apiRouter := chi.NewRouter() apiRouter := chi.NewRouter()
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)
}
}
+10
View File
@@ -0,0 +1,10 @@
package main
import (
"RenewCMS/internal/infrastructure/update/releasesign"
"os"
)
func main() {
os.Exit(releasesign.Run(os.Args[1:], os.Stdout, os.Stderr))
}
+90
View File
@@ -0,0 +1,90 @@
package main
import (
"RenewCMS/api"
"RenewCMS/internal/domain/update"
"flag"
"fmt"
"io"
"log"
)
const (
enableCommand = "enable-browser-updates"
disableCommand = "disable-browser-updates"
applyCommand = "apply-update"
exitOK = 0
exitError = 1
exitUsage = 2
usageText = "usage: renewcms [--version]\n" +
" renewcms enable-browser-updates [--service NAME] [--binary PATH] [--data-dir DIR] [--port PORT]\n" +
" renewcms disable-browser-updates [--service NAME] [--data-dir DIR]\n" +
" renewcms apply-update --binary PATH --data-dir DIR [--service NAME] [--port PORT]"
applyNeedsFlagsMessage = "apply-update needs --binary and --data-dir."
)
func runCommand(args []string, stdout io.Writer, stderr io.Writer) int {
name, rest := args[0], args[1:]
switch name {
case enableCommand:
return withOptions(name, rest, stderr, func(options update.InstallOptions) int {
message, err := api.InitBrowserUpdateCommands().Enable.Enable(options)
return printOutcome(stdout, stderr, message, err)
})
case disableCommand:
return withOptions(name, rest, stderr, func(options update.InstallOptions) int {
message, err := api.InitBrowserUpdateCommands().Disable.Disable(options)
return printOutcome(stdout, stderr, message, err)
})
case applyCommand:
return withOptions(name, rest, stderr, func(options update.InstallOptions) int {
return apply(options, stderr)
})
}
fmt.Fprintln(stderr, usageText)
return exitUsage
}
func withOptions(name string, args []string, stderr io.Writer, run func(update.InstallOptions) int) int {
flags := flag.NewFlagSet(name, flag.ContinueOnError)
flags.SetOutput(stderr)
var options update.InstallOptions
flags.StringVar(&options.Service, "service", "", "systemd service name of RenewCMS")
flags.StringVar(&options.Binary, "binary", "", "path of the RenewCMS binary")
flags.StringVar(&options.DataDir, "data-dir", "", "directory that holds DB_FILE")
flags.StringVar(&options.Port, "port", "", "port RenewCMS listens on")
if err := flags.Parse(args); err != nil {
return exitUsage
}
return run(options)
}
func printOutcome(stdout io.Writer, stderr io.Writer, message string, err error) int {
if err != nil {
fmt.Fprintln(stderr, err)
return exitError
}
fmt.Fprintln(stdout, message)
return exitOK
}
func apply(options update.InstallOptions, stderr io.Writer) int {
if options.Binary == "" || options.DataDir == "" {
fmt.Fprintln(stderr, applyNeedsFlagsMessage)
return exitUsage
}
if options.Service == "" {
options.Service = update.DefaultService
}
if options.Port == "" {
options.Port = update.DefaultPort
}
if err := api.InitApplyUpdateCommand(options).Apply(); err != nil {
log.Printf("apply-update failed: %v", err)
return exitError
}
return exitOK
}
+63 -13
View File
@@ -7,14 +7,15 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io/fs"
"log" "log"
"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"
@@ -43,13 +44,38 @@ 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",
"RENEWCMS_CONTAINER": "false",
config.EnvImageVariantMaxSourceBytes: config.DefaultImageVariantMaxSourceBytes,
config.EnvImageVariantMaxSourcePixels: config.DefaultImageVariantMaxSourcePixels,
config.EnvImageVariantMaxConcurrentRenders: config.DefaultImageVariantMaxConcurrentRenders,
} }
const versionFlag = "--version"
func main() { func main() {
if len(os.Args) == 2 && os.Args[1] == versionFlag {
fmt.Println(version)
return
}
if len(os.Args) > 1 {
os.Exit(runCommand(os.Args[1:], os.Stdout, os.Stderr))
}
if err := run(); err != nil { if err := run(); err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -69,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) return serveUntilStopped(server, settings, container.RecordRunningInfoUseCase.Record, container.RunSchedulers)
} }
func initEnvVariables() { func initEnvVariables() {
@@ -80,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")
} }
@@ -96,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())
} }
} }
@@ -120,12 +153,21 @@ func newServer(handler http.Handler, settings config.Settings) *http.Server {
} }
} }
func serveUntilStopped(server *http.Server, settings config.Settings) 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) go listen(server, serverErr, settings, recordRunning)
select { select {
case err := <-serverErr: case err := <-serverErr:
@@ -135,9 +177,17 @@ func serveUntilStopped(server *http.Server, settings config.Settings) error {
} }
} }
func listen(server *http.Server, serverErr chan<- error, settings config.Settings) { func listen(server *http.Server, serverErr chan<- error, settings config.Settings, recordRunning func() error) {
listener, err := net.Listen("tcp", server.Addr)
if err != nil {
serverErr <- err
return
}
if err := recordRunning(); err != nil {
log.Printf("WARNING: could not write the running-version file: %v", err)
}
log.Printf("RenewCMS %s (%s) listening on %s, public URL %s", version, settings.Environment, server.Addr, settings.PublicURL) log.Printf("RenewCMS %s (%s) listening on %s, public URL %s", version, settings.Environment, server.Addr, settings.PublicURL)
err := server.ListenAndServe() err = server.Serve(listener)
if errors.Is(err, http.ErrServerClosed) { if errors.Is(err, http.ErrServerClosed) {
err = nil err = nil
} }
+7
View File
@@ -0,0 +1,7 @@
services:
renewcms:
build:
context: .
args:
VERSION: ${VERSION:-dev}
image: renewcms:${VERSION:-dev}
+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:
+24
View File
@@ -0,0 +1,24 @@
services:
renewcms:
image: ${RENEWCMS_IMAGE:-git.floriansylvain.fr/fsylvain/renewcms:beta}
restart: unless-stopped
env_file:
- docker.env
environment:
DB_FILE: /data/renewcms.db
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:
+34
View File
@@ -0,0 +1,34 @@
PUBLIC_URL=https://cms.example.com
JWT_SECRET=replace-with-output-of-openssl-rand-hex-32
SMTP_HOST=smtp.example.com
SMTP_PORT=465
SMTP_LOGIN=noreply@example.com
SMTP_PASSWORD=replace-me
SMTP_SENDER=noreply@example.com
ENVIRONMENT=production
CORS_ALLOWED_ORIGINS=
AUTH_RATE_LIMIT_MAX_ATTEMPTS=5
AUTH_RATE_LIMIT_WINDOW_SECONDS=60
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
+613
View File
@@ -0,0 +1,613 @@
# API reference
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
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
The origin that serves the app, for example `https://cms.example`. Every path starts with `/v1`,
for example `https://cms.example/v1/article/`.
## Authentication
- Public routes need nothing: `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo`
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:
`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.
- 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
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.
- 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 (30)
| Group | Routes |
| --- | --- |
| 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/, 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/ (`users.view`), PATCH /v1/user/me, GET /v1/user/me/export, DELETE /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 |
| 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.request_email_change`, `user.resend_email_change`, `user.cancel_email_change`, `user.confirm_email_change`, `user.export_data`, `user.delete_account` (flagged `security`), `user.change_role`, `user.remove`, `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
`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). |
| 409 | `target_role_not_found` | `POST /v1/role/{id}/reassign` when the target role does not exist (any longer). |
| 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, 0, a locked role, a role the caller cannot cover). |
| 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`, `to_name` (the destination role's name at move time; absent on older events) 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.
## Changing the email address
Four session-only routes under `/v1/auth` let a verified user change their own email address. A missing or invalid
cookie is 401, an API key is 403 `This action needs a real session.` and an unverified user is 403
`You must verify your account to access this resource.`
| Route | Body | Success |
| --- | --- | --- |
| `POST /v1/auth/email/change` | `{ "new_email": "...", "current_password": "..." }` | 200 `{ "pending_email": "<new>" }` |
| `POST /v1/auth/email/resend` | none | 200 `{ "pending_email": "<new>" }` |
| `DELETE /v1/auth/email/change` | none | 204, no body |
| `POST /v1/auth/email/confirm` | `{ "code": "<raw code>" }` | 200 `{ "email": "<new>" }` |
`users.email` does not change until the link is confirmed, so the old address keeps receiving password-reset links and
the password and username keep working. A request stores `pending_email`, a bcrypt hash of a random code and an
expiration two hours ahead, then mails the raw code as a link to the new address (`verify-new-email`) and a notice to
the old one (`email-change-notice`). A new request or a resend replaces the previous code; cancelling clears it. The
link works once.
Errors are plain text and the code is the whole body: 400 `same_email` (the new address equals the current one,
whatever its case), 403 `wrong_password`, 404 `no_pending_email_change` (resend only), 403
`invalid_email_change_code` (nothing pending, wrong, expired, cancelled or issued to another user) and 409
`email_unavailable` (the address belongs to another user at confirmation time; the pending change is cleared). A body
that is not JSON is 400 with the body-mismatch message, and a failed validation is 400 with the validator text
(`new_email` must be a full address, `current_password` and `code` are required).
A request for an address that another user already holds answers exactly like a success, stores the pending change and
still sends the notice to the old address, but sends no verification mail, so the response never reveals that an account
exists. A failed send is logged without the code and does not change the response. `POST /v1/auth/email/change` and
`POST /v1/auth/email/resend` share one limit of 5 attempts per user per 15 minutes; the sixth is 429 with a
`Retry-After` header. Cancel and confirm are not limited.
Audit actions (category `users`, source `web`): `user.request_email_change`, `user.resend_email_change` and
`user.confirm_email_change` are flagged `security`; `user.cancel_email_change` is not, and cancelling with nothing
pending records nothing. A refused request, resend or confirm records a failed event of the same action, flagged `security`, with a specific reason. No email address is written into an event, and a wrong password records a failed
`user.request_email_change` with the reason `Wrong password.`
## Exporting your own data
`GET /v1/user/me/export` answers a ZIP of the caller's personal data. It needs a real, verified session: no cookie is 401,
an API key is 403 "This action needs a real session." and an unverified account is 403. There is no body and no permission
gate, since it only reads the caller's own data. The success answer is 200 with `Content-Type: application/zip`,
`Cache-Control: no-store` and `Content-Disposition: attachment; filename="renewcms-export-<username>-<YYYY-MM-DD>.zip"`;
the username is lowercased and reduced to `a-z`, `0-9` and `-`, and the date is the UTC day of the export. The archive is
built in memory first, so a failure is a plain text 500 and never a half file.
| File | Content |
| --- | --- |
| `profile.json` | `id`, `username`, `display_name`, `email`, `pending_email`, `role {id, name}`, `is_verified`, `created_at`, `updated_at`, `author` (the linked author profile or `null`), `api_keys [{id, name, prefix, created_at, last_used_at, revoked_at}]` |
| `authored-content.json` | `articles [{id, title, body, slug, site_id, is_online, created_at, updated_at, authors}]` created by the caller (drafts included) or published and crediting their author profile, with `authors [{id, name, slug}]`, and `entries [{id, content_type, data, created_at, updated_at}]` created by the caller; soft-deleted articles are left out |
| `uploaded-media.json` | `images [{id, filename, mime, size, hash, uploaded_at}]`: references only, no file bytes; soft-deleted images are left out |
| `account-activity.json` | `events [{id, created_at, action, category, target, outcome, security, source, ip, user_agent, params}]` where the caller is the actor, without events whose target is another user, an invite or another person's author profile |
The archive never holds the password hash, a verification, reset or email-change code, the token version or an API-key
hash. One export succeeds per user every 15 minutes; a second attempt is 429 "Too many requests. Please try again later."
with a `Retry-After` header in seconds. An export that fails with a 500 does not use the slot. A successful export records
one `user.export_data` audit event (category `users`, not flagged `security`).
The archive is built in memory. At most 2 exports are built at the same time across all users (the constant
`maxConcurrentDataExports` in `api/dependecyInjection.go`); another request waits for a free slot. If the request is
cancelled or times out while waiting, the answer is 503 "Service unavailable" and the user's 15-minute slot is not used.
## Deleting your own account
`DELETE /v1/user/me` deletes the caller's account and personal data. It needs a real, verified session: no cookie is 401,
an API key is 403 "This action needs a real session." and an unverified account is 403. It is not `DELETE /v1/user/{id}`,
which stays the admin action and is unchanged.
Body: `{"current_password": "...", "confirmation": "DELETE"}`. The password is required, and `confirmation` must be exactly
`DELETE` (case-sensitive, no spaces). The checks run in this order:
| Answer | Body | When |
| --- | --- | --- |
| 400 | the validator message, or "The request cannot be processed due to a mismatch in the format of the body." | the body is not JSON, the password is empty or `confirmation` is not `DELETE` |
| 403 | `wrong_password` | the password is not the account's |
| 409 | `last_admin`, with the header `X-Last-Role: Owner` or `X-Last-Role: Admin` | the caller holds the built-in Owner role and no other verified user does, or the built-in Admin role and no other verified user holds Owner or Admin. Other roles, custom ones included, are never blocked. |
| 409 | `account_not_deletable` | the holders of the role changed between the check and the removal; nothing was removed |
| 429 | "Too many requests. Please try again later." with `Retry-After` | more than 5 attempts per user in 15 minutes, counted before any check |
| 500 | "Internal server error" | the removal failed; everything is rolled back |
| 204 | none, and a `Set-Cookie` that expires `jwt` | the account is deleted |
The removal is one atomic operation: the user row, all the user's API keys, the linked author (deleted when no article
credits it, otherwise kept as "Deleted user" with the slug `deleted-user-<author id>`, no bio, avatar, website or links and
no user; the avatar image is deleted too, with its stored file after the commit, unless something else uses it), and the user's past activity events (actor and target names become "Deleted user", the IP address, user agent, `params` and `changes` are emptied, and author events on the user's own profile lose the profile name; the events stay). Articles, entries and images keep the old user id. The current session is revoked, and
one `account-deleted` mail (the site address only, no personal data) is sent to the address the account had; a failed send
is logged and does not change the 204. A successful deletion records one `user.delete_account` event (category `users`,
flagged `security`, actor and target named "Deleted user", no email in the params); a refused attempt records a failed
`user.delete_account` event with the reason `Wrong password.`, `Last Owner.` or `Last administrator.`.
## 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 `pending_email` (string or `null`), `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. |
| `DELETE /v1/user/{id}?expected_role_id=N` | `users.remove` | yes | Removes an accepted user (204, no body). `expected_role_id` is required (a whole number above 0, else 400 `invalid_role`) and must be the id of the role the target still holds: a compare-and-swap (`DeleteIfRole`), 409 `user_role_changed` otherwise. Order of checks: session, permission, id, `expected_role_id`, target (404 `user_not_found`), target is the caller (403 `own_role`), stale role (409), target role locked (403 `role_locked`), target role covered (403 `escalation`), delete. The removed user's cookie sessions and API keys answer 401 afterwards; articles, entries, media, invites and audit events they created stay. Records `user.remove` (category `users`, target the user); a refusal records nothing and a storage failure records it with outcome `failed`. Invites are cancelled with `DELETE /v1/user/invite/{id}`. |
| `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` | `DELETE /v1/user/{id}` as well: 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
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.
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
- 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.
+23
View File
@@ -0,0 +1,23 @@
# API keys
Developers can authenticate against `/v1` with a named API key sent as `Authorization: Bearer rcms_...` instead of the `jwt` cookie. A key acts with exactly the rights of the user who created it.
## Managing keys
Key management needs a real session (the `jwt` cookie from a verified user).
- `POST /v1/api-key` with `{"name": "ci", "expires_at": "<RFC3339, optional>"}` answers `201` with `id`, `name`, `prefix`, `key`, `created_at`, `expires_at`. The full `key` is shown once and never again.
- `GET /v1/api-key` lists the caller's keys (all keys for the owner), newest first, with a `status` of `active`, `expired` or `revoked`.
- `DELETE /v1/api-key/{id}` revokes a key and answers `204`. Revoking twice keeps the first `revoked_at`.
Only the SHA-256 hash and a 12 character prefix are stored.
## Where a key is accepted
`/v1/article` (public and protected routes), `/v1/user`, `/v1/media`, `/v1/settings` and `GET /v1/auth/me`. An invalid, expired or revoked `rcms_` key answers `401` and is never treated as anonymous. Each key is limited to 120 requests per minute (`429` with `Retry-After`). `last_used_at` is updated at most once per minute.
## Session-only routes
`/v1/api-key`, `POST /v1/auth/verify`, `DELETE /v1/auth/register` and every `/v1/system` route answer `403 This action needs a real session.` to any `rcms_` Bearer header. The public auth routes ignore the header.
A successful password reset revokes every key of that user.
+221
View File
@@ -0,0 +1,221 @@
# Reading articles
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`
- `GET /v1/article/`
- `GET /v1/article/{id}`
- `POST /v1/article/`
- `PATCH /v1/article/{id}`
- `PATCH /v1/article/{id}/publish`
- `PATCH /v1/article/{id}/unpublish`
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
| Value | `body` | `body_format` |
| --- | --- | --- |
| empty or absent | markdown string | `markdown` |
| `markdown` | markdown string | `markdown` |
| `html` | the stored HTML string | `html` |
| `json` | JSON array of blocks | `json` |
Values are case-insensitive. When `format` is repeated, the first value is used. Any other value answers `400` with the plain-text message `The format must be one of: markdown, html, json.` before any article is read or written. An invalid id is reported first, ahead of the format.
Breaking change: the default used to be HTML. Clients that need HTML must send `?format=html`. The web interface does so.
## Article object
```json
{
"id": 1,
"title": "Backyard Renovation Update",
"body": "## Plan\n\nReplace the **old** deck.",
"body_format": "markdown",
"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",
"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
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.
### Text alignment
Markdown has no syntax for alignment, so centered, right-aligned and justified paragraphs and headings are wrapped in a `div` with an inline style. The blank lines around the content make CommonMark parse the inside as markdown:
```markdown
<div style="text-align: center">
A **centered** paragraph
</div>
```
Left alignment is the default and produces no wrapper. Your markdown renderer must allow raw HTML for the alignment to show (`html: true` in markdown-it); otherwise the wrapper tags appear as text, and a client that does not want them can request `?format=json` instead, where the alignment is a field.
## JSON block schema
`body` is a native JSON array. Every block has a `type`.
| Block | Fields |
| --- | --- |
| `heading` | `level` (1 to 6), `align` (only when `center`, `right` or `justify`), `children` (text nodes) |
| `paragraph` | `align` (only when `center`, `right` or `justify`), `children` (text nodes, empty array for an empty paragraph) |
| `list` | `ordered` (boolean), `items` (array of items, each an array of blocks) |
| `blockquote` | `children` (blocks) |
| `code` | `language` (empty string when unknown), `text` |
| `hr` | none |
| `image` | `src`, `alt` (empty string when missing) |
Text node: `{"type":"text","text":"...","marks":[...]}`. `marks` is always an array, listed outermost first. A mark is `{"type":"bold"}`, `{"type":"italic"}` or `{"type":"link","href":"..."}`. A line break becomes a `"\n"` inside the text. Adjacent text with identical marks is merged. Inline code, underline, strike-through and other formatting degrade to plain text. An image inside a paragraph becomes its own block and splits the paragraph. An empty body is `[]`.
Example for `<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>`:
```json
[
{"type":"heading","level":2,"children":[{"type":"text","text":"Plan","marks":[]}]},
{"type":"paragraph","children":[
{"type":"text","text":"Replace the ","marks":[]},
{"type":"text","text":"old","marks":[{"type":"bold"}]},
{"type":"text","text":" deck.","marks":[]}
]}
]
```
## Errors
| Status | Body |
| --- | --- |
| `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` | `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` |
| `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 |
+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.
+85 -7
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,19 +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. |
| `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).
## 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
- 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.
+192 -1
View File
@@ -8,7 +8,9 @@ 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 |
Its only runtime state is the SQLite database (`DB_FILE`) and the uploads directory (`UPLOAD_DIR`). Nothing else needs to be installed on the server. 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).
## 1. Build ## 1. Build
@@ -19,8 +21,146 @@ task build # this OS/arch → bin/renewcms[.exe]
task release # all targets → bin/renewcms_<version>_<os>_<arch>[.exe] task release # all targets → bin/renewcms_<version>_<os>_<arch>[.exe]
``` ```
`task release` also signs the release: it writes `bin/checksums.txt` (SHA-256 of the six binaries) and signs it into `bin/checksums.txt.sig` with an Ed25519 key. It refuses to start, before building anything, unless `RELEASE_SIGNING_KEY` points to the private key file and that key matches the public key committed as `internal/infrastructure/update/release.pub`. The one-time setup is below.
The frontend is built once and embedded into every binary. The binaries are static (`CGO_ENABLED=0`), so a Linux build runs on any distribution. The version comes from `git describe`: tag a release (`git tag v1.2.0`) before building, and the binary logs its version at startup. The frontend is built once and embedded into every binary. The binaries are static (`CGO_ENABLED=0`), so a Linux build runs on any distribution. The version comes from `git describe`: tag a release (`git tag v1.2.0`) before building, and the binary logs its version at startup.
### Release signing key (maintainers, once)
1. Run `task release:keygen -- <path>` with a path outside the repository (for example `~/.renewcms/release.key`). The private key is written there and never leaves your machine; keygen refuses a path inside the work tree.
2. Commit the printed public key as `internal/infrastructure/update/release.pub`. A placeholder key (32 zero bytes) prevents signing. Official releases already embed the project's public key; this setup is for release maintainers, not site operators. Never copy the private signing key to a CMS server.
3. Export `RELEASE_SIGNING_KEY=<path to the private key>` before every `task release`.
To check a download by hand: `go run ./cmd/releasesign verify checksums.txt`, with `checksums.txt.sig` next to it, then compare the binary's hash with the matching line of `checksums.txt`.
### Update checks
The owner's Settings page asks the release feed for the newest release (see `UPDATE_FEED_URL` in [configuration.md](configuration.md)) and keeps the last result in `update-status.json`, next to `DB_FILE`. Back it up with the rest of the data directory if you like; it is rebuilt by the next check when missing. The Settings page reports a newer version. To install it from the browser, set up [Browser updates](#browser-updates) once on the server. In Docker (`RENEWCMS_CONTAINER=true`) browser updates are not offered; Settings tells the owner to pull a new image instead, see [docker.md](docker.md).
### Update downloads
Once the restart helper is installed (it writes `update-helper.json` next to `DB_FILE`), the owner's Update button downloads the new binary, `checksums.txt` and `checksums.txt.sig` from the release feed host over https only, checks the ed25519 signature and the SHA-256, runs the staged binary with `--version`, copies the database into `backups/` (the last 3 copies are kept) and stages everything under `updates/<tag>/`, all next to `DB_FILE`. The job record is `update-job.json`. Nothing running is replaced and the site is not restarted by the download: the owner's Restart button does that, as described in [Browser updates](#browser-updates). `renewcms --version` prints the build version and exits.
### Browser updates
The owner's Restart button installs a downloaded update, or just restarts the site, without touching the server by hand. The app itself stays unprivileged: the systemd unit below keeps `ProtectSystem=strict` and only the data directory (the directory that holds `DB_FILE`) is writable. The only privileged code is a small helper, the same binary started as `renewcms apply-update` by a systemd service that runs as root.
First install the helper manually, back up the database, uploads and current binary, and confirm the app runs as an unprivileged service user. The binary and its parent directory should be controlled by root. Owners can start updates and restarts; admins have read-only update status.
#### Find the actual database directory
The helper's `--data-dir` must be the directory containing `DB_FILE`. Inspect the relevant settings without printing the configuration's secrets:
```sh
sudo systemctl show renewcms -p User -p WorkingDirectory
sudo grep '^DB_FILE=' /etc/renewcms/renewcms.env
```
| WorkingDirectory | DB_FILE | Correct --data-dir |
| --- | --- | --- |
| `/var/lib/renewcms` | `/var/lib/renewcms/renewcms.db` | `/var/lib/renewcms` |
| `/var/lib/renewcms` | `./data/renewcms.db` | `/var/lib/renewcms/data` |
Leave an existing `DB_FILE` unchanged: changing it can open a different, empty database. Configure the helper to match the existing database instead.
#### Enable the helper
For this guide's absolute database path and port 8080, run:
```sh
sudo /opt/renewcms/renewcms enable-browser-updates \
--service renewcms \
--binary /opt/renewcms/renewcms \
--data-dir /var/lib/renewcms \
--port 8080
```
Replace these flags with your actual paths and port. For the relative database example above and port 8788, use `--data-dir /var/lib/renewcms/data --port 8788`. The absolute executable path works even when `/opt/renewcms` is absent from sudo's PATH.
Without explicit flags, discovery reads `/etc/systemd/system/renewcms.service` and takes its first `ReadWritePaths` entry as the data directory. It does not resolve `DB_FILE`, so explicit flags are necessary when these directories differ or the unit lives elsewhere. The port comes from `--port`, else `PORT` in the unit's `EnvironmentFile`, else 8080. The helper polls `/healthz` on `127.0.0.1` at that port. It then:
- writes `renewcms-update.path` and `renewcms-update.service` next to your other units in `/etc/systemd/system` (no secret and no environment value goes into them);
- runs `systemctl daemon-reload` and enables and starts the path unit;
- writes `update-helper.json` in the data directory last, so the Settings page only offers Update and Restart once everything else worked.
Running it again rewrites the units and the status file to the current values. If a step fails, the units it wrote are removed again and `update-helper.json` is not written.
#### Verify setup and test a plain restart
For the layout above, run these checks, substituting your service user and database directory if different:
```sh
systemctl is-active renewcms-update.path
systemctl is-enabled renewcms-update.path
sudo systemctl cat renewcms-update.service
sudo -u renewcms cat /var/lib/renewcms/update-helper.json
```
Expect `active`, `enabled`, an `ExecStart` with the correct paths and port, and readable JSON. Reload Settings as owner. With no update waiting, Restart should appear. Press it, confirm the plain restart dialog, and expect the overlay followed by the site returning and a “Site restarted” message.
Then run:
```sh
systemctl is-active renewcms
sudo cat /var/lib/renewcms/update-result.json
sudo cat /var/lib/renewcms/update-running.json
sudo journalctl -u renewcms-update.service -n 30 --no-pager
curl --retry 5 --retry-connrefused --retry-delay 2 -fsS http://127.0.0.1:8080/healthz
```
Expect an active app service, result `kind: restart` with `outcome: succeeded`, the installed version and a new process in `update-running.json`, and health response `ok`. Check `/healthz` through your public HTTPS hostname too. A brief connection refusal during startup can be transient. The oneshot helper normally becomes inactive after success; the path unit stays active.
#### Install a future update
When a newer signed release exists, check for updates in Settings, read its notes and click Update. The site stays online while the files are downloaded, verified and backed up. Wait for Ready to install, then click Restart and confirm installation. Afterwards, check the version in Settings, public health, the helper journal and `update-result.json`: expect `kind: update` and `outcome: succeeded`.
On the latest release you can test only a plain restart. Test installation, forced rollback and hostile-file cases on an isolated Linux/systemd machine with its own data and service. The helper unit names are fixed, so setting up another instance on the same machine replaces that machine's existing helper configuration.
#### Troubleshooting
- **Restart is missing:** reload Settings, confirm you are owner, and inspect `GET /v1/system/update` in the browser's Network panel. For an idle owner, `can_apply: false` means the app did not detect the helper. Check that `update-helper.json` is beside the actual database and readable by the service user.
- **Wrong helper directory:** disable the helper with the old `--data-dir`, then enable it with the correct directory and port. Do not change `DB_FILE` to compensate.
- **Overlay does not clear:** inspect the helper journal, `update-result.json` and `update-running.json` in the actual database directory. Check localhost health on the configured port, then public health through the proxy. Do not cache `/healthz` at the proxy.
- **Old binary panics on `--version`:** releases before v2.6.0-beta treat it as normal startup and require server environment variables. A separately launched process failing this way does not stop the systemd service.
- **Download or verification fails:** inspect the app journal. All assets and redirects must use HTTPS on the configured feed host. The release must include the six binaries, `checksums.txt` and `checksums.txt.sig`.
- **Update rolls back:** check the previous version and helper journal. Automatic rollback restores the binary, not the database; schema changes still require a recovery plan.
#### Disable the helper
Use the same database directory you supplied when enabling it:
```sh
sudo /opt/renewcms/renewcms disable-browser-updates \
--service renewcms \
--data-dir /var/lib/renewcms
```
This stops and disables the path unit, removes both unit files and `update-helper.json`, and runs `systemctl daemon-reload`. The restart request, result, staged files and backups stay. `can_apply` becomes false: Settings shows “Not prepared” when an update is available, or “You're up to date” without Restart otherwise.
How it works: pressing Restart makes the app write `update-restart-request.json` in the data directory. The path unit notices it and starts the helper, which deletes the request, checks the staged files again, installs the update, restarts the service and waits up to 60 seconds for the site to answer `/healthz` and for the new process to write `update-running.json`. The helper writes its outcome to `update-result.json`; the Settings page reads it. The technical reason of a failure goes to the journal only (`journalctl -u renewcms-update`).
Files in the data directory:
| File | Written by | What |
|------|------------|------|
| `update-restart-request.json` | the app | The request: `kind` (`update` or `restart`), `tag` (updates only), `requested_at`, `requested_by`. The helper deletes it first. |
| `update-result.json` | the helper | The outcome: `running`, `succeeded`, `rolled_back` or `failed`, with `kind`, `tag`, `from_version`, `started_at`, `finished_at`. It stays until the next download or restart. |
| `update-helper.json` | `enable-browser-updates` | Marks browser updates as set up (`version`, `service`, `binary`, `data_dir`, `installed_at`). |
| `update-running.json` | the server, at every start | `version`, `pid` and `started_at` of the running process. The helper uses it to tell a new process from an old one. |
What is supported and what is not:
- Linux with systemd only. Not Windows, macOS, Docker or other init systems: there the command refuses with a clear message and the Settings page keeps showing "Not prepared".
- Before the swap the helper copies the staged files into a private temp directory, checks the signature and the SHA-256 on that copy and installs the copy. It refuses symbolic links under `updates/` and on the request file.
- The previous binary is kept as `<name>.previous` next to the binary, and the update download keeps a database backup in `backups/`.
- If the new version does not come up within 60 seconds, the helper puts `<name>.previous` back, restarts the service and records `rolled_back`. If even that fails the result is `failed` and the journal says so loudly.
- A plain restart (no update waiting) restarts the service the same way and records `succeeded` or `failed`; no binary is replaced.
- Only the owner can press Restart. The site is unavailable for about 10 seconds.
#### Verification status
Only the plain browser restart has been confirmed on a real Linux/systemd machine. Signed installation, forced rollback and adversarial filesystem checks remain unverified; the manual cases are in [the restart QA procedure](../features/updates_restart_helper.qa.md).
## 2. Lay out the server ## 2. Lay out the server
The following is a suggested layout for Linux: The following is a suggested layout for Linux:
@@ -130,8 +270,59 @@ If RenewCMS runs on the same host as the proxy, consider firewalling port 8080.
Either way, set `TRUSTED_PROXIES` (see [configuration.md](configuration.md)) to the proxy's own address as seen by RenewCMS, typically `127.0.0.1` when the proxy and RenewCMS share a host, so the login/password-reset rate limiter honors the forwarded client IP instead of the proxy's own address. Either way, set `TRUSTED_PROXIES` (see [configuration.md](configuration.md)) to the proxy's own address as seen by RenewCMS, typically `127.0.0.1` when the proxy and RenewCMS share a host, so the login/password-reset rate limiter honors the forwarded client IP instead of the proxy's own address.
### Security headers
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 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
+27 -38
View File
@@ -1,47 +1,36 @@
# RenewCMS design # RenewCMS design
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 (the brand mark excepted). Light and dark, at 1440×900 and 390×844. Radius 0. Behaviour comes from `features/`; where the two disagree, stop and ask.
## Files ## Files
| File | What it holds | | File | Content |
|---|---| | --- | --- |
| `routes.md` | Every route: layout, auth requirement, page component, API calls | | `routes.md` | Routes with their auth requirement. |
| `components.md` | The component inventory with its Nuxt UI mapping, props, emits and slots, then the full component and primitive specs | | `components.md` | Component inventory with its Nuxt UI mapping, then the full component specs. |
| `states.md` | Every state of every screen with its trigger, and the HTTP-status-to-copy table | | `states.md` | Every state per screen, plus the HTTP-status-to-copy table. |
| `screens/<screen>.md` | One file per screen: route, API, and per state the exact copy, fields, alerts, dialogs and toasts | | `screens/<screen>.md` | Exact copy per state. `visual:check` takes the screen name from the file name. |
| `foundations.md` | Colors, contrast, type, spacing, motion and icons, and where the token files are | | `foundations.md` | Tokens, spacing, type and contrast. |
| `emails.md` | The three email templates and their variables | | `emails.md` | The mail templates. |
| `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). | | `decisions.md` | Product and interface decisions that are not visible in the specs: search and sharing, sites and authors, roles, account self-service and the interface defaults. |
| `media-field.md` | Spec of the Media field type: control, states, copy, accessibility and API. |
| `source/` | The raw export. The `.dc.html` boards are the visual reference, to open in a browser. `tokens/` and `emails/` hold the ready-to-use token and template files. |
## Screens ## Boards (in `source/`)
| Screen | Route | States | Board | - `RenewCMS Screens Back-office.dc.html`: every back-office screen, with the frames `AppFrame` (shell), `DirectoryFrame` (Sites and Authors), `ActivityFrame`, `MediaFrame`, `VariantsFrame`, `IntegrationPage`, `IntegrationSearch`, `IntegrationImages`, `CodeSnippet` and `StatesMatrix`.
|---|---|---|---| - `RenewCMS Screens Auth.dc.html`: log in, register, pending, verify, accept invite, forgot and reset password, verify new email, account deleted.
| [Log in](screens/log-in.md) | `/login` | 9 | `source/RenewCMS Screens Auth.dc.html` | - `RenewCMS Screens API keys.dc.html`.
| [Register](screens/register.md) | `/register` | 6 | `source/RenewCMS Screens Auth.dc.html` | - `RenewCMS Screens Roles.dc.html`: the role editor, users and ownership (`RolesFrame`, data `roles-spec.js`).
| [Pending](screens/pending.md) | `/register/pending` | 6 | `source/RenewCMS Screens Auth.dc.html` | - `RenewCMS Screens Schema.dc.html`: custom content types, entries, relations and the Media field (`SchemaFrame`).
| [Verify](screens/verify.md) | `/register/validate?c=CODE` | 6 | `source/RenewCMS Screens Auth.dc.html` | - `RenewCMS Screens Account.dc.html`: change email, export your data and delete your account (`AccountCards`, copy in `account-copy.js`).
| [Forgot password](screens/forgot-password.md) | `/register/reset/request` | 6 | `source/RenewCMS Screens Auth.dc.html` | - `RenewCMS Emails.dc.html`, `RenewCMS Foundations.dc.html`, `RenewCMS Components Primitives.dc.html`, `RenewCMS Components Product.dc.html`, `RenewCMS Handoff.dc.html`, `RenewCMS Directions.dc.html` and `RenewCMS Prototype.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` | 4 | `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` | 2 | `source/RenewCMS Screens Back-office.dc.html` |
| [Users](screens/users.md) | `/users` | 17 | `source/RenewCMS Screens Back-office.dc.html` |
| [Settings](screens/settings.md) | `/settings` | 1 | `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 Every state has a desktop and a mobile frame, and the theme switch gives light and dark. States that render a relative time or a countdown are tagged clock-sensitive in `states.md` and skipped by `task visual:check`.
- `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. ## Known gaps
- `source/RenewCMS Prototype.dc.html`: the clickable flows (first run, edit and publish, password reset, delete, session expiry mid-edit).
- `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`. - The Foundations, Components and Prototype boards do not show the account self-service pieces or every newer token.
- `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". - Duplicate implementations that look alike are not merged in code: toasts, badges, empty states and modal shells.
- `source/RenewCMS Directions.dc.html`: the three brand directions (history only). - The Back-office board still shows the owner, admin and user navigation. Writer frames use the restricted `user` navigation of `AppFrame`.
- The Schema board group 8 (Permissions) is superseded by the role editor, and `/settings/permissions` redirects to `/settings/roles`.
+157 -773
View File
@@ -1,786 +1,170 @@
# 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 | UCard, UBadge, UTabs | method: 'GET', path, description, examples: { fetch, curl, response } | none | none | Integration |
| 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 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. 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 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, System, Light and Dark (14 px text, a check icon at the right edge on the current choice), a separator, Log out (log-out). 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 items keep the same order and separators.
- **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 menuitemcheckbox , with aria-checked on the current choice. The segmented variant is a radiogroup named "Theme".
## 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." Helper text under the toggle: "Links open in the same tab unless you turn on a new tab."
- **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.
## 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).
## 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".
## Sites and authors (pass 3)
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.
| Component | Status | Nuxt UI | Notes |
|---|---|---|---|
| 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 |
| SiteForm, AuthorForm | New | USlideover + UForm, UFormField, UInput, UTextarea, URadioGroup, USwitch | Sheet on mobile. Close with changes opens the discard UModal |
| 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 |
| 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.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. |
## Account (change email, export, delete)
| Component | Status | Nuxt UI mapping | Where |
|---|---|---|---|
| EmailChangeForm | New | UFormField + UInput (email, password) + UButton, inside the Account UCard | Profile |
| PendingEmailNotice | New | UAlert (info) + two UButton (outline) | Profile |
| DataExportCard | New | UCard + UButton; notices as UAlert | Profile |
| DangerZoneCard | New | UCard with border var(--ui-error), UButton color error | Profile |
| DeleteAccountDialog | New | UModal (existing destructive ConfirmDialog shell) + UInput x2 + list + UAlert info + ULink | Profile |
| BlockedDialog | New | UModal with dismissible false, no close button; UButton primary for the next step | Profile |
Specs: field heights 40 (44 mobile), buttons 36 (44 mobile), card padding 16, section gap 16. Colors from tokens only. Destructive confirm is disabled at 40% opacity until valid. Implemented in AccountCards.dc.html and the AppFrame modal.
+117
View File
@@ -0,0 +1,117 @@
# Design decisions
Product and interface decisions that outlive the design passes. Behaviour comes from `features/`; where the two disagree, stop and ask. Screen copy and states are in `screens/*.md`, `states.md` and `routes.md`.
## Search and sharing (SEO)
### Interface
**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.
### Decisions
| # | Question | Decision |
| --- | --- | --- |
| 1 | Is `slug` immutable after publication? | No. It stays editable. RenewCMS does not manage redirects, so the frontend owns them. No extra warning copy is added to the design. |
| 2 | SEO object for drafts | `GET /v1/article/{id}/seo` answers drafts to authenticated callers who can read the article, and 404 to anonymous callers. The editor preview needs it. |
| 3 | Noindex in the sitemap | Included with `noindex: true`, as drawn. The frontend decides. |
| 4 | Who applies `title_template` | The API. `title` in the SEO object is resolved. The raw `seo_title` and the site template stay readable on their own routes. |
| 5 | Backfill | Migration generates a slug for every existing article from its title (same slug rule as authors), unique per site, with `-2`, `-3` suffixes. A title with no letter or digit becomes `article`, then `article-2`, `article-3`. It runs once and is idempotent. |
| 6 | Excerpt in the list payload | Yes. Article items carry `excerpt`. |
### Implicit decisions
- Suggestions come from one server-side rule set. The same function feeds `seo_status` and `seo_suggestions` in the article list, the collapsed section summary, and the editor panel.
- The editor panel's "checking" states and the "slug taken" state use one non-persisting endpoint: `POST /v1/article/{id}/seo/check`. Its body takes the same optional SEO fields as `PATCH /v1/article/{id}`, plus an optional `body` (never `site_id` or `author_ids`), and overrides the saved values for the check only. It answers `{ status, suggestions: [{ code, field, params }], slug: { available, suggestion } }`. Copy for each code lives in the SPA, with a French entry. `seo_dismissed` codes are filtered out of `suggestions` and are never counted in the list.
- The suggestion codes are the ones the board uses: `title_long`, `title_empty`, `desc_short`, `desc_long`, `cover_alt`, `cover_none`, `slug_long`, `no_au`, `no_h`, `body_alt`, `short`.
- Cover, social and site social images are media usages: `GET /media/{id}/usage`, `used_in`, the unused filter, merge, purge and delete protection must count them. This also closes the existing gap where author avatars and site social images are missing from `GET /media/{id}/usage`.
- Roles: anyone who can edit an article edits its SEO fields and runs the check. Only the owner edits site SEO fields (403 "Only the owner can do this."). `GET /v1/site/{key}/sitemap` is public, like the other read routes that serve online articles. SEO edits are logged as an `articles` event; no field values or body text are stored.
- `PATCH /v1/article/{id}` still requires at least one known field and still answers 400 on `null`. The SEO fields join `body`, `site_id` and `author_ids`.
## Sites and authors
### Who can do what
| Action | Owner | Admin | Other roles |
|---|---|---|---|
| Create, edit, set default, delete a site | yes | no | no |
| Read the site list, choose an article's site | yes | yes | yes, if they can edit the article |
| Create, edit, delete any author | yes | yes | no |
| Read authors, pick authors on an article | yes | yes | yes |
| Create and edit their own linked author (`POST /v1/author/me`) | yes | yes | yes |
| Create an author from the editor ("Create author…", "This is me") | yes | yes | no |
### Decisions
- One site per article (`site_id` on the article). No per-site publish state: `is_online` stays the global switch. API keys stay global.
- The site key is immutable after creation. Exactly one default site exists and it cannot be deleted. Deleting a site with articles requires `reassign_to`.
- Authors are separate from users. An optional, unique link to a user. The slug is globally unique and not tied to a site. Zero authors on an article is allowed and never blocks publishing. Deleting an author with articles requires `reassign_to` (an author id or `none`).
- Media used as an author avatar or a site's default social image counts as media usage.
- The default site is seeded once from `PUBLIC_URL`. That variable is the CMS's own address (email links), so the seed is only a starting value that the owner edits. The base URL must be https, except for loopback hosts.
- Existing articles are backfilled to the default site and get no authors.
## Roles and permissions
Event names follow the backend: `role.create`, `role.edit`, `role.delete`, `role.reassign`, `user.change_role`, `user.transfer_ownership`, `user.remove`.
### Decisions
| # | 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. |
## Account self-service
Authored content is anonymized to "Deleted user". The email-change link lasts 2 hours. One export succeeds per user every 15 minutes, and a direct download is the export flow. "Last administrator" counts Owner. A change request for an address already in use answers like success. The account-deleted email goes to the address the account had.
## Interface defaults
- Slide-over rule: only forms with a live preview and record details; on mobile always a 92% bottom sheet.
- 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%).
- Article pagination: Shipped shapes: the article list body is {items, total, page, size, counts}; the media list body is an array with the total in `X-Total-Count`. Users and authors follow their own routes (see routes.md).
- The Remove action in the link popover has no confirmation; undo restores the link.
- 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).
+11
View File
@@ -14,3 +14,14 @@ Previews: `source/RenewCMS Emails.dc.html`.
Three Go html/template files: emails/verification.html, emails/password-reset.html and emails/invitation.html . They use a table layout with inline CSS and a 600 px maximum width. The button is bulletproof: a colored table cell plus a padded link, so it survives Outlook. Each has a plain-URL fallback, a hidden preheader, and prefers-color-scheme overrides for clients that support them. The mark is live text in a black cell rather than an image, so it shows even when images are blocked. Previews below use example values. Three Go html/template files: emails/verification.html, emails/password-reset.html and emails/invitation.html . They use a table layout with inline CSS and a 600 px maximum width. The button is bulletproof: a colored table cell plus a padded link, so it survives Outlook. Each has a plain-URL fallback, a hidden preheader, and prefers-color-scheme overrides for clients that support them. The mark is live text in a black cell rather than an image, so it shows even when images are blocked. Previews below use example values.
Suggested subjects are "Verify your RenewCMS account", "Reset your RenewCMS password" and "<InvitedBy> invited you to RenewCMS", where .InvitedBy is the inviter's display name or username. html/template escapes the .Email value for the query string in the reset link (the @ becomes %40), and the confirm route reads it back decoded. Suggested subjects are "Verify your RenewCMS account", "Reset your RenewCMS password" and "<InvitedBy> invited you to RenewCMS", where .InvitedBy is the inviter's display name or username. html/template escapes the .Email value for the query string in the reset link (the @ becomes %40), and the confirm route reads it back decoded.
## Account emails (change email, deletion, export)
Go `html/template` files built from `source/emails/verification.html` (same table layout, tokens, dark overrides, bulletproof button, URL fallback). English only.
| File | To | Subject | Variables |
|---|---|---|---|
| `source/emails/verify-new-email.html` | the new address | Confirm your new RenewCMS email address | `.Host`, `.Username`, `.VerificationCode`, linking to `{{.Host}}/account/email/confirm?c={{.VerificationCode}}` (expires in 2 hours, works once) |
| `source/emails/email-change-notice.html` | the old address, at request time | Your RenewCMS email address change was requested | `.Host`, `.Username`, `.NewEmail`, linking to `{{.Host}}/profile` |
| `source/emails/account-deleted.html` | the address the account had, sent once | Your RenewCMS account has been deleted | `.Host`, `.Username`, `.DeletedAt`; no button, generic text, no personal data |
Previews are rows on `source/RenewCMS Emails.dc.html`.
+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.
+103
View File
@@ -0,0 +1,103 @@
# Media field type
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.
+80 -23
View File
@@ -1,26 +1,83 @@
# 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/ |
| `/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 |
| `/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 |
| `/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.create, role.edit (with a diff of added and removed permissions), role.delete, role.reassign, user.change_role, user.transfer_ownership. Present tense, as the backend emits them. |
## Account: change email, export, delete
| Route | Screen | Auth | API (assumed) |
|---|---|---|---|
| /account/email/confirm?c=CODE | Verify new email (AuthCard). States in screens/account.md | Session required; without one, inline log in (as /register/validate) | POST /v1/auth/email/confirm |
| /profile | Gains change email, Your data, Danger zone | Any verified user | POST /v1/auth/email/change, POST /v1/auth/email/resend, DELETE /v1/auth/email/change, GET /v1/user/me/export, DELETE /v1/user/me |
| /login?reason=account-deleted | One-time message after deletion | Public | none |
+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."
+72
View File
@@ -0,0 +1,72 @@
# Account: change email, export my data, delete account
Route: `/profile` (extends the Account card, adds "Your data" and "Danger zone" cards). Verification landing: `/account/email/confirm?c=CODE`. API calls are assumed, not specified.
Placement: Profile already holds the read-only Email in the Account card and the Password card, so the email change extends that card; the two other features are cards at the bottom. No new navigation. Order: Display name, Account, Your role, Author profile, Password, Your data, Danger zone.
Boards: `RenewCMS Screens Account.dc.html` (Profile states), `RenewCMS Screens Auth.dc.html` (groups "Verify new email" and "Account deleted"). Frames: `AppFrame`, `AccountCards`, `AuthCard`. Copy source: `account-copy.js`. English only.
## Change email
| State | Copy |
|---|---|
| Default | "Email" field, link "Change email" |
| Form | "New email address" · "Current password" · "We'll send a verification link to the new address. Your email stays the same until you open it." · "Cancel" · "Send verification link" |
| Invalid format | "Enter a full email address, like name@example.com." |
| Same as current | "That's already your email address." |
| Password missing | "Enter your current password." |
| Wrong password (403) | "That password isn't correct. Try again." |
| Sending | "Sending…" |
| Pending | "Waiting for verification" · "We sent a link to marta.alves@atelier-renew.example. Until you open it, you keep logging in with marta@renew.example." · "Resend link" · "Cancel change"; toast "Verification link sent" / "Check marta.alves@atelier-renew.example. The link works once, within 2 hours." |
| Resending | "Sending…" |
| Resent | toast "Link sent again" / "The previous link no longer works." |
| Resend rate limited (429) | "Resend in 4:30" (disabled) · "Too many requests. Try again in a few minutes." |
| Cancelled | toast "Email change cancelled" / "You keep logging in with marta@renew.example." |
| Rate limited (429) | "Too many requests. Try again in a few minutes.", Send disabled |
| Server or network | "Something went wrong on our side. Nothing was changed. Try again in a moment." |
Rules: the old email stays active for login until the link is opened. The old address gets the notice email at request time. A request for an address already in use answers like success, so the UI and the response never reveal that an account exists.
## Verification landing (AuthCard)
| State | Copy |
|---|---|
| Confirming | "Confirming your new email…" |
| Success | "Your email address has changed" · "Your account now uses marta.alves@atelier-renew.example. Use it the next time you log in." · "Go to profile"; toast "Email address changed" / "Your account now uses marta.alves@atelier-renew.example." |
| Invalid or expired (403) | "This link doesn't work" · "It may have expired (links last 2 hours), been cancelled or already been used." · "Request a new link from your profile." · "Go to profile" |
| Address unavailable (409) | "We couldn't change your email address" · "This address can't be used for your account. Go back to your profile and try a different one." · "Go to profile" (same copy whether the address is taken or rejected) |
| No session (401) | "Log in to confirm your new email" · "You opened this link somewhere you're not logged in. Log in here and we'll confirm the change right after." · "Log in and confirm" |
| Missing code | "This link is incomplete" · "The confirmation code is missing. Open the link from your email again, or copy the full address into your browser." · "Go to log in" |
| Server error | "Couldn't confirm the change" · "Something went wrong on our side. Nothing was changed. Try again in a moment." · "Try again" |
## Your data
Contents listed in the card: Profile (Username, display name, email, role and author profile.); Authored content (Your articles and entries.); Uploaded media (References to the files you uploaded.); Account activity (Your log-ins and the actions recorded under your name.). Card copy: "Your data" · "Download a copy of the personal data RenewCMS holds about you, as JSON files in a ZIP.".
| State | Copy |
|---|---|
| Direct (recommended): default | "Download my data" · "Ready in a few seconds." |
| Preparing | "Preparing download…" |
| Started | toast "Download started" / "renewcms-export-marta-2026-10-29.zip is in your downloads." |
| Rate limited (429, one per 15 minutes, clock-sensitive) | "You can export once every 15 minutes" · "Your last export was at 09:41. Try again in 12 minutes." · "Available in 12:00" |
| Error | "Couldn't prepare your data" · "Nothing was changed. Try again in a moment." |
| Alternative, emailed link: default | "Request my data" · "Larger exports are prepared in the background. We'll email a download link to marta@renew.example when it's ready." |
| Requested | "We're preparing your export" · "This can take a few minutes. We'll email a link to marta@renew.example when it's ready." · "Export requested"; toast "Export requested" / "Check marta@renew.example in a few minutes." |
| Already in progress | "An export is already being prepared" · "We'll email a link to marta@renew.example when it's ready." |
Decision: direct download. An emailed export link is out of scope.
## Delete account
Danger zone: "Danger zone" · "Delete your account and your personal data. This can't be undone." · "Delete my account".
| State | Copy |
|---|---|
| Dialog | "Delete your account?" · "This permanently deletes your account. It can't be undone." · list: "Your username, display name and email address", "Your author profile and API keys", "Your sessions: you'll be logged out everywhere" · "Your articles and media stay" / "They stay on the site, credited to "Deleted user". Your name is removed from them." · "Export my data first" · "Current password" · "Type DELETE to confirm" · "Keep my account" · "Delete my account" |
| Empty, typing | Confirm disabled until password is filled and the word equals "DELETE" (exact, case-sensitive) |
| Wrong password (403) | "That password isn't correct. Try again." |
| Deleting | "Deleting…"; fields and Cancel disabled; then sign out and redirect to /login?reason=account-deleted |
| Rate limited (429) | "Too many requests. Try again in a few minutes." |
| Server error | "Couldn't delete your account. Nothing was deleted. Try again in a moment." |
| Blocked: last Owner or administrator | "You can't delete your account yet" · Owner: "You are the last Owner. RenewCMS needs at least one. Transfer ownership or promote another user first, then come back to delete your account." · Administrator: "You are the last administrator. RenewCMS needs at least one. Promote another user first, then come back to delete your account." · "Back to profile" · "Go to Users" |
| Blocked: cannot be deleted | "This account can't be deleted" · "RenewCMS can't delete this account right now. Ask an administrator to help, then try again." · "Back to profile" |
| Login after deletion | "Your account has been deleted" · "Your articles and media were kept without your name." |
Blocked dialogs have no close button, do not close on Escape or backdrop click, and always give a next step.
Authored content: anonymized to "Deleted user", stated in the dialog. Export-ready email: the link needs a logged-in session, stated in the email and the card hint. Account-deleted email: generic, no personal data, sent to the address the account had at deletion.
+125
View File
@@ -0,0 +1,125 @@
# 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. | |
## Roles sync (pass 6): event names, failed sentences, categories
Event names are present tense, as the backend emits them: role.create, role.edit, role.delete, role.reassign, user.change_role, user.transfer_ownership. The outcome is a field; a failed event keeps its name and uses the failed sentence.
| Event | Sentence | Failed sentence |
|---|---|---|
| role.create | Role Contributor created | Failed to create the role Contributor |
| role.edit | Role Contributor edited | Failed to edit the role Contributor |
| role.delete | Role Intern deleted | Failed to delete the role Intern |
| role.reassign | Moved 3 members from Intern to Writer (one member: Moved 1 member from Intern to Writer) | Failed to move 3 members from Intern to Writer |
| user.change_role | Ines Duarte's role changed from Writer to Content lead | Failed to change the role of Ines Duarte |
| user.remove | Removed Ines Duarte | Failed to remove Ines Duarte |
| user.transfer_ownership | Ownership transferred from Marta Alves to Jonas Weber | Failed to transfer ownership to Jonas Weber |
Failed template: "Failed to {verb} {object}". A failed row shows the Failed badge after the sentence; the details panel shows "Why it failed" with the reason from the API.
Category filter values: Articles, Media, Users and invites (was "Users & invites"), Roles (new), API keys, Authentication, System. French and the failed sentences for every event are in screens/roles.md, group 8 and the table "Activity events: failed sentences, EN and FR". Frames: Roles board, group "8 Activity".
### Fallback wordings (app sync)
| Event | Condition | Sentence |
|---|---|---|
| role.reassign | target role unknown | Moved 3 members from Intern to another role |
| role.reassign | count unknown | Moved the members of the role Intern |
| role.reassign | failed, count unknown | Failed to move the members of the role Intern |
| role.reassign | failed, one member | Failed to move 1 member |
| user.remove | name unknown | Removed a user (proposal, not in the app) |
Event names match the backend: role.create, role.edit, role.delete, role.reassign, user.change_role, user.transfer_ownership, user.remove.
+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. | |
Loaded 100 of 1953 files, more files were not shown because too many files have changed in this diff. Show more