domma-cms 0.85.0 → 0.87.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin/js/lib/sidebar-renderer.js +3 -3
- package/config/menus/admin-sidebar.json +21 -8
- package/config/plugins.json +0 -18
- package/package.json +1 -1
- package/plugins/_lib/mail/diagnostics.js +3 -2
- package/plugins/blog/CLAUDE.md +33 -0
- package/plugins/blog/lib/access.js +64 -0
- package/plugins/blog/lib/notices.js +38 -0
- package/plugins/blog/plugin.js +84 -26
- package/plugins/blog/plugin.json +173 -20
- package/plugins/blog/roles/blog-author.json +15 -2
- package/plugins/blog/tests/access.test.js +32 -0
- package/plugins/blog/tests/comment-notices.test.js +140 -0
- package/plugins/blog/tests/notices.test.js +20 -0
- package/plugins/blog/tests/public.test.js +3 -1
- package/plugins/free-tier.lock.json +15 -10
- package/plugins/mail-reader/plugin.js +12 -1
- package/plugins/mail-reader/plugin.json +23 -4
- package/server/routes/api/notifications.js +4 -3
- package/server/routes/api/todo.js +11 -0
- package/server/services/notification-sources.js +62 -1
- package/server/services/notify.js +4 -2
- package/server/services/pluginScope.js +85 -0
- package/server/services/plugins.js +7 -2
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* blog: a comment held for approval raises a notice for the site's staff, and
|
|
3
|
+
* moderating or deleting it resolves that notice. The plugin runs in Fastify
|
|
4
|
+
* against the domma-cms checkout's collections; the blog collections'
|
|
5
|
+
* data files and config/plugins.json are put back byte for byte.
|
|
6
|
+
*/
|
|
7
|
+
import {test, before, after} from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import Fastify from 'fastify';
|
|
12
|
+
import plugin from '../plugin.js';
|
|
13
|
+
import {commentKey} from '../lib/notices.js';
|
|
14
|
+
|
|
15
|
+
const SLUGS = {posts: 'blog-posts', comments: 'blog-comments', categories: 'blog-categories'};
|
|
16
|
+
// The checkout's content/ carries an old demo blog-comments schema (post_slug, author_name...), not the
|
|
17
|
+
// plugin's: the plugin's own schemas stand in for the run, and every file goes back as it was.
|
|
18
|
+
const KEEP = ['config/plugins.json', ...Object.values(SLUGS).flatMap(c => [`content/collections/${c}/data.json`, `content/collections/${c}/schema.json`])]
|
|
19
|
+
.map(f => path.resolve(f));
|
|
20
|
+
const HERE = path.dirname(new URL(import.meta.url).pathname);
|
|
21
|
+
const saved = new Map();
|
|
22
|
+
// Registering the plugin seeds its layout blocks into content/blocks: whatever it adds goes again.
|
|
23
|
+
const BLOCKS = path.resolve('content/blocks');
|
|
24
|
+
let blocksBefore = new Set();
|
|
25
|
+
const MIGRATIONS = path.resolve('content/.migrations.json');
|
|
26
|
+
let hadMigrations = true;
|
|
27
|
+
const notes = [];
|
|
28
|
+
const resolved = [];
|
|
29
|
+
const sources = [];
|
|
30
|
+
// Who holds what - as a site that shipped this release would have it.
|
|
31
|
+
const WRITE = ['super-admin', 'admin', 'blog-editor', 'blog-author'];
|
|
32
|
+
const EDIT = ['super-admin', 'admin', 'blog-editor'];
|
|
33
|
+
const HOLDERS = {
|
|
34
|
+
'blog-posts.read': WRITE, 'blog-posts.create': WRITE, 'blog-posts.update': WRITE, 'blog-categories.read': WRITE,
|
|
35
|
+
'blog-posts.delete': EDIT, 'blog-posts.publish': EDIT, 'blog-categories.create': EDIT, 'blog-categories.update': EDIT,
|
|
36
|
+
'blog-categories.delete': EDIT, 'blog-comments.read': EDIT, 'blog-comments.update': EDIT, 'blog-comments.delete': EDIT
|
|
37
|
+
};
|
|
38
|
+
let app;
|
|
39
|
+
|
|
40
|
+
before(async () => {
|
|
41
|
+
for (const f of KEEP) saved.set(f, await fs.readFile(f, 'utf8').catch(() => null));
|
|
42
|
+
blocksBefore = new Set(await fs.readdir(BLOCKS).catch(() => []));
|
|
43
|
+
const {invalidate} = await import('../../../server/services/adapterRegistry.js').catch(() => ({}));
|
|
44
|
+
for (const [dir, slug] of Object.entries(SLUGS)) {
|
|
45
|
+
const target = path.resolve('content/collections', slug);
|
|
46
|
+
await fs.mkdir(target, {recursive: true});
|
|
47
|
+
await fs.copyFile(path.join(HERE, '..', 'collections', dir, 'schema.json'), path.join(target, 'schema.json'));
|
|
48
|
+
await fs.writeFile(path.join(target, 'data.json'), '[]\n');
|
|
49
|
+
invalidate?.(slug);
|
|
50
|
+
}
|
|
51
|
+
hadMigrations = await fs.access(MIGRATIONS).then(() => true, () => false);
|
|
52
|
+
app = Fastify();
|
|
53
|
+
const pass = async () => {};
|
|
54
|
+
await app.register(plugin, {prefix: '/b', settings: {},
|
|
55
|
+
// Who is asking: the x-as header (a role), super-admin by default.
|
|
56
|
+
getPermissionsFor: (key) => HOLDERS[key] || ['super-admin'],
|
|
57
|
+
auth: {authenticate: async (req) => { const r = req.headers['x-as'] || 'super-admin'; req.user = {id: `u-${r}`, role: r, name: r,
|
|
58
|
+
...(req.headers['x-also'] ? {additionalRoles: [req.headers['x-also']]} : {})}; }, requireAdmin: pass,
|
|
59
|
+
requirePermission: () => pass, requireRole: () => pass, requireVisibility: () => pass},
|
|
60
|
+
hooks: {notify: (p) => { notes.push(p); }, resolveNotification: (k, s) => { resolved.push(`${s}|${k}`); },
|
|
61
|
+
registerNotificationSource: (d) => { sources.push(d.id); }}});
|
|
62
|
+
await app.ready();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
after(async () => {
|
|
66
|
+
await app?.close();
|
|
67
|
+
for (const name of await fs.readdir(BLOCKS).catch(() => [])) {
|
|
68
|
+
if (!blocksBefore.has(name)) await fs.rm(path.join(BLOCKS, name), {force: true});
|
|
69
|
+
}
|
|
70
|
+
if (!hadMigrations) await fs.rm(MIGRATIONS, {force: true});
|
|
71
|
+
for (const [f, text] of saved) {
|
|
72
|
+
if (text === null) await fs.rm(f, {force: true});
|
|
73
|
+
else await fs.writeFile(f, text);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const call = async (method, url, body, as, also) => {
|
|
78
|
+
const headers = {...(as ? {'x-as': as} : {}), ...(also ? {'x-also': also} : {})};
|
|
79
|
+
const res = await app.inject({method, url: `/b${url}`, payload: body, headers});
|
|
80
|
+
return {status: res.statusCode, body: res.json()};
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
test('a held comment notifies; approving it, or deleting one, resolves it', async () => {
|
|
84
|
+
assert.ok(sources.includes('comments'));
|
|
85
|
+
assert.equal((await call('PUT', '/settings', {commentsEnabled: true, commentModeration: true})).status, 200, 'comments on, held for approval');
|
|
86
|
+
const post = (await call('POST', '/posts', {title: 'Spring opening hours', slug: `zz-notice-${Date.now()}`, status: 'published',
|
|
87
|
+
content: 'Open late.', body: 'Open late.'})).body;
|
|
88
|
+
assert.ok(post.id, JSON.stringify(post));
|
|
89
|
+
await call('POST', `/posts/${post.id}/publish`).catch(() => {});
|
|
90
|
+
|
|
91
|
+
const say = (text) => call('POST', '/comments', {postId: post.id, authorName: 'Jo', authorEmail: 'jo@example.test', body: text});
|
|
92
|
+
const first = await say('<b>Great</b> news');
|
|
93
|
+
assert.equal(first.status, 201, JSON.stringify(first.body));
|
|
94
|
+
const n = notes.find(x => x.dedupeKey === commentKey(first.body.id));
|
|
95
|
+
assert.equal(n.title, 'New comment on "Spring opening hours"');
|
|
96
|
+
assert.match(n.body, /^Jo: <b>Great<\/b> news/, 'kept as text - the screen escapes it');
|
|
97
|
+
assert.equal(n.source, 'comments');
|
|
98
|
+
assert.equal(n.link, '#/plugins/blog/comments');
|
|
99
|
+
|
|
100
|
+
await call('PUT', `/comments/${first.body.id}`, {status: 'approved'});
|
|
101
|
+
assert.ok(resolved.includes(`comments|${commentKey(first.body.id)}`));
|
|
102
|
+
|
|
103
|
+
const second = await say('Spam?');
|
|
104
|
+
resolved.length = 0;
|
|
105
|
+
await call('PUT', `/comments/${second.body.id}`, {status: 'pending'});
|
|
106
|
+
assert.equal(resolved.length, 0, 'still waiting: nothing to resolve');
|
|
107
|
+
await call('DELETE', `/comments/${second.body.id}`);
|
|
108
|
+
assert.ok(resolved.includes(`comments|${commentKey(second.body.id)}`));
|
|
109
|
+
|
|
110
|
+
// The honeypot makes nothing, and says nothing.
|
|
111
|
+
notes.length = 0;
|
|
112
|
+
await call('POST', '/comments', {postId: post.id, authorName: 'Bot', authorEmail: 'b@x.test', body: 'buy', website: 'http://spam'});
|
|
113
|
+
assert.equal(notes.length, 0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('by permission: a plain account gets nothing; an author only their own; an editor held as an additional role moderates', async () => {
|
|
117
|
+
const draft = (await call('POST', '/posts', {title: 'Staff draft', slug: `zz-perm-${Date.now()}`})).body;
|
|
118
|
+
const say = (await call('POST', '/comments', {postId: draft.id, authorName: 'Jo', authorEmail: 'jo@example.test', body: 'x'}));
|
|
119
|
+
// A plain signed-in account: no drafts, no commenters, no writing, no moderating.
|
|
120
|
+
for (const [m, u, b] of [['GET', '/posts'], ['GET', `/posts/${draft.id}`], ['POST', '/posts', {title: 'x'}], ['GET', '/comments'],
|
|
121
|
+
['GET', '/comments/pending-count'], ['GET', '/settings'], ['GET', '/categories']]) {
|
|
122
|
+
assert.equal((await call(m, u, b, 'user')).status, 403, `${m} ${u}`);
|
|
123
|
+
}
|
|
124
|
+
if (say.body?.id) {
|
|
125
|
+
assert.equal((await call('PUT', `/comments/${say.body.id}`, {status: 'approved'}, 'user')).status, 403);
|
|
126
|
+
assert.equal((await call('DELETE', `/comments/${say.body.id}`, null, 'user')).status, 403);
|
|
127
|
+
}
|
|
128
|
+
// An author: writes, sees only their own, cannot publish, delete or read comments.
|
|
129
|
+
const mine = (await call('POST', '/posts', {title: 'Mine', slug: `zz-mine-${Date.now()}`}, 'blog-author')).body;
|
|
130
|
+
assert.ok(mine.id);
|
|
131
|
+
const listed = (await call('GET', '/posts', null, 'blog-author')).body.map(p => p.id);
|
|
132
|
+
assert.ok(listed.includes(mine.id) && !listed.includes(draft.id), 'own posts only');
|
|
133
|
+
assert.equal((await call('GET', `/posts/${draft.id}`, null, 'blog-author')).status, 404, "someone else's draft is not there");
|
|
134
|
+
assert.equal((await call('POST', `/posts/${mine.id}/publish`, null, 'blog-author')).status, 403);
|
|
135
|
+
assert.equal((await call('DELETE', `/posts/${mine.id}`, null, 'blog-author')).status, 403);
|
|
136
|
+
assert.equal((await call('GET', '/comments', null, 'blog-author')).status, 403);
|
|
137
|
+
// An editor held as an ADDITIONAL role: role names are not what counts.
|
|
138
|
+
assert.equal((await call('GET', '/comments', null, 'user', 'blog-editor')).status, 200);
|
|
139
|
+
if (say.body?.id) assert.equal((await call('PUT', `/comments/${say.body.id}`, {status: 'approved'}, 'user', 'blog-editor')).status, 200);
|
|
140
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The moderation notice (blog/lib/notices.js). */
|
|
2
|
+
import {test} from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import {commentKey, commentNotice} from '../lib/notices.js';
|
|
5
|
+
|
|
6
|
+
test('a held comment makes a notice; an approved one does not', () => {
|
|
7
|
+
const n = commentNotice({id: 'c1', status: 'pending', authorName: 'Sam', body: 'Lovely post,\nthanks'}, 'Spring hours', '#/plugins/blog/comments');
|
|
8
|
+
assert.deepEqual(n, {title: 'New comment on "Spring hours"', body: 'Sam: Lovely post, thanks\n\nWaiting for approval.',
|
|
9
|
+
severity: 'info', link: '#/plugins/blog/comments', source: 'comments', dedupeKey: commentKey('c1')});
|
|
10
|
+
assert.equal(commentNotice({id: 'c2', status: 'approved', body: 'x'}, 'P', '#/x'), null);
|
|
11
|
+
assert.equal(commentNotice(null, 'P', '#/x'), null);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('a long comment is one line, cut short; a missing name is "Someone"', () => {
|
|
15
|
+
const n = commentNotice({id: 'c3', status: 'pending', body: 'word '.repeat(200)}, 'T'.repeat(100), '#/x');
|
|
16
|
+
assert.ok(n.title.length <= 100);
|
|
17
|
+
assert.ok(n.body.startsWith('Someone: word word'));
|
|
18
|
+
assert.ok(n.body.split('\n')[0].length <= 212);
|
|
19
|
+
assert.ok(n.body.includes('…'));
|
|
20
|
+
});
|
|
@@ -16,7 +16,7 @@ import zlib from 'zlib';
|
|
|
16
16
|
import Fastify from 'fastify';
|
|
17
17
|
import compress from '@fastify/compress';
|
|
18
18
|
import blogPublicPlugin from '../plugin.public.js';
|
|
19
|
-
import {postFields} from '../plugin.js';
|
|
19
|
+
import {postFields, usePermissionLookup} from '../plugin.js';
|
|
20
20
|
|
|
21
21
|
async function buildPublic() {
|
|
22
22
|
const app = Fastify();
|
|
@@ -54,6 +54,8 @@ test('a display can be asked for, and an unknown one falls back', async () => {
|
|
|
54
54
|
});
|
|
55
55
|
|
|
56
56
|
test('an author cannot publish by writing the status - that is what the publish route is for', () => {
|
|
57
|
+
// Who may publish is a permission: editors and admins hold blog-posts.publish.
|
|
58
|
+
usePermissionLookup((k) => (k === 'blog-posts.publish' ? ['super-admin', 'admin', 'blog-editor'] : []));
|
|
57
59
|
const author = {id: 'a', role: 'blog-author'};
|
|
58
60
|
const editor = {id: 'e', role: 'blog-editor'};
|
|
59
61
|
assert.equal(postFields({status: 'published'}, author).status, undefined);
|
|
@@ -1,11 +1,11 @@
|
|
|
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@0335bfa",
|
|
4
4
|
"plugins": {
|
|
5
5
|
"blog": {
|
|
6
|
-
"version": "1.
|
|
6
|
+
"version": "1.8.0",
|
|
7
7
|
"files": {
|
|
8
|
-
"CLAUDE.md": "
|
|
8
|
+
"CLAUDE.md": "026438d4d93f5a637be16ac22d5131e8248e93d2df1bd9b30d5f1cfe357ba2cc",
|
|
9
9
|
"admin/css/index.css": "6142a6b756e1218157842528830b1e84f592f0b84b7d5ce6449db421b6f4b039",
|
|
10
10
|
"admin/templates/blog.html": "a6054c1febe764bb1edf5c9e466d44a9080d9c83ba5486437b7a8244acf5ba54",
|
|
11
11
|
"admin/templates/categories.html": "ab7d6fc2fedd48876c585bc216a1532b3bdd78c946dee976c1171bfe4664feb6",
|
|
@@ -41,13 +41,15 @@
|
|
|
41
41
|
"collections/comments/schema.json": "29cbd289cbfcfc4158c9357a218db4ce06eddc3370bfbf9afb7d7f52065a849e",
|
|
42
42
|
"collections/posts/schema.json": "19a5a770d3eefea73f4117454bd1312a7a7e3d727cfcdfde141139fbc81cabd7",
|
|
43
43
|
"config.js": "ee67396262762f5c31269d258affd75f7392a3e364f72f7792c2f1cdafd94bba",
|
|
44
|
+
"lib/access.js": "af47440c7233a56000641a3231d7e9ff81b12f40dfb9528aa6dd4e2dd9bca8df",
|
|
44
45
|
"lib/layouts.js": "bbb61ac7ec2e0cecd3d59700e9f3bd1064cd53b2e9f639d28b45ffc2ad80ab27",
|
|
45
46
|
"lib/nav-link.js": "6a8f9368bb8aee1c21e90fb335a691859cb9a9bc250c2b9167a33ca9189fa2ff",
|
|
47
|
+
"lib/notices.js": "a9a2918ee6f5f7e0bbaeced0d3539584279bdbe68d15472a8615bdddb4ef905b",
|
|
46
48
|
"lib/page.js": "464face32871185368de096ed9a6aa635523bf319af6459308cfceb77d66f82c",
|
|
47
49
|
"lib/render.js": "0e97c6a950bc962c8d8ea4b2f21d06cb73fbcdc738d8942d6935d01309a29b1d",
|
|
48
50
|
"lib/samples.js": "e8cdb77ce22e43937707f6854d3a5cb722437dc584e22ac7b24480bef686cf85",
|
|
49
|
-
"plugin.js": "
|
|
50
|
-
"plugin.json": "
|
|
51
|
+
"plugin.js": "db93fd831f6ff6762c6768d47e658830b6284b6f4a9efd2bbffaab5966ba0361",
|
|
52
|
+
"plugin.json": "d6a254e9ec114cbc469e8e2b5a9ca33376c3f88e49c54d42ea589ac883e3dac3",
|
|
51
53
|
"plugin.public.js": "8efc1ff3a2ead68d36fbd545f8ce590f600b293896ba6f4947418b1efcdf2947",
|
|
52
54
|
"public/blog.css": "fd733e6b624190da1e70dc1b6613e2376efd7f7570a28fab311d81006d03d960",
|
|
53
55
|
"public/blog.js": "d01c72a9834dcc7c5241923371f2b164ac30d20ac9f1a5c5dc5b71fa3cb09f08",
|
|
@@ -57,23 +59,26 @@
|
|
|
57
59
|
"public/samples/harbour.svg": "a5f09eafa1406da3c2d017e9bee308a98543df15090ed27227cbbf8341aec460",
|
|
58
60
|
"public/samples/meadow.svg": "90efae21e209b7d48901eed457f47d0a347c58a6b6e1ea81ba98c131c4c559cb",
|
|
59
61
|
"public/samples/rosewood.svg": "0f963fe5633585786271255b7c9df36be67748ae751f9cf7b0bbda6f5b93449d",
|
|
60
|
-
"roles/blog-author.json": "
|
|
62
|
+
"roles/blog-author.json": "2784a98dd31667bd3aefdf3f8ad3895e6fb200e26a9f1bb7c873f9e9993d4a2b",
|
|
61
63
|
"roles/blog-editor.json": "bea0b673cf9e3da04c5882d51dd33bdcaf035b047c1cfe87624a9f16e6514ffc",
|
|
62
64
|
"templates/index.html": "3c2cf8a4ffde8599d451f6d00b736aeba42fd073b726ada3b0ffe4d56e1ef0f4",
|
|
63
65
|
"templates/post.html": "28e9f06071115ecc4fbba007b35782732bf12ef72528525893d424e3c4f0313f",
|
|
66
|
+
"tests/access.test.js": "65b760910117c3a44195543acdcff91074f39b4e5567911a2bd8676204152cb2",
|
|
67
|
+
"tests/comment-notices.test.js": "34026cc9a2a4746cf87bc41df8be849fadaa43091a72ac2aa1d06c1f437bf618",
|
|
64
68
|
"tests/layouts.test.js": "5325aad79a153f1d17f49b27223f50be182a2acce8ee8be7da658ae92ae36fe8",
|
|
65
69
|
"tests/nav-link.test.js": "d36e33ad7483abca27ae6e2098ad2f75bafa18d91f3beba70372ebe6b9b88994",
|
|
66
|
-
"tests/
|
|
70
|
+
"tests/notices.test.js": "2b02aa84e780dc4c82e6c503fc8fe17c64a63bee0709627866ef97c99cb4e131",
|
|
71
|
+
"tests/public.test.js": "a6fcdc4ede242aa050ffe5a52f970d6bec1f4a70ae78967eb8a7bdeec73df19d"
|
|
67
72
|
}
|
|
68
73
|
},
|
|
69
74
|
"mail-reader": {
|
|
70
|
-
"version": "1.
|
|
75
|
+
"version": "1.2.0",
|
|
71
76
|
"files": {
|
|
72
77
|
"CLAUDE.md": "7f317e69b560457f2d4c401b466754850afe75f6983bc41414c71d8c276c3583",
|
|
73
78
|
"admin/views/mail.js": "c5c6483f9618c75cac8f4be32d3aea99511db76d4b9d38b8ef43bdc1d35db3c5",
|
|
74
79
|
"config.js": "6e65803ad822a9acda40c203631b8eb32a87cf9d9e5f1c7cb59d01aa406075c6",
|
|
75
|
-
"plugin.js": "
|
|
76
|
-
"plugin.json": "
|
|
80
|
+
"plugin.js": "7f602a60c4eca7d3637659d03d5de9e242584666846b16c9e5a1b758e4856c9f",
|
|
81
|
+
"plugin.json": "60111616d915be07c8bb53a54b8aeae906e167d157e54295bb440546128acd55"
|
|
77
82
|
}
|
|
78
83
|
},
|
|
79
84
|
"security": {
|
|
@@ -25,8 +25,19 @@ export default async function mailReaderPlugin(fastify, options) {
|
|
|
25
25
|
// rather than leave the mail server holding dead sessions.
|
|
26
26
|
fastify.addHook('onClose', async () => pool.closeAll());
|
|
27
27
|
|
|
28
|
+
// The Email permission (1.2), shared with Email Pro: every route needs it.
|
|
29
|
+
// Granted to admin only - a mailbox here opens connections from this server.
|
|
30
|
+
// Give it to other roles under Roles. An older core keeps the old rule.
|
|
31
|
+
const {authenticate: signedIn, requirePermission} = options.auth;
|
|
32
|
+
const mayUse = typeof requirePermission === 'function' ? requirePermission('email', 'read') : async () => {};
|
|
33
|
+
async function authenticate(request, reply) {
|
|
34
|
+
const out = await signedIn(request, reply);
|
|
35
|
+
if (reply.sent) return out;
|
|
36
|
+
return mayUse(request, reply);
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
registerReadRoutes(fastify, {
|
|
29
|
-
auth: options.auth,
|
|
40
|
+
auth: {...options.auth, authenticate},
|
|
30
41
|
hooks: options.hooks,
|
|
31
42
|
config,
|
|
32
43
|
pool,
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mail-reader",
|
|
3
3
|
"displayName": "Mail Reader",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.2.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-26",
|
|
9
9
|
"icon": "mail",
|
|
10
10
|
"admin": {
|
|
11
11
|
"sidebar": [
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"icon": "mail",
|
|
16
16
|
"url": "#/plugins/mail-reader",
|
|
17
17
|
"section": "#/plugins/mail-reader",
|
|
18
|
-
"countUrl": "/api/plugins/mail-reader/unread"
|
|
18
|
+
"countUrl": "/api/plugins/mail-reader/unread",
|
|
19
|
+
"permission": "email"
|
|
19
20
|
}
|
|
20
21
|
],
|
|
21
22
|
"routes": [
|
|
@@ -31,5 +32,23 @@
|
|
|
31
32
|
"exportName": "mailView"
|
|
32
33
|
}
|
|
33
34
|
}
|
|
34
|
-
}
|
|
35
|
+
},
|
|
36
|
+
"permissions": [
|
|
37
|
+
{
|
|
38
|
+
"key": "email",
|
|
39
|
+
"label": "Email",
|
|
40
|
+
"group": "Plugins",
|
|
41
|
+
"description": "Your own mailboxes in the admin: read, and with Email Pro send, from this server.",
|
|
42
|
+
"actions": [
|
|
43
|
+
{
|
|
44
|
+
"key": "read",
|
|
45
|
+
"label": "Use",
|
|
46
|
+
"description": "Add your own mailboxes, read and (Email Pro) send mail - from this server's address"
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
"grant": [
|
|
50
|
+
"admin"
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
]
|
|
35
54
|
}
|
|
@@ -116,9 +116,10 @@ export function notificationsBadge(active = []) {
|
|
|
116
116
|
*
|
|
117
117
|
* @param {Date} now
|
|
118
118
|
* @param {string} origin - the site's own address, for the one sample with a link
|
|
119
|
+
* @param {string|null} [forUser] - the id of whoever asked: only they see them
|
|
119
120
|
* @returns {object[]} entries, `{id, data, meta}`
|
|
120
121
|
*/
|
|
121
|
-
export function sampleNotifications(now = new Date(), origin = '') {
|
|
122
|
+
export function sampleNotifications(now = new Date(), origin = '', forUser = null) {
|
|
122
123
|
const expiresAt = new Date(now.getTime() + 7 * 86_400_000).toISOString();
|
|
123
124
|
const note = `\n\n(A sample - it goes by itself on ${expiresAt.slice(0, 10)}.)`;
|
|
124
125
|
const samples = [
|
|
@@ -138,7 +139,7 @@ export function sampleNotifications(now = new Date(), origin = '') {
|
|
|
138
139
|
return {
|
|
139
140
|
id: randomUUID(),
|
|
140
141
|
data: {title: x.title, body: x.body + note, severity: x.severity, source: 'sample', link: x.link || null,
|
|
141
|
-
createdAt: at, expiresAt, readBy: [], dismissedBy: []},
|
|
142
|
+
createdAt: at, expiresAt, readBy: [], dismissedBy: [], audience: forUser ? {userIds: [String(forUser)]} : null},
|
|
142
143
|
meta: {createdAt: at, updatedAt: at, source: 'sample'}
|
|
143
144
|
};
|
|
144
145
|
});
|
|
@@ -354,7 +355,7 @@ export async function notificationsRoutes(fastify, opts = {}) {
|
|
|
354
355
|
fastify.post('/system/notifications/samples', canDelete, async (request, reply) => {
|
|
355
356
|
const adapter = await getAdapter(SLUG);
|
|
356
357
|
const origin = `${request.protocol}://${request.host || request.hostname}`;
|
|
357
|
-
const made = sampleNotifications(new Date(), origin);
|
|
358
|
+
const made = sampleNotifications(new Date(), origin, request.user?.id);
|
|
358
359
|
// A second click replaces the samples rather than stacking another set.
|
|
359
360
|
for (const e of await adapter.all(SLUG)) if (e?.data?.source === 'sample') await adapter.remove(SLUG, e.id);
|
|
360
361
|
for (const entry of made) await adapter.insert(SLUG, entry);
|
|
@@ -87,6 +87,14 @@ export async function todoRoutes(fastify, opts = {}) {
|
|
|
87
87
|
return request.user?.id ?? request.user?.sub ?? null;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** A to-do was completed, removed or re-dated: this person's notices follow at once (0.87). Never throws. */
|
|
91
|
+
async function tell(uid) {
|
|
92
|
+
try {
|
|
93
|
+
const {todoChanged} = await import('../../services/notification-sources.js');
|
|
94
|
+
await todoChanged(uid, await loadAll(uid), localDay(new Date()));
|
|
95
|
+
} catch { /* a notice never breaks a to-do */ }
|
|
96
|
+
}
|
|
97
|
+
|
|
90
98
|
async function loadAll(uid) {
|
|
91
99
|
const {entries} = await listEntries(SLUG, {limit: 10000, sort: 'createdAt', order: 'asc'});
|
|
92
100
|
let items = entries.map(toTodo);
|
|
@@ -185,6 +193,7 @@ export async function todoRoutes(fastify, opts = {}) {
|
|
|
185
193
|
}
|
|
186
194
|
|
|
187
195
|
const updated = await updateEntry(SLUG, id, merged);
|
|
196
|
+
if ('status' in patch || 'dueAt' in patch) tell(entry.data.userId);
|
|
188
197
|
return reply.send(toTodo(updated));
|
|
189
198
|
});
|
|
190
199
|
|
|
@@ -194,6 +203,7 @@ export async function todoRoutes(fastify, opts = {}) {
|
|
|
194
203
|
const all = await loadAll(uid);
|
|
195
204
|
const completed = all.filter(t => t.status === 'completed');
|
|
196
205
|
await Promise.all(completed.map(t => deleteEntry(SLUG, t.id)));
|
|
206
|
+
if (completed.length) tell(uid);
|
|
197
207
|
return reply.send({success: true, removed: completed.length});
|
|
198
208
|
});
|
|
199
209
|
|
|
@@ -207,6 +217,7 @@ export async function todoRoutes(fastify, opts = {}) {
|
|
|
207
217
|
if (uid && entry.data.userId !== uid) return reply.code(404).send({error: 'Todo not found'});
|
|
208
218
|
|
|
209
219
|
await deleteEntry(SLUG, id);
|
|
220
|
+
tell(entry.data.userId);
|
|
210
221
|
return reply.send({ success: true });
|
|
211
222
|
});
|
|
212
223
|
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import fs from 'fs';
|
|
19
19
|
import path from 'path';
|
|
20
20
|
import {config, getConfig} from '../config.js';
|
|
21
|
-
import {notify, registerNotificationSource} from './notify.js';
|
|
21
|
+
import {notify, registerNotificationSource, resolveNotification} from './notify.js';
|
|
22
22
|
|
|
23
23
|
const day = (d = new Date()) => d.toISOString().slice(0, 10);
|
|
24
24
|
|
|
@@ -133,6 +133,53 @@ export function recordCrash(err) {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
/** Register every core source. Called once, before plugins load. */
|
|
136
|
+
/**
|
|
137
|
+
* To-dos (0.87): each person's overdue and due-today to-dos, as at most TWO
|
|
138
|
+
* notices for them (one listing the overdue, one the due today) - never one
|
|
139
|
+
* per task, so twenty overdue tasks are one notice. Completed ones and those
|
|
140
|
+
* with no due day are left out. Pure.
|
|
141
|
+
*
|
|
142
|
+
* @param {object[]} todos - {id, userId, text, status, dueAt}
|
|
143
|
+
* @param {string} today - YYYY-MM-DD (the server's day)
|
|
144
|
+
* @returns {{raise: object[], clear: string[]}} notices, and the keys to resolve (users with nothing in that state)
|
|
145
|
+
*/
|
|
146
|
+
export function todoNotices(todos, today) {
|
|
147
|
+
const endOfDay = new Date(Date.parse(`${today}T00:00:00Z`) + 86400000).toISOString();
|
|
148
|
+
const byUser = new Map();
|
|
149
|
+
for (const t of todos || []) {
|
|
150
|
+
if (!t?.userId) continue;
|
|
151
|
+
if (!byUser.has(t.userId)) byUser.set(t.userId, {overdue: [], today: []});
|
|
152
|
+
if (t.status === 'completed') continue;
|
|
153
|
+
const day = /^(\d{4}-\d{2}-\d{2})/.exec(String(t.dueAt ?? ''))?.[1];
|
|
154
|
+
if (!day) continue;
|
|
155
|
+
if (day < today) byUser.get(t.userId).overdue.push(t);
|
|
156
|
+
else if (day === today) byUser.get(t.userId).today.push(t);
|
|
157
|
+
}
|
|
158
|
+
const name = (t) => String(t.text || 'To-do').replace(/\s+/g, ' ').trim().slice(0, 80) || 'To-do';
|
|
159
|
+
const list = (ts) => ts.slice(0, 5).map(t => `- ${name(t)}`).join('\n') + (ts.length > 5 ? `\n- and ${ts.length - 5} more` : '');
|
|
160
|
+
const raise = [];
|
|
161
|
+
const clear = [];
|
|
162
|
+
for (const [uid, {overdue, today: due}] of byUser) {
|
|
163
|
+
const who = {userIds: [String(uid)]};
|
|
164
|
+
if (overdue.length) {
|
|
165
|
+
raise.push({title: overdue.length === 1 ? `Overdue: ${name(overdue[0])}` : `${overdue.length} to-dos are overdue`,
|
|
166
|
+
body: list(overdue), severity: 'warning', link: '#/todo', dedupeKey: `todo-overdue:${uid}`, audience: who});
|
|
167
|
+
} else clear.push(`todo-overdue:${uid}`);
|
|
168
|
+
if (due.length) {
|
|
169
|
+
raise.push({title: due.length === 1 ? `Due today: ${name(due[0])}` : `${due.length} to-dos are due today`,
|
|
170
|
+
body: list(due), severity: 'info', link: '#/todo', dedupeKey: `todo-today:${uid}:${today}`, audience: who, expiresAt: endOfDay});
|
|
171
|
+
} else clear.push(`todo-today:${uid}:${today}`);
|
|
172
|
+
}
|
|
173
|
+
return {raise, clear};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A person's to-dos changed (completed, deleted, re-dated): if nothing is overdue any more, their notice goes now. */
|
|
177
|
+
export async function todoChanged(userId, todos, today) {
|
|
178
|
+
if (!userId) return;
|
|
179
|
+
const {clear} = todoNotices((todos || []).filter(t => t.userId === userId).concat([{userId}]), today);
|
|
180
|
+
for (const key of clear) await resolveNotification(key, 'core:todo').catch(() => 0);
|
|
181
|
+
}
|
|
182
|
+
|
|
136
183
|
export async function registerCoreSources() {
|
|
137
184
|
const plugins = await import('./plugins.js');
|
|
138
185
|
const {classifyEntitlement} = await import('./pluginEntitlement.js');
|
|
@@ -154,6 +201,20 @@ export async function registerCoreSources() {
|
|
|
154
201
|
}
|
|
155
202
|
});
|
|
156
203
|
|
|
204
|
+
registerNotificationSource({
|
|
205
|
+
id: 'core:todo', label: 'To-dos overdue or due today', severity: 'warning', every: 'daily', atBoot: true,
|
|
206
|
+
description: "Each morning: a person's overdue to-dos, and those due today - one notice each, to that person.",
|
|
207
|
+
async check({notify: raise, resolve, now}) {
|
|
208
|
+
const {listEntries} = await import('./collections.js');
|
|
209
|
+
const {entries} = await listEntries('todo-items', {limit: 100000}).catch(() => ({entries: []}));
|
|
210
|
+
const d = now || new Date();
|
|
211
|
+
const today = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
212
|
+
const {raise: notices, clear} = todoNotices(entries.map(e => ({id: e.id, ...e.data})), today);
|
|
213
|
+
for (const key of clear) await resolve(key);
|
|
214
|
+
for (const n of notices) await raise(n);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
157
218
|
registerNotificationSource({
|
|
158
219
|
id: 'core:licence', label: 'Plugin licences', severity: 'warning', every: 'daily', atBoot: true,
|
|
159
220
|
description: 'A licensed plugin that has stopped, is in its grace period, or ends within 14 days.',
|
|
@@ -64,13 +64,15 @@ export function cleanLink(link) {
|
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
66
|
* May this user see a notification with this audience. None (manager pushes,
|
|
67
|
-
* everything before 0.80) is
|
|
67
|
+
* everything before 0.80) is admins and up: roles a plugin gives the bell to
|
|
68
|
+
* (Job Board's company, agent and candidate - people from outside, who may have
|
|
69
|
+
* signed themselves up) must never see what was meant for the site's staff.
|
|
68
70
|
*
|
|
69
71
|
* @param {object|null|undefined} audience - {roles: string[]} | {userIds: string[]}
|
|
70
72
|
* @param {object} user - request.user: {id, role, additionalRoles}
|
|
71
73
|
*/
|
|
72
74
|
export function inAudience(audience, user) {
|
|
73
|
-
if (!audience || typeof audience !== 'object')
|
|
75
|
+
if (!audience || typeof audience !== 'object') audience = AUDIENCES.admins;
|
|
74
76
|
if (Array.isArray(audience.userIds)) return audience.userIds.includes(user?.id);
|
|
75
77
|
if (Array.isArray(audience.roles)) {
|
|
76
78
|
const held = getEffectiveRoles(user || {});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin APIs and the admin scope.
|
|
3
|
+
*
|
|
4
|
+
* A role can confine its holders to a few admin screens (`role.admin =
|
|
5
|
+
* {home, allow}` - Job Board's company, agent and candidate roles, say, which
|
|
6
|
+
* people outside the business can sign themselves up for). The admin's router
|
|
7
|
+
* keeps them on those screens (admin/js/lib/admin-scope.js), but that is the
|
|
8
|
+
* browser: every plugin's API was open to ANY signed-in user whose route only
|
|
9
|
+
* asked `authenticate`, and most plugins' write routes asked nothing more. So
|
|
10
|
+
* a job seeker could publish, edit or delete blog posts through the API.
|
|
11
|
+
*
|
|
12
|
+
* The rule here: a user whose roles confine them may call a plugin's
|
|
13
|
+
* authenticated routes only if their scope admits at least one of that
|
|
14
|
+
* plugin's admin screens. An unconfined user (every staff role, the level-0
|
|
15
|
+
* role) is untouched, and so is every route that does not authenticate (the
|
|
16
|
+
* public side). Core's own APIs are guarded by permissions, as before.
|
|
17
|
+
*
|
|
18
|
+
* Pure except for the role lookup, which is passed in.
|
|
19
|
+
*
|
|
20
|
+
* @module services/pluginScope
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Does an allowed prefix cover a path? The admin's own rule (admin-scope.js). */
|
|
24
|
+
export function covers(allowed, path) {
|
|
25
|
+
if (allowed === '/') return path === '/';
|
|
26
|
+
return path === allowed || path.startsWith(`${allowed}/`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The admin screens a plugin owns: its routes, and where its sidebar entries
|
|
31
|
+
* point (`#/plugins/blog` -> `/plugins/blog`).
|
|
32
|
+
*
|
|
33
|
+
* @param {object} manifest - plugin.json
|
|
34
|
+
* @returns {string[]}
|
|
35
|
+
*/
|
|
36
|
+
export function pluginAdminPaths(manifest) {
|
|
37
|
+
const admin = manifest?.admin || {};
|
|
38
|
+
const paths = [];
|
|
39
|
+
for (const r of Array.isArray(admin.routes) ? admin.routes : []) if (typeof r?.path === 'string') paths.push(r.path);
|
|
40
|
+
const walk = (items) => {
|
|
41
|
+
for (const it of Array.isArray(items) ? items : []) {
|
|
42
|
+
for (const u of [it?.url, it?.section]) if (typeof u === 'string' && u.startsWith('#/')) paths.push(u.slice(1));
|
|
43
|
+
walk(it?.children);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
walk(admin.sidebar);
|
|
47
|
+
return [...new Set(paths.map(p => p.split('?')[0].replace(/\/:[^/]+/g, '').replace(/\/+$/, '') || '/'))];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* May a user with this admin scope call this plugin's authenticated API?
|
|
52
|
+
*
|
|
53
|
+
* @param {{home: string, allow: string[]}|null} scope - null = the whole admin
|
|
54
|
+
* @param {object} manifest
|
|
55
|
+
* @returns {boolean}
|
|
56
|
+
*/
|
|
57
|
+
export function pluginInScope(scope, manifest) {
|
|
58
|
+
if (!scope) return true;
|
|
59
|
+
const allow = Array.isArray(scope.allow) ? scope.allow : [];
|
|
60
|
+
return pluginAdminPaths(manifest).some(p => allow.some(a => covers(a, p)));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The `authenticate` a plugin is handed: core's, then the scope rule.
|
|
65
|
+
*
|
|
66
|
+
* @param {Function} authenticate - core's preHandler
|
|
67
|
+
* @param {object} manifest
|
|
68
|
+
* @param {(user: object) => ({home: string, allow: string[]}|null)} scopeOf - the user's admin scope
|
|
69
|
+
* @returns {Function} a preHandler
|
|
70
|
+
*/
|
|
71
|
+
export function authenticateFor(authenticate, manifest, scopeOf) {
|
|
72
|
+
return async function authenticateInScope(request, reply) {
|
|
73
|
+
const out = await authenticate(request, reply);
|
|
74
|
+
if (reply.sent) return out;
|
|
75
|
+
let scope = null;
|
|
76
|
+
try { scope = scopeOf(request.user); } catch { scope = null; }
|
|
77
|
+
if (!pluginInScope(scope, manifest)) {
|
|
78
|
+
return reply.code(403).send({
|
|
79
|
+
statusCode: 403, error: 'Forbidden',
|
|
80
|
+
message: `Your account cannot use ${manifest?.displayName || manifest?.name || 'this plugin'}.`
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -33,7 +33,9 @@ import {classifyEntitlement, mayLoad} from './pluginEntitlement.js';
|
|
|
33
33
|
import {notifierFor} from './notify.js';
|
|
34
34
|
import {createCollection, getCollection} from './collections.js';
|
|
35
35
|
import * as defaultRolesService from './roles.js';
|
|
36
|
-
import {normaliseAdminScope} from './roles.js';
|
|
36
|
+
import {getAdminScope, normaliseAdminScope} from './roles.js';
|
|
37
|
+
import {authenticateFor} from './pluginScope.js';
|
|
38
|
+
import {getEffectiveRoles} from './userRoles.js';
|
|
37
39
|
import * as defaultUsersService from './users.js';
|
|
38
40
|
import {registerLoginCheck, registerPasswordCheck} from './authChecks.js';
|
|
39
41
|
|
|
@@ -473,7 +475,10 @@ export async function registerPlugins(fastify) {
|
|
|
473
475
|
const prefix = `/api/plugins/${manifest.name}`;
|
|
474
476
|
await fastify.register(plugin, {
|
|
475
477
|
prefix,
|
|
476
|
-
|
|
478
|
+
// A user confined to some admin screens (role.admin) may call this plugin's
|
|
479
|
+
// authenticated routes only if one of its screens is among them - see pluginScope.js.
|
|
480
|
+
auth: {authenticate: authenticateFor(authenticate, manifest, (user) => getAdminScope(getEffectiveRoles(user || {}))),
|
|
481
|
+
requireRole, requireAdmin, requireVisibility, requirePermission},
|
|
477
482
|
hooks: {registerShortcode, registerSanitizeRules, registerTransform, registerMenuLocation,
|
|
478
483
|
// Stamped with the plugin's name, so the Menus editor can say where a Tool came from.
|
|
479
484
|
registerSidebarItem: (opts) => registerSidebarItem({...opts, item: {source: manifest.displayName || manifest.name, ...opts?.item}}),
|