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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Media API
|
|
3
3
|
* GET /api/media - list media files
|
|
4
|
+
* GET /api/media/_count - the sidebar badge (plain text; see mediaBadge)
|
|
4
5
|
* POST /api/media - upload a file
|
|
5
6
|
* DELETE /api/media/:name - delete a file
|
|
6
7
|
*/
|
|
@@ -8,6 +9,7 @@ import path from 'path';
|
|
|
8
9
|
import {deleteMedia, listMedia, renameMedia, saveMedia} from '../../services/content.js';
|
|
9
10
|
import {getImageInfo, isEditableImage, transformImage} from '../../services/images.js';
|
|
10
11
|
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
12
|
+
import {ago, clip, plural} from '../../services/sidebarBadges.js';
|
|
11
13
|
|
|
12
14
|
const ALLOWED_MIME_TYPES = new Set([
|
|
13
15
|
// Images
|
|
@@ -30,6 +32,50 @@ function sanitiseFilename(name) {
|
|
|
30
32
|
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
/** 0 B, 812 B, 14.2 KB, 3.1 MB - the badge's sizes. */
|
|
36
|
+
function bytes(n) {
|
|
37
|
+
const v0 = Number(n) || 0;
|
|
38
|
+
if (v0 < 1024) return `${v0} B`;
|
|
39
|
+
const units = ['KB', 'MB', 'GB', 'TB'];
|
|
40
|
+
let v = v0 / 1024;
|
|
41
|
+
let i = 0;
|
|
42
|
+
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
|
43
|
+
return `${v.toFixed(1)} ${units[i]}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const IMAGE_RE = /\.(png|jpe?g|gif|webp|svg|avif|ico|bmp|tiff?)$/i;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The badge on Content › Media: how many files, how much space, the newest
|
|
50
|
+
* few. Informational - nothing on this screen is ever overdue. Every value is
|
|
51
|
+
* plain text; the sidebar popover sets it with textContent (file names are
|
|
52
|
+
* whatever was uploaded).
|
|
53
|
+
*
|
|
54
|
+
* @param {{name: string, size: number, createdAt: string}[]} files
|
|
55
|
+
* @param {number} [now]
|
|
56
|
+
*/
|
|
57
|
+
export function mediaBadge(files = [], now = Date.now()) {
|
|
58
|
+
const total = files.reduce((s, f) => s + (Number(f.size) || 0), 0);
|
|
59
|
+
const images = files.filter(f => IMAGE_RE.test(f.name)).length;
|
|
60
|
+
const newest = [...files].sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
|
|
61
|
+
return {
|
|
62
|
+
count: files.length,
|
|
63
|
+
label: files.length ? `${plural(files.length, 'file')}, ${bytes(total)}` : 'No media yet',
|
|
64
|
+
tone: 'info',
|
|
65
|
+
details: {
|
|
66
|
+
title: 'Media',
|
|
67
|
+
rows: [
|
|
68
|
+
{label: 'Files', value: String(files.length)},
|
|
69
|
+
{label: 'Images', value: String(images)},
|
|
70
|
+
{label: 'Other files', value: String(files.length - images)},
|
|
71
|
+
{label: 'Space used', value: bytes(total)}
|
|
72
|
+
],
|
|
73
|
+
items: newest.slice(0, 5).map(f => ({text: clip(f.name, 48), meta: `${bytes(f.size)} · ${ago(f.createdAt, now)}`})),
|
|
74
|
+
empty: 'Nothing uploaded yet.'
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
33
79
|
export async function mediaRoutes(fastify) {
|
|
34
80
|
const canRead = {preHandler: [authenticate, requirePermission('media', 'read')]};
|
|
35
81
|
const canCreate = {preHandler: [authenticate, requirePermission('media', 'create')]};
|
|
@@ -40,6 +86,9 @@ export async function mediaRoutes(fastify) {
|
|
|
40
86
|
return listMedia();
|
|
41
87
|
});
|
|
42
88
|
|
|
89
|
+
// `_count` is not a file route: the only GET under /media/:name is /media/:name/info.
|
|
90
|
+
fastify.get('/media/_count', canRead, async () => mediaBadge(await listMedia()));
|
|
91
|
+
|
|
43
92
|
fastify.post('/media', canCreate, async (request, reply) => {
|
|
44
93
|
const results = [];
|
|
45
94
|
for await (const data of request.files()) {
|
|
@@ -84,7 +133,13 @@ export async function mediaRoutes(fastify) {
|
|
|
84
133
|
|
|
85
134
|
fastify.delete('/media/:name', canDelete, async (request, reply) => {
|
|
86
135
|
const name = sanitiseFilename(request.params.name);
|
|
87
|
-
|
|
136
|
+
try {
|
|
137
|
+
await deleteMedia(name);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
// Gone already (another tab, another admin) is a 404, not a server error.
|
|
140
|
+
if (err.code === 'ENOENT') return reply.status(404).send({error: 'File not found'});
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
88
143
|
return { success: true };
|
|
89
144
|
});
|
|
90
145
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Menus API
|
|
3
3
|
*
|
|
4
4
|
* GET /api/menus - list (metadata only)
|
|
5
|
+
* GET /api/menus/_count - the sidebar badge (the underscore can never be a slug)
|
|
5
6
|
* GET /api/menus/:slug - full menu
|
|
6
7
|
* POST /api/menus - create
|
|
7
8
|
* PUT /api/menus/:slug - replace
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
createMenu,
|
|
22
23
|
deleteMenu,
|
|
23
24
|
duplicateMenu,
|
|
25
|
+
getLocationRegistry,
|
|
24
26
|
getLocations,
|
|
25
27
|
getMenu,
|
|
26
28
|
listMenus,
|
|
@@ -28,6 +30,43 @@ import {
|
|
|
28
30
|
} from '../../services/menus.js';
|
|
29
31
|
import * as cache from '../../services/cache/index.js';
|
|
30
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The sidebar badge on Menus (its countUrl): how many menus, which locations
|
|
35
|
+
* have none, and which menus are in no location. A count to glance at, not a
|
|
36
|
+
* warning. Plain text throughout - the popover sets it with textContent.
|
|
37
|
+
*
|
|
38
|
+
* "Not in a location" is not "unused": a `[menu]` shortcode can show any menu.
|
|
39
|
+
* The overlay slot is left out of "empty" - it is optional by design.
|
|
40
|
+
*
|
|
41
|
+
* @param {{menus: object[], locations: Record<string, string>, registry: object[]}} state
|
|
42
|
+
* @returns {{count: number, label: string, tone: string, details: object}}
|
|
43
|
+
*/
|
|
44
|
+
export function menusBadge({menus = [], locations = {}, registry = []} = {}) {
|
|
45
|
+
const mapped = new Set(Object.values(locations || {}).filter(Boolean));
|
|
46
|
+
const placed = (m) => mapped.has(m.slug) || Boolean(m.binding?.slot);
|
|
47
|
+
const unplaced = menus.filter(m => !placed(m));
|
|
48
|
+
const bindable = registry.filter(r => r.slot !== 'overlay');
|
|
49
|
+
const empty = bindable.filter(r => !locations?.[r.slot]);
|
|
50
|
+
const n = menus.length;
|
|
51
|
+
return {
|
|
52
|
+
count: n,
|
|
53
|
+
label: `${n} menu${n === 1 ? '' : 's'}${unplaced.length ? `, ${unplaced.length} not in a location` : ''}`,
|
|
54
|
+
tone: 'info',
|
|
55
|
+
details: {
|
|
56
|
+
title: 'Menus',
|
|
57
|
+
rows: [
|
|
58
|
+
{label: 'In a location', value: `${n - unplaced.length} of ${n}`},
|
|
59
|
+
{label: 'Locations filled', value: `${bindable.length - empty.length} of ${bindable.length}`}
|
|
60
|
+
],
|
|
61
|
+
items: [
|
|
62
|
+
...empty.map(r => ({text: r.label || r.slot, meta: 'location - no menu'})),
|
|
63
|
+
...unplaced.map(m => ({text: m.name || m.slug, meta: `${m.slug} · not in a location`}))
|
|
64
|
+
].slice(0, 8),
|
|
65
|
+
empty: 'Every location has a menu, and every menu is in one.'
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
31
70
|
/**
|
|
32
71
|
* Register the menus routes.
|
|
33
72
|
*
|
|
@@ -44,17 +83,25 @@ export async function menusRoutes(fastify, opts = {}) {
|
|
|
44
83
|
const canUpdate = {preHandler: [authenticate, requirePermission('menus', 'update')]};
|
|
45
84
|
const canDelete = {preHandler: [authenticate, requirePermission('menus', 'delete')]};
|
|
46
85
|
|
|
47
|
-
|
|
86
|
+
/** The menus this user may see - listMenus is metadata only, so each is read for its meta. */
|
|
87
|
+
async function visibleMenus(user) {
|
|
48
88
|
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
49
|
-
const all = await listMenus();
|
|
50
89
|
const filtered = [];
|
|
51
|
-
for (const m of
|
|
52
|
-
// listMenus returns metadata only; getMenu returns full record with meta
|
|
90
|
+
for (const m of await listMenus()) {
|
|
53
91
|
const full = await getMenu(m.slug);
|
|
54
|
-
if (canSeeArtefact(
|
|
92
|
+
if (canSeeArtefact(user, full)) filtered.push(m);
|
|
55
93
|
}
|
|
56
94
|
return filtered;
|
|
57
|
-
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fastify.get('/menus', canRead, async (request) => visibleMenus(request.user));
|
|
98
|
+
|
|
99
|
+
// Registered before /menus/:slug; `_` is outside the slug alphabet, so no menu can shadow it.
|
|
100
|
+
fastify.get('/menus/_count', canRead, async (request) => menusBadge({
|
|
101
|
+
menus: await visibleMenus(request.user),
|
|
102
|
+
locations: await getLocations(),
|
|
103
|
+
registry: getLocationRegistry()
|
|
104
|
+
}));
|
|
58
105
|
|
|
59
106
|
fastify.get('/menus/:slug', canRead, async (request, reply) => {
|
|
60
107
|
const menu = await getMenu(request.params.slug);
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
* POST /api/system/notifications - Manager push (requireManager)
|
|
4
4
|
* GET /api/system/notifications - List active notifications (admin/manager)
|
|
5
5
|
* GET /api/system/notifications/unread-count - Unread count for bell badge
|
|
6
|
+
* GET /api/system/notifications/_count - The sidebar badge (unread, with a popover)
|
|
6
7
|
* POST /api/system/notifications/:id/read - Mark read (idempotent)
|
|
8
|
+
* POST /api/system/notifications/:id/unread - Mark unread again (idempotent)
|
|
9
|
+
* POST /api/system/notifications/dismiss-all - Dismiss every active one, for this user
|
|
10
|
+
* POST /api/system/notifications/read-all - Mark every active one read, for this user
|
|
7
11
|
* POST /api/system/notifications/:id/dismiss - Mark dismissed
|
|
8
12
|
* DELETE /api/system/notifications/:id - Hard delete (admin only)
|
|
9
13
|
*/
|
|
@@ -12,6 +16,7 @@ import {
|
|
|
12
16
|
requirePermission as defaultRequirePermission
|
|
13
17
|
} from '../../middleware/auth.js';
|
|
14
18
|
import { requireManager } from '../../middleware/managerAuth.js';
|
|
19
|
+
import { randomUUID } from 'crypto';
|
|
15
20
|
import { getAdapter } from '../../services/adapterRegistry.js';
|
|
16
21
|
|
|
17
22
|
const SLUG = 'notifications';
|
|
@@ -45,6 +50,18 @@ function notificationData(entry, log) {
|
|
|
45
50
|
return null;
|
|
46
51
|
}
|
|
47
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Write a notification's payload back as a whole entry - `{id, data, meta}`.
|
|
55
|
+
*
|
|
56
|
+
* The adapter's update() REPLACES the stored entry with what it is given, and
|
|
57
|
+
* until 0.76.0 these routes gave it the bare payload: marking a notification
|
|
58
|
+
* read turned it into a flat record with no id and no `data`, which the list
|
|
59
|
+
* then skipped as malformed - read once, gone for good.
|
|
60
|
+
*/
|
|
61
|
+
async function saveData(adapter, entry, data) {
|
|
62
|
+
return adapter.update(SLUG, entry.id, {...entry, data, meta: {...(entry.meta || {}), updatedAt: new Date().toISOString()}});
|
|
63
|
+
}
|
|
64
|
+
|
|
48
65
|
/**
|
|
49
66
|
* Is this notification live for this user right now: not expired, not dismissed.
|
|
50
67
|
*
|
|
@@ -58,6 +75,43 @@ function isActiveFor(data, userId, now) {
|
|
|
58
75
|
return !(Array.isArray(data.dismissedBy) && data.dismissedBy.includes(userId));
|
|
59
76
|
}
|
|
60
77
|
|
|
78
|
+
/**
|
|
79
|
+
* The sidebar badge on Notifications (its countUrl): the unread ones - the same
|
|
80
|
+
* number the sidebar always showed there - red while any of them is critical.
|
|
81
|
+
* Plain text throughout: the popover sets it with textContent.
|
|
82
|
+
*
|
|
83
|
+
* @param {object[]} active - this user's active entries, each with `unread`
|
|
84
|
+
* @returns {{count: number, label: string, tone: string, details: object}}
|
|
85
|
+
*/
|
|
86
|
+
export function notificationsBadge(active = []) {
|
|
87
|
+
const unread = active.filter(e => e.unread)
|
|
88
|
+
.sort((a, b) => String(b.data.createdAt || '').localeCompare(String(a.data.createdAt || '')));
|
|
89
|
+
const sev = (e) => e.data?.severity || 'info';
|
|
90
|
+
const critical = unread.filter(e => sev(e) === 'critical').length;
|
|
91
|
+
const warning = unread.filter(e => sev(e) === 'warning').length;
|
|
92
|
+
const n = unread.length;
|
|
93
|
+
return {
|
|
94
|
+
count: n,
|
|
95
|
+
label: n ? `${n} unread notification${n === 1 ? '' : 's'}${critical ? `, ${critical} critical` : ''}` : 'No unread notifications',
|
|
96
|
+
tone: critical ? 'danger' : 'info',
|
|
97
|
+
details: {
|
|
98
|
+
title: 'Notifications',
|
|
99
|
+
rows: [
|
|
100
|
+
{label: 'Unread', value: String(n)},
|
|
101
|
+
{label: 'Critical', value: String(critical), ...(critical && {tone: 'danger'})},
|
|
102
|
+
{label: 'Warnings', value: String(warning)},
|
|
103
|
+
{label: 'In the inbox', value: String(active.length)}
|
|
104
|
+
],
|
|
105
|
+
items: unread.slice(0, 6).map(e => ({
|
|
106
|
+
text: String(e.data.title || '(no title)').slice(0, 80),
|
|
107
|
+
meta: [sev(e), String(e.data.createdAt || '').slice(0, 10)].filter(Boolean).join(' · '),
|
|
108
|
+
...(sev(e) === 'critical' && {tone: 'danger'})
|
|
109
|
+
})),
|
|
110
|
+
empty: 'Nothing unread.'
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
61
115
|
export async function notificationsRoutes(fastify, opts = {}) {
|
|
62
116
|
// Injectable so tests can supply no-ops rather than a seeded roles cache -
|
|
63
117
|
// the same DI pattern as menus.js, projects.js and context-menus.js.
|
|
@@ -94,17 +148,25 @@ export async function notificationsRoutes(fastify, opts = {}) {
|
|
|
94
148
|
}
|
|
95
149
|
}
|
|
96
150
|
|
|
151
|
+
// A whole entry, {id, data, meta}: the adapter stores what it is given, and
|
|
152
|
+
// before 0.76.0 this handed it the bare payload - no id, no `data` - so the
|
|
153
|
+
// list skipped every pushed notification as malformed and none ever showed.
|
|
97
154
|
const adapter = await getAdapter(SLUG);
|
|
155
|
+
const now = new Date().toISOString();
|
|
98
156
|
const entry = await adapter.insert(SLUG, {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
157
|
+
id: randomUUID(),
|
|
158
|
+
data: {
|
|
159
|
+
title: title.trim(),
|
|
160
|
+
body: body.trim(),
|
|
161
|
+
severity,
|
|
162
|
+
source: 'manager',
|
|
163
|
+
link: link || null,
|
|
164
|
+
createdAt: now,
|
|
165
|
+
expiresAt: expiresAt || null,
|
|
166
|
+
readBy: [],
|
|
167
|
+
dismissedBy: []
|
|
168
|
+
},
|
|
169
|
+
meta: {createdAt: now, updatedAt: now, source: 'manager'}
|
|
108
170
|
});
|
|
109
171
|
|
|
110
172
|
return reply.code(201).send(entry);
|
|
@@ -147,6 +209,73 @@ export async function notificationsRoutes(fastify, opts = {}) {
|
|
|
147
209
|
return { count };
|
|
148
210
|
});
|
|
149
211
|
|
|
212
|
+
/** This user's active notifications, newest first, each flagged `unread`. */
|
|
213
|
+
async function activeFor(userId) {
|
|
214
|
+
const now = new Date().toISOString();
|
|
215
|
+
const adapter = await getAdapter(SLUG);
|
|
216
|
+
return (await adapter.all(SLUG))
|
|
217
|
+
.filter(entry => {
|
|
218
|
+
const d = notificationData(entry, fastify.log);
|
|
219
|
+
return !!d && isActiveFor(d, userId, now);
|
|
220
|
+
})
|
|
221
|
+
.map(entry => ({...entry, unread: !Array.isArray(entry.data.readBy) || !entry.data.readBy.includes(userId)}));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// --- Sidebar badge (static path: GET has no /:id route to shadow) ---
|
|
225
|
+
fastify.get('/system/notifications/_count', canRead, async (request) => notificationsBadge(await activeFor(request.user.id)));
|
|
226
|
+
|
|
227
|
+
// --- Dismiss everything active, for this user ---
|
|
228
|
+
fastify.post('/system/notifications/dismiss-all', canRead, async (request) => {
|
|
229
|
+
const userId = request.user.id;
|
|
230
|
+
const adapter = await getAdapter(SLUG);
|
|
231
|
+
let dismissed = 0;
|
|
232
|
+
for (const entry of await activeFor(userId)) {
|
|
233
|
+
const {unread: _u, ...stored} = entry;
|
|
234
|
+
const data = stored.data;
|
|
235
|
+
const dismissedBy = Array.isArray(data.dismissedBy) ? data.dismissedBy : [];
|
|
236
|
+
const readBy = Array.isArray(data.readBy) ? data.readBy : [];
|
|
237
|
+
await saveData(adapter, stored, {
|
|
238
|
+
...data,
|
|
239
|
+
dismissedBy: [...dismissedBy, userId],
|
|
240
|
+
readBy: readBy.includes(userId) ? readBy : [...readBy, userId]
|
|
241
|
+
});
|
|
242
|
+
dismissed++;
|
|
243
|
+
}
|
|
244
|
+
return {ok: true, dismissed};
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// --- Mark everything active read, for this user ---
|
|
248
|
+
// One request, one pass: the screen used to fire a mark-read per notification
|
|
249
|
+
// at once, and on file storage each read-modify-write overwrote the others -
|
|
250
|
+
// six marked, one stuck.
|
|
251
|
+
fastify.post('/system/notifications/read-all', canRead, async (request) => {
|
|
252
|
+
const userId = request.user.id;
|
|
253
|
+
const adapter = await getAdapter(SLUG);
|
|
254
|
+
let marked = 0;
|
|
255
|
+
for (const entry of await activeFor(userId)) {
|
|
256
|
+
if (!entry.unread) continue;
|
|
257
|
+
const {unread: _u, ...stored} = entry;
|
|
258
|
+
const readBy = Array.isArray(stored.data.readBy) ? stored.data.readBy : [];
|
|
259
|
+
await saveData(adapter, stored, {...stored.data, readBy: [...readBy, userId]});
|
|
260
|
+
marked++;
|
|
261
|
+
}
|
|
262
|
+
return {ok: true, marked};
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// --- Mark unread again (idempotent) ---
|
|
266
|
+
fastify.post('/system/notifications/:id/unread', canRead, async (request, reply) => {
|
|
267
|
+
const { id } = request.params;
|
|
268
|
+
const userId = request.user.id;
|
|
269
|
+
const adapter = await getAdapter(SLUG);
|
|
270
|
+
const entry = await adapter.get(SLUG, id);
|
|
271
|
+
const data = notificationData(entry, fastify.log);
|
|
272
|
+
if (!data) return reply.code(404).send({ error: 'Notification not found.' });
|
|
273
|
+
const readBy = Array.isArray(data.readBy) ? data.readBy : [];
|
|
274
|
+
if (!readBy.includes(userId)) return { ok: true };
|
|
275
|
+
const updated = await saveData(adapter, entry, {...data, readBy: readBy.filter(u => u !== userId)});
|
|
276
|
+
return { ok: true, entry: updated };
|
|
277
|
+
});
|
|
278
|
+
|
|
150
279
|
// --- Mark read (idempotent) ---
|
|
151
280
|
fastify.post('/system/notifications/:id/read', canRead, async (request, reply) => {
|
|
152
281
|
const { id } = request.params;
|
|
@@ -161,10 +290,7 @@ export async function notificationsRoutes(fastify, opts = {}) {
|
|
|
161
290
|
const readBy = Array.isArray(data.readBy) ? data.readBy : [];
|
|
162
291
|
if (readBy.includes(userId)) return { ok: true }; // already read - idempotent
|
|
163
292
|
|
|
164
|
-
const updated = await adapter
|
|
165
|
-
...data,
|
|
166
|
-
readBy: [...readBy, userId]
|
|
167
|
-
});
|
|
293
|
+
const updated = await saveData(adapter, entry, {...data, readBy: [...readBy, userId]});
|
|
168
294
|
|
|
169
295
|
return { ok: true, entry: updated };
|
|
170
296
|
});
|
|
@@ -182,7 +308,7 @@ export async function notificationsRoutes(fastify, opts = {}) {
|
|
|
182
308
|
const dismissedBy = Array.isArray(data.dismissedBy) ? data.dismissedBy : [];
|
|
183
309
|
const readBy = Array.isArray(data.readBy) ? data.readBy : [];
|
|
184
310
|
|
|
185
|
-
const updated = await adapter
|
|
311
|
+
const updated = await saveData(adapter, entry, {
|
|
186
312
|
...data,
|
|
187
313
|
dismissedBy: dismissedBy.includes(userId) ? dismissedBy : [...dismissedBy, userId],
|
|
188
314
|
readBy: readBy.includes(userId) ? readBy : [...readBy, userId] // dismissing also marks read
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pages API
|
|
3
3
|
* GET /api/pages - list all pages
|
|
4
|
+
* GET /api/pages/_count - the sidebar badge on Pages (drafts)
|
|
4
5
|
* GET /api/pages/* - get single page by URL path
|
|
5
6
|
* POST /api/pages - create page
|
|
6
7
|
* PUT /api/pages/* - update page
|
|
@@ -23,6 +24,44 @@ import {
|
|
|
23
24
|
import {getVersionCount} from '../../services/versions.js';
|
|
24
25
|
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
25
26
|
import {getConfig, saveConfig} from '../../config.js';
|
|
27
|
+
import {ago, clip, plural} from '../../services/sidebarBadges.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The sidebar badge on Pages (its countUrl): the drafts - unpublished work -
|
|
31
|
+
* with a popover of what is published, restricted and recently changed, and
|
|
32
|
+
* the drafts themselves, newest first. No drafts, no badge. Plain text
|
|
33
|
+
* throughout: the popover sets it with textContent.
|
|
34
|
+
*
|
|
35
|
+
* @param {object[]} pages - listPages()
|
|
36
|
+
* @param {number} [now]
|
|
37
|
+
*/
|
|
38
|
+
export function pagesBadge(pages, now = Date.now()) {
|
|
39
|
+
const list = pages || [];
|
|
40
|
+
const drafts = list.filter(p => p.status !== 'published')
|
|
41
|
+
.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')));
|
|
42
|
+
const restricted = list.filter(p => p.visibility && p.visibility !== 'public').length;
|
|
43
|
+
const week = list.filter(p => p.updatedAt && now - new Date(p.updatedAt).getTime() < 7 * 86_400_000).length;
|
|
44
|
+
return {
|
|
45
|
+
// The number stays what the sidebar has always shown on Pages (all of them); drafts are the label.
|
|
46
|
+
count: list.length,
|
|
47
|
+
label: drafts.length ? `${plural(list.length, 'page')}, ${plural(drafts.length, 'draft')}` : `${plural(list.length, 'page')}, no drafts`,
|
|
48
|
+
tone: 'info',
|
|
49
|
+
details: {
|
|
50
|
+
title: 'Pages',
|
|
51
|
+
rows: [
|
|
52
|
+
{label: 'Published', value: String(list.length - drafts.length)},
|
|
53
|
+
{label: 'Drafts', value: String(drafts.length)},
|
|
54
|
+
{label: 'Not public', value: String(restricted)},
|
|
55
|
+
{label: 'Changed this week', value: String(week)}
|
|
56
|
+
],
|
|
57
|
+
items: drafts.slice(0, 6).map(p => ({
|
|
58
|
+
text: clip(p.title || p.urlPath),
|
|
59
|
+
meta: [p.urlPath, p.updatedAt ? ago(p.updatedAt, now) : ''].filter(Boolean).join(' · ')
|
|
60
|
+
})),
|
|
61
|
+
empty: 'No drafts - everything is published.'
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
26
65
|
|
|
27
66
|
export async function pagesRoutes(fastify) {
|
|
28
67
|
const canRead = {preHandler: [authenticate, requirePermission('pages', 'read')]};
|
|
@@ -116,6 +155,9 @@ export async function pagesRoutes(fastify) {
|
|
|
116
155
|
return { tags: [...tagSet].sort() };
|
|
117
156
|
});
|
|
118
157
|
|
|
158
|
+
// The sidebar badge. Static, so it is matched before the /pages/* wildcard below.
|
|
159
|
+
fastify.get('/pages/_count', canRead, async () => pagesBadge(await listPages()));
|
|
160
|
+
|
|
119
161
|
// List all pages
|
|
120
162
|
fastify.get('/pages', canRead, async (request, reply) => {
|
|
121
163
|
const pages = await listPages();
|
|
@@ -133,6 +175,10 @@ export async function pagesRoutes(fastify) {
|
|
|
133
175
|
tags: p.tags || [],
|
|
134
176
|
project: p.project ?? null,
|
|
135
177
|
resolvedProject: p.resolvedProject ?? null,
|
|
178
|
+
// The list's "managed by a plugin" chip and delete warning read these;
|
|
179
|
+
// they were dropped here, so neither ever showed.
|
|
180
|
+
plugin: p.plugin || null,
|
|
181
|
+
bundled: p.bundled === true,
|
|
136
182
|
updatedAt: p.updatedAt,
|
|
137
183
|
createdAt: p.createdAt,
|
|
138
184
|
versionCount: counts[i]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Plugins API
|
|
3
3
|
* GET /api/plugins - list all discovered plugins + state (admin only)
|
|
4
|
+
* GET /api/plugins/_count - the sidebar badge (`_` is outside the slug alphabet)
|
|
4
5
|
* POST /api/plugins/scaffold - create a new plugin from plugins/_template (admin only)
|
|
5
6
|
* PUT /api/plugins/:name - enable/disable, update settings (admin only)
|
|
6
7
|
* GET /api/plugins/admin-config - sidebar/routes/views for enabled plugins (authenticated)
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
getAdminPluginConfig,
|
|
15
16
|
getEntitlementStates,
|
|
16
17
|
getLoadedPlugins,
|
|
18
|
+
getPluginSettings,
|
|
17
19
|
getPluginStates,
|
|
18
20
|
isCorePlugin,
|
|
19
21
|
pluginLicence,
|
|
@@ -26,7 +28,8 @@ import {deletePluginFile, listPluginFiles, readPluginFile, writePluginFile} from
|
|
|
26
28
|
import {getConfig} from '../../config.js';
|
|
27
29
|
import path from 'path';
|
|
28
30
|
import {existsSync, readFileSync} from 'node:fs';
|
|
29
|
-
import {installPlugin} from '../../services/pluginInstaller.js';
|
|
31
|
+
import {compareVersions, installPlugin} from '../../services/pluginInstaller.js';
|
|
32
|
+
import {fetchCatalogue, isManaged} from '../../services/managerClient.js';
|
|
30
33
|
import {packEnvelope, readEnvelope, readManifestFromTarball, tarDirectory} from '../../services/pluginEnvelope.js';
|
|
31
34
|
|
|
32
35
|
/**
|
|
@@ -95,6 +98,111 @@ function autoRestartEnabled() {
|
|
|
95
98
|
}
|
|
96
99
|
}
|
|
97
100
|
|
|
101
|
+
/** Entitlement states that stop a plugin running (the loader refuses them). */
|
|
102
|
+
const BLOCKING = new Set(['expired', 'unlicensed']);
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The sidebar badge on Plugins (its countUrl, GET /api/plugins/_count). The
|
|
106
|
+
* number stays what the sidebar always showed there - how many are installed -
|
|
107
|
+
* and the tone and popover say what needs doing: red while a licensed plugin
|
|
108
|
+
* that is switched on is not running, amber for an update or a licence in its
|
|
109
|
+
* grace period. Plain text throughout; the popover sets it with textContent.
|
|
110
|
+
*
|
|
111
|
+
* @param {{name, displayName, version, enabled, core, entitlement}[]} plugins
|
|
112
|
+
* @param {Map<string, string>} [latest] - slug -> newest catalogue version (managed sites only)
|
|
113
|
+
* @returns {{count, label, tone, details}}
|
|
114
|
+
*/
|
|
115
|
+
export function pluginsBadge(plugins = [], latest = new Map()) {
|
|
116
|
+
const name = (p) => String(p.displayName || p.name);
|
|
117
|
+
const stopped = plugins.filter(p => p.enabled && BLOCKING.has(p.entitlement));
|
|
118
|
+
const grace = plugins.filter(p => p.entitlement === 'grace');
|
|
119
|
+
const updates = plugins.filter(p => latest.has(p.name) && compareVersions(latest.get(p.name), p.version) > 0);
|
|
120
|
+
const on = plugins.filter(p => p.enabled || p.core).length;
|
|
121
|
+
const n = plugins.length;
|
|
122
|
+
const extra = [
|
|
123
|
+
stopped.length && `${stopped.length} not running`,
|
|
124
|
+
updates.length && `${updates.length} update${updates.length === 1 ? '' : 's'}`
|
|
125
|
+
].filter(Boolean).join(', ');
|
|
126
|
+
return {
|
|
127
|
+
count: n,
|
|
128
|
+
label: `${n} plugin${n === 1 ? '' : 's'} installed${extra ? `, ${extra}` : ''}`,
|
|
129
|
+
tone: stopped.length ? 'danger' : (updates.length || grace.length) ? 'attention' : 'info',
|
|
130
|
+
details: {
|
|
131
|
+
title: 'Plugins',
|
|
132
|
+
rows: [
|
|
133
|
+
{label: 'Switched on', value: String(on)},
|
|
134
|
+
{label: 'Switched off', value: String(n - on)},
|
|
135
|
+
...(latest.size ? [{label: 'Updates available', value: String(updates.length)}] : []),
|
|
136
|
+
...(stopped.length ? [{label: 'Not running (licence)', value: String(stopped.length), tone: 'danger'}] : [])
|
|
137
|
+
],
|
|
138
|
+
items: [
|
|
139
|
+
...stopped.map(p => ({text: name(p), meta: `not running - licence ${p.entitlement}`, tone: 'danger'})),
|
|
140
|
+
...grace.map(p => ({text: name(p), meta: 'licence ended - running in its grace period, renew'})),
|
|
141
|
+
...updates.map(p => ({text: name(p), meta: `update ${p.version} → ${latest.get(p.name)}`}))
|
|
142
|
+
].slice(0, 8),
|
|
143
|
+
empty: 'Everything is running and up to date.'
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The newest catalogue version of each plugin, for a managed site - kept ten
|
|
150
|
+
* minutes: the badge is asked for every minute from every admin tab, and each
|
|
151
|
+
* ask would otherwise be a round trip to the manager.
|
|
152
|
+
*/
|
|
153
|
+
let _latest = {at: 0, value: new Map()};
|
|
154
|
+
async function latestVersions() {
|
|
155
|
+
if (!isManaged()) return new Map();
|
|
156
|
+
if (Date.now() - _latest.at < 10 * 60_000) return _latest.value;
|
|
157
|
+
const catalogue = await fetchCatalogue().catch(() => []);
|
|
158
|
+
_latest = {at: Date.now(), value: new Map((catalogue || []).map(e => [e.slug, e.version]))};
|
|
159
|
+
return _latest.value;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Plugin settings reach the admin browser (the Marketplace's Configure form), so
|
|
164
|
+
* a value whose key looks secret - a DVLA key, an SMTP password, a connection
|
|
165
|
+
* URI - is sent as SETTING_MASK instead. Saving sends the mask back for any
|
|
166
|
+
* secret left untouched, and restoreMasked() puts the stored value back; a
|
|
167
|
+
* mask with nothing stored behind it (a config.js default) is dropped, so the
|
|
168
|
+
* default still applies. Same rule as isSecretKey() in admin/js/lib/plugins-arrange.js.
|
|
169
|
+
*/
|
|
170
|
+
export const SETTING_MASK = '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022';
|
|
171
|
+
const SECRET_KEY = /pass(word)?|secret|token|api[-_]?key|private|credential|uri$|dsn/i;
|
|
172
|
+
|
|
173
|
+
/** @param {Set<string>} [secretKeys] - keys a plugin's settingsSchema marks `secret: true` */
|
|
174
|
+
export function maskSettings(value, key = '', secretKeys = new Set()) {
|
|
175
|
+
// An array's strings are secret when its key is (apiKeys: ['a', 'b']).
|
|
176
|
+
if (Array.isArray(value)) return value.map(v => maskSettings(v, key, secretKeys));
|
|
177
|
+
if (value && typeof value === 'object') {
|
|
178
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, maskSettings(v, k, secretKeys)]));
|
|
179
|
+
}
|
|
180
|
+
const secret = SECRET_KEY.test(key) || secretKeys.has(key);
|
|
181
|
+
return secret && typeof value === 'string' && value !== '' ? SETTING_MASK : value;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const schemaSecrets = (manifest) => new Set((Array.isArray(manifest.settingsSchema) ? manifest.settingsSchema : [])
|
|
185
|
+
.filter(f => f && f.secret === true && typeof f.key === 'string').map(f => f.key));
|
|
186
|
+
|
|
187
|
+
export function restoreMasked(incoming, stored) {
|
|
188
|
+
// Arrays by position: maskSettings masks inside them too ({accounts: [{password}]}),
|
|
189
|
+
// and returning the array as sent stored the mask over the real value.
|
|
190
|
+
if (Array.isArray(incoming)) {
|
|
191
|
+
const before = Array.isArray(stored) ? stored : [];
|
|
192
|
+
return incoming.map((v, i) => (v === SETTING_MASK ? (before[i] ?? '') : restoreMasked(v, before[i])));
|
|
193
|
+
}
|
|
194
|
+
if (incoming && typeof incoming === 'object') {
|
|
195
|
+
const out = {};
|
|
196
|
+
for (const [k, v] of Object.entries(incoming)) {
|
|
197
|
+
const before = stored && typeof stored === 'object' ? stored[k] : undefined;
|
|
198
|
+
if (v === SETTING_MASK) { if (before !== undefined) out[k] = before; continue; }
|
|
199
|
+
out[k] = restoreMasked(v, before);
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
return incoming;
|
|
204
|
+
}
|
|
205
|
+
|
|
98
206
|
export async function pluginsRoutes(fastify, opts = {}) {
|
|
99
207
|
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
100
208
|
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
@@ -114,7 +222,7 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
114
222
|
const loadedPlugins = getLoadedPlugins();
|
|
115
223
|
const entitlementStates = getEntitlementStates();
|
|
116
224
|
|
|
117
|
-
return manifests.map(manifest => ({
|
|
225
|
+
return Promise.all(manifests.map(async manifest => ({
|
|
118
226
|
name: manifest.name,
|
|
119
227
|
displayName: manifest.displayName || manifest.name,
|
|
120
228
|
version: manifest.version || '1.0.0',
|
|
@@ -143,8 +251,30 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
143
251
|
// Enabled but displaced by another plugin that replaces it. Shown
|
|
144
252
|
// so the screen can explain the inactivity instead of looking broken.
|
|
145
253
|
supersededBy: loadedPlugins[manifest.name]?.supersededBy ?? null,
|
|
146
|
-
settings: states[manifest.name]?.settings || {}
|
|
254
|
+
settings: maskSettings(states[manifest.name]?.settings || {}, '', schemaSecrets(manifest)),
|
|
255
|
+
// What Configure edits: an optional plugin.json `settingsSchema`
|
|
256
|
+
// ([{key, label, type: text|number|select|boolean, options, min,
|
|
257
|
+
// max, help}]), else the plugin's config.js defaults merged with the
|
|
258
|
+
// stored overrides (`effectiveSettings`) - so every plugin with a
|
|
259
|
+
// config.js can be configured, not only one that declared a schema
|
|
260
|
+
// (none ever had, so the old Settings button never appeared).
|
|
261
|
+
settingsSchema: Array.isArray(manifest.settingsSchema) ? manifest.settingsSchema : null,
|
|
262
|
+
effectiveSettings: maskSettings(await getPluginSettings(manifest.name).catch(() => ({})), '', schemaSecrets(manifest))
|
|
263
|
+
})));
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// The sidebar badge. Registered with the other static /plugins/* routes;
|
|
267
|
+
// `_` is outside the plugin slug alphabet, so no plugin can shadow it.
|
|
268
|
+
fastify.get('/plugins/_count', canRead, async () => {
|
|
269
|
+
const manifests = await discoverPlugins();
|
|
270
|
+
const states = getPluginStates();
|
|
271
|
+
const ent = getEntitlementStates();
|
|
272
|
+
const plugins = manifests.map(m => ({
|
|
273
|
+
name: m.name, displayName: m.displayName || m.name, version: m.version || '1.0.0',
|
|
274
|
+
core: isCorePlugin(m.name), enabled: isCorePlugin(m.name) || !!states[m.name]?.enabled,
|
|
275
|
+
entitlement: ent[m.name] ?? null
|
|
147
276
|
}));
|
|
277
|
+
return pluginsBadge(plugins, await latestVersions());
|
|
148
278
|
});
|
|
149
279
|
|
|
150
280
|
// Scaffold a new plugin from plugins/_template. The plugin is created
|
|
@@ -183,7 +313,8 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
183
313
|
const prevState = getPluginStates()[name] || {};
|
|
184
314
|
const update = {};
|
|
185
315
|
if (enabled !== undefined) update.enabled = !!enabled;
|
|
186
|
-
|
|
316
|
+
// A secret sent back as the mask keeps its stored value (see maskSettings).
|
|
317
|
+
if (settings !== undefined) update.settings = restoreMasked(settings, prevState.settings || {});
|
|
187
318
|
savePluginState(name, update);
|
|
188
319
|
|
|
189
320
|
if (!prevState.enabled && !!enabled) {
|