domma-cms 0.85.0 → 0.86.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/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);
|
|
@@ -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}}),
|