domma-cms 0.109.1 → 0.109.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.109.1",
3
+ "version": "0.109.3",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -34,6 +34,7 @@ const {
34
34
  unreadInSubtree
35
35
  } = await import(versioned('folder-tree.js'));
36
36
  const {imageSendersSection} = await import(versioned('image-senders-section.js'));
37
+ const {createSettingsSection, READ_DELAY_KEY, readDelaySeconds, scheduleMarkRead} = await import(versioned('settings-section.js'));
37
38
  const {createSplitters, fitToViewport} = await import(versioned('panes.js'));
38
39
  const {createDiagnosticsSection} = await import(versioned('diagnostics-section.js'));
39
40
  const {fmtDate, fmtDateTime} = await import(new URL(`../ui/dates.js?v=${ASSET_VERSION}`, import.meta.url).href);
@@ -184,7 +185,6 @@ const THREADS_KEY = 'domma-mail.threads';
184
185
  * Long enough that arrowing through a list does not mark everything it passed
185
186
  * over, short enough that actually reading something always marks it.
186
187
  */
187
- const MARK_READ_DELAY_MS = 1000;
188
188
 
189
189
  /** How long the undo strip stays on screen. */
190
190
  const UNDO_WINDOW_MS = 12_000;
@@ -740,6 +740,14 @@ return {
740
740
  });
741
741
 
742
742
  const sections = [
743
+ createSettingsSection({
744
+ canWrite: features.write,
745
+ load: () => readDelaySeconds(S.get(READ_DELAY_KEY)),
746
+ save: seconds => {
747
+ S.set(READ_DELAY_KEY, seconds);
748
+ cancelMarkRead();
749
+ }
750
+ }),
743
751
  {
744
752
  id: 'mailboxes',
745
753
  label: 'Mailboxes',
@@ -2126,10 +2134,8 @@ return {
2126
2134
  const message = await api(`${BASE}/messages/${uid}?${params}`);
2127
2135
  renderMessage(message);
2128
2136
 
2129
- // Opening mail marks it read after a pause, not instantly.
2130
- // Arrowing through a list would otherwise mark everything it
2131
- // passed over, and the delay is the difference between reading
2132
- // something and glancing at it.
2137
+ // The reading preference controls how long mail stays open
2138
+ // before marking it read; zero applies the flag immediately.
2133
2139
  //
2134
2140
  // Only where the edition can write at all: the free reader's
2135
2141
  // guarantee is that looking at mail never changes it, and that
@@ -2137,14 +2143,14 @@ return {
2137
2143
  const row = messages.find(m => m.uid === uid);
2138
2144
  if (features.write && row && row.seen === false) {
2139
2145
  const forFolder = openFolder;
2140
- markReadTimer = setTimeout(() => {
2146
+ markReadTimer = scheduleMarkRead(() => {
2141
2147
  markReadTimer = null;
2142
2148
  // Still the message on screen? If the user moved on
2143
2149
  // during the pause, they did not read this one.
2144
2150
  if (selectedUid$.value !== uid) return;
2145
2151
  changeFlags({...message, folder: forFolder}, {add: ['\\Seen']})
2146
2152
  .catch(() => {/* not worth interrupting a read for */});
2147
- }, MARK_READ_DELAY_MS);
2153
+ }, readDelaySeconds(S.get(READ_DELAY_KEY)));
2148
2154
  }
2149
2155
  } catch (err) {
2150
2156
  handleError(err);
@@ -0,0 +1,60 @@
1
+ /** Browser preferences for the shared mail reader. */
2
+ export const READ_DELAY_KEY = 'domma-mail.mark-read-seconds';
3
+ export const DEFAULT_READ_DELAY_SECONDS = 1;
4
+
5
+ export function readDelaySeconds(value) {
6
+ if (value === null || value === undefined || value === '') return DEFAULT_READ_DELAY_SECONDS;
7
+ const seconds = Number(value);
8
+ return Number.isFinite(seconds) && seconds >= 0 && seconds <= 2147483
9
+ ? seconds : DEFAULT_READ_DELAY_SECONDS;
10
+ }
11
+
12
+ export function scheduleMarkRead(markRead, seconds, schedule = setTimeout) {
13
+ const delay = readDelaySeconds(seconds);
14
+ if (delay === 0) {
15
+ markRead();
16
+ return null;
17
+ }
18
+ return schedule(markRead, delay * 1000);
19
+ }
20
+
21
+ export function createSettingsSection({canWrite, load, save}) {
22
+ return {
23
+ id: 'settings',
24
+ label: 'Settings',
25
+ help: 'Preferences for reading email in this browser.',
26
+ async render(container, ctx) {
27
+ const kit = await import(new URL(`./section-kit.js?v=${ctx.assetVersion ?? '1'}`, import.meta.url).href);
28
+ kit.note(container, 'Settings are saved in this browser and apply to all your mailboxes.');
29
+ if (!canWrite) {
30
+ kit.note(container, 'This reader leaves messages unread. Automatic marking as read requires an edition that can update messages.');
31
+ return;
32
+ }
33
+ const {state} = kit.formIn(container, {
34
+ markReadSeconds: {
35
+ type: 'number',
36
+ label: 'How long before a message is marked as read? (seconds)',
37
+ required: true,
38
+ min: 0,
39
+ max: 2147483,
40
+ formConfig: {hint: '0 marks a message as read immediately. Otherwise, it must stay open for this long.'}
41
+ }
42
+ }, {markReadSeconds: load()});
43
+ const bar = kit.actionBar(container);
44
+ kit.button(bar.row, 'Save', () => {
45
+ const value = state.markReadSeconds;
46
+ const seconds = Number(value);
47
+ if (value === '' || value === null || value === undefined || !Number.isFinite(seconds) || seconds < 0 || seconds > 2147483) {
48
+ bar.say('Enter a number of seconds between 0 and 2147483.', 'bad');
49
+ return;
50
+ }
51
+ try {
52
+ save(seconds);
53
+ bar.say('Saved.', 'ok');
54
+ } catch (err) {
55
+ bar.say(err.message || 'Settings could not be saved.', 'bad');
56
+ }
57
+ }, {variant: 'primary'});
58
+ }
59
+ };
60
+ }
package/server/server.js CHANGED
@@ -29,7 +29,8 @@ import {seedDefaultBlocks} from './services/blocks.js';
29
29
  import {seedDefaultComponents} from './services/components.js';
30
30
  import {refreshComponentTagAllowlist} from './services/markdown.js';
31
31
  import * as cache from './services/cache/index.js';
32
- import {isGenuinelyLocal, parseTrustProxy} from './services/network.js';
32
+ import {parseTrustProxy} from './services/network.js';
33
+ import {rateLimitOptions} from './services/rate-limit.js';
33
34
 
34
35
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
35
36
  const ROOT = path.resolve(__dirname, '..');
@@ -148,15 +149,7 @@ if (publicCorsPrefixes.length) {
148
149
  app.log.info(`[cors] open reads on ${publicCorsPrefixes.join(', ')}`);
149
150
  }
150
151
  await app.register(multipart, { limits: { fileSize: serverConfig.uploads.maxFileSize } });
151
- await app.register(rateLimit, {
152
- global: true, // apply default limit to all routes; stricter per-route limits override this
153
- max: 500,
154
- timeWindow: '1 minute',
155
- // Only a genuinely local call is exempt (loopback socket, no X-Forwarded-For).
156
- // A static loopback list exempted every visitor relayed by a proxy that is
157
- // not trusted - on dcm, everyone, the login included. See services/network.js.
158
- allowList: (request) => isGenuinelyLocal(request),
159
- });
152
+ await app.register(rateLimit, rateLimitOptions());
160
153
 
161
154
  // Negotiates brotli/gzip/deflate per Accept-Encoding. Skips already-compressed
162
155
  // content types (images, fonts, video) and responses under threshold.
@@ -744,4 +737,3 @@ try {
744
737
  process.exit(1);
745
738
  }
746
739
  // scaffold-restart 1779615684
747
-
@@ -0,0 +1,37 @@
1
+ import {isGenuinelyLocal} from './network.js';
2
+
3
+ /** Browser assets should never spend the API's request budget. */
4
+ export function isBrowserAsset(request) {
5
+ if (!['GET', 'HEAD'].includes(request.method)) return false;
6
+ const path = request.url.split('?')[0];
7
+ return /^\/(?:admin|plugins|dist|public)\//.test(path)
8
+ && /\.(?:m?js|css|map|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf|html)$/i.test(path);
9
+ }
10
+
11
+ export function rateLimitOptions({publicMax = 500, authenticatedMax = 3000} = {}) {
12
+ // Keep verified identities separate from public IP buckets. A supplied
13
+ // bearer string alone is never enough to receive the larger allowance.
14
+ const identities = new WeakMap();
15
+ return {
16
+ global: true,
17
+ timeWindow: '1 minute',
18
+ async keyGenerator(request) {
19
+ // Explicit route limits retain their original IP-based buckets,
20
+ // even when the caller also holds a valid access token.
21
+ if (request.routeOptions.config.rateLimit) return request.ip;
22
+ if (request.url.startsWith('/api/') && request.headers.authorization) {
23
+ try {
24
+ const user = await request.jwtVerify();
25
+ if (user.type === 'access' && user.id) {
26
+ identities.set(request, user.id);
27
+ return `user:${user.id}`;
28
+ }
29
+ } catch { /* invalid tokens retain the public limit */ }
30
+ }
31
+ return request.ip;
32
+ },
33
+ max: request => identities.has(request) ? authenticatedMax : publicMax,
34
+ allowList: request => isGenuinelyLocal(request)
35
+ || (!request.routeOptions.config.rateLimit && isBrowserAsset(request))
36
+ };
37
+ }