domma-cms 0.75.0 → 0.76.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin/js/app.js +4 -4
- package/admin/js/lib/api-tokens-arrange.js +3 -0
- package/admin/js/lib/effects-arrange.js +3 -0
- package/admin/js/lib/layouts-arrange.js +1 -0
- package/admin/js/lib/my-profile-arrange.js +1 -0
- package/admin/js/lib/notifications-arrange.js +1 -0
- package/admin/js/lib/plugins-arrange.js +2 -0
- package/admin/js/lib/roles-arrange.js +1 -0
- package/admin/js/lib/search-settings-arrange.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +2 -2
- package/admin/js/lib/site-settings-arrange.js +1 -0
- package/admin/js/lib/users-arrange.js +1 -0
- package/admin/js/templates/api-tokens.html +83 -10
- package/admin/js/templates/effects.html +155 -72
- package/admin/js/templates/layouts.html +126 -43
- package/admin/js/templates/my-profile.html +100 -12
- package/admin/js/templates/notifications.html +70 -10
- package/admin/js/templates/plugins.html +100 -28
- package/admin/js/templates/roles.html +78 -7
- package/admin/js/templates/search.html +193 -216
- package/admin/js/templates/settings.html +404 -472
- package/admin/js/templates/users.html +69 -9
- package/admin/js/views/api-tokens.js +62 -8
- package/admin/js/views/effects.js +31 -1
- package/admin/js/views/layouts.js +81 -1
- package/admin/js/views/my-profile.js +1 -1
- package/admin/js/views/notifications.js +18 -1
- package/admin/js/views/plugins.js +97 -39
- package/admin/js/views/roles.js +49 -4
- package/admin/js/views/search.js +7 -1
- package/admin/js/views/settings.js +13 -3
- package/admin/js/views/users.js +13 -4
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +97 -40
- package/plugins/_lib/admin/mail/contacts.js +4 -1
- package/plugins/_lib/admin/mail/diagnostics-section.js +4 -1
- package/plugins/_lib/admin/mail/folder-tree.js +1 -1
- package/plugins/_lib/admin/mail/image-senders-section.js +69 -90
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/mail.html +1 -1
- package/plugins/_lib/admin/mail/reader-view.js +303 -44
- package/plugins/_lib/admin/mail/when-closed.js +26 -0
- package/plugins/_lib/mail/readRoutes.js +43 -1
- package/plugins/_lib/mail/unreadBadge.js +42 -0
- package/plugins/free-tier.lock.json +5 -5
- package/plugins/mail-reader/CLAUDE.md +1 -0
- package/plugins/mail-reader/plugin.js +3 -1
- package/plugins/mail-reader/plugin.json +5 -4
- package/server/routes/api/api-tokens.js +56 -0
- package/server/routes/api/auth.js +7 -1
- package/server/routes/api/collections.js +13 -1
- package/server/routes/api/effects.js +68 -13
- package/server/routes/api/layouts.js +93 -2
- package/server/routes/api/notifications.js +140 -14
- package/server/routes/api/plugins.js +135 -4
- package/server/routes/api/roles.js +111 -0
- package/server/routes/api/search.js +38 -4
- package/server/routes/api/settings.js +52 -0
- package/server/routes/api/users.js +53 -1
- package/server/server.js +12 -0
- package/server/services/plugins.js +7 -0
- package/server/services/roles.js +3 -0
- package/server/services/search.js +58 -0
- package/server/services/users.js +14 -0
- package/admin/js/templates/plugin-marketplace.html +0 -29
- package/admin/js/views/plugin-marketplace.js +0 -1
|
@@ -38,6 +38,7 @@ import {readPriority} from './priority.js';
|
|
|
38
38
|
import {listAttachments, renderBody} from './render.js';
|
|
39
39
|
import {readUnsubscribe} from './unsubscribe.js';
|
|
40
40
|
import {MailKeyError} from './secretbox.js';
|
|
41
|
+
import {unreadBadge} from './unreadBadge.js';
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
44
|
* Flatten an envelope address list into something the browser can render.
|
|
@@ -52,7 +53,7 @@ function addresses(list) {
|
|
|
52
53
|
}));
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginName, cachedList = null, diagnostics = null}) {
|
|
56
|
+
export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginName, cachedList = null, diagnostics = null, unreadBadge: withUnreadBadge = false}) {
|
|
56
57
|
const {authenticate} = auth;
|
|
57
58
|
|
|
58
59
|
// A deleted user's stored mailbox passwords must not outlive their
|
|
@@ -165,6 +166,47 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
165
166
|
// Accounts
|
|
166
167
|
// -------------------------------------------------------------------------
|
|
167
168
|
|
|
169
|
+
/**
|
|
170
|
+
* GET /unread - the sidebar badge (the plugin.json countUrl). Opt-in:
|
|
171
|
+
* Email Pro counts from its store and registers its own. INBOX only, by
|
|
172
|
+
* STATUS (no select, so nothing can be marked read), and kept a minute
|
|
173
|
+
* per user - the sidebar asks every minute from every open admin tab.
|
|
174
|
+
*/
|
|
175
|
+
if (withUnreadBadge) {
|
|
176
|
+
const badgeCache = new Map();
|
|
177
|
+
// A mailbox that failed (a changed password, a server down) is not asked
|
|
178
|
+
// again every minute: that is a failed IMAP login per minute per open tab,
|
|
179
|
+
// enough to trip fail2ban or a provider lockout. It backs off 5 min, doubling
|
|
180
|
+
// to an hour; saving the account (a new updatedAt) retries straight away.
|
|
181
|
+
const failures = new Map();
|
|
182
|
+
const failKey = (uid, account) => `${uid}:${account.id}:${account.updatedAt || account.createdAt || ''}`;
|
|
183
|
+
fastify.get('/unread', {preHandler: [authenticate]}, async (request, reply) => {
|
|
184
|
+
const uid = userId(request);
|
|
185
|
+
if (!uid) return reply.code(401).send({error: 'Authentication required.'});
|
|
186
|
+
const hit = badgeCache.get(uid);
|
|
187
|
+
if (hit && Date.now() - hit.at < 60_000) return hit.value;
|
|
188
|
+
const boxes = [];
|
|
189
|
+
for (const account of await accounts.listAccounts(uid)) {
|
|
190
|
+
const key = failKey(uid, account);
|
|
191
|
+
const failed = failures.get(key);
|
|
192
|
+
if (failed && Date.now() < failed.until) { boxes.push({label: account.label, unseen: null}); continue; }
|
|
193
|
+
try {
|
|
194
|
+
const status = await pool.withClient(`${uid}:${account.id}`, accounts.toConnection(account),
|
|
195
|
+
client => client.status('INBOX', {unseen: true}));
|
|
196
|
+
failures.delete(key);
|
|
197
|
+
boxes.push({label: account.label, unseen: status?.unseen ?? 0});
|
|
198
|
+
} catch {
|
|
199
|
+
const wait = Math.min((failed?.wait || 150_000) * 2, 3_600_000);
|
|
200
|
+
failures.set(key, {wait, until: Date.now() + wait});
|
|
201
|
+
boxes.push({label: account.label, unseen: null});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const value = unreadBadge(boxes);
|
|
205
|
+
badgeCache.set(uid, {at: Date.now(), value});
|
|
206
|
+
return value;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
168
210
|
/** GET /accounts - every mailbox this user owns, without passwords. */
|
|
169
211
|
fastify.get('/accounts', {preHandler: [authenticate]}, async (request, reply) => {
|
|
170
212
|
const uid = userId(request);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The free reader's sidebar badge - unread mail in each mailbox's INBOX.
|
|
3
|
+
*
|
|
4
|
+
* Pure: the route asks IMAP (STATUS, which reads without selecting and so can
|
|
5
|
+
* never mark anything read) and hands the figures here. Email Pro has its own
|
|
6
|
+
* badge over the synchronised store; this is the edition without one, so it
|
|
7
|
+
* counts INBOX only - asking a server about every folder once a minute is
|
|
8
|
+
* how a mailbox gets rate-limited.
|
|
9
|
+
*
|
|
10
|
+
* Plain text throughout: the sidebar popover sets it with textContent.
|
|
11
|
+
*
|
|
12
|
+
* @module _lib/mail/unreadBadge
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {{label?: string, unseen: number|null, error?: string}[]} boxes - one per mailbox
|
|
19
|
+
* @returns {{count: number, label: string, tone: string, details: object}}
|
|
20
|
+
*/
|
|
21
|
+
export function unreadBadge(boxes = []) {
|
|
22
|
+
const known = boxes.filter(b => Number.isFinite(b.unseen));
|
|
23
|
+
const count = known.reduce((n, b) => n + b.unseen, 0);
|
|
24
|
+
const failed = boxes.length - known.length;
|
|
25
|
+
return {
|
|
26
|
+
count,
|
|
27
|
+
label: boxes.length
|
|
28
|
+
? `${plural(count, 'unread message')} in the inbox${boxes.length > 1 ? 'es' : ''}${failed ? ` (${plural(failed, 'mailbox', 'mailboxes')} could not be checked)` : ''}`
|
|
29
|
+
: 'No mailbox set up',
|
|
30
|
+
tone: 'info',
|
|
31
|
+
details: {
|
|
32
|
+
title: 'Mail',
|
|
33
|
+
rows: boxes.map(b => ({
|
|
34
|
+
label: String(b.label || 'Mailbox'),
|
|
35
|
+
value: Number.isFinite(b.unseen) ? plural(b.unseen, 'unread', 'unread') : 'not reachable',
|
|
36
|
+
...(Number.isFinite(b.unseen) ? {} : {tone: 'danger'})
|
|
37
|
+
})),
|
|
38
|
+
items: [],
|
|
39
|
+
empty: boxes.length ? 'Nothing unread in the inbox.' : 'Add a mailbox to see unread mail here.'
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
|
|
3
|
-
"source": "dcms-marketplace@
|
|
3
|
+
"source": "dcms-marketplace@5f64a68",
|
|
4
4
|
"plugins": {
|
|
5
5
|
"blog": {
|
|
6
6
|
"version": "1.6.3",
|
|
@@ -67,13 +67,13 @@
|
|
|
67
67
|
}
|
|
68
68
|
},
|
|
69
69
|
"mail-reader": {
|
|
70
|
-
"version": "1.
|
|
70
|
+
"version": "1.1.0",
|
|
71
71
|
"files": {
|
|
72
|
-
"CLAUDE.md": "
|
|
72
|
+
"CLAUDE.md": "7f317e69b560457f2d4c401b466754850afe75f6983bc41414c71d8c276c3583",
|
|
73
73
|
"admin/views/mail.js": "c5c6483f9618c75cac8f4be32d3aea99511db76d4b9d38b8ef43bdc1d35db3c5",
|
|
74
74
|
"config.js": "6e65803ad822a9acda40c203631b8eb32a87cf9d9e5f1c7cb59d01aa406075c6",
|
|
75
|
-
"plugin.js": "
|
|
76
|
-
"plugin.json": "
|
|
75
|
+
"plugin.js": "ebe4c41bdd8836fb3128af3a0e789d47f89b00e9e37dc9378bb308aaae8d41b2",
|
|
76
|
+
"plugin.json": "ed1f02d9405f6ae1257313ea4155ea49e307fb2210650fd247b92e6988aa78d1"
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
}
|
|
@@ -24,6 +24,7 @@ All under `/api/plugins/mail-reader`, all requiring authentication.
|
|
|
24
24
|
|
|
25
25
|
| Method | Path | Purpose |
|
|
26
26
|
|---|---|---|
|
|
27
|
+
| GET | `/unread` | The sidebar badge: unread in each mailbox's INBOX, by STATUS (never selects, so nothing is marked read), cached a minute per user. `registerReadRoutes({unreadBadge: true})` - Email Pro counts from its store instead. |
|
|
27
28
|
| GET | `/accounts` | Every mailbox this user owns. Never returns passwords, only `hasPassword`. |
|
|
28
29
|
| POST | `/accounts` | Add a mailbox. |
|
|
29
30
|
| PUT | `/accounts/:id` | Update one. A blank password means "keep the stored one". |
|
|
@@ -30,7 +30,9 @@ export default async function mailReaderPlugin(fastify, options) {
|
|
|
30
30
|
hooks: options.hooks,
|
|
31
31
|
config,
|
|
32
32
|
pool,
|
|
33
|
-
pluginName: 'mail-reader'
|
|
33
|
+
pluginName: 'mail-reader',
|
|
34
|
+
// The sidebar badge (plugin.json countUrl): unread in each INBOX.
|
|
35
|
+
unreadBadge: true
|
|
34
36
|
});
|
|
35
37
|
}
|
|
36
38
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mail-reader",
|
|
3
3
|
"displayName": "Mail Reader",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.1.0",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Read-only IMAP mailbox reader. Connects to a standard IMAP server and reads mail without ever writing to it.",
|
|
7
7
|
"author": "Darryl Waterhouse",
|
|
8
|
-
"date": "2026-09-
|
|
8
|
+
"date": "2026-09-25",
|
|
9
9
|
"icon": "mail",
|
|
10
10
|
"admin": {
|
|
11
11
|
"sidebar": [
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"text": "Mail Reader",
|
|
15
15
|
"icon": "mail",
|
|
16
16
|
"url": "#/plugins/mail-reader",
|
|
17
|
-
"section": "#/plugins/mail-reader"
|
|
17
|
+
"section": "#/plugins/mail-reader",
|
|
18
|
+
"countUrl": "/api/plugins/mail-reader/unread"
|
|
18
19
|
}
|
|
19
20
|
],
|
|
20
21
|
"routes": [
|
|
@@ -26,7 +27,7 @@
|
|
|
26
27
|
],
|
|
27
28
|
"views": {
|
|
28
29
|
"plugin-mail-reader": {
|
|
29
|
-
"entry": "mail-reader/admin/views/mail.js?v=
|
|
30
|
+
"entry": "mail-reader/admin/views/mail.js?v=2",
|
|
30
31
|
"exportName": "mailView"
|
|
31
32
|
}
|
|
32
33
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* API Tokens API
|
|
3
3
|
*
|
|
4
4
|
* GET /api/api-tokens - list tokens visible to the caller (sanitised; never the hash)
|
|
5
|
+
* GET /api/api-tokens/_count - the sidebar badge: tokens that need attention (expired / expiring)
|
|
5
6
|
* POST /api/api-tokens - create; response carries the plaintext ONCE
|
|
6
7
|
* PUT /api/api-tokens/:id - rename, enable/disable, edit scopes/expiry
|
|
7
8
|
* DELETE /api/api-tokens/:id - revoke (immediate)
|
|
@@ -23,6 +24,58 @@ import {
|
|
|
23
24
|
} from '../../services/apiTokens.js';
|
|
24
25
|
import {canSeeArtefact} from '../../services/projects.js';
|
|
25
26
|
|
|
27
|
+
/** A token this close to its expiry is called out - SOON_DAYS in admin/js/lib/api-tokens-arrange.js. */
|
|
28
|
+
const SOON_DAYS = 14;
|
|
29
|
+
const DAY = 86_400_000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The sidebar badge on API Tokens (its countUrl, GET /api/api-tokens/_count).
|
|
33
|
+
*
|
|
34
|
+
* The number is a signal, not an inventory: the tokens that need someone -
|
|
35
|
+
* switched on but already expired (every call with one is being refused, and
|
|
36
|
+
* nothing else says so), and switched on and expiring within SOON_DAYS. Zero is
|
|
37
|
+
* no badge. Red while any has expired. Plain text - the popover sets it with
|
|
38
|
+
* textContent; token names are the admin's own, never the secret.
|
|
39
|
+
*
|
|
40
|
+
* @param {object[]} tokens - sanitised, as listTokensSanitised returns them
|
|
41
|
+
* @param {number} [now]
|
|
42
|
+
*/
|
|
43
|
+
export function apiTokensBadge(tokens = [], now = Date.now()) {
|
|
44
|
+
const on = tokens.filter(t => t.enabled !== false);
|
|
45
|
+
const at = (t) => (t.expiresAt ? Date.parse(t.expiresAt) : NaN);
|
|
46
|
+
const expired = on.filter(t => at(t) <= now);
|
|
47
|
+
const soon = on.filter(t => at(t) > now && at(t) - now <= SOON_DAYS * DAY)
|
|
48
|
+
.sort((a, b) => at(a) - at(b));
|
|
49
|
+
const off = tokens.length - on.length;
|
|
50
|
+
const need = expired.length + soon.length;
|
|
51
|
+
const plural = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
|
|
52
|
+
const days = (t) => {
|
|
53
|
+
const d = Math.ceil((at(t) - now) / DAY);
|
|
54
|
+
return d <= 0 ? 'today' : d === 1 ? 'tomorrow' : `in ${d} days`;
|
|
55
|
+
};
|
|
56
|
+
return {
|
|
57
|
+
count: need,
|
|
58
|
+
label: need
|
|
59
|
+
? [expired.length && `${plural(expired.length, 'token')} expired`, soon.length && `${soon.length} expiring soon`].filter(Boolean).join(', ')
|
|
60
|
+
: `${plural(tokens.length, 'token')}, none expiring soon`,
|
|
61
|
+
tone: expired.length ? 'danger' : 'attention',
|
|
62
|
+
details: {
|
|
63
|
+
title: 'API Tokens',
|
|
64
|
+
rows: [
|
|
65
|
+
{label: 'Working', value: String(on.length - expired.length)},
|
|
66
|
+
{label: 'Expiring soon', value: String(soon.length)},
|
|
67
|
+
{label: 'Expired', value: String(expired.length), ...(expired.length && {tone: 'danger'})},
|
|
68
|
+
{label: 'Switched off', value: String(off)}
|
|
69
|
+
],
|
|
70
|
+
items: [
|
|
71
|
+
...expired.map(t => ({text: String(t.name || '(unnamed)'), meta: `${t.project} · expired - callers are refused`, tone: 'danger'})),
|
|
72
|
+
...soon.map(t => ({text: String(t.name || '(unnamed)'), meta: `${t.project} · expires ${days(t)}`}))
|
|
73
|
+
].slice(0, 8),
|
|
74
|
+
empty: 'No token has expired or expires in the next two weeks.'
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
26
79
|
/**
|
|
27
80
|
* Register the api-tokens routes.
|
|
28
81
|
*
|
|
@@ -43,6 +96,9 @@ export async function apiTokensRoutes(fastify, opts = {}) {
|
|
|
43
96
|
return listTokensSanitised(request.user);
|
|
44
97
|
});
|
|
45
98
|
|
|
99
|
+
// There is no GET /api-tokens/:id, and ids are uuids - nothing can shadow this.
|
|
100
|
+
fastify.get('/api-tokens/_count', canRead, async (request) => apiTokensBadge(await listTokensSanitised(request.user)));
|
|
101
|
+
|
|
46
102
|
fastify.post('/api-tokens', canCreate, async (request, reply) => {
|
|
47
103
|
const {name, project, scopes, expiresAt} = request.body || {};
|
|
48
104
|
if (project && !canSeeArtefact(request.user, {meta: {project}})) {
|
|
@@ -170,7 +170,13 @@ export async function authRoutes(fastify) {
|
|
|
170
170
|
if (email) coreUpdates.email = email;
|
|
171
171
|
if (password) coreUpdates.password = password;
|
|
172
172
|
|
|
173
|
-
|
|
173
|
+
let user;
|
|
174
|
+
try {
|
|
175
|
+
user = await updateUser(id, coreUpdates);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if (err.code === 'EMAIL_TAKEN') return reply.status(409).send({error: err.message});
|
|
178
|
+
throw err;
|
|
179
|
+
}
|
|
174
180
|
|
|
175
181
|
if (profile && typeof profile === 'object') {
|
|
176
182
|
await updateProfile(id, profile);
|
|
@@ -54,7 +54,7 @@ import {
|
|
|
54
54
|
updateEntry
|
|
55
55
|
} from '../../services/collections.js';
|
|
56
56
|
import {authenticate, requireAdmin, requirePermission} from '../../middleware/auth.js';
|
|
57
|
-
import {getRoleLevel, invalidate as invalidateRoles} from '../../services/roles.js';
|
|
57
|
+
import {getRoleLevel, invalidate as invalidateRoles, isBaseRole} from '../../services/roles.js';
|
|
58
58
|
import {getEffectiveLevel} from '../../services/userRoles.js';
|
|
59
59
|
import {getConfig, saveConfig} from '../../config.js';
|
|
60
60
|
import {PRESET_COLLECTION_SLUGS} from '../../services/presetCollections.js';
|
|
@@ -262,6 +262,9 @@ async function keepRoleSystemFields(id, data) {
|
|
|
262
262
|
if (existing[field] !== undefined) out[field] = existing[field];
|
|
263
263
|
else delete out[field];
|
|
264
264
|
}
|
|
265
|
+
// A built-in role keeps its name: renaming `admin` and then deleting it went round
|
|
266
|
+
// the delete guard (and every user and route that names it would lose it).
|
|
267
|
+
if (isBaseRole(existing.name)) out.name = existing.name;
|
|
265
268
|
return out;
|
|
266
269
|
}
|
|
267
270
|
|
|
@@ -494,6 +497,11 @@ export async function collectionsRoutes(fastify) {
|
|
|
494
497
|
if (entry?.data?.level === 0) {
|
|
495
498
|
return reply.status(403).send({ error: 'Cannot delete the root admin role' });
|
|
496
499
|
}
|
|
500
|
+
// admin and user too: removeRole() refused them by name, but this route did not,
|
|
501
|
+
// and the old Roles screen offered Delete on both.
|
|
502
|
+
if (isBaseRole(entry?.data?.name)) {
|
|
503
|
+
return reply.status(403).send({ error: `Cannot delete the built-in role '${entry.data.name}'` });
|
|
504
|
+
}
|
|
497
505
|
}
|
|
498
506
|
try {
|
|
499
507
|
await deleteEntry(request.params.slug, request.params.id);
|
|
@@ -507,6 +515,10 @@ export async function collectionsRoutes(fastify) {
|
|
|
507
515
|
|
|
508
516
|
// Clear all entries - DELETE /collections/:slug/entries (no :id)
|
|
509
517
|
fastify.delete('/collections/:slug/entries', canDelete, async (request, reply) => {
|
|
518
|
+
// Clearing roles would leave every user holding a role that no longer exists.
|
|
519
|
+
if (request.params.slug === 'roles') {
|
|
520
|
+
return reply.status(403).send({ error: 'Roles cannot be cleared - delete them one at a time' });
|
|
521
|
+
}
|
|
510
522
|
try {
|
|
511
523
|
await clearEntries(request.params.slug);
|
|
512
524
|
return { success: true };
|
|
@@ -2,22 +2,77 @@
|
|
|
2
2
|
* Effects API Routes
|
|
3
3
|
* GET /api/effects/settings - public (read by public site IIFE)
|
|
4
4
|
* PUT /api/effects/settings - admin-only (save user overrides)
|
|
5
|
+
*
|
|
6
|
+
* The PUT whitelists and checks what it stores (normaliseEffectsSettings): it
|
|
7
|
+
* used to write the request body to config/effects.json verbatim, and GET
|
|
8
|
+
* hands that file to every public page. Keys this route does not know are
|
|
9
|
+
* left as they are in the file, never added from a request.
|
|
5
10
|
*/
|
|
6
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
authenticate as defaultAuthenticate,
|
|
13
|
+
requireAdmin as defaultRequireAdmin
|
|
14
|
+
} from '../../middleware/auth.js';
|
|
7
15
|
import {getConfig, saveConfig} from '../../config.js';
|
|
8
16
|
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
export const EFFECT_ANIMATIONS = ['fade', 'slide-up', 'slide-down', 'zoom', 'flip'];
|
|
18
|
+
export const EFFECTS_DEFAULTS = {respectMotion: true, defaultDuration: 600, defaultAnimation: 'fade', defaultThreshold: 0.1};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A request body -> {errors, settings}. The same rules as checkSettings() in
|
|
22
|
+
* admin/js/lib/effects-arrange.js. A duration of 0 is refused: public/js/effects.js
|
|
23
|
+
* reads `defaultDuration || 600`, so 0 would silently mean 600.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} body
|
|
26
|
+
* @returns {{errors: string[], settings: object}}
|
|
27
|
+
*/
|
|
28
|
+
export function normaliseEffectsSettings(body) {
|
|
29
|
+
const b = body && typeof body === 'object' ? body : {};
|
|
30
|
+
const errors = [];
|
|
31
|
+
const settings = {};
|
|
32
|
+
if ('respectMotion' in b) settings.respectMotion = b.respectMotion !== false;
|
|
33
|
+
if ('defaultDuration' in b) {
|
|
34
|
+
const n = Number(b.defaultDuration);
|
|
35
|
+
if (!Number.isInteger(n) || n < 1 || n > 10000) errors.push('defaultDuration must be a whole number from 1 to 10000');
|
|
36
|
+
else settings.defaultDuration = n;
|
|
37
|
+
}
|
|
38
|
+
if ('defaultAnimation' in b) {
|
|
39
|
+
if (!EFFECT_ANIMATIONS.includes(b.defaultAnimation)) errors.push(`defaultAnimation must be one of ${EFFECT_ANIMATIONS.join(', ')}`);
|
|
40
|
+
else settings.defaultAnimation = b.defaultAnimation;
|
|
41
|
+
}
|
|
42
|
+
if ('defaultThreshold' in b) {
|
|
43
|
+
const n = Number(b.defaultThreshold);
|
|
44
|
+
if (b.defaultThreshold === '' || b.defaultThreshold === null || !Number.isFinite(n) || n < 0 || n > 1) {
|
|
45
|
+
errors.push('defaultThreshold must be a number from 0 to 1');
|
|
46
|
+
} else settings.defaultThreshold = n;
|
|
47
|
+
}
|
|
48
|
+
return {errors, settings};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function loadSettings() {
|
|
52
|
+
try {
|
|
53
|
+
return {...EFFECTS_DEFAULTS, ...(getConfig('effects') || {})};
|
|
54
|
+
} catch {
|
|
55
|
+
return {...EFFECTS_DEFAULTS};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
61
|
+
* @param {{authenticate?: Function, requireAdmin?: Function}} [opts] - injectable for tests
|
|
62
|
+
*/
|
|
63
|
+
export async function effectsRoutes(fastify, opts = {}) {
|
|
64
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
65
|
+
const requireAdmin = opts.requireAdmin || defaultRequireAdmin;
|
|
66
|
+
|
|
67
|
+
fastify.get('/effects/settings', async () => loadSettings());
|
|
17
68
|
|
|
18
|
-
fastify.put('/effects/settings', {preHandler: [authenticate, requireAdmin]}, async (request) => {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
69
|
+
fastify.put('/effects/settings', {preHandler: [authenticate, requireAdmin]}, async (request, reply) => {
|
|
70
|
+
const {errors, settings} = normaliseEffectsSettings(request.body);
|
|
71
|
+
if (errors.length) return reply.status(400).send({error: errors.join('; ')});
|
|
72
|
+
let current = {};
|
|
73
|
+
try { current = getConfig('effects') || {}; } catch { /* no file yet */ }
|
|
74
|
+
const next = {...current, ...settings};
|
|
75
|
+
saveConfig('effects', next);
|
|
76
|
+
return {ok: true, settings: {...EFFECTS_DEFAULTS, ...next}};
|
|
22
77
|
});
|
|
23
78
|
}
|
|
@@ -7,9 +7,16 @@
|
|
|
7
7
|
* DELETE /api/layouts/:key - delete a preset (builtin presets cannot be deleted)
|
|
8
8
|
* GET /api/layouts/options - get layout options
|
|
9
9
|
* PUT /api/layouts/options - save layout options
|
|
10
|
+
* GET /api/layouts/_usage - which pages use each layout, and which use one that is gone
|
|
11
|
+
* GET /api/layouts/_count - the sidebar badge: pages whose layout no longer exists
|
|
12
|
+
*
|
|
13
|
+
* `_` is outside the key alphabet (slugify below), so no layout can shadow the last two.
|
|
10
14
|
*/
|
|
11
15
|
import {getConfig, saveConfig} from '../../config.js';
|
|
12
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
authenticate as defaultAuthenticate,
|
|
18
|
+
requirePermission as defaultRequirePermission
|
|
19
|
+
} from '../../middleware/auth.js';
|
|
13
20
|
|
|
14
21
|
const VALID_WIDTHS = new Set(['narrow', 'normal', 'wide', 'full']);
|
|
15
22
|
const BG_COLOR_RE = /^[a-zA-Z0-9#(),.\s%/-]+$/;
|
|
@@ -78,7 +85,74 @@ function sanitisePreset(data) {
|
|
|
78
85
|
};
|
|
79
86
|
}
|
|
80
87
|
|
|
81
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Which pages use each layout. A page names its layout in frontmatter
|
|
90
|
+
* (`layout: key`); none means `default`. A page naming a layout that no longer
|
|
91
|
+
* exists is rendered with the default one (renderer.js: presets[page.layout] ||
|
|
92
|
+
* presets.default) - nothing says so, which is what `missing` is for.
|
|
93
|
+
*
|
|
94
|
+
* @param {Record<string, object>} presets
|
|
95
|
+
* @param {{urlPath: string, title?: string, status?: string, layout?: string}[]} pages
|
|
96
|
+
* @returns {{byKey: Record<string, {urlPath, title, status}[]>, missing: {urlPath, title, status, layout}[]}}
|
|
97
|
+
*/
|
|
98
|
+
export function layoutUsage(presets = {}, pages = []) {
|
|
99
|
+
const byKey = Object.fromEntries(Object.keys(presets).map(k => [k, []]));
|
|
100
|
+
const missing = [];
|
|
101
|
+
for (const p of pages) {
|
|
102
|
+
const key = typeof p.layout === 'string' && p.layout.trim() ? p.layout.trim() : 'default';
|
|
103
|
+
const item = {urlPath: p.urlPath, title: p.title || p.urlPath, status: p.status || 'published'};
|
|
104
|
+
if (byKey[key]) byKey[key].push(item);
|
|
105
|
+
else missing.push({...item, layout: key});
|
|
106
|
+
}
|
|
107
|
+
return {byKey, missing};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The sidebar badge on Layouts: pages that name a layout which no longer
|
|
112
|
+
* exists (they quietly show as Default). A count of layouts would be noise -
|
|
113
|
+
* nothing needs doing about it - so this answers 0 (no badge) until something
|
|
114
|
+
* is wrong. Plain text throughout: the popover sets it with textContent.
|
|
115
|
+
*
|
|
116
|
+
* @param {Record<string, object>} presets
|
|
117
|
+
* @param {ReturnType<typeof layoutUsage>} usage
|
|
118
|
+
*/
|
|
119
|
+
export function layoutsBadge(presets = {}, usage = {byKey: {}, missing: []}) {
|
|
120
|
+
const all = Object.values(presets);
|
|
121
|
+
const builtin = all.filter(p => p.builtin).length;
|
|
122
|
+
const missing = usage.missing || [];
|
|
123
|
+
const inUse = Object.values(usage.byKey || {}).filter(list => list.length).length;
|
|
124
|
+
const n = missing.length;
|
|
125
|
+
return {
|
|
126
|
+
count: n,
|
|
127
|
+
label: n
|
|
128
|
+
? `${n} page${n === 1 ? '' : 's'} use${n === 1 ? 's' : ''} a layout that no longer exists`
|
|
129
|
+
: `${all.length} layout${all.length === 1 ? '' : 's'}, every page's layout exists`,
|
|
130
|
+
tone: 'attention',
|
|
131
|
+
details: {
|
|
132
|
+
title: 'Layouts',
|
|
133
|
+
rows: [
|
|
134
|
+
{label: 'Layouts', value: `${all.length} (${builtin} built in)`},
|
|
135
|
+
{label: 'In use', value: String(inUse)},
|
|
136
|
+
{label: 'Pages on a missing layout', value: String(n), ...(n && {tone: 'danger'})}
|
|
137
|
+
],
|
|
138
|
+
items: missing.slice(0, 8).map(p => ({
|
|
139
|
+
text: String(p.title || p.urlPath),
|
|
140
|
+
meta: `${p.urlPath} · "${p.layout}" is gone - showing as Default`,
|
|
141
|
+
tone: 'danger'
|
|
142
|
+
})),
|
|
143
|
+
empty: "Every page's layout exists."
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
150
|
+
* @param {{authenticate?: Function, requirePermission?: Function, listPages?: Function}} [opts] - DI for tests
|
|
151
|
+
*/
|
|
152
|
+
export async function layoutsRoutes(fastify, opts = {}) {
|
|
153
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
154
|
+
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
155
|
+
const listPages = opts.listPages || (async () => (await import('../../services/content.js')).listPages());
|
|
82
156
|
// ── Merge-in seeding: ensure built-in presets exist ──────────────────────
|
|
83
157
|
const existing = getConfig('presets') || {};
|
|
84
158
|
let seeded = false;
|
|
@@ -174,6 +248,23 @@ export async function layoutsRoutes(fastify) {
|
|
|
174
248
|
return {success: true};
|
|
175
249
|
});
|
|
176
250
|
|
|
251
|
+
// ── Usage and the sidebar badge ──────────────────────────────────────────
|
|
252
|
+
const usageNow = async () => layoutUsage(getConfig('presets') || {}, await listPages().catch(() => []));
|
|
253
|
+
|
|
254
|
+
fastify.get('/layouts/_usage', canRead, async () => usageNow());
|
|
255
|
+
|
|
256
|
+
// Polled every minute from every open admin tab, and reading every page is not
|
|
257
|
+
// free: the answer is kept a minute (the screen's own _usage always reads afresh).
|
|
258
|
+
let badgeCache = {at: 0, key: '', value: null};
|
|
259
|
+
fastify.get('/layouts/_count', canRead, async () => {
|
|
260
|
+
const presets = getConfig('presets') || {};
|
|
261
|
+
const key = Object.keys(presets).sort().join('|');
|
|
262
|
+
if (!badgeCache.value || badgeCache.key !== key || Date.now() - badgeCache.at > 60_000) {
|
|
263
|
+
badgeCache = {at: Date.now(), key, value: layoutsBadge(presets, await usageNow())};
|
|
264
|
+
}
|
|
265
|
+
return badgeCache.value;
|
|
266
|
+
});
|
|
267
|
+
|
|
177
268
|
// ── Layout options ────────────────────────────────────────────────────────
|
|
178
269
|
fastify.get('/layouts/options', canRead, async () => {
|
|
179
270
|
return getConfig('site')?.layoutOptions ?? {spacerSize: 8};
|