domma-cms 0.74.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/CLAUDE.md +3 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/actions-arrange.js +1 -0
- package/admin/js/lib/api-endpoints-arrange.js +1 -0
- package/admin/js/lib/api-tokens-arrange.js +3 -0
- package/admin/js/lib/blocks-arrange.js +1 -0
- package/admin/js/lib/collections-arrange.js +1 -0
- package/admin/js/lib/components-arrange.js +1 -0
- package/admin/js/lib/context-menus-arrange.js +1 -0
- package/admin/js/lib/effects-arrange.js +3 -0
- package/admin/js/lib/forms-arrange.js +1 -0
- package/admin/js/lib/layouts-arrange.js +1 -0
- package/admin/js/lib/media-arrange.js +1 -0
- package/admin/js/lib/menus-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/pages-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 +3 -3
- package/admin/js/lib/site-settings-arrange.js +1 -0
- package/admin/js/lib/theme-settings-arrange.js +1 -0
- package/admin/js/lib/users-arrange.js +1 -0
- package/admin/js/lib/views-arrange.js +1 -0
- package/admin/js/templates/actions-list.html +73 -14
- package/admin/js/templates/api-endpoints.html +63 -10
- package/admin/js/templates/api-tokens.html +83 -10
- package/admin/js/templates/blocks.html +76 -15
- package/admin/js/templates/collections.html +108 -52
- package/admin/js/templates/components.html +82 -15
- package/admin/js/templates/context-menus.html +55 -13
- package/admin/js/templates/effects.html +155 -72
- package/admin/js/templates/forms.html +67 -14
- package/admin/js/templates/layouts.html +126 -43
- package/admin/js/templates/media.html +99 -13
- package/admin/js/templates/menus.html +57 -11
- package/admin/js/templates/my-profile.html +100 -12
- package/admin/js/templates/notifications.html +70 -10
- package/admin/js/templates/pages.html +78 -23
- 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/theme.html +164 -132
- package/admin/js/templates/users.html +69 -9
- package/admin/js/templates/views-list.html +63 -9
- package/admin/js/views/actions-list.js +37 -1
- package/admin/js/views/api-endpoints.js +11 -7
- package/admin/js/views/api-tokens.js +62 -8
- package/admin/js/views/blocks.js +34 -5
- package/admin/js/views/collections.js +36 -1
- package/admin/js/views/components.js +27 -5
- package/admin/js/views/context-menus.js +10 -5
- package/admin/js/views/effects.js +31 -1
- package/admin/js/views/forms.js +57 -1
- package/admin/js/views/layouts.js +81 -1
- package/admin/js/views/media.js +40 -1
- package/admin/js/views/menus.js +11 -5
- package/admin/js/views/my-profile.js +1 -1
- package/admin/js/views/notifications.js +18 -1
- package/admin/js/views/pages.js +28 -8
- 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/theme.js +55 -1
- package/admin/js/views/users.js +13 -4
- package/admin/js/views/views-list.js +11 -1
- 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/actions.js +64 -2
- package/server/routes/api/api-endpoints.js +41 -0
- package/server/routes/api/api-tokens.js +56 -0
- package/server/routes/api/auth.js +7 -1
- package/server/routes/api/blocks.js +170 -8
- package/server/routes/api/collections.js +66 -2
- package/server/routes/api/components.js +46 -0
- package/server/routes/api/context-menus.js +8 -0
- package/server/routes/api/effects.js +68 -13
- package/server/routes/api/forms.js +123 -10
- package/server/routes/api/layouts.js +93 -2
- package/server/routes/api/media.js +56 -1
- package/server/routes/api/menus.js +53 -6
- package/server/routes/api/notifications.js +140 -14
- package/server/routes/api/pages.js +46 -0
- 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/theme.js +55 -0
- package/server/routes/api/users.js +53 -1
- package/server/routes/api/views.js +27 -3
- package/server/server.js +28 -1
- package/server/services/contextMenus.js +41 -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/server/services/views.js +38 -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
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Admin endpoints (authenticated + actions permission):
|
|
5
5
|
* GET /actions - List all action configs
|
|
6
|
+
* GET /actions/_count - Sidebar badge (never 503: file mode answers count 0, available: false)
|
|
6
7
|
* POST /actions - Create action config
|
|
7
8
|
* GET /actions/:slug - Get action config
|
|
8
9
|
* PUT /actions/:slug - Update action config
|
|
@@ -24,12 +25,63 @@ import {
|
|
|
24
25
|
updateAction
|
|
25
26
|
} from '../../services/actions.js';
|
|
26
27
|
import {getEntry} from '../../services/collections.js';
|
|
27
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
authenticate as defaultAuthenticate,
|
|
30
|
+
requirePermission as defaultRequirePermission
|
|
31
|
+
} from '../../middleware/auth.js';
|
|
28
32
|
import {getRoleLevel} from '../../services/roles.js';
|
|
29
33
|
import {getEffectiveLevel} from '../../services/userRoles.js';
|
|
30
34
|
import {checkEntryAccess} from '../../services/rowAccess.js';
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
/**
|
|
37
|
+
* The sidebar badge on Actions (its countUrl): how many actions this user can
|
|
38
|
+
* see, which collections they act on, which are state-machine transitions.
|
|
39
|
+
* Plain text throughout - the popover sets it with textContent. `null` (no
|
|
40
|
+
* MongoDB) answers count 0, so the sidebar shows no badge and logs nothing;
|
|
41
|
+
* `available` lets the list screen find that out without a 503 of its own.
|
|
42
|
+
*
|
|
43
|
+
* @param {object[]|null} actions
|
|
44
|
+
* @returns {{count: number, label: string, tone: string, details: object}}
|
|
45
|
+
*/
|
|
46
|
+
export function actionsBadge(actions) {
|
|
47
|
+
if (!Array.isArray(actions)) {
|
|
48
|
+
return {
|
|
49
|
+
count: 0, label: 'Actions need MongoDB', tone: 'info', available: false,
|
|
50
|
+
details: {title: 'Actions', rows: [], items: [],
|
|
51
|
+
empty: 'Actions are a Pro feature: they need a MongoDB connection.'}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const n = actions.length;
|
|
55
|
+
const collections = new Set(actions.map(a => a.collection).filter(Boolean));
|
|
56
|
+
const transitions = actions.filter(a => a.transition?.field).length;
|
|
57
|
+
const sorted = [...actions].sort((a, b) => String(a.title || a.slug).localeCompare(String(b.title || b.slug)));
|
|
58
|
+
return {
|
|
59
|
+
count: n,
|
|
60
|
+
label: `${n} action${n === 1 ? '' : 's'}`,
|
|
61
|
+
tone: 'info',
|
|
62
|
+
available: true,
|
|
63
|
+
details: {
|
|
64
|
+
title: 'Actions',
|
|
65
|
+
rows: [
|
|
66
|
+
{label: 'Collections', value: String(collections.size)},
|
|
67
|
+
{label: 'Transitions', value: String(transitions)}
|
|
68
|
+
],
|
|
69
|
+
items: sorted.slice(0, 8).map(a => ({
|
|
70
|
+
text: String(a.title || a.slug),
|
|
71
|
+
meta: [a.collection, `${(a.steps || []).length} step${(a.steps || []).length === 1 ? '' : 's'}`].filter(Boolean).join(' · ')
|
|
72
|
+
})),
|
|
73
|
+
empty: 'No actions yet.'
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
80
|
+
* @param {{authenticate?: Function, requirePermission?: Function}} [opts] - auth as DI, so tests can stub it
|
|
81
|
+
*/
|
|
82
|
+
export async function actionsRoutes(fastify, opts = {}) {
|
|
83
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
84
|
+
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
33
85
|
const canRead = {preHandler: [authenticate, requirePermission('actions', 'read')]};
|
|
34
86
|
const canCreate = {preHandler: [authenticate, requirePermission('actions', 'create')]};
|
|
35
87
|
const canUpdate = {preHandler: [authenticate, requirePermission('actions', 'update')]};
|
|
@@ -60,6 +112,16 @@ export async function actionsRoutes(fastify) {
|
|
|
60
112
|
}
|
|
61
113
|
});
|
|
62
114
|
|
|
115
|
+
// `_count`: slugify() turns '_' into '-', so no action slug can shadow it.
|
|
116
|
+
fastify.get('/actions/_count', canRead, async (request) => {
|
|
117
|
+
try {
|
|
118
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
119
|
+
return actionsBadge((await listActions()).filter(a => canSeeArtefact(request.user, a)));
|
|
120
|
+
} catch {
|
|
121
|
+
return actionsBadge(null);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
63
125
|
// Static sub-route declared before parameterised :slug for radix-tree priority
|
|
64
126
|
fastify.get('/actions/collection/:collectionSlug', canRead, async (request, reply) => {
|
|
65
127
|
try {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* API Endpoints API (admin management of custom endpoint definitions)
|
|
3
3
|
*
|
|
4
4
|
* GET /api/api-endpoints - list definitions visible to the caller
|
|
5
|
+
* GET /api/api-endpoints/_count - the sidebar badge (ids are uuids - `_count` is never one)
|
|
5
6
|
* GET /api/api-endpoints/:id - single definition (the builder editor)
|
|
6
7
|
* POST /api/api-endpoints - create (400 validation, 409 duplicate path shape)
|
|
7
8
|
* PUT /api/api-endpoints/:id - update (project binding immutable)
|
|
@@ -42,6 +43,43 @@ async function canUseCollection(user, slug) {
|
|
|
42
43
|
return canSeeArtefact(user, schema);
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The sidebar badge on API Builder (its countUrl): how many endpoints the
|
|
48
|
+
* caller can see, with the public ones called out - they answer anyone, so
|
|
49
|
+
* they are the ones worth a glance. Plain text throughout: the sidebar
|
|
50
|
+
* popover sets it with textContent.
|
|
51
|
+
*
|
|
52
|
+
* @param {object[]} endpoints - sanitised definitions
|
|
53
|
+
* @returns {{count: number, label: string, tone: string, details: object}}
|
|
54
|
+
*/
|
|
55
|
+
export function endpointsBadge(endpoints = []) {
|
|
56
|
+
const list = [...endpoints].sort((a, b) => String(a.project).localeCompare(String(b.project))
|
|
57
|
+
|| String(a.path).localeCompare(String(b.path)));
|
|
58
|
+
const n = list.length;
|
|
59
|
+
const off = list.filter(e => e.enabled === false).length;
|
|
60
|
+
const pub = list.filter(e => e.enabled !== false && e.auth === 'public').length;
|
|
61
|
+
const url = (e) => `/api/x/${e.project}${e.path}`;
|
|
62
|
+
return {
|
|
63
|
+
count: n,
|
|
64
|
+
label: `${n} endpoint${n === 1 ? '' : 's'}${pub ? `, ${pub} public` : ''}${off ? `, ${off} disabled` : ''}`,
|
|
65
|
+
tone: 'info',
|
|
66
|
+
details: {
|
|
67
|
+
title: 'API Builder',
|
|
68
|
+
rows: [
|
|
69
|
+
{label: 'Enabled', value: String(n - off)},
|
|
70
|
+
{label: 'Public', value: String(pub)},
|
|
71
|
+
{label: 'Disabled', value: String(off)}
|
|
72
|
+
],
|
|
73
|
+
items: list.slice(0, 8).map(e => ({
|
|
74
|
+
text: `GET ${url(e)}`,
|
|
75
|
+
meta: [e.name, e.auth === 'public' ? 'public' : e.auth === 'token' ? 'API token' : `role ${e.auth}`,
|
|
76
|
+
e.enabled === false ? 'disabled' : ''].filter(Boolean).join(' · ')
|
|
77
|
+
})),
|
|
78
|
+
empty: 'No endpoints yet.'
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
45
83
|
/**
|
|
46
84
|
* Register the api-endpoints routes.
|
|
47
85
|
*
|
|
@@ -64,6 +102,9 @@ export async function apiEndpointsRoutes(fastify, opts = {}) {
|
|
|
64
102
|
return listEndpointsSanitised(request.user);
|
|
65
103
|
});
|
|
66
104
|
|
|
105
|
+
// Before /:id in intent (Fastify matches the static segment first anyway).
|
|
106
|
+
fastify.get('/api-endpoints/_count', canRead, async (request) => endpointsBadge(await listEndpointsSanitised(request.user)));
|
|
107
|
+
|
|
67
108
|
fastify.get('/api-endpoints/:id', canRead, async (request, reply) => {
|
|
68
109
|
const def = await getEndpointSanitised(request.params.id);
|
|
69
110
|
if (!def) return reply.status(404).send({error: 'Endpoint not found'});
|
|
@@ -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);
|
|
@@ -1,15 +1,150 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Blocks API
|
|
3
3
|
* CRUD for reusable HTML block templates stored in content/blocks/*.html.
|
|
4
|
-
* GET /api/blocks - list all blocks
|
|
4
|
+
* GET /api/blocks - list all blocks (each with hasCss, description, example;
|
|
5
|
+
* ?usage=1 adds where each is used: pages and views)
|
|
6
|
+
* GET /api/blocks/_count - the sidebar badge (`_` is outside the block-name alphabet)
|
|
5
7
|
* GET /api/blocks/:name - get block content
|
|
6
8
|
* GET /api/blocks/:name/export - download block as .dmblock.json bundle
|
|
7
9
|
* POST /api/blocks/import - import a .dmblock.json bundle
|
|
8
10
|
* PUT /api/blocks/:name - create or update block
|
|
9
11
|
* DELETE /api/blocks/:name - delete block
|
|
10
12
|
*/
|
|
13
|
+
import fs from 'fs/promises';
|
|
14
|
+
import path from 'path';
|
|
11
15
|
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
12
16
|
import {deleteBlock, getBlock, listBlocks, saveBlock} from '../../services/blocks.js';
|
|
17
|
+
import {config} from '../../config.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A block's own doc comment - the one every block opens with - read for the
|
|
21
|
+
* list: its first line ("contact-card - Contact entry summary card.") gives a
|
|
22
|
+
* description, and the line after "Example:" a shortcode to copy. Plain text;
|
|
23
|
+
* a block without a comment gives neither.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} html
|
|
26
|
+
* @param {string} name
|
|
27
|
+
* @returns {{description: string, example: string}}
|
|
28
|
+
*/
|
|
29
|
+
export function blockDoc(html, name = '') {
|
|
30
|
+
const m = /^\s*<!--([\s\S]*?)-->/.exec(String(html || ''));
|
|
31
|
+
if (!m) return {description: '', example: ''};
|
|
32
|
+
const lines = m[1].split('\n').map(l => l.trim());
|
|
33
|
+
// The first paragraph - a description may wrap over several lines.
|
|
34
|
+
const start = lines.findIndex(Boolean);
|
|
35
|
+
const para = [];
|
|
36
|
+
// …ending at a blank line or the next heading ("Example:", "Parameters (all optional):").
|
|
37
|
+
for (let i = Math.max(0, start); start >= 0 && i < lines.length && lines[i]; i++) {
|
|
38
|
+
if (i > start && /^[\w ()/-]+:$/.test(lines[i])) break;
|
|
39
|
+
para.push(lines[i]);
|
|
40
|
+
}
|
|
41
|
+
const first = para.join(' ');
|
|
42
|
+
const lead = new RegExp(`^${name.replace(/[-]/g, '\\-')}\\s*[-–:]\\s*`, 'i');
|
|
43
|
+
const description = name && lead.test(first) ? first.replace(lead, '') : (/^[\w-]+\s*[-–:]\s*/.test(first) ? first.replace(/^[\w-]+\s*[-–:]\s*/, '') : first);
|
|
44
|
+
// The example may wrap over several lines: from its `[` to the line ending `]`.
|
|
45
|
+
let example = '';
|
|
46
|
+
const at = lines.findIndex(l => /^example:?$/i.test(l));
|
|
47
|
+
if (at >= 0) {
|
|
48
|
+
const from = lines.findIndex((l, i) => i > at && l.startsWith('['));
|
|
49
|
+
if (from > 0) {
|
|
50
|
+
const parts = [];
|
|
51
|
+
for (let i = from; i < lines.length && lines[i]; i++) {
|
|
52
|
+
parts.push(lines[i]);
|
|
53
|
+
if (lines[i].endsWith(']')) break;
|
|
54
|
+
}
|
|
55
|
+
example = parts.join(' ');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return {description: description.slice(0, 200), example: example.slice(0, 300)};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Escape a block name for a RegExp. Names are [a-z0-9-] so only `-` needs care. */
|
|
62
|
+
const reName = (n) => n.replace(/-/g, '\\-');
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Where each block is used, by reading - not rendering - the page sources and
|
|
66
|
+
* view configs: `block="x"` (collection/view/carousel/listgroup displays),
|
|
67
|
+
* `template="x"` (the [block] shortcode), the `[x …/]` short tag, and a view's
|
|
68
|
+
* `display.block`. Cheap enough for the list and the badge; a block pulled in
|
|
69
|
+
* only by a plugin's own code is not seen (so "unused" means "not used by a
|
|
70
|
+
* page or view").
|
|
71
|
+
*
|
|
72
|
+
* @param {string[]} names
|
|
73
|
+
* @param {{url: string, raw: string}[]} pages
|
|
74
|
+
* @param {{slug: string, name?: string, display?: {block?: string}}[]} views
|
|
75
|
+
* @returns {Record<string, {pages: string[], views: string[]}>}
|
|
76
|
+
*/
|
|
77
|
+
export function blockUsage(names, pages = [], views = []) {
|
|
78
|
+
const out = Object.fromEntries(names.map(n => [n, {pages: [], views: []}]));
|
|
79
|
+
for (const n of names) {
|
|
80
|
+
const re = new RegExp(`(?:\\b(?:block|template)\\s*=\\s*["']${reName(n)}["'])|(?:\\[${reName(n)}(?=[\\s/\\]]))`);
|
|
81
|
+
for (const p of pages) if (re.test(p.raw)) out[n].pages.push(p.url);
|
|
82
|
+
for (const v of views) if (v?.display?.block === n) out[n].views.push(v.slug || v.name || '');
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Every page's source, with its URL - read from disk, never rendered. */
|
|
88
|
+
async function readPageSources() {
|
|
89
|
+
const root = path.join(config.content.contentDir, 'pages');
|
|
90
|
+
const out = [];
|
|
91
|
+
async function walk(dir) {
|
|
92
|
+
let entries = [];
|
|
93
|
+
try { entries = await fs.readdir(dir, {withFileTypes: true}); } catch { return; }
|
|
94
|
+
for (const e of entries) {
|
|
95
|
+
const full = path.join(dir, e.name);
|
|
96
|
+
if (e.isDirectory()) await walk(full);
|
|
97
|
+
else if (e.name.endsWith('.md')) {
|
|
98
|
+
const rel = path.relative(root, full).replace(/\.md$/, '').split(path.sep).join('/');
|
|
99
|
+
const url = rel === 'index' ? '/' : '/' + rel.replace(/\/index$/, '');
|
|
100
|
+
try { out.push({url, raw: await fs.readFile(full, 'utf8')}); } catch { /* unreadable - skip */ }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
await walk(root);
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function usageFor(names) {
|
|
109
|
+
const [pages, views] = await Promise.all([
|
|
110
|
+
readPageSources(),
|
|
111
|
+
import('../../services/views.js').then(m => m.listViews()).catch(() => [])
|
|
112
|
+
]);
|
|
113
|
+
return blockUsage(names, pages, views);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The sidebar badge on Blocks: how many there are, with the unused ones and
|
|
118
|
+
* the latest changes in the popover. A count to glance at, never a warning.
|
|
119
|
+
* Plain text throughout - the popover sets it with textContent.
|
|
120
|
+
*
|
|
121
|
+
* @param {{name: string, updatedAt?: string, hasCss?: boolean, bundled?: boolean, usage?: {pages: string[], views: string[]}}[]} blocks
|
|
122
|
+
*/
|
|
123
|
+
export function blocksBadge(blocks = []) {
|
|
124
|
+
const n = blocks.length;
|
|
125
|
+
const used = (b) => (b.usage?.pages?.length || 0) + (b.usage?.views?.length || 0);
|
|
126
|
+
const unused = blocks.filter(b => b.usage && !used(b)).length;
|
|
127
|
+
const recent = [...blocks].sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''))).slice(0, 6);
|
|
128
|
+
return {
|
|
129
|
+
count: n,
|
|
130
|
+
label: `${n} block${n === 1 ? '' : 's'}${unused ? `, ${unused} not used on any page` : ''}`,
|
|
131
|
+
tone: 'info',
|
|
132
|
+
details: {
|
|
133
|
+
title: 'Blocks',
|
|
134
|
+
rows: [
|
|
135
|
+
{label: 'Blocks', value: String(n)},
|
|
136
|
+
{label: 'With CSS', value: String(blocks.filter(b => b.hasCss).length)},
|
|
137
|
+
{label: 'Not used on a page or view', value: String(unused)},
|
|
138
|
+
{label: 'Built in', value: String(blocks.filter(b => b.bundled).length)}
|
|
139
|
+
],
|
|
140
|
+
items: recent.map(b => ({
|
|
141
|
+
text: b.name,
|
|
142
|
+
meta: [b.updatedAt ? `changed ${String(b.updatedAt).slice(0, 10)}` : '', b.usage ? `used ${used(b)} time${used(b) === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ')
|
|
143
|
+
})),
|
|
144
|
+
empty: 'No blocks yet.'
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
13
148
|
|
|
14
149
|
/** Current export bundle schema version. Bump when the shape changes. */
|
|
15
150
|
const BUNDLE_FORMAT = 1;
|
|
@@ -19,18 +154,45 @@ export async function blocksRoutes(fastify) {
|
|
|
19
154
|
const canUpdate = {preHandler: [authenticate, requirePermission('pages', 'update')]};
|
|
20
155
|
const canDelete = {preHandler: [authenticate, requirePermission('pages', 'delete')]};
|
|
21
156
|
|
|
22
|
-
|
|
23
|
-
|
|
157
|
+
/**
|
|
158
|
+
* The blocks this user may see, each with what the list shows beyond the
|
|
159
|
+
* file: whether it has CSS, and its doc comment's description and example.
|
|
160
|
+
* getBlock was already read per block for the project check, so these come free.
|
|
161
|
+
*/
|
|
162
|
+
async function visibleBlocks(user) {
|
|
24
163
|
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
for (const b of all) {
|
|
164
|
+
const out = [];
|
|
165
|
+
for (const b of await listBlocks()) {
|
|
28
166
|
// listBlocks returns metadata only; getBlock returns full record with meta
|
|
29
167
|
let full = null;
|
|
30
168
|
try { full = await getBlock(b.name); } catch { /* skip */ }
|
|
31
|
-
if (canSeeArtefact(
|
|
169
|
+
if (!canSeeArtefact(user, full)) continue;
|
|
170
|
+
out.push({...b, hasCss: Boolean(full?.css), cssSize: full?.css?.length || 0, ...blockDoc(full?.content, b.name)});
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// List all blocks (?usage=1: where each is used)
|
|
176
|
+
fastify.get('/blocks', canRead, async (request) => {
|
|
177
|
+
const blocks = await visibleBlocks(request.user);
|
|
178
|
+
if (request.query?.usage !== '1') return blocks;
|
|
179
|
+
const usage = await usageFor(blocks.map(b => b.name));
|
|
180
|
+
return blocks.map(b => ({...b, usage: usage[b.name]}));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// The sidebar badge. Registered before /blocks/:name; `_` is not a valid block name.
|
|
184
|
+
// Usage reads every page's source, and the badge is asked for every minute from
|
|
185
|
+
// every admin tab: it is kept a minute (the list's ?usage=1 always reads afresh).
|
|
186
|
+
let usageCache = {at: 0, key: '', value: null};
|
|
187
|
+
fastify.get('/blocks/_count', canRead, async (request) => {
|
|
188
|
+
const blocks = await visibleBlocks(request.user);
|
|
189
|
+
const names = blocks.map(b => b.name);
|
|
190
|
+
const key = names.join('\n');
|
|
191
|
+
if (!usageCache.value || usageCache.key !== key || Date.now() - usageCache.at > 60_000) {
|
|
192
|
+
usageCache = {at: Date.now(), key, value: await usageFor(names)};
|
|
32
193
|
}
|
|
33
|
-
|
|
194
|
+
const usage = usageCache.value;
|
|
195
|
+
return blocksBadge(blocks.map(b => ({...b, usage: usage[b.name]})));
|
|
34
196
|
});
|
|
35
197
|
|
|
36
198
|
// Get single block
|