domma-cms 0.88.0 → 0.89.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/css/admin.css +1 -1
- package/admin/js/lib/login-arrange.js +1 -1
- package/admin/js/templates/login.html +74 -52
- package/admin/js/views/login.js +1 -11
- package/package.json +1 -1
- package/plugins/blog/CLAUDE.md +6 -0
- package/plugins/blog/plugin.js +23 -0
- package/plugins/blog/plugin.json +2 -2
- package/plugins/blog/plugin.public.js +15 -2
- package/plugins/free-tier.lock.json +34 -14
- package/plugins/security/CLAUDE.md +7 -0
- package/plugins/security/admin/lib/resets.js +82 -0
- package/plugins/security/admin/lib/settings.js +1 -1
- package/plugins/security/admin/templates/security.html +3 -0
- package/plugins/security/admin/views/security.js +23 -10
- package/plugins/security/plugin.js +39 -1
- package/plugins/security/plugin.json +2 -2
- package/plugins/security/tests/security.test.js +34 -0
- package/plugins/seo/CLAUDE.md +48 -0
- package/plugins/seo/admin/lib/analyse.js +286 -0
- package/plugins/seo/admin/lib/redirects.js +163 -0
- package/plugins/seo/admin/lib/schema.js +113 -0
- package/plugins/seo/admin/lib/settings.js +141 -0
- package/plugins/seo/admin/templates/seo.html +224 -0
- package/plugins/seo/admin/views/seo.js +912 -0
- package/plugins/seo/config.js +7 -0
- package/plugins/seo/plugin.js +477 -0
- package/plugins/seo/plugin.json +64 -0
- package/plugins/seo/server/audit.js +106 -0
- package/plugins/seo/server/registry.js +57 -0
- package/plugins/seo/server/store.js +76 -0
- package/plugins/seo/tests/seo.test.js +192 -0
- package/server/routes/api/forms.js +3 -0
- package/server/routes/public.js +4 -3
- package/server/services/content.js +11 -0
- package/server/services/hooks.js +45 -1
- package/server/services/plugins.js +6 -0
- package/server/services/renderer.js +136 -55
- package/server/services/siteGitignore.js +114 -3
- package/server/services/sitemap.js +89 -18
package/package.json
CHANGED
package/plugins/blog/CLAUDE.md
CHANGED
|
@@ -165,6 +165,12 @@ with `?display=` and swaps the cards, since each display is different block mark
|
|
|
165
165
|
|
|
166
166
|
## Gotchas
|
|
167
167
|
|
|
168
|
+
- **In the sitemap (1.9.0, core 0.89+).** `registerSitemapSource('blog')` lists the index and every public post at the
|
|
169
|
+
live base path (a post with `seo.noindex` is left out); a non-GET that succeeds on /posts, /settings or /samples calls
|
|
170
|
+
`sitemapChanged()`. Post pages pass `baseUrl` (canonical/og:url - they had none before), `noindex` and
|
|
171
|
+
`item: {type: 'blog:post', id}` to renderBlogPage, so the SEO plugin knows what it is tagging. PUT /posts/:id MERGES
|
|
172
|
+
`seo` (the editor sends title + description only; the SEO Tool may have set image/noindex/focus).
|
|
173
|
+
|
|
168
174
|
- **Public handlers must RETURN the reply.** `reply.type(...).send(html)` without `return` in an
|
|
169
175
|
async handler loses the body under @fastify/compress - every browser got a 200 with nothing in it,
|
|
170
176
|
while curl (no Accept-Encoding) saw the page. `tests/public.test.js` drives gzip and brotli.
|
package/plugins/blog/plugin.js
CHANGED
|
@@ -291,6 +291,23 @@ export default async function blogPlugin(fastify, options) {
|
|
|
291
291
|
registerEmbedShortcodes(options.hooks.registerShortcode, () => liveBase(settings));
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
+
// The blog in /sitemap.xml (core 0.89+; feature-detected): the index and
|
|
295
|
+
// every public post, at the base path as it is saved now. A post marked
|
|
296
|
+
// "hide from search engines" (seo.noindex) is left out.
|
|
297
|
+
if (typeof options.hooks?.registerSitemapSource === 'function') {
|
|
298
|
+
options.hooks.registerSitemapSource({id: 'blog', label: 'Blog posts', list: async () => {
|
|
299
|
+
const base = liveBase(settings);
|
|
300
|
+
const posts = (await loadAll('blog-posts')).filter(p => isVisible(p) && p.seo?.noindex !== true);
|
|
301
|
+
return [{urlPath: base, title: 'Blog', changefreq: 'daily'},
|
|
302
|
+
...posts.map(p => ({urlPath: `${base}/${encodeURIComponent(p.slug)}`, title: p.title || '',
|
|
303
|
+
lastmod: p.updatedAt || p.publishedAt || null, ...(safeImageUrl(p.featuredImage) && {image: safeImageUrl(p.featuredImage)})}))];
|
|
304
|
+
}});
|
|
305
|
+
// A saved post, publish or setting changes the list: the sitemap is rebuilt on its next request.
|
|
306
|
+
fastify.addHook('onResponse', async (request, reply) => {
|
|
307
|
+
if (request.method !== 'GET' && reply.statusCode < 400 && /\/(posts|settings|samples)\b/.test(request.url)) options.hooks.sitemapChanged?.();
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
294
311
|
// The layouts are blocks; put the shipped ones where the Blocks tool can
|
|
295
312
|
// edit them. Once each, never over a site's own copy (lib/render.js).
|
|
296
313
|
try {
|
|
@@ -487,6 +504,12 @@ export default async function blogPlugin(fastify, options) {
|
|
|
487
504
|
}
|
|
488
505
|
|
|
489
506
|
const merged = { ...entry.data, ...postFields(request.body ?? {}, user) };
|
|
507
|
+
// `seo` is merged too: the editor sends title and description only, and
|
|
508
|
+
// must not wipe what the SEO Tool set (image, noindex, focus keyword...).
|
|
509
|
+
if (merged.seo && typeof merged.seo === 'object' && entry.data.seo && typeof entry.data.seo === 'object') {
|
|
510
|
+
merged.seo = {...entry.data.seo, ...merged.seo};
|
|
511
|
+
for (const [k, v] of Object.entries(merged.seo)) if (v === null) delete merged.seo[k];
|
|
512
|
+
}
|
|
490
513
|
|
|
491
514
|
const updated = await updateEntry(POSTS_SLUG, id, merged);
|
|
492
515
|
return reply.send(toPost(updated));
|
package/plugins/blog/plugin.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blog",
|
|
3
3
|
"displayName": "Blog",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.9.0",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Public-facing blog with posts, categories and comments - layouts and listings built from editable blocks.",
|
|
7
7
|
"author": "Domma CMS",
|
|
8
|
-
"date": "2026-09-
|
|
8
|
+
"date": "2026-09-27",
|
|
9
9
|
"icon": "file-text",
|
|
10
10
|
"admin": {
|
|
11
11
|
"css": [
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import path from 'path';
|
|
16
16
|
import {fileURLToPath} from 'url';
|
|
17
17
|
import {renderBlogPage} from '../../server/services/renderer.js';
|
|
18
|
+
import {getConfig} from '../../server/config.js';
|
|
18
19
|
import {byNewest, escapeHtml, matchBlogPath, pickDisplay, safeImageUrl} from './lib/layouts.js';
|
|
19
20
|
import {catalogue, renderCards} from './lib/render.js';
|
|
20
21
|
import {authorNamesFor, isVisible, liveBase, liveSettings, loadEntries, renderPostPage, resolveAuthorName} from './lib/page.js';
|
|
@@ -49,6 +50,15 @@ function buildPagination(page, totalPages, basePath, query = '') {
|
|
|
49
50
|
return `<nav class="blog-pagination" aria-label="Pages">${parts.join('')}</nav>`;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The site's own origin for canonical and og:url - as core's getBaseUrl():
|
|
55
|
+
* the Settings address when there is one, else the request's.
|
|
56
|
+
*/
|
|
57
|
+
function originOf(request) {
|
|
58
|
+
const set = String(getConfig('site')?.baseUrl || '').trim().replace(/\/+$/, '');
|
|
59
|
+
return set || `${request.protocol}://${request.host || request.headers.host || request.hostname}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
52
62
|
/** JSON inside a <script> element, safe against `</script>` in any value. */
|
|
53
63
|
function scriptJson(value) {
|
|
54
64
|
return JSON.stringify(value).replace(/</g, '\\u003c');
|
|
@@ -121,7 +131,7 @@ ${items || ''}
|
|
|
121
131
|
pagination: interactive ? '' : buildPagination(safePage, totalPages, base, requested),
|
|
122
132
|
assetV: ASSET_V
|
|
123
133
|
},
|
|
124
|
-
{title, urlPath: request.url.split('?')[0]}
|
|
134
|
+
{title, urlPath: request.url.split('?')[0], baseUrl: originOf(request)}
|
|
125
135
|
);
|
|
126
136
|
return reply.type('text/html').send(html);
|
|
127
137
|
}
|
|
@@ -215,7 +225,10 @@ ${items}
|
|
|
215
225
|
ogImage: safeImageUrl(post.featuredImage),
|
|
216
226
|
ogType: 'article',
|
|
217
227
|
usedComponents: page.usedComponents,
|
|
218
|
-
urlPath: `${base}/${slug}
|
|
228
|
+
urlPath: `${base}/${slug}`,
|
|
229
|
+
baseUrl: originOf(request),
|
|
230
|
+
noindex: post.seo?.noindex === true,
|
|
231
|
+
item: {type: 'blog:post', id: post.id}
|
|
219
232
|
}
|
|
220
233
|
);
|
|
221
234
|
return reply.type('text/html').send(html);
|
|
@@ -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@466a485",
|
|
4
4
|
"plugins": {
|
|
5
5
|
"blog": {
|
|
6
|
-
"version": "1.
|
|
6
|
+
"version": "1.9.0",
|
|
7
7
|
"files": {
|
|
8
|
-
"CLAUDE.md": "
|
|
8
|
+
"CLAUDE.md": "5f3260acc22f202ce3dd1a262a41d4a98016a71e05da8f95732dcd73b6e08e33",
|
|
9
9
|
"admin/css/index.css": "6142a6b756e1218157842528830b1e84f592f0b84b7d5ce6449db421b6f4b039",
|
|
10
10
|
"admin/templates/blog.html": "a6054c1febe764bb1edf5c9e466d44a9080d9c83ba5486437b7a8244acf5ba54",
|
|
11
11
|
"admin/templates/categories.html": "ab7d6fc2fedd48876c585bc216a1532b3bdd78c946dee976c1171bfe4664feb6",
|
|
@@ -48,9 +48,9 @@
|
|
|
48
48
|
"lib/page.js": "464face32871185368de096ed9a6aa635523bf319af6459308cfceb77d66f82c",
|
|
49
49
|
"lib/render.js": "0e97c6a950bc962c8d8ea4b2f21d06cb73fbcdc738d8942d6935d01309a29b1d",
|
|
50
50
|
"lib/samples.js": "e8cdb77ce22e43937707f6854d3a5cb722437dc584e22ac7b24480bef686cf85",
|
|
51
|
-
"plugin.js": "
|
|
52
|
-
"plugin.json": "
|
|
53
|
-
"plugin.public.js": "
|
|
51
|
+
"plugin.js": "a6e4442400594116c43aab1db706f0b9c2953e85699aa458709752c6c27fd781",
|
|
52
|
+
"plugin.json": "234c8067a4bf9f214667b5c99e05933c3321f0b8b35815a76e8ac8eacbbda857",
|
|
53
|
+
"plugin.public.js": "ab23fadd5655fab8eea0e636a3e1f8ced78432d6da929059a4a147f0805ff3a4",
|
|
54
54
|
"public/blog.css": "fd733e6b624190da1e70dc1b6613e2376efd7f7570a28fab311d81006d03d960",
|
|
55
55
|
"public/blog.js": "d01c72a9834dcc7c5241923371f2b164ac30d20ac9f1a5c5dc5b71fa3cb09f08",
|
|
56
56
|
"public/samples/aurora.svg": "feb29e2b46465913fc29de9eda221b00b1541f1fa3ea7120f0c1d1cd11a9a4f6",
|
|
@@ -82,27 +82,47 @@
|
|
|
82
82
|
}
|
|
83
83
|
},
|
|
84
84
|
"security": {
|
|
85
|
-
"version": "1.0
|
|
85
|
+
"version": "1.1.0",
|
|
86
86
|
"files": {
|
|
87
|
-
"CLAUDE.md": "
|
|
87
|
+
"CLAUDE.md": "dc6f8755b8e6cdcac69cd928654395cde794e5b698032c132ce83dede24acead",
|
|
88
88
|
"admin/lib/blocks.js": "5ef346b7de751518f8beb9212e3b89b270ae236560ae3d8a1f40d129c50918ff",
|
|
89
89
|
"admin/lib/health.js": "3b233a3d29b2d69c333321602c13233b8221cfe1114aa03903739d9ae8ad65d5",
|
|
90
90
|
"admin/lib/ip.js": "cf33be4fa2e2cad37bafd12ebec17f293891a88de402caad1bf9c9a57ce87680",
|
|
91
91
|
"admin/lib/lockout.js": "c5e5e4b2934296f81ae8ed013725485bc269a0ef3cf3bdf3c16c53936635a1ee",
|
|
92
92
|
"admin/lib/policy.js": "a4aedfd28f992e0e7759bbdac6344681eebe617c27849e91fa4405f6190f8462",
|
|
93
93
|
"admin/lib/probes.js": "d56318903cb645a39f56e7762eddb7411941a72e1f446c1f9d79e35157f1d31b",
|
|
94
|
-
"admin/lib/
|
|
95
|
-
"admin/
|
|
96
|
-
"admin/
|
|
94
|
+
"admin/lib/resets.js": "3724a2aec9f3c18ada96948aa6ce375ae3505bb7623a2c556e2e2670c5ca343c",
|
|
95
|
+
"admin/lib/settings.js": "b319953914f3a6f029ba991b36fe6fcd521956ea099578be42505f30e6c32faf",
|
|
96
|
+
"admin/templates/security.html": "df5798497ca1fd5ed200ce0ff921a055168ac7720f38b2db1bebf2c0efe71f04",
|
|
97
|
+
"admin/views/security.js": "280c1681f1aae6cf82a141e670d046c160c5f5fc3cb9de43f9d134a08b15a199",
|
|
97
98
|
"bin/unlock.js": "5fcc3c0ef3ba3c424a366aa62896f51abad578183dfde283aa838a86549ca785",
|
|
98
99
|
"config.js": "e6a00f7816637687d6a68ebbac65a71489246a926c28523c2a78d859e75b7ae0",
|
|
99
|
-
"plugin.js": "
|
|
100
|
-
"plugin.json": "
|
|
100
|
+
"plugin.js": "db5dc669a6a26bbe8756da59bdd04217e403be48012921b6d2b868cec57af489",
|
|
101
|
+
"plugin.json": "319d4333d03ed7d42ef9c2d0999befff7594343be963b6fee9c90bf7916ad142",
|
|
101
102
|
"server/common-passwords.NOTICE": "3b148a49a569bfa24feaa7879db602da8f0607fa61ab6c52b7a62391fc7c009a",
|
|
102
103
|
"server/common-passwords.txt": "68782d6a4a19a4768d5f15dd66bd534e7a33055cc755411e33f16d18c50fdcce",
|
|
103
104
|
"server/facts.js": "25538a8d1cfe012688fceb8ec0d0dd7b43ac9e168430723d6fb731183701fb46",
|
|
104
105
|
"server/state.js": "1319b322e94a4aec176948990b2d674b28dfece761eef4d5685df474037bfc4a",
|
|
105
|
-
"tests/security.test.js": "
|
|
106
|
+
"tests/security.test.js": "f0cb70dcb28bc8b3de8d3b39064616296382c5b0f97bfd615ee8e24fe3b031bc"
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
"seo": {
|
|
110
|
+
"version": "1.0.0",
|
|
111
|
+
"files": {
|
|
112
|
+
"CLAUDE.md": "c4b326afeacded36d076f96ec3554b6cd875087ea5c84b29dffb144059292ff7",
|
|
113
|
+
"admin/lib/analyse.js": "368b9e20bc1eac957870272ee6909b5f7c09ecf0a2fc2c344fb5cd85473f18cd",
|
|
114
|
+
"admin/lib/redirects.js": "a506ca40b712c3d8087d6698397b900fe5005ae6094f80dbc3f3091d5d9b6819",
|
|
115
|
+
"admin/lib/schema.js": "4313e0ed528562ee8b85c18479663c8216101f892848b44426baefc8d7c1c7e8",
|
|
116
|
+
"admin/lib/settings.js": "94e76c2960376f0cc0570128a7226af177f5de7ee115357e37baba45b1e05e19",
|
|
117
|
+
"admin/templates/seo.html": "e8684d98bd0649c001d7395b75b2bc3c74234f947d3dbda508ff4ecb91e20d1c",
|
|
118
|
+
"admin/views/seo.js": "b37774a5df642bdbdb6f7fb89b12dbc6d45d06d2b70bd2de7faf4c3098c26189",
|
|
119
|
+
"config.js": "d90f4cfb10b2f44b528de37522d31fd01351a6ebbab95556b5dfa2820d69e1a8",
|
|
120
|
+
"plugin.js": "3dd88bec75535349e6a748bae90fc49e54e2781c4765b9c8ff02898795ef476d",
|
|
121
|
+
"plugin.json": "6fe2fb5d50d37cc9749cf84db7ed59c5557a52c1b38f63c8e7bc1744065cd7d6",
|
|
122
|
+
"server/audit.js": "f51fcf9403ad40fde9e9ba5f31e58ed95fc2a5c8514c921146f23ee1ca2e0bd3",
|
|
123
|
+
"server/registry.js": "9336614bff3ab8b4fe9d50de074a9633307ab41c46fa62113ca82379cfad90a6",
|
|
124
|
+
"server/store.js": "3584ec3efd8a2d0ba8fd5be7e56d97cca76b5b2358a7dda99e379c42be67253c",
|
|
125
|
+
"tests/seo.test.js": "bdc52f2a9f8f02aaa0cb907f76f1acf8ff887034172e18d9ae108d3675b68f1a"
|
|
106
126
|
}
|
|
107
127
|
},
|
|
108
128
|
"shopping-cart": {
|
|
@@ -15,6 +15,13 @@ Ships with the engine (free tier, `make sync-free-tier`); switched ON when it fi
|
|
|
15
15
|
MIT - see the NOTICE), not containing the name/email, not one or two characters repeated.
|
|
16
16
|
- **Sign-in log**: `data/signins.jsonl`, kept `signinDays`; notifications (sources `security:lockouts`,
|
|
17
17
|
`security:suspicious`, `security:probes` - the last off by default).
|
|
18
|
+
- **Reset requests** (1.1.0; core 0.88's `auth:resetRequested`): each "Forgot your password?" is a `kind: 'reset'`
|
|
19
|
+
line in the same log (`ok` = a link was sent, `reason` = the core's outcome - `admin/lib/resets.js` RESET_REASONS).
|
|
20
|
+
Sign-ins › Resets lists them; the overview tile counts 24 h. `security:resets` (setting `notify.resetProbing`, on) is
|
|
21
|
+
raised when one address asks about 3 different accounts within an hour - ALL the core allows it (forgot-password is
|
|
22
|
+
rate-limited to 3 an hour per address, so a higher threshold never trips) - or when 10 requests within an hour, from
|
|
23
|
+
any addresses, name accounts that do not exist (someone rotating addresses). Anything counting sign-ins must skip
|
|
24
|
+
`kind: 'reset'` lines - their `ok` is not a sign-in. Older engines never emit the event: nothing changes there.
|
|
18
25
|
- **Health check**: `server/facts.js` gathers (users, engine vs npm latest, certificate via tls, security headers of the
|
|
19
26
|
public home page, private paths fetched LOCALLY, TRUST_PROXY, bind, SMTP, licences); `admin/lib/health.js` judges.
|
|
20
27
|
Unknown = 'info', never a pass. Cached 10 min; the sidebar badge (`/count`) = checks needing attention, red only
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security - password reset requests (core's auth:resetRequested, 0.88). Pure.
|
|
3
|
+
*
|
|
4
|
+
* Each "Forgot your password?" goes into the sign-in log as `kind: 'reset'`.
|
|
5
|
+
* One address asking for resets of many different accounts is someone trying
|
|
6
|
+
* addresses, or trying to fill inboxes - that is what `recordReset` watches.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** What became of a request, as the core reports it in `reason`. */
|
|
10
|
+
export const RESET_REASONS = {
|
|
11
|
+
sent: 'Link sent',
|
|
12
|
+
unknown: 'No such account',
|
|
13
|
+
inactive: 'Account switched off',
|
|
14
|
+
throttled: 'Held back - 3 an hour already',
|
|
15
|
+
'no-smtp': 'Not sent - no mail server',
|
|
16
|
+
'no-origin': 'Not sent - no Site URL',
|
|
17
|
+
failed: 'Email failed'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* When it is worth telling someone. The core lets one address ask 3 times an hour
|
|
22
|
+
* (a route rate limit), and a person only ever asks about their own account - so
|
|
23
|
+
* one address using all 3 on different accounts is trying addresses. Someone
|
|
24
|
+
* rotating addresses gets past that, which is what the site-wide count of
|
|
25
|
+
* requests for accounts that do not exist is for.
|
|
26
|
+
*/
|
|
27
|
+
export const RESET_PROBE = {threshold: 3, windowMin: 60};
|
|
28
|
+
export const RESET_UNKNOWN = {threshold: 10, windowMin: 60};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Note a reset request from an address and say whether it has just crossed the
|
|
32
|
+
* line: `threshold` different accounts within `windowMin`. Mutates `recent`
|
|
33
|
+
* (a Map of address -> [{at, email}]), dropping what is older than the window.
|
|
34
|
+
*
|
|
35
|
+
* @returns {{accounts: number, tripped: boolean}} tripped only on the request that reaches the threshold
|
|
36
|
+
*/
|
|
37
|
+
export function recordReset(recent, ip, email, {now = Date.now(), threshold = RESET_PROBE.threshold, windowMin = RESET_PROBE.windowMin} = {}) {
|
|
38
|
+
if (!ip) return {accounts: 0, tripped: false};
|
|
39
|
+
const since = now - windowMin * 60_000;
|
|
40
|
+
const list = (recent.get(ip) || []).filter(r => r.at >= since);
|
|
41
|
+
const before = new Set(list.map(r => r.email)).size;
|
|
42
|
+
list.push({at: now, email: String(email || '').trim().toLowerCase()});
|
|
43
|
+
recent.set(ip, list);
|
|
44
|
+
const accounts = new Set(list.map(r => r.email)).size;
|
|
45
|
+
return {accounts, tripped: accounts >= threshold && before < threshold};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Note a request for an account that does not exist, from any address, and say
|
|
50
|
+
* whether the site has just reached `threshold` of them within `windowMin`.
|
|
51
|
+
* Mutates `stamps` (an array of times).
|
|
52
|
+
*
|
|
53
|
+
* @returns {{count: number, tripped: boolean}} tripped only on the request that reaches the threshold
|
|
54
|
+
*/
|
|
55
|
+
export function recordUnknown(stamps, {now = Date.now(), threshold = RESET_UNKNOWN.threshold, windowMin = RESET_UNKNOWN.windowMin} = {}) {
|
|
56
|
+
const since = now - windowMin * 60_000;
|
|
57
|
+
const keep = stamps.filter(t => t >= since);
|
|
58
|
+
stamps.length = 0;
|
|
59
|
+
stamps.push(...keep, now);
|
|
60
|
+
return {count: stamps.length, tripped: stamps.length === threshold};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Forget site-wide stamps older than the window. Mutates `stamps`. */
|
|
64
|
+
export function pruneUnknown(stamps, {now = Date.now(), windowMin = RESET_UNKNOWN.windowMin} = {}) {
|
|
65
|
+
const keep = stamps.filter(t => t >= now - windowMin * 60_000);
|
|
66
|
+
stamps.length = 0;
|
|
67
|
+
stamps.push(...keep);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Forget addresses with nothing inside the window. */
|
|
71
|
+
export function pruneResets(recent, {now = Date.now(), windowMin = RESET_PROBE.windowMin} = {}) {
|
|
72
|
+
const since = now - windowMin * 60_000;
|
|
73
|
+
for (const [ip, list] of recent) {
|
|
74
|
+
const keep = list.filter(r => r.at >= since);
|
|
75
|
+
if (keep.length) recent.set(ip, keep); else recent.delete(ip);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One log entry as the Sign-ins list shows it. */
|
|
80
|
+
export function resetRowText(e) {
|
|
81
|
+
return {okText: 'Reset asked', tone: 'reset', note: RESET_REASONS[e.reason] || e.reason || (e.ok ? 'Link sent' : 'Not sent')};
|
|
82
|
+
}
|
|
@@ -11,7 +11,7 @@ export const DEFAULT_SETTINGS = {
|
|
|
11
11
|
probes: {enabled: true, blockAfter: 5, withinMin: 10, blockForMin: 60, extra: [], allow: []},
|
|
12
12
|
signinDays: 30,
|
|
13
13
|
staleAdminDays: 90,
|
|
14
|
-
notify: {lockouts: true, successAfterFails: true, probeBlocks: false}
|
|
14
|
+
notify: {lockouts: true, successAfterFails: true, probeBlocks: false, resetProbing: true}
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
const int = (v, min, max) => {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
<div class="scx-card scx-tile"><small>Failed sign-ins, 24h <span data-help="Failed sign-ins in the last 24 hours: a wrong password, an unknown email, a locked account."></span></small><strong data-bind-text="ov.value.failed">—</strong></div>
|
|
23
23
|
<div class="scx-card scx-tile"><small>Blocked now <span data-help="Addresses blocked by a rule or for probing, and accounts or addresses locked after wrong passwords."></span></small><strong data-bind-text="ov.value.blocked">—</strong></div>
|
|
24
24
|
<div class="scx-card scx-tile"><small>Probes refused today <span data-help="Requests for things no Domma site has - WordPress logins, .env files, database dumps. Scanners send them to every site they find; they are answered with an empty 'not found'."></span></small><strong data-bind-text="ov.value.probes">—</strong></div>
|
|
25
|
+
<div class="scx-card scx-tile"><small>Reset requests, 24h <span data-help=""Forgot your password?" requests in the last 24 hours, sent or not. Sign-ins › Resets lists them, with what happened and where from."></span></small><strong data-bind-text="ov.value.resets">—</strong></div>
|
|
25
26
|
</div>
|
|
26
27
|
|
|
27
28
|
<div class="scx-card">
|
|
@@ -41,6 +42,7 @@
|
|
|
41
42
|
<button type="button" data-on-click="res_all" data-bind-class="result.value === 'all' && 'is-on'">All</button>
|
|
42
43
|
<button type="button" data-on-click="res_failed" data-bind-class="result.value === 'failed' && 'is-on'">Failed</button>
|
|
43
44
|
<button type="button" data-on-click="res_ok" data-bind-class="result.value === 'ok' && 'is-on'">Succeeded</button>
|
|
45
|
+
<button type="button" data-on-click="res_resets" data-bind-class="result.value === 'resets' && 'is-on'">Resets</button>
|
|
44
46
|
</div>
|
|
45
47
|
<select class="form-input scx-days" aria-label="Period" data-model="days.value">
|
|
46
48
|
<option value="1">Last 24 hours</option><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="365">All kept</option>
|
|
@@ -150,6 +152,7 @@
|
|
|
150
152
|
.scx-cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
151
153
|
.scx-pill { display: inline-flex; align-items: center; justify-self: start; font-size: var(--dm-text-xs); font-weight: 600; padding: .05rem .5rem; border-radius: 999px; }
|
|
152
154
|
.scx-pill[data-ok="true"] { color: var(--dm-success); background: color-mix(in srgb, var(--dm-success) 14%, transparent); }
|
|
155
|
+
.scx-pill[data-ok="reset"] { color: var(--dm-info); background: color-mix(in srgb, var(--dm-info) 14%, transparent); }
|
|
153
156
|
.scx-pill[data-ok="false"] { color: var(--dm-danger); background: color-mix(in srgb, var(--dm-danger) 12%, transparent); }
|
|
154
157
|
.scx-kebab { border: 0; background: none; cursor: pointer; font-size: var(--dm-text-lg); line-height: 1; color: var(--dm-text-muted); border-radius: var(--dm-radius-sm); padding: 0 .25rem; }
|
|
155
158
|
.scx-kebab:hover { color: var(--dm-primary); }
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* only, and rows are addressed by index.
|
|
11
11
|
* `#/plugins/security/<tab>` opens a tab. Pure logic: ../lib/*.js.
|
|
12
12
|
*/
|
|
13
|
+
import {resetRowText} from '../lib/resets.js';
|
|
13
14
|
import {checkSettings, DEFAULT_SETTINGS, mergeDefaults} from '../lib/settings.js';
|
|
14
15
|
import {ipRuleValid} from '../lib/ip.js';
|
|
15
16
|
|
|
@@ -152,7 +153,8 @@ export const securityView = {
|
|
|
152
153
|
const asOf = M.computed(() => (health.value?.at ? `Checked ${new Date(health.value.at).toLocaleTimeString('en-GB', {hour: '2-digit', minute: '2-digit'})}` : ''));
|
|
153
154
|
const ov = M.computed(() => {
|
|
154
155
|
const o = overview.value;
|
|
155
|
-
return o ? {failed: String(o.failed24h), blocked: String(o.blocked + o.locks), probes: String(o.today?.probes || 0)
|
|
156
|
+
return o ? {failed: String(o.failed24h), blocked: String(o.blocked + o.locks), probes: String(o.today?.probes || 0), resets: String(o.resets24h ?? 0)}
|
|
157
|
+
: {failed: '—', blocked: '—', probes: '—', resets: '—'};
|
|
156
158
|
});
|
|
157
159
|
const blockedCount = M.computed(() => { const b = blocks.value; const n = b.rules.length + b.temp.length + b.locks.length; return n ? String(n) : ''; });
|
|
158
160
|
|
|
@@ -185,18 +187,25 @@ export const securityView = {
|
|
|
185
187
|
const q = query.value.trim().toLowerCase();
|
|
186
188
|
const r = result.value;
|
|
187
189
|
const only = ipFilter.value;
|
|
188
|
-
|
|
190
|
+
// A reset request is in the log too (kind 'reset'): only under All and Resets.
|
|
191
|
+
const isReset = (e) => e.kind === 'reset';
|
|
192
|
+
signinList = signins.value.filter(e => (r === 'all' || (r === 'resets' ? isReset(e) : !isReset(e) && (r === 'ok') === Boolean(e.ok)))
|
|
189
193
|
&& (!only || e.ip === only)
|
|
190
194
|
&& (!q || [e.email, e.ip, e.ua, e.reason].some(v => String(v || '').toLowerCase().includes(q))));
|
|
191
|
-
signinRows.value = signinList.slice(0, 1000).map((e, i) =>
|
|
192
|
-
id: `s${i}`, when: when(e.at),
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
195
|
+
signinRows.value = signinList.slice(0, 1000).map((e, i) => {
|
|
196
|
+
const row = {id: `s${i}`, when: when(e.at), email: e.email || '—', ip: e.ip || '—', ua: e.ua || ''};
|
|
197
|
+
if (isReset(e)) {
|
|
198
|
+
const t = resetRowText(e);
|
|
199
|
+
return {...row, ok: t.tone, okText: t.okText, note: `${t.note} · ${browserOf(e.ua)}`};
|
|
200
|
+
}
|
|
201
|
+
return {...row, ok: String(Boolean(e.ok)), okText: e.ok ? 'Signed in' : 'Failed',
|
|
202
|
+
note: e.ok ? browserOf(e.ua) : `${REASONS[e.reason] || e.reason || 'Failed'} · ${browserOf(e.ua)}`};
|
|
203
|
+
});
|
|
196
204
|
}));
|
|
197
205
|
const signinText = M.computed(() => {
|
|
198
206
|
const n = signinList.length + 0 * signinRows.value.length;
|
|
199
207
|
const f = ipFilter.value ? ` from ${ipFilter.value}` : '';
|
|
208
|
+
if (result.value === 'resets') return `${n.toLocaleString('en-GB')} reset request${n === 1 ? '' : 's'}${f}`;
|
|
200
209
|
return `${n.toLocaleString('en-GB')} sign-in${n === 1 ? '' : 's'}${f}`;
|
|
201
210
|
});
|
|
202
211
|
const signinEmpty = M.computed(() => (query.value.trim() || ipFilter.value ? 'No sign-ins match.' : 'No sign-ins in this period.'));
|
|
@@ -280,7 +289,9 @@ export const securityView = {
|
|
|
280
289
|
}
|
|
281
290
|
function exportCsv() {
|
|
282
291
|
if (!signinList.length) { E.toast('Nothing to export.', {type: 'info'}); return; }
|
|
283
|
-
const rows = signinList.map(e => (
|
|
292
|
+
const rows = signinList.map(e => (e.kind === 'reset'
|
|
293
|
+
? {time: e.at, result: 'reset requested', email: e.email, address: e.ip, reason: resetRowText(e).note, browser: e.ua}
|
|
294
|
+
: {time: e.at, result: e.ok ? 'signed in' : 'failed', email: e.email, address: e.ip, reason: e.ok ? '' : (REASONS[e.reason] || e.reason || ''), browser: e.ua}));
|
|
284
295
|
const url = URL.createObjectURL(new Blob([toCsv(rows)], {type: 'text/csv;charset=utf-8'}));
|
|
285
296
|
const a = Object.assign(document.createElement('a'), {href: url, download: `sign-ins-${location.hostname}-${new Date().toISOString().slice(0, 10)}.csv`});
|
|
286
297
|
document.body.appendChild(a); a.click(); a.remove();
|
|
@@ -385,14 +396,14 @@ export const securityView = {
|
|
|
385
396
|
probeOn: M.observable(s.probes.enabled), after: M.observable(String(s.probes.blockAfter)), within: M.observable(String(s.probes.withinMin)), forMin: M.observable(String(s.probes.blockForMin)),
|
|
386
397
|
extra: M.observable(s.probes.extra.join('\n')), allow: M.observable(s.probes.allow.join('\n')),
|
|
387
398
|
signinDays: M.observable(String(s.signinDays)), staleDays: M.observable(String(s.staleAdminDays)),
|
|
388
|
-
nLock: M.observable(s.notify.lockouts), nAfter: M.observable(s.notify.successAfterFails), nProbe: M.observable(s.notify.probeBlocks)
|
|
399
|
+
nLock: M.observable(s.notify.lockouts), nAfter: M.observable(s.notify.successAfterFails), nProbe: M.observable(s.notify.probeBlocks), nReset: M.observable(s.notify.resetProbing)
|
|
389
400
|
};
|
|
390
401
|
const body = () => ({
|
|
391
402
|
lockout: {enabled: f.lockOn.value, accountThreshold: f.acc.value, addressThreshold: f.addr.value, windowMin: f.win.value, baseMin: f.base.value, maxMin: f.max.value},
|
|
392
403
|
policy: {enabled: f.polOn.value, minLength: f.minLen.value, blockCommon: f.common.value, blockPersonal: f.personal.value},
|
|
393
404
|
probes: {enabled: f.probeOn.value, blockAfter: f.after.value, withinMin: f.within.value, blockForMin: f.forMin.value, extra: f.extra.value, allow: f.allow.value},
|
|
394
405
|
signinDays: f.signinDays.value, staleAdminDays: f.staleDays.value,
|
|
395
|
-
notify: {lockouts: f.nLock.value, successAfterFails: f.nAfter.value, probeBlocks: f.nProbe.value}
|
|
406
|
+
notify: {lockouts: f.nLock.value, successAfterFails: f.nAfter.value, probeBlocks: f.nProbe.value, resetProbing: f.nReset.value}
|
|
396
407
|
});
|
|
397
408
|
const opened = JSON.stringify(body());
|
|
398
409
|
const saving = M.observable(false);
|
|
@@ -436,6 +447,7 @@ export const securityView = {
|
|
|
436
447
|
${sw('nLock', 'Notify when an account or address is locked')}
|
|
437
448
|
${sw('nAfter', 'Notify when someone signs in after many wrong passwords')}
|
|
438
449
|
${sw('nProbe', 'Notify when a scanner is blocked', 'Off by default: on a public site this happens daily.')}
|
|
450
|
+
${sw('nReset', 'Notify when password resets look like someone trying addresses', 'One address asking about 3 different accounts within an hour (a person only asks about their own), or 10 requests in an hour for accounts that do not exist, from anywhere. Every request is in the sign-in log either way.')}
|
|
439
451
|
</div></section>
|
|
440
452
|
<section><h4>Locked out yourself?</h4>
|
|
441
453
|
<p class="scx-note">Run this in the site's folder on the server - it clears lockouts and blocks, and the site notices within seconds:</p>
|
|
@@ -499,6 +511,7 @@ export const securityView = {
|
|
|
499
511
|
const model = {tab, loading, error, checksOk, canManage, scoreText, scoreWidth, scoreTone, attentionText, asOf, ov, blockedCount, checks,
|
|
500
512
|
result, days, query, signinRows, signinText, signinEmpty, lockRows, tempRows, ruleRows, newRule, newNote, youText};
|
|
501
513
|
const methods = {
|
|
514
|
+
res_resets: () => { result.value = 'resets'; },
|
|
502
515
|
tab_overview: () => setTab('overview'), tab_signins: () => setTab('signins'), tab_blocked: () => setTab('blocked'),
|
|
503
516
|
res_all: () => { result.value = 'all'; }, res_failed: () => { result.value = 'failed'; }, res_ok: () => { result.value = 'ok'; },
|
|
504
517
|
exportCsv,
|
|
@@ -31,6 +31,7 @@ import {checkSettings, mergeDefaults} from './admin/lib/settings.js';
|
|
|
31
31
|
import {accountKey, activeLocks, addressKey, lockedUntil, pruneLockouts, recordFailure, recordSuccess, retryText} from './admin/lib/lockout.js';
|
|
32
32
|
import {passwordProblems} from './admin/lib/policy.js';
|
|
33
33
|
import {isProbe} from './admin/lib/probes.js';
|
|
34
|
+
import {pruneResets, pruneUnknown, recordReset, recordUnknown} from './admin/lib/resets.js';
|
|
34
35
|
import {blockedBy, isTrusted, pruneBlocks, recordProbe} from './admin/lib/blocks.js';
|
|
35
36
|
import {ipRuleValid, isLoopbackIp, plainIp} from './admin/lib/ip.js';
|
|
36
37
|
import {getConfig, saveConfig} from '../../server/config.js';
|
|
@@ -72,6 +73,7 @@ async function security(fastify, options = {}) {
|
|
|
72
73
|
const notify = (p) => { try { hooks.notify?.(p); } catch { /* notifications are a courtesy */ } };
|
|
73
74
|
hooks.registerNotificationSource?.({id: 'lockouts', label: 'Sign-in lockouts', description: 'An account or address was locked after repeated wrong passwords.', severity: 'warning', audience: 'admins'});
|
|
74
75
|
hooks.registerNotificationSource?.({id: 'suspicious', label: 'Suspicious sign-ins', description: 'Someone signed in after many wrong passwords.', severity: 'warning', audience: 'admins'});
|
|
76
|
+
hooks.registerNotificationSource?.({id: 'resets', label: 'Password reset probing', description: 'One address asked for password resets of many different accounts.', severity: 'warning', audience: 'admins'});
|
|
75
77
|
hooks.registerNotificationSource?.({id: 'probes', label: 'Scanners blocked', description: 'An address probing for weaknesses was blocked.', severity: 'info', audience: 'admins', defaultOn: false});
|
|
76
78
|
|
|
77
79
|
// ---------------------------------------------------------------------
|
|
@@ -161,6 +163,38 @@ async function security(fastify, options = {}) {
|
|
|
161
163
|
}
|
|
162
164
|
});
|
|
163
165
|
|
|
166
|
+
// Password reset requests (core 0.88's auth:resetRequested): into the
|
|
167
|
+
// sign-in log, and a notice when one address asks about many accounts.
|
|
168
|
+
const recentResets = new Map();
|
|
169
|
+
const unknownResets = [];
|
|
170
|
+
hooks.on?.('auth:resetRequested', (e) => {
|
|
171
|
+
try {
|
|
172
|
+
const ip = plainIp(e.ip);
|
|
173
|
+
state.logSignin({at: new Date().toISOString(), kind: 'reset', ok: Boolean(e.sent), email: e.email || '', userId: e.userId || null,
|
|
174
|
+
ip, ua: e.ua || '', reason: e.reason || ''});
|
|
175
|
+
if (isLoopbackIp(ip)) return;
|
|
176
|
+
if (e.reason === 'unknown') {
|
|
177
|
+
const many = recordUnknown(unknownResets);
|
|
178
|
+
if (many.tripped && cfg.notify.resetProbing !== false) {
|
|
179
|
+
notify({source: 'resets', severity: 'warning', title: `${many.count} password resets for accounts that do not exist`,
|
|
180
|
+
dedupeKey: 'reset-unknown',
|
|
181
|
+
body: `Within the hour, ${many.count} "Forgot your password?" requests named accounts this site does not have, from ${new Set(state.readSignins(Date.now() - 3_600_000).filter(x => x.kind === 'reset' && x.reason === 'unknown').map(x => x.ip)).size || 'several'} addresses - someone trying email addresses. The sign-in log (Resets) shows which.`,
|
|
182
|
+
link: '#/plugins/security/signins'});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (!ip) return;
|
|
186
|
+
const {accounts, tripped} = recordReset(recentResets, ip, e.email);
|
|
187
|
+
if (tripped && cfg.notify.resetProbing !== false) {
|
|
188
|
+
notify({source: 'resets', severity: 'warning', title: `Password resets asked for ${accounts} accounts from ${ip}`,
|
|
189
|
+
dedupeKey: `reset-probe:${ip}`,
|
|
190
|
+
body: `${ip} asked for password resets of ${accounts} different accounts within the hour - a person only ever asks about their own, so this is someone trying email addresses. The log shows which; block the address if it is not someone you know.`,
|
|
191
|
+
link: '#/plugins/security/signins'});
|
|
192
|
+
}
|
|
193
|
+
} catch (err) {
|
|
194
|
+
log.warn(`[security] reset request not recorded: ${err.message}`);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
164
198
|
hooks.on?.('auth:loginSucceeded', (e) => {
|
|
165
199
|
try {
|
|
166
200
|
const ip = plainIp(e.ip);
|
|
@@ -217,12 +251,14 @@ async function security(fastify, options = {}) {
|
|
|
217
251
|
routes.get('/overview', may('read'), async () => {
|
|
218
252
|
const s = state.get();
|
|
219
253
|
const now = Date.now();
|
|
220
|
-
const
|
|
254
|
+
const all = state.readSignins(now - 86_400_000);
|
|
255
|
+
const day = all.filter(e => e.kind !== 'reset');
|
|
221
256
|
const today = s.counters.day === new Date().toISOString().slice(0, 10) ? s.counters : {probes: 0, blocks: 0, failed: 0};
|
|
222
257
|
return {
|
|
223
258
|
today,
|
|
224
259
|
failed24h: day.filter(e => !e.ok).length,
|
|
225
260
|
succeeded24h: day.filter(e => e.ok).length,
|
|
261
|
+
resets24h: all.length - day.length,
|
|
226
262
|
locks: activeLocks(s.lockouts, now).length,
|
|
227
263
|
blocked: Object.values(s.temp).filter(t => t.until > now).length + s.rules.length,
|
|
228
264
|
checks: canCheck,
|
|
@@ -302,6 +338,8 @@ async function security(fastify, options = {}) {
|
|
|
302
338
|
// ---------------------------------------------------------------------
|
|
303
339
|
const tidy = () => {
|
|
304
340
|
state.change((s) => { pruneLockouts(s.lockouts, {windowMin: cfg.lockout.windowMin}); pruneBlocks(s); });
|
|
341
|
+
pruneResets(recentResets);
|
|
342
|
+
pruneUnknown(unknownResets);
|
|
305
343
|
state.pruneSignins(cfg.signinDays);
|
|
306
344
|
};
|
|
307
345
|
const daily = setInterval(tidy, 6 * 3_600_000);
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "security",
|
|
3
3
|
"displayName": "Security",
|
|
4
|
-
"version": "1.0
|
|
4
|
+
"version": "1.1.0",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Keeps a Domma CMS site safe: a health check of what is weak and how to fix it, sign-in lockout after repeated wrong passwords, password rules, a log of every sign-in, and blocking of the scanners that probe every site for WordPress, .env files and the like.",
|
|
7
7
|
"author": "Domma CMS",
|
|
8
|
-
"date": "2026-09-
|
|
8
|
+
"date": "2026-09-27",
|
|
9
9
|
"icon": "shield",
|
|
10
10
|
"permissions": [
|
|
11
11
|
{
|
|
@@ -132,3 +132,37 @@ test('health: facts become checks; unknown is info, never a pass; the score coun
|
|
|
132
132
|
assert.equal(unknown.checks.find(c => c.id === 'certificate').status, 'info');
|
|
133
133
|
assert.equal(unknown.checks.find(c => c.id === 'headers').status, 'info');
|
|
134
134
|
});
|
|
135
|
+
|
|
136
|
+
test('resets: one address asking about 3 different accounts in an hour trips once', async () => {
|
|
137
|
+
const {recordReset, pruneResets, resetRowText} = await import('../admin/lib/resets.js');
|
|
138
|
+
const recent = new Map();
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
const at = (m) => ({now: now + m * MIN});
|
|
141
|
+
assert.deepEqual(recordReset(recent, '1.2.3.4', 'a@x.test', at(0)), {accounts: 1, tripped: false});
|
|
142
|
+
// The same account again is not another account.
|
|
143
|
+
assert.equal(recordReset(recent, '1.2.3.4', 'A@x.test', at(1)).accounts, 1);
|
|
144
|
+
assert.equal(recordReset(recent, '1.2.3.4', 'b@x.test', at(2)).tripped, false);
|
|
145
|
+
assert.deepEqual(recordReset(recent, '1.2.3.4', 'c@x.test', at(3)), {accounts: 3, tripped: true});
|
|
146
|
+
assert.equal(recordReset(recent, '1.2.3.4', 'd@x.test', at(4)).tripped, false, 'only on crossing');
|
|
147
|
+
// Another address is counted on its own; an hour later the first starts again.
|
|
148
|
+
assert.equal(recordReset(recent, '5.6.7.8', 'a@x.test', at(5)).accounts, 1);
|
|
149
|
+
assert.equal(recordReset(recent, '1.2.3.4', 'z@x.test', at(70)).accounts, 1);
|
|
150
|
+
pruneResets(recent, {now: now + 200 * MIN});
|
|
151
|
+
assert.equal(recent.size, 0);
|
|
152
|
+
assert.equal(recordReset(recent, '', 'a@x.test').tripped, false);
|
|
153
|
+
assert.equal(resetRowText({reason: 'no-smtp'}).note, 'Not sent - no mail server');
|
|
154
|
+
assert.equal(resetRowText({reason: 'odd'}).note, 'odd');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('resets: 10 requests for unknown accounts in an hour, from anywhere, trip once', async () => {
|
|
158
|
+
const {recordUnknown, pruneUnknown} = await import('../admin/lib/resets.js');
|
|
159
|
+
const stamps = [];
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
for (let i = 0; i < 9; i++) assert.equal(recordUnknown(stamps, {now: now + i * MIN}).tripped, false);
|
|
162
|
+
assert.deepEqual(recordUnknown(stamps, {now: now + 9 * MIN}), {count: 10, tripped: true});
|
|
163
|
+
assert.equal(recordUnknown(stamps, {now: now + 10 * MIN}).tripped, false, 'only on reaching it');
|
|
164
|
+
// An hour on, the early ones have gone.
|
|
165
|
+
assert.equal(recordUnknown(stamps, {now: now + 65 * MIN}).count, 7, 'minutes 5-10 still inside the hour, and this one');
|
|
166
|
+
pruneUnknown(stamps, {now: now + 200 * MIN});
|
|
167
|
+
assert.equal(stamps.length, 0);
|
|
168
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# SEO (free) - AI Assistant Guide
|
|
2
|
+
|
|
3
|
+
How the site looks to search engines, and what to fix. Free tier (`make sync-free-tier` ships it with the engine).
|
|
4
|
+
SEO Pro (paid) is an ADD-ON (`requires: ["seo"]`, no supersedes) that plugs in through `server/registry.js`.
|
|
5
|
+
Social (paid, planned) will be its own plugin, sold with SEO Pro.
|
|
6
|
+
|
|
7
|
+
Needs core 0.89 for: `hooks.registerSitemapSource` / `sitemapChanged`, and the `seo:meta`, `sitemap:entries`,
|
|
8
|
+
`robots:lines` transforms (core `renderer.js` buildSeoMeta/renderSeoTags, `sitemap.js`). On an older engine the plugin
|
|
9
|
+
logs it, the screen says so, and the audit, editor and redirects still work (`hasCore` in plugin.js).
|
|
10
|
+
|
|
11
|
+
## What it does
|
|
12
|
+
- **Audit** (`server/audit.js`): every sitemap URL (`listSitemapEntries`) plus published pages kept out of it, fetched
|
|
13
|
+
IN-PROCESS with `fastify.inject` (UA `DommaSEO/1 (audit)`, header `x-domma-seo: audit` - analytics / SEO Pro skip
|
|
14
|
+
those), judged by `admin/lib/analyse.js` (pure: `scanHtml` regex scanner - no DOM, `analyse`, `siteIssues`, `snippet`).
|
|
15
|
+
Every `audit.everyHours` (first run 3 min after start if stale), or POST /audit. Kept in `data/audit.json`.
|
|
16
|
+
- **Editor per URL** (GET/PUT /item): a core page is edited IN PLACE (`updatePage(path, {seo})`, empty -> null so core's
|
|
17
|
+
merge removes the key); anything else (a blog post) gets an override in `data/overrides.json`, applied by the
|
|
18
|
+
`seo:meta` transform (`admin/lib/schema.js applySeo`). Fields: title, description, image, noindex, focus.
|
|
19
|
+
- **Redirects** (`admin/lib/redirects.js`, pure): exact and "/prefix/*" rules, 301/302/307/308/410, query kept, no
|
|
20
|
+
loops (checkRule follows the chain), `onMoved` on core's `content:pageRenamed` re-aims older rules (no chains) and
|
|
21
|
+
drops a rule FROM the new address. Guard = global `onRequest` (fastify-plugin), GET/HEAD, never /api or /admin.
|
|
22
|
+
Hits counted in memory, saved each minute.
|
|
23
|
+
- **Sitemap/robots**: `sitemap:entries` drops excluded paths, switched-off sources, overridden noindex and redirected
|
|
24
|
+
addresses; `robots:lines` adds rules (plain rules into the `*` group; `User-agent:` blocks as their own groups) or
|
|
25
|
+
blocks everything. JSON-LD: Organization/LocalBusiness/Person on `/`, BlogPosting when `page.item.type === 'blog:post'`.
|
|
26
|
+
|
|
27
|
+
## Data (plugins/seo/data - kept by updates, never bundled)
|
|
28
|
+
`redirects.json`, `overrides.json`, `audit.json` - in memory (the guard and transform run on every request), debounced
|
|
29
|
+
writes, re-read within ~3 s when changed on disk (`server/store.js`).
|
|
30
|
+
|
|
31
|
+
## Permission
|
|
32
|
+
`seo` (read / manage), granted to admin. Routes: /overview /count /audit (GET, POST) /item (GET, PUT) /redirects
|
|
33
|
+
(GET, POST, PUT/:id, DELETE/:id - ids comma-separated) /redirects/import /redirects/test /sitemap /settings /extensions.
|
|
34
|
+
|
|
35
|
+
## Extensions (SEO Pro)
|
|
36
|
+
`globalThis[Symbol.for('domma.seo')]` (`server/registry.js`): `register(owner, {label, tabs})`, `on('audit', fn)`,
|
|
37
|
+
`services` {settings, listEntries, auditList, fetchPage, scanHtml, analyse, audit {latest, summary, run},
|
|
38
|
+
redirects {list, match, add}, auditUserAgent}. Tabs: `{key, label, icon, module, help}`; `module` is a
|
|
39
|
+
`/plugins/<slug>/...` URL exporting `mount(root, ctx)` -> dispose; ctx = {kit, api, scope, here, openItem, reload, E, M, I}.
|
|
40
|
+
|
|
41
|
+
## Admin
|
|
42
|
+
`admin/views/seo.js` + `admin/templates/seo.html` (prefix `sox-`): Pages (the audit + "Across the site"), Redirects,
|
|
43
|
+
Sitemap, extension tabs; page editor slideover (search result + share card previews, meters, checks); settings
|
|
44
|
+
slideover. Rows by index, text through data-bind-text only.
|
|
45
|
+
|
|
46
|
+
## Tests
|
|
47
|
+
`tests/seo.test.js` (pure). Headless: throwaway CMS from a clean domma-cms worktree with plugins/seo + blog copied in;
|
|
48
|
+
puppeteer-core + ~/.cache/ms-playwright chromium_headless_shell for screenshots.
|