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.
Files changed (116) hide show
  1. package/CLAUDE.md +3 -1
  2. package/admin/js/app.js +4 -4
  3. package/admin/js/lib/actions-arrange.js +1 -0
  4. package/admin/js/lib/api-endpoints-arrange.js +1 -0
  5. package/admin/js/lib/api-tokens-arrange.js +3 -0
  6. package/admin/js/lib/blocks-arrange.js +1 -0
  7. package/admin/js/lib/collections-arrange.js +1 -0
  8. package/admin/js/lib/components-arrange.js +1 -0
  9. package/admin/js/lib/context-menus-arrange.js +1 -0
  10. package/admin/js/lib/effects-arrange.js +3 -0
  11. package/admin/js/lib/forms-arrange.js +1 -0
  12. package/admin/js/lib/layouts-arrange.js +1 -0
  13. package/admin/js/lib/media-arrange.js +1 -0
  14. package/admin/js/lib/menus-arrange.js +1 -0
  15. package/admin/js/lib/my-profile-arrange.js +1 -0
  16. package/admin/js/lib/notifications-arrange.js +1 -0
  17. package/admin/js/lib/pages-arrange.js +1 -0
  18. package/admin/js/lib/plugins-arrange.js +2 -0
  19. package/admin/js/lib/roles-arrange.js +1 -0
  20. package/admin/js/lib/search-settings-arrange.js +1 -0
  21. package/admin/js/lib/sidebar-renderer.js +3 -3
  22. package/admin/js/lib/site-settings-arrange.js +1 -0
  23. package/admin/js/lib/theme-settings-arrange.js +1 -0
  24. package/admin/js/lib/users-arrange.js +1 -0
  25. package/admin/js/lib/views-arrange.js +1 -0
  26. package/admin/js/templates/actions-list.html +73 -14
  27. package/admin/js/templates/api-endpoints.html +63 -10
  28. package/admin/js/templates/api-tokens.html +83 -10
  29. package/admin/js/templates/blocks.html +76 -15
  30. package/admin/js/templates/collections.html +108 -52
  31. package/admin/js/templates/components.html +82 -15
  32. package/admin/js/templates/context-menus.html +55 -13
  33. package/admin/js/templates/effects.html +155 -72
  34. package/admin/js/templates/forms.html +67 -14
  35. package/admin/js/templates/layouts.html +126 -43
  36. package/admin/js/templates/media.html +99 -13
  37. package/admin/js/templates/menus.html +57 -11
  38. package/admin/js/templates/my-profile.html +100 -12
  39. package/admin/js/templates/notifications.html +70 -10
  40. package/admin/js/templates/pages.html +78 -23
  41. package/admin/js/templates/plugins.html +100 -28
  42. package/admin/js/templates/roles.html +78 -7
  43. package/admin/js/templates/search.html +193 -216
  44. package/admin/js/templates/settings.html +404 -472
  45. package/admin/js/templates/theme.html +164 -132
  46. package/admin/js/templates/users.html +69 -9
  47. package/admin/js/templates/views-list.html +63 -9
  48. package/admin/js/views/actions-list.js +37 -1
  49. package/admin/js/views/api-endpoints.js +11 -7
  50. package/admin/js/views/api-tokens.js +62 -8
  51. package/admin/js/views/blocks.js +34 -5
  52. package/admin/js/views/collections.js +36 -1
  53. package/admin/js/views/components.js +27 -5
  54. package/admin/js/views/context-menus.js +10 -5
  55. package/admin/js/views/effects.js +31 -1
  56. package/admin/js/views/forms.js +57 -1
  57. package/admin/js/views/layouts.js +81 -1
  58. package/admin/js/views/media.js +40 -1
  59. package/admin/js/views/menus.js +11 -5
  60. package/admin/js/views/my-profile.js +1 -1
  61. package/admin/js/views/notifications.js +18 -1
  62. package/admin/js/views/pages.js +28 -8
  63. package/admin/js/views/plugins.js +97 -39
  64. package/admin/js/views/roles.js +49 -4
  65. package/admin/js/views/search.js +7 -1
  66. package/admin/js/views/settings.js +13 -3
  67. package/admin/js/views/theme.js +55 -1
  68. package/admin/js/views/users.js +13 -4
  69. package/admin/js/views/views-list.js +11 -1
  70. package/package.json +1 -1
  71. package/plugins/_lib/admin/mail/compose-window.js +97 -40
  72. package/plugins/_lib/admin/mail/contacts.js +4 -1
  73. package/plugins/_lib/admin/mail/diagnostics-section.js +4 -1
  74. package/plugins/_lib/admin/mail/folder-tree.js +1 -1
  75. package/plugins/_lib/admin/mail/image-senders-section.js +69 -90
  76. package/plugins/_lib/admin/mail/mail.css +1 -1
  77. package/plugins/_lib/admin/mail/mail.html +1 -1
  78. package/plugins/_lib/admin/mail/reader-view.js +303 -44
  79. package/plugins/_lib/admin/mail/when-closed.js +26 -0
  80. package/plugins/_lib/mail/readRoutes.js +43 -1
  81. package/plugins/_lib/mail/unreadBadge.js +42 -0
  82. package/plugins/free-tier.lock.json +5 -5
  83. package/plugins/mail-reader/CLAUDE.md +1 -0
  84. package/plugins/mail-reader/plugin.js +3 -1
  85. package/plugins/mail-reader/plugin.json +5 -4
  86. package/server/routes/api/actions.js +64 -2
  87. package/server/routes/api/api-endpoints.js +41 -0
  88. package/server/routes/api/api-tokens.js +56 -0
  89. package/server/routes/api/auth.js +7 -1
  90. package/server/routes/api/blocks.js +170 -8
  91. package/server/routes/api/collections.js +66 -2
  92. package/server/routes/api/components.js +46 -0
  93. package/server/routes/api/context-menus.js +8 -0
  94. package/server/routes/api/effects.js +68 -13
  95. package/server/routes/api/forms.js +123 -10
  96. package/server/routes/api/layouts.js +93 -2
  97. package/server/routes/api/media.js +56 -1
  98. package/server/routes/api/menus.js +53 -6
  99. package/server/routes/api/notifications.js +140 -14
  100. package/server/routes/api/pages.js +46 -0
  101. package/server/routes/api/plugins.js +135 -4
  102. package/server/routes/api/roles.js +111 -0
  103. package/server/routes/api/search.js +38 -4
  104. package/server/routes/api/settings.js +52 -0
  105. package/server/routes/api/theme.js +55 -0
  106. package/server/routes/api/users.js +53 -1
  107. package/server/routes/api/views.js +27 -3
  108. package/server/server.js +28 -1
  109. package/server/services/contextMenus.js +41 -0
  110. package/server/services/plugins.js +7 -0
  111. package/server/services/roles.js +3 -0
  112. package/server/services/search.js +58 -0
  113. package/server/services/users.js +14 -0
  114. package/server/services/views.js +38 -0
  115. package/admin/js/templates/plugin-marketplace.html +0 -29
  116. 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@d9bfe49",
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.0.0",
70
+ "version": "1.1.0",
71
71
  "files": {
72
- "CLAUDE.md": "a8dd2cb285e1d24b2f072aa11a644e1c8c1be89013da6f6cc46d9820fe24ccd5",
72
+ "CLAUDE.md": "7f317e69b560457f2d4c401b466754850afe75f6983bc41414c71d8c276c3583",
73
73
  "admin/views/mail.js": "c5c6483f9618c75cac8f4be32d3aea99511db76d4b9d38b8ef43bdc1d35db3c5",
74
74
  "config.js": "6e65803ad822a9acda40c203631b8eb32a87cf9d9e5f1c7cb59d01aa406075c6",
75
- "plugin.js": "79b17346ed0192b092040460cc8d762d5207ee6e4af7bfc2d1cf654a51e2fa81",
76
- "plugin.json": "5f5b98757514aa70d7c7a2be5a1fbfdde8ef21a2ccc7586b3b5aea9b44fbe5e6"
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.0.0",
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-19",
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=1",
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 {authenticate, requirePermission} from '../../middleware/auth.js';
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
- export async function actionsRoutes(fastify) {
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
- const user = await updateUser(id, coreUpdates);
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
- // List all blocks
23
- fastify.get('/blocks', canRead, async (request) => {
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 all = await listBlocks();
26
- const filtered = [];
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(request.user, full)) filtered.push(b);
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
- return filtered;
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