domma-cms 1.6.37 → 1.6.39
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/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/package.json +2 -2
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/oauth-panel.js +303 -0
- package/plugins/_lib/admin/mail/reader-view.js +126 -13
- package/plugins/_lib/mail/accounts.js +548 -225
- package/plugins/_lib/mail/imapPool.js +63 -13
- package/plugins/_lib/mail/oauth.js +453 -0
- package/plugins/_lib/mail/oauthRoutes.js +294 -0
- package/plugins/_lib/mail/readRoutes.js +23 -2
- package/plugins/_lib/mail/scheduler.js +30 -6
- package/plugins/blog/CLAUDE.md +11 -1
- package/plugins/blog/lib/cache.js +43 -0
- package/plugins/blog/lib/page.js +28 -9
- package/plugins/blog/plugin.js +14 -13
- package/plugins/blog/plugin.json +1 -1
- package/plugins/blog/plugin.public.js +1 -1
- package/plugins/blog/tests/cache.test.js +65 -0
- package/plugins/booking/CLAUDE.md +14 -2
- package/plugins/booking/admin/lib/diary-layout.js +18 -0
- package/plugins/booking/admin/views/diary.js +153 -152
- package/plugins/booking/admin/views/people.js +64 -66
- package/plugins/booking/admin/views/settings.js +31 -32
- package/plugins/booking/plugin.json +1 -1
- package/plugins/booking/tests/admin-diary.test.js +9 -1
- package/plugins/feedback/CLAUDE.md +62 -0
- package/plugins/feedback/admin/css/index.css +16 -1
- package/plugins/feedback/admin/lib/kit.js +106 -0
- package/plugins/feedback/admin/lib/settings.js +107 -0
- package/plugins/feedback/admin/views/feedback.js +4 -1
- package/plugins/feedback/admin/views/inbox.js +11 -2
- package/plugins/feedback/admin/views/send.js +74 -7
- package/plugins/feedback/docs/guide.md +9 -9
- package/plugins/feedback/lib/attachments.js +133 -0
- package/plugins/feedback/lib/cache.js +43 -0
- package/plugins/feedback/lib/receiver.js +36 -6
- package/plugins/feedback/lib/sender.js +89 -6
- package/plugins/feedback/lib/shape.js +111 -6
- package/plugins/feedback/plugin.js +173 -17
- package/plugins/feedback/plugin.json +2 -2
- package/plugins/feedback/tests/api.test.js +125 -3
- package/plugins/feedback/tests/settings.test.js +59 -0
- package/plugins/feedback/tests/shape.test.js +87 -1
- package/plugins/free-tier.lock.json +74 -62
- package/plugins/mail-reader/CLAUDE.md +15 -4
- package/plugins/mail-reader/plugin.json +1 -1
- package/plugins/shopping-cart/CLAUDE.md +35 -2
- package/plugins/shopping-cart/admin/css/index.css +3 -0
- package/plugins/shopping-cart/admin/views/orders.js +75 -23
- package/plugins/shopping-cart/admin/views/shop.js +1 -1
- package/plugins/shopping-cart/docs/guide.md +7 -3
- package/plugins/shopping-cart/lib/payments.js +15 -2
- package/plugins/shopping-cart/lib/refunds.js +51 -0
- package/plugins/shopping-cart/lib/sales.js +15 -8
- package/plugins/shopping-cart/lib/shop.js +38 -3
- package/plugins/shopping-cart/plugin.js +47 -19
- package/plugins/shopping-cart/plugin.json +2 -2
- package/plugins/shopping-cart/tests/api.test.js +52 -0
- package/plugins/shopping-cart/tests/commission.test.js +1 -1
- package/plugins/shopping-cart/tests/pure.test.js +16 -0
- package/plugins/shopping-cart/tests/refunds.test.js +42 -0
- package/plugins/support/CLAUDE.md +58 -0
- package/plugins/support/admin/css/index.css +16 -1
- package/plugins/support/admin/lib/kit.js +192 -8
- package/plugins/support/admin/lib/settings.js +123 -0
- package/plugins/support/admin/views/desk.js +55 -12
- package/plugins/support/admin/views/inbox.js +17 -4
- package/plugins/support/admin/views/support.js +4 -1
- package/plugins/support/docs/guide.md +7 -9
- package/plugins/support/lib/attachments.js +133 -0
- package/plugins/support/lib/cache.js +43 -0
- package/plugins/support/lib/receiver.js +45 -10
- package/plugins/support/lib/sender.js +96 -10
- package/plugins/support/lib/shape.js +153 -12
- package/plugins/support/plugin.js +159 -29
- package/plugins/support/plugin.json +2 -2
- package/plugins/support/tests/api.test.js +125 -3
- package/plugins/support/tests/settings.test.js +68 -0
- package/plugins/support/tests/shape.test.js +76 -2
- package/server/routes/outreach-public.js +2 -2
- package/server/services/request-log.js +3 -1
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OAuth2 sign-in routes for mailboxes: Connect, the provider's callback,
|
|
3
|
+
* Disconnect and Test. Registered by Email Pro (which feature-detects this
|
|
4
|
+
* file - an engine without it simply offers no sign-in); the free reader does
|
|
5
|
+
* not register them, but reads a signed-in mailbox all the same.
|
|
6
|
+
*
|
|
7
|
+
* Each customer registers THEIR OWN app with Microsoft or Google - the Docs
|
|
8
|
+
* Pro cloud-copies design. The admin form shows this site's exact redirect
|
|
9
|
+
* URI with a guide; the client secret goes in sealed and never comes out.
|
|
10
|
+
*
|
|
11
|
+
* The callback is the one route without a session. Its `state` is a one-time
|
|
12
|
+
* ticket, ten minutes long, naming the person who pressed Connect, and the
|
|
13
|
+
* callback refuses a browser signed in to this site as somebody else.
|
|
14
|
+
*
|
|
15
|
+
* @module _lib/mail/oauthRoutes
|
|
16
|
+
*/
|
|
17
|
+
import {createHash, randomBytes, timingSafeEqual} from 'node:crypto';
|
|
18
|
+
|
|
19
|
+
import {originOf, trustedOrigin} from '../../../server/services/passwordReset.js';
|
|
20
|
+
import {resolveViewer} from '../../../server/services/viewerSession.js';
|
|
21
|
+
import * as accounts from './accounts.js';
|
|
22
|
+
import {MailKeyError, open, seal} from './secretbox.js';
|
|
23
|
+
import {verifySmtp} from './send.js';
|
|
24
|
+
import {
|
|
25
|
+
announceConnected, authorizeUrl, cleanTenant, configureOAuth, describeProviders, exchangeCode,
|
|
26
|
+
mailboxFor, pkcePair, PROVIDERS, revokeAccount
|
|
27
|
+
} from './oauth.js';
|
|
28
|
+
|
|
29
|
+
const STATE_TTL = 10 * 60 * 1000;
|
|
30
|
+
/** Pending sign-ins kept at once - a ceiling on what a busy (or hostile) admin can pile up. */
|
|
31
|
+
const MAX_PENDING = 200;
|
|
32
|
+
/** Pending sign-ins per person: a new one beyond this replaces their oldest. */
|
|
33
|
+
const MAX_PENDING_PER_USER = 5;
|
|
34
|
+
|
|
35
|
+
const sha256 = (v) => createHash('sha256').update(String(v)).digest();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The browser-binding cookie's name for one sign-in. Per state, so two
|
|
39
|
+
* Connects in two tabs do not overwrite each other's cookie.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} state
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function bindCookieName(state) {
|
|
45
|
+
return `dm_mail_oauth_${sha256(state).toString('hex').slice(0, 16)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One cookie's value from a raw Cookie header (no cookie plugin assumed).
|
|
50
|
+
*
|
|
51
|
+
* @param {string|undefined} header
|
|
52
|
+
* @param {string} name
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function readCookie(header, name) {
|
|
56
|
+
for (const part of String(header ?? '').split(';')) {
|
|
57
|
+
const at = part.indexOf('=');
|
|
58
|
+
if (at !== -1 && part.slice(0, at).trim() === name) return part.slice(at + 1).trim();
|
|
59
|
+
}
|
|
60
|
+
return '';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const text = (v, max = 300) => String(v ?? '').trim().slice(0, max);
|
|
64
|
+
// JSON inside a <script>: nothing in it can close the element early.
|
|
65
|
+
const js = (v) => JSON.stringify(v).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
|
|
66
|
+
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {object} fastify
|
|
70
|
+
* @param {{authenticate: Function, pool: object, pluginName: string, config?: object,
|
|
71
|
+
* onNeedsReconnect?: Function, onConnected?: Function, fetch?: Function}} options
|
|
72
|
+
*/
|
|
73
|
+
export function registerOAuthRoutes(fastify, {authenticate, pool, pluginName, config = {}, onNeedsReconnect, onConnected, fetch}) {
|
|
74
|
+
configureOAuth({fetch, onNeedsReconnect: onNeedsReconnect ?? null, onConnected: onConnected ?? null});
|
|
75
|
+
|
|
76
|
+
/** state -> {userId, accountId, provider, clientId, clientSecretSealed, tenant, label, username, verifier, redirectUri, expires} */
|
|
77
|
+
const pending = new Map();
|
|
78
|
+
const userId = (request) => request.user?.id ?? request.user?.sub ?? null;
|
|
79
|
+
|
|
80
|
+
/** This site's exact return address - what the customer registers with the provider. */
|
|
81
|
+
async function redirectUriFor(request) {
|
|
82
|
+
const origin = await trustedOrigin(originOf(request)).catch(() => '') || originOf(request);
|
|
83
|
+
return `${origin}/api/plugins/${pluginName}/oauth/callback`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sweep() {
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
for (const [k, v] of pending) if (v.expires < now) pending.delete(k);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** GET /oauth/status - providers and the redirect URI, for the form's guide. */
|
|
92
|
+
fastify.get('/oauth/status', {preHandler: [authenticate]}, async (request) => {
|
|
93
|
+
const redirectUri = await redirectUriFor(request);
|
|
94
|
+
return {available: true, redirectUri, https: redirectUri.startsWith('https://'), providers: describeProviders()};
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* POST /oauth/start - where to send the person to sign in.
|
|
99
|
+
* Body: {accountId?, provider, clientId, clientSecret (blank = keep the saved one), tenant?, label?, username?}
|
|
100
|
+
*/
|
|
101
|
+
fastify.post('/oauth/start', {preHandler: [authenticate], config: {rateLimit: {max: 20, timeWindow: '1 minute'}}}, async (request, reply) => {
|
|
102
|
+
const uid = userId(request);
|
|
103
|
+
if (!uid) return reply.code(401).send({error: 'Authentication required.'});
|
|
104
|
+
const body = request.body ?? {};
|
|
105
|
+
const provider = text(body.provider, 20);
|
|
106
|
+
if (!PROVIDERS[provider]) return reply.code(400).send({error: 'Choose Microsoft or Google.'});
|
|
107
|
+
|
|
108
|
+
const accountId = body.accountId ? text(body.accountId, 80) : null;
|
|
109
|
+
const existing = accountId ? await accounts.getAccount(uid, accountId) : null;
|
|
110
|
+
if (accountId && !existing) return reply.code(404).send({error: 'That mailbox does not exist.'});
|
|
111
|
+
|
|
112
|
+
const clientId = text(body.clientId, 200);
|
|
113
|
+
if (!clientId) return reply.code(400).send({error: PROVIDERS[provider].id === 'microsoft' ? 'The Application (client) ID is needed.' : 'The Client ID is needed.'});
|
|
114
|
+
|
|
115
|
+
// Blank secret = the one already saved, but only for the same app.
|
|
116
|
+
let clientSecret = text(body.clientSecret, 2000);
|
|
117
|
+
if (!clientSecret) {
|
|
118
|
+
const o = existing?.oauth;
|
|
119
|
+
if (o?.clientSecretSealed && o.provider === provider && o.clientId === clientId) {
|
|
120
|
+
try {
|
|
121
|
+
clientSecret = open(o.clientSecretSealed);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (err instanceof MailKeyError) return reply.code(409).send({error: 'The saved client secret can no longer be read - type it again.'});
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!clientSecret) return reply.code(400).send({error: 'The client secret is needed.'});
|
|
129
|
+
|
|
130
|
+
const username = text(body.username, 254).toLowerCase();
|
|
131
|
+
if (username && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(username)) return reply.code(400).send({error: 'That is not an email address.'});
|
|
132
|
+
|
|
133
|
+
sweep();
|
|
134
|
+
// Per person first: their oldest waiting sign-in makes room. Then the
|
|
135
|
+
// site-wide ceiling, which only many people at once can reach.
|
|
136
|
+
const mine = [...pending.entries()].filter(([, v]) => v.userId === uid).sort((a, b) => a[1].expires - b[1].expires);
|
|
137
|
+
while (mine.length >= MAX_PENDING_PER_USER) pending.delete(mine.shift()[0]);
|
|
138
|
+
if (pending.size >= MAX_PENDING) return reply.code(429).send({error: 'Too many sign-ins are waiting. Try again in a few minutes.'});
|
|
139
|
+
|
|
140
|
+
const state = randomBytes(24).toString('base64url');
|
|
141
|
+
const {verifier, challenge} = pkcePair();
|
|
142
|
+
const redirectUri = await redirectUriFor(request);
|
|
143
|
+
const tenant = PROVIDERS[provider].needsTenant ? cleanTenant(body.tenant) : null;
|
|
144
|
+
// Binds the sign-in to THIS browser: the callback refuses one that does
|
|
145
|
+
// not bring the cookie back. Without it, an anonymous browser could
|
|
146
|
+
// finish a sign-in someone else started (send a colleague the
|
|
147
|
+
// provider link, and their mailbox lands under your account).
|
|
148
|
+
// HttpOnly, SameSite=Lax (sent on the provider's top-level redirect
|
|
149
|
+
// back), scoped to the callback path, gone in ten minutes.
|
|
150
|
+
const bind = randomBytes(24).toString('base64url');
|
|
151
|
+
const callbackPath = new URL(redirectUri).pathname;
|
|
152
|
+
reply.header('Set-Cookie', `${bindCookieName(state)}=${bind}; Path=${callbackPath}; Max-Age=${STATE_TTL / 1000}; HttpOnly; SameSite=Lax`
|
|
153
|
+
+ (redirectUri.startsWith('https://') ? '; Secure' : ''));
|
|
154
|
+
pending.set(state, {
|
|
155
|
+
userId: uid, accountId, provider, clientId, clientSecretSealed: seal(clientSecret), tenant,
|
|
156
|
+
label: text(body.label, 80), username, verifier, redirectUri, bindHash: sha256(bind), expires: Date.now() + STATE_TTL
|
|
157
|
+
});
|
|
158
|
+
return {
|
|
159
|
+
url: authorizeUrl({provider, clientId, tenant, redirectUri, state, codeChallenge: challenge,
|
|
160
|
+
loginHint: username || existing?.username || ''}),
|
|
161
|
+
redirectUri
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* GET /oauth/callback - the provider sends the person back here. Answers a
|
|
167
|
+
* small page that tells the mail screen (its opener) and closes itself.
|
|
168
|
+
*/
|
|
169
|
+
fastify.get('/oauth/callback', {config: {rateLimit: {max: 30, timeWindow: '1 minute'}}}, async (request, reply) => {
|
|
170
|
+
const q = request.query ?? {};
|
|
171
|
+
const key = String(q.state ?? '');
|
|
172
|
+
const ticket = pending.get(key);
|
|
173
|
+
pending.delete(key);
|
|
174
|
+
|
|
175
|
+
const page = (ok, message, origin, accountId = null) => reply
|
|
176
|
+
.type('text/html; charset=utf-8')
|
|
177
|
+
.header('Cache-Control', 'no-store')
|
|
178
|
+
.header('Referrer-Policy', 'no-referrer')
|
|
179
|
+
.header('X-Robots-Tag', 'noindex')
|
|
180
|
+
.send(`<!doctype html><meta charset="utf-8"><meta name="robots" content="noindex"><title>${ok ? 'Mailbox connected' : 'Not connected'}</title>`
|
|
181
|
+
+ `<body style="font-family:system-ui,sans-serif;display:grid;place-items:center;min-height:90vh;margin:0"><main style="max-width:30rem;text-align:center;padding:1rem">`
|
|
182
|
+
+ `<h1 style="font-size:1.3rem">${ok ? 'Mailbox connected' : 'Not connected'}</h1><p>${esc(message)}</p><p>You can close this window.</p></main>`
|
|
183
|
+
+ `<script>try{window.opener&&window.opener.postMessage(${js({type: 'domma-mail-oauth', ok, message, accountId})},${js(origin || '')});}catch(e){}${ok ? 'setTimeout(function(){window.close();},1200);' : ''}</script>`);
|
|
184
|
+
|
|
185
|
+
if (!ticket || ticket.expires < Date.now()) {
|
|
186
|
+
return page(false, 'This sign-in link has expired or was already used. Go back to Email and press Connect again.', null);
|
|
187
|
+
}
|
|
188
|
+
const origin = new URL(ticket.redirectUri).origin;
|
|
189
|
+
// Used up either way; clear it.
|
|
190
|
+
const cookieName = bindCookieName(key);
|
|
191
|
+
reply.header('Set-Cookie', `${cookieName}=; Path=${new URL(ticket.redirectUri).pathname}; Max-Age=0; HttpOnly; SameSite=Lax`);
|
|
192
|
+
if (q.error) return page(false, `The sign-in was not completed: ${text(q.error_description || q.error, 200)}`, origin);
|
|
193
|
+
|
|
194
|
+
// The browser that pressed Connect, and no other.
|
|
195
|
+
const bound = readCookie(request.headers.cookie, cookieName);
|
|
196
|
+
if (!bound || !timingSafeEqual(sha256(bound), ticket.bindHash)) {
|
|
197
|
+
return page(false, `This sign-in has to be finished in the browser that pressed Connect, with the admin open at ${origin}. Press Connect again there.`, origin);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Signed in to this site as somebody else: refuse - whoever pressed
|
|
201
|
+
// Connect must be the one who finishes it.
|
|
202
|
+
const viewer = await resolveViewer(request).catch(() => null);
|
|
203
|
+
if (viewer?.id && viewer.id !== ticket.userId) {
|
|
204
|
+
return page(false, 'This sign-in was started by someone else. Press Connect yourself.', origin);
|
|
205
|
+
}
|
|
206
|
+
if (ticket.accountId && !(await accounts.getAccount(ticket.userId, ticket.accountId))) {
|
|
207
|
+
return page(false, 'That mailbox no longer exists.', origin);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const clientSecret = open(ticket.clientSecretSealed);
|
|
212
|
+
const got = await exchangeCode({
|
|
213
|
+
provider: ticket.provider, clientId: ticket.clientId, clientSecret, tenant: ticket.tenant,
|
|
214
|
+
code: text(q.code, 4000), redirectUri: ticket.redirectUri, codeVerifier: ticket.verifier
|
|
215
|
+
});
|
|
216
|
+
const box = mailboxFor(ticket.provider, got.claims);
|
|
217
|
+
// Typed wins: a Microsoft 365 shared mailbox is signed in to by a
|
|
218
|
+
// person but addressed as itself (XOAUTH2 user = the shared address).
|
|
219
|
+
const username = ticket.username || box.email;
|
|
220
|
+
if (!username) {
|
|
221
|
+
return page(false, 'The provider did not say which mailbox was signed in to. Type the email address in the form and Connect again.', origin);
|
|
222
|
+
}
|
|
223
|
+
const p = PROVIDERS[ticket.provider];
|
|
224
|
+
const saved = await accounts.saveOAuthAccount(ticket.userId, ticket.accountId, {
|
|
225
|
+
label: ticket.label || username,
|
|
226
|
+
username,
|
|
227
|
+
provider: ticket.provider,
|
|
228
|
+
clientId: ticket.clientId,
|
|
229
|
+
tenant: ticket.tenant,
|
|
230
|
+
clientSecret,
|
|
231
|
+
refreshToken: got.refreshToken,
|
|
232
|
+
accessToken: got.accessToken,
|
|
233
|
+
expiresAt: got.expiresAt,
|
|
234
|
+
scope: got.scope,
|
|
235
|
+
imap: p.imap,
|
|
236
|
+
smtp: box.smtp
|
|
237
|
+
});
|
|
238
|
+
if (!saved) return page(false, 'That mailbox no longer exists.', origin);
|
|
239
|
+
// A connection opened with the old details must not be reused.
|
|
240
|
+
pool.release(`${ticket.userId}:${saved.id}`);
|
|
241
|
+
announceConnected(saved);
|
|
242
|
+
return page(true, `${username} is connected through ${p.short}.`, origin, saved.id);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
fastify.log.warn(`[${pluginName}] mailbox sign-in failed: ${err.message}`);
|
|
245
|
+
return page(false, text(err.message, 300), origin);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
/** POST /oauth/:id/disconnect - hand the sign-in back (where the provider allows) and forget it. */
|
|
250
|
+
fastify.post('/oauth/:id/disconnect', {preHandler: [authenticate]}, async (request, reply) => {
|
|
251
|
+
const uid = userId(request);
|
|
252
|
+
if (!uid) return reply.code(401).send({error: 'Authentication required.'});
|
|
253
|
+
const account = await accounts.getAccount(uid, request.params.id);
|
|
254
|
+
if (!account) return reply.code(404).send({error: 'That mailbox does not exist.'});
|
|
255
|
+
if (!accounts.isOAuth(account)) return reply.code(400).send({error: 'This mailbox uses a password, not a sign-in.'});
|
|
256
|
+
|
|
257
|
+
const result = await revokeAccount(account);
|
|
258
|
+
pool.release(`${uid}:${account.id}`);
|
|
259
|
+
// A new generation: a renewal already on the wire must not write a
|
|
260
|
+
// working token back over the disconnect (see accounts.updateOAuth).
|
|
261
|
+
const saved = await accounts.updateOAuth(uid, account.id, {
|
|
262
|
+
status: 'disconnected',
|
|
263
|
+
error: 'Disconnected. Reconnect to use this mailbox again.',
|
|
264
|
+
refreshTokenSealed: null,
|
|
265
|
+
accessTokenSealed: null,
|
|
266
|
+
expiresAt: 0
|
|
267
|
+
}, {bumpGen: true});
|
|
268
|
+
return {account: accounts.toPublic(saved), revoked: result.revoked, revokeSupported: result.supported};
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
/** POST /oauth/:id/test - sign in to both servers with the stored sign-in. */
|
|
272
|
+
fastify.post('/oauth/:id/test', {preHandler: [authenticate], config: {rateLimit: {max: 10, timeWindow: '1 minute'}}}, async (request, reply) => {
|
|
273
|
+
const uid = userId(request);
|
|
274
|
+
if (!uid) return reply.code(401).send({error: 'Authentication required.'});
|
|
275
|
+
const account = await accounts.getAccount(uid, request.params.id);
|
|
276
|
+
if (!account) return reply.code(404).send({error: 'That mailbox does not exist.'});
|
|
277
|
+
if (!accounts.isOAuth(account)) return reply.code(400).send({error: 'This mailbox uses a password, not a sign-in.'});
|
|
278
|
+
|
|
279
|
+
let imap;
|
|
280
|
+
let smtp;
|
|
281
|
+
try {
|
|
282
|
+
imap = await pool.test(accounts.toConnection(account));
|
|
283
|
+
} catch (err) {
|
|
284
|
+
imap = {ok: false, error: err.message};
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
const out = accounts.toSmtpConnection(account);
|
|
288
|
+
smtp = out ? await verifySmtp(out.transport, config.allowInsecureTLS === true) : {ok: false, error: 'No outgoing server.'};
|
|
289
|
+
} catch (err) {
|
|
290
|
+
smtp = {ok: false, error: err.message};
|
|
291
|
+
}
|
|
292
|
+
return {ok: imap.ok && smtp.ok, imap, smtp};
|
|
293
|
+
});
|
|
294
|
+
}
|
|
@@ -140,7 +140,10 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
140
140
|
};
|
|
141
141
|
} catch (err) {
|
|
142
142
|
if (err instanceof MailKeyError) {
|
|
143
|
-
|
|
143
|
+
// needsPassword stays true for a dead sign-in too: older
|
|
144
|
+
// screens already open settings on it, which is the right place.
|
|
145
|
+
reply.code(409).send({error: err.message, needsPassword: true,
|
|
146
|
+
...(err.needsReconnect ? {needsReconnect: true} : {}), accountId: account.id});
|
|
144
147
|
return null;
|
|
145
148
|
}
|
|
146
149
|
throw err;
|
|
@@ -245,7 +248,8 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
245
248
|
|
|
246
249
|
const result = accounts.validate(request.body ?? {}, existing);
|
|
247
250
|
if (!result.ok) return reply.code(400).send({error: result.error});
|
|
248
|
-
|
|
251
|
+
// A signed-in mailbox's servers are the provider's and cannot be edited here.
|
|
252
|
+
const where = result.value.oauth ? {ok: true} : await guardHost(result.value.host);
|
|
249
253
|
if (!where.ok) return reply.code(400).send({error: where.error});
|
|
250
254
|
|
|
251
255
|
const saved = await accounts.saveAccount(uid, request.params.id, result.value);
|
|
@@ -270,8 +274,15 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
270
274
|
if (!uid) return reply.code(401).send({error: 'Authentication required.'});
|
|
271
275
|
|
|
272
276
|
pool.release(`${uid}:${request.params.id}`);
|
|
277
|
+
const existing = await accounts.getAccount(uid, request.params.id);
|
|
273
278
|
const deleted = await accounts.deleteAccount(uid, request.params.id);
|
|
274
279
|
if (!deleted) return reply.code(404).send({error: 'That mailbox does not exist.'});
|
|
280
|
+
// A signed-in mailbox hands its sign-in back where the provider allows
|
|
281
|
+
// it (Google). Best-effort and after the delete: forgetting it here
|
|
282
|
+
// must not wait on, or fail because of, the provider.
|
|
283
|
+
if (accounts.isOAuth?.(existing)) {
|
|
284
|
+
import('./oauth.js').then(m => m.revokeAccount(existing)).catch(() => {});
|
|
285
|
+
}
|
|
275
286
|
return {deleted: true};
|
|
276
287
|
});
|
|
277
288
|
|
|
@@ -292,6 +303,16 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
292
303
|
|
|
293
304
|
const result = accounts.validate(body, existing);
|
|
294
305
|
if (!result.ok) return reply.code(400).send({error: result.error});
|
|
306
|
+
// A signed-in mailbox is tested as stored: the provider's own server,
|
|
307
|
+
// its token renewed if need be. Nothing typed is involved.
|
|
308
|
+
if (result.value.oauth) {
|
|
309
|
+
try {
|
|
310
|
+
return await pool.test(accounts.toConnection(existing));
|
|
311
|
+
} catch (err) {
|
|
312
|
+
if (err instanceof MailKeyError) return {ok: false, error: err.message};
|
|
313
|
+
throw err;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
295
316
|
const where = await guardHost(result.value.host);
|
|
296
317
|
if (!where.ok) return reply.code(400).send({error: where.error});
|
|
297
318
|
|
|
@@ -40,6 +40,17 @@ const MAX_ATTEMPTS = 3;
|
|
|
40
40
|
/** How long to wait before trying a failed send again. */
|
|
41
41
|
const RETRY_MS = 120_000;
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The reason recorded on a queued item whose mailbox needs signing in again.
|
|
45
|
+
*
|
|
46
|
+
* @param {Error} err
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function reconnectReason(err) {
|
|
50
|
+
if (!err?.needsReconnect) return err?.message || 'The mailbox could not be used.';
|
|
51
|
+
return `The mailbox needs signing in again (Mail Settings > Mailboxes > Reconnect): ${err.message}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
43
54
|
/**
|
|
44
55
|
* Create the scheduler.
|
|
45
56
|
*
|
|
@@ -70,7 +81,16 @@ export function createScheduler({store, pool, config, logger, tickMs = TICK_MS})
|
|
|
70
81
|
return;
|
|
71
82
|
}
|
|
72
83
|
|
|
73
|
-
|
|
84
|
+
// Built INSIDE the try: a signed-in mailbox whose sign-in is dead throws
|
|
85
|
+
// here (OAuthReconnectError), and escaping would leave the claimed item
|
|
86
|
+
// `sending` with no finishSend - picked up again on every reclaim.
|
|
87
|
+
let smtp;
|
|
88
|
+
try {
|
|
89
|
+
smtp = accounts.toSmtpConnection(account, item.identityId ?? null);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
await store.finishSend(item._id, {status: 'failed', error: reconnectReason(err)});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
74
94
|
if (!smtp) {
|
|
75
95
|
await store.finishSend(item._id, {
|
|
76
96
|
status: 'failed',
|
|
@@ -94,9 +114,11 @@ export function createScheduler({store, pool, config, logger, tickMs = TICK_MS})
|
|
|
94
114
|
});
|
|
95
115
|
await store.finishSend(item._id, {status: 'sent', result, error: null});
|
|
96
116
|
} catch (err) {
|
|
97
|
-
|
|
117
|
+
// A dead sign-in will not fix itself between retries: fail now, so
|
|
118
|
+
// the sender is told (the send-failed notice) instead of waiting.
|
|
119
|
+
const giveUp = err?.needsReconnect || (item.attempts ?? 1) >= MAX_ATTEMPTS;
|
|
98
120
|
await store.finishSend(item._id, giveUp
|
|
99
|
-
? {status: 'failed', error: err.message}
|
|
121
|
+
? {status: 'failed', error: err?.needsReconnect ? reconnectReason(err) : err.message}
|
|
100
122
|
// Back to pending, a little later. `finishedAt` is set either
|
|
101
123
|
// way, which is harmless on a retry and is what tells a
|
|
102
124
|
// screen when the last attempt was.
|
|
@@ -126,9 +148,11 @@ export function createScheduler({store, pool, config, logger, tickMs = TICK_MS})
|
|
|
126
148
|
}
|
|
127
149
|
|
|
128
150
|
const poolKey = `${item.userId}:${item.accountId}`;
|
|
129
|
-
|
|
151
|
+
let connection;
|
|
130
152
|
|
|
131
153
|
try {
|
|
154
|
+
// Inside the try for the same reason as runSend: a dead sign-in throws here.
|
|
155
|
+
connection = accounts.toConnection(account);
|
|
132
156
|
// The UID the move reported, when the server has UIDPLUS. Without
|
|
133
157
|
// it the message is found by its Message-ID instead - a snooze on
|
|
134
158
|
// such a server is still a snooze, and refusing to bring it back
|
|
@@ -160,9 +184,9 @@ export function createScheduler({store, pool, config, logger, tickMs = TICK_MS})
|
|
|
160
184
|
// does not read, absent from the list that was supposed to bring
|
|
161
185
|
// it back.
|
|
162
186
|
const attempts = (item.attempts ?? 0) + 1;
|
|
163
|
-
const giveUp = attempts >= MAX_ATTEMPTS;
|
|
187
|
+
const giveUp = err?.needsReconnect || attempts >= MAX_ATTEMPTS;
|
|
164
188
|
await store.finishSnooze(item._id, giveUp
|
|
165
|
-
? {status: 'failed', error: err.message, attempts}
|
|
189
|
+
? {status: 'failed', error: err?.needsReconnect ? `${reconnectReason(err)} The message is still in ${item.folder}.` : err.message, attempts}
|
|
166
190
|
: {status: 'pending', at: new Date(Date.now() + RETRY_MS), error: err.message, attempts});
|
|
167
191
|
logger.warn(
|
|
168
192
|
`[mail] could not wake snoozed message ${item._id} (attempt ${attempts}): ${err.message}`
|
package/plugins/blog/CLAUDE.md
CHANGED
|
@@ -20,7 +20,8 @@ Public-facing blog for the CMS. Supports posts with categories, tags, and option
|
|
|
20
20
|
- `blocks/*.html|css` - the shipped layouts, copied into content/blocks/ once
|
|
21
21
|
- `public/blog.js`, `public/blog.css` - the browser half (listing, right-click menu, post extras)
|
|
22
22
|
- `public/samples/*.svg` - sample covers
|
|
23
|
-
- `tests/access.test.js`, `tests/comment-notices.test.js`, `tests/layouts.test.js`, `tests/nav-link.test.js`, `tests/notices.test.js`, `tests/public.test.js`
|
|
23
|
+
- `tests/access.test.js`, `tests/comment-notices.test.js`, `tests/layouts.test.js`, `tests/nav-link.test.js`, `tests/notices.test.js`, `tests/public.test.js`, `tests/cache.test.js`
|
|
24
|
+
- `lib/cache.js` - PURE: the 30 s read cache (forgotten on every write route)
|
|
24
25
|
- `admin/views/blog.js` - post list (KPIs + status/category/search filters)
|
|
25
26
|
- `admin/views/post-editor.js` - create / edit a post
|
|
26
27
|
- `admin/views/categories.js`, `admin/views/comments.js`, `admin/views/settings.js`
|
|
@@ -329,3 +330,12 @@ Declared in plugin.json `permissions` (granted once to `admin`), checked on EVER
|
|
|
329
330
|
tested), needs a plausible email, and refuses a post that does not exist or is not visible (404) - each of those
|
|
330
331
|
used to raise a staff notification.
|
|
331
332
|
- `ASSET_V` / the preview `assetV` are 1.9.6 (they were 1.6.x while the plugin was 1.9.5 - keep them with the version).
|
|
333
|
+
|
|
334
|
+
## 1.9.7 (2026-10-13 Tool sweep - caching)
|
|
335
|
+
|
|
336
|
+
- **Whole-collection reads are cached 30 s** (`lib/cache.js`, pure, `tests/cache.test.js`): `loadEntries()` in
|
|
337
|
+
lib/page.js (public pages, shortcodes, the sitemap source, the `/comments/pending-count` badge) and
|
|
338
|
+
`resolveAuthorName()` (one user lookup per author per window). The promise is kept, so concurrent misses share one
|
|
339
|
+
read. **Every non-GET route of this plugin forgets it in an `onSend` hook** - before the writer's response leaves -
|
|
340
|
+
so the admin's own next read, and the public POST /comments, are fresh. The TTL only covers writes made outside
|
|
341
|
+
the plugin (Collections tool, Blog Pro). Callers share the cached array: filter/sort copies, never mutate it.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A short memory for whole-collection reads.
|
|
3
|
+
*
|
|
4
|
+
* Every public page view and every sidebar badge poll (once a minute from
|
|
5
|
+
* every open admin tab) read ALL posts, categories and comments from the
|
|
6
|
+
* adapter. The data changes only when someone writes, so a read is kept for a
|
|
7
|
+
* little while and every write route of this plugin throws the memory away
|
|
8
|
+
* (`forget()`, from an onSend hook in plugin.js - before the writer's own
|
|
9
|
+
* response leaves, so their next read is fresh). The TTL is the safety net
|
|
10
|
+
* for writes this plugin never sees (the Collections tool, a Pro take-over).
|
|
11
|
+
*
|
|
12
|
+
* PURE - no I/O of its own; the loader is handed in. Tested in tests/cache.test.js.
|
|
13
|
+
*
|
|
14
|
+
* @module blog/lib/cache
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const CACHE_MS = 30_000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {{ttl?: number, clock?: () => number}} [opts]
|
|
21
|
+
* @returns {{get: (key: string, load: () => Promise<any>) => Promise<any>, forget: () => void, size: number}}
|
|
22
|
+
*/
|
|
23
|
+
export function createCache({ttl = CACHE_MS, clock = Date.now} = {}) {
|
|
24
|
+
// key -> {at, value: Promise}. The PROMISE is kept, not the result, so
|
|
25
|
+
// concurrent misses share one read, and a write landing while a read is in
|
|
26
|
+
// flight clears the map before that read can be stored - nothing stale
|
|
27
|
+
// survives a forget().
|
|
28
|
+
const kept = new Map();
|
|
29
|
+
return {
|
|
30
|
+
get(key, load) {
|
|
31
|
+
const hit = kept.get(key);
|
|
32
|
+
if (hit && clock() - hit.at < ttl) return hit.value;
|
|
33
|
+
const value = Promise.resolve().then(load);
|
|
34
|
+
const entry = {at: clock(), value};
|
|
35
|
+
kept.set(key, entry);
|
|
36
|
+
// A failed read is not remembered: the next caller tries again.
|
|
37
|
+
value.catch(() => { if (kept.get(key) === entry) kept.delete(key); });
|
|
38
|
+
return value;
|
|
39
|
+
},
|
|
40
|
+
forget() { kept.clear(); },
|
|
41
|
+
get size() { return kept.size; }
|
|
42
|
+
};
|
|
43
|
+
}
|
package/plugins/blog/lib/page.js
CHANGED
|
@@ -13,6 +13,13 @@ import defaultConfig from '../config.js';
|
|
|
13
13
|
import {fmtDate} from '../../_lib/admin/ui/dates.js';
|
|
14
14
|
import {buildToc, byNewest, cardVars, cleanBasePath, DEFAULT_POST_LAYOUT, DISPLAY_DEFAULTS, escapeHtml, interpolate, pickPostLayout, relatedPosts, safeImageUrl} from './layouts.js';
|
|
15
15
|
import {catalogue, loadBlock, renderCards, styleTag} from './render.js';
|
|
16
|
+
import {createCache} from './cache.js';
|
|
17
|
+
|
|
18
|
+
// Whole-collection reads and author names, kept a short while (lib/cache.js):
|
|
19
|
+
// every public page view and every badge poll read all posts, categories and
|
|
20
|
+
// comments. plugin.js calls forgetEntries() on every write route of this plugin.
|
|
21
|
+
const remembered = createCache();
|
|
22
|
+
export const forgetEntries = () => remembered.forget();
|
|
16
23
|
|
|
17
24
|
/**
|
|
18
25
|
* Whether readers see a post. Blog Pro files posts under several blogs
|
|
@@ -69,12 +76,21 @@ export function liveBase(registered) {
|
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
/** Always returns the actual array of entries, regardless of adapter shape. */
|
|
72
|
-
|
|
79
|
+
async function readEntries(slug) {
|
|
73
80
|
const result = await listEntries(slug, {limit: 100000, sort: 'createdAt', order: 'desc'});
|
|
74
81
|
const arr = Array.isArray(result) ? result : (Array.isArray(result?.entries) ? result.entries : []);
|
|
75
82
|
return arr.map((entry) => ({id: entry.id, ...(entry.data ?? entry)}));
|
|
76
83
|
}
|
|
77
84
|
|
|
85
|
+
/**
|
|
86
|
+
* A collection's entries, newest first - from the cache when a read is fresh.
|
|
87
|
+
* Callers get the SAME array, so none may change it in place (they filter and
|
|
88
|
+
* sort copies, as before).
|
|
89
|
+
*/
|
|
90
|
+
export function loadEntries(slug) {
|
|
91
|
+
return remembered.get(`entries:${slug}`, () => readEntries(slug));
|
|
92
|
+
}
|
|
93
|
+
|
|
78
94
|
/** Render a post body through the full Markdown + shortcode pipeline. */
|
|
79
95
|
export async function renderPostBody(content) {
|
|
80
96
|
if (!content) return {html: '', usedComponents: []};
|
|
@@ -87,14 +103,17 @@ export async function renderPostBody(content) {
|
|
|
87
103
|
}
|
|
88
104
|
}
|
|
89
105
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
106
|
+
/** A user's display name - one lookup per author per cache window, not per card. */
|
|
107
|
+
export function resolveAuthorName(authorId) {
|
|
108
|
+
if (!authorId) return Promise.resolve('Unknown');
|
|
109
|
+
return remembered.get(`author:${authorId}`, async () => {
|
|
110
|
+
try {
|
|
111
|
+
const user = await getUserById(authorId);
|
|
112
|
+
return user?.name || user?.email || 'Unknown';
|
|
113
|
+
} catch {
|
|
114
|
+
return 'Unknown';
|
|
115
|
+
}
|
|
116
|
+
});
|
|
98
117
|
}
|
|
99
118
|
|
|
100
119
|
/** One lookup per author, however many posts they wrote. */
|
package/plugins/blog/plugin.js
CHANGED
|
@@ -5,7 +5,7 @@ import {getPluginStates, savePluginState} from '../../server/services/plugins.js
|
|
|
5
5
|
import {renderBlogPage} from '../../server/services/renderer.js';
|
|
6
6
|
import {cleanBasePath, DISPLAY_DEFAULTS, pickDisplaySettings, safeImageUrl} from './lib/layouts.js';
|
|
7
7
|
import {catalogue, seedBlocks} from './lib/render.js';
|
|
8
|
-
import {liveBase, liveSettings, loadEntries as loadAll, renderPostPage, slugHolder} from './lib/page.js';
|
|
8
|
+
import {forgetEntries, liveBase, liveSettings, loadEntries as loadAll, renderPostPage, slugHolder} from './lib/page.js';
|
|
9
9
|
import {addNavLink, moveNavLink, removeNavLink} from './lib/nav-link.js';
|
|
10
10
|
import {SAMPLE_CATEGORIES, samplePosts} from './lib/samples.js';
|
|
11
11
|
import {commentKey, commentNotice} from './lib/notices.js';
|
|
@@ -167,17 +167,9 @@ function isVisible(post) {
|
|
|
167
167
|
|| (post.status === 'scheduled' && post.publishedAt && new Date(post.publishedAt) <= new Date());
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
return arr.map((e) => ({ id: e.id, ...(e.data ?? e) }));
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
async function loadCategories() {
|
|
177
|
-
const result = await listEntries(CATEGORIES_SLUG, { limit: 10000 });
|
|
178
|
-
const arr = Array.isArray(result) ? result : (result?.entries || []);
|
|
179
|
-
return arr.map((e) => ({ id: e.id, ...(e.data ?? e) }));
|
|
180
|
-
}
|
|
170
|
+
// The shortcodes run on public page views: the same cached reads as the pages.
|
|
171
|
+
const loadPosts = () => loadAll(POSTS_SLUG);
|
|
172
|
+
const loadCategories = () => loadAll(CATEGORIES_SLUG);
|
|
181
173
|
|
|
182
174
|
function selectPosts(posts, { limit, categorySlug, categoryMap }) {
|
|
183
175
|
let filtered = posts.filter(isVisible);
|
|
@@ -337,6 +329,15 @@ export default async function blogPlugin(fastify, options) {
|
|
|
337
329
|
});
|
|
338
330
|
}
|
|
339
331
|
|
|
332
|
+
// Every write route of this plugin (posts, categories, comments - the public
|
|
333
|
+
// POST /comments too - samples) retires the cached reads behind the public
|
|
334
|
+
// pages, the shortcodes and the badge. onSend, not onResponse: the memory is
|
|
335
|
+
// gone BEFORE the writer's response leaves, so their very next read is fresh.
|
|
336
|
+
// The hook is encapsulated - only this plugin's routes reach it.
|
|
337
|
+
fastify.addHook('onSend', async (request) => {
|
|
338
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') forgetEntries();
|
|
339
|
+
});
|
|
340
|
+
|
|
340
341
|
// The layouts are blocks; put the shipped ones where the Blocks tool can
|
|
341
342
|
// edit them. Once each, never over a site's own copy (lib/render.js).
|
|
342
343
|
try {
|
|
@@ -392,7 +393,7 @@ export default async function blogPlugin(fastify, options) {
|
|
|
392
393
|
});
|
|
393
394
|
const html = await renderBlogPage(
|
|
394
395
|
fileURLToPath(new URL('./templates/post.html', import.meta.url)),
|
|
395
|
-
{ post: page.html, assetV: '1.9.
|
|
396
|
+
{ post: page.html, assetV: '1.9.7' },
|
|
396
397
|
{ title: post.title || 'Preview', description: post.excerpt || '', ogImage: safeImageUrl(post.featuredImage),
|
|
397
398
|
usedComponents: page.usedComponents, urlPath: `${basePath}/${post.slug}`, noindex: true }
|
|
398
399
|
);
|
package/plugins/blog/plugin.json
CHANGED
|
@@ -24,7 +24,7 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
|
24
24
|
const TEMPLATES_DIR = path.join(__dirname, 'templates');
|
|
25
25
|
|
|
26
26
|
/** Bumped with the plugin, so a release is never served a stale script. */
|
|
27
|
-
const ASSET_V = '1.9.
|
|
27
|
+
const ASSET_V = '1.9.7';
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Above this many posts a listing pages on the server instead: the browser
|