@snagset/server 0.1.10 → 0.1.11

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.
Files changed (94) hide show
  1. package/dist/app.js +5 -0
  2. package/dist/app.js.map +1 -1
  3. package/dist/config.generated.js +2 -1
  4. package/dist/config.generated.js.map +1 -1
  5. package/dist/db/migrations/0008_outbox_org_rows.sql +29 -0
  6. package/dist/jobs/set.js +15 -4
  7. package/dist/jobs/set.js.map +1 -1
  8. package/dist/mail/digest.js +291 -0
  9. package/dist/mail/digest.js.map +1 -0
  10. package/dist/mail/links.js +41 -0
  11. package/dist/mail/links.js.map +1 -0
  12. package/dist/mail/notify.js +126 -25
  13. package/dist/mail/notify.js.map +1 -1
  14. package/dist/net/publicFetch.js +202 -0
  15. package/dist/net/publicFetch.js.map +1 -0
  16. package/dist/routes/commentEdit.js +20 -5
  17. package/dist/routes/commentEdit.js.map +1 -1
  18. package/dist/routes/imageProxy.js +77 -0
  19. package/dist/routes/imageProxy.js.map +1 -0
  20. package/dist/routes/threads.js +182 -42
  21. package/dist/routes/threads.js.map +1 -1
  22. package/package.json +3 -3
  23. package/static/dashboard/assets/CompositeItem-Bg_GF93m.js +1 -0
  24. package/static/dashboard/assets/{ConfirmDialog-eCzw7vjK.js → ConfirmDialog-WnZ92dk2.js} +1 -1
  25. package/static/dashboard/assets/{CopyButton-lcjux1TA.js → CopyButton-rL26RLOJ.js} +1 -1
  26. package/static/dashboard/assets/{FieldItemContext-i5zdyQYl.js → FieldItemContext-BFJSEdB-.js} +1 -1
  27. package/static/dashboard/assets/{InstallTag-Eqs3RM9e.js → InstallTag-BKIArjPk.js} +1 -1
  28. package/static/dashboard/assets/{SaveState-Cf_bnJmg.js → SaveState-DVHi0zeB.js} +1 -1
  29. package/static/dashboard/assets/{_app-CuKLSKZ2.js → _app-D2r-zfbF.js} +1 -1
  30. package/static/dashboard/assets/_app-iK-weT5A.js +56 -0
  31. package/static/dashboard/assets/activity-CCqTW3Th.js +1 -0
  32. package/static/dashboard/assets/{arrow-left-CQUHEFUP.js → arrow-left-BEHXEKLy.js} +1 -1
  33. package/static/dashboard/assets/{avatar-CWT37Ivf.js → avatar-BnPBt2UW.js} +1 -1
  34. package/static/dashboard/assets/{badge-thtKAJ0h.js → badge-B2rHdH8b.js} +1 -1
  35. package/static/dashboard/assets/{card-CsXE7vKD.js → card-BNGTf8dq.js} +1 -1
  36. package/static/dashboard/assets/{check-Bc_1fmPx.js → check-C3gaSIDM.js} +1 -1
  37. package/static/dashboard/assets/{chevron-right-neQQkaOt.js → chevron-right-CLLS6JcX.js} +1 -1
  38. package/static/dashboard/assets/{copy-O3USBatC.js → copy-Dy7nBeu2.js} +1 -1
  39. package/static/dashboard/assets/{dialog-MC-TOvQV.js → dialog-q6J2VL2H.js} +2 -2
  40. package/static/dashboard/assets/{dropdown-menu-C2ZhmONu.js → dropdown-menu-DFZrgFpI.js} +1 -1
  41. package/static/dashboard/assets/{image-off-Bu6l8gP5.js → image-off-Bug-3iC-.js} +1 -1
  42. package/static/dashboard/assets/index-ClhJtAFR.css +1 -0
  43. package/static/dashboard/assets/index-CtyJ8ep7.js +169 -0
  44. package/static/dashboard/assets/{label-BTdAT39L.js → label-DoQ07Q_y.js} +1 -1
  45. package/static/dashboard/assets/{layout-grid-rYWIGaIJ.js → layout-grid-B3BkFR60.js} +1 -1
  46. package/static/dashboard/assets/me-NFjosbdz.js +1 -0
  47. package/static/dashboard/assets/{parts-kPNZVO9H.js → parts-BTWw4Uv-.js} +1 -1
  48. package/static/dashboard/assets/{plus-CcJ1Zwa7.js → plus-DqdyxL8q.js} +1 -1
  49. package/static/dashboard/assets/s._site-kVvU6ucw.js +1 -0
  50. package/static/dashboard/assets/s._site.comments-kEGEpFR2.js +54 -0
  51. package/static/dashboard/assets/{s._site.reviewers-Djq2t6Dx.js → s._site.reviewers-C1LLoSS5.js} +1 -1
  52. package/static/dashboard/assets/s._site.settings-CeKMuuRo.js +1 -0
  53. package/static/dashboard/assets/{setup-D748SEIm.js → setup-CdZYVZmd.js} +1 -1
  54. package/static/dashboard/assets/{sign-in-CKSiZWCo.js → sign-in-CvstpETe.js} +1 -1
  55. package/static/dashboard/assets/{sign-up-BX9j_Ya2.js → sign-up-Cq7YvdYN.js} +1 -1
  56. package/static/dashboard/assets/sites-CUWMKMCM.js +1 -0
  57. package/static/dashboard/assets/{studio-HH_J6PPv.js → studio-BfA8kzFc.js} +1 -1
  58. package/static/dashboard/assets/studio-uAl1i7Nd.js +1 -0
  59. package/static/dashboard/assets/studio.index-BTF7YIX-.js +1 -0
  60. package/static/dashboard/assets/studio.integrations-Nr_jBcXh.js +1 -0
  61. package/static/dashboard/assets/{studio.members-DTbKcrEI.js → studio.members-BtbPt4iX.js} +1 -1
  62. package/static/dashboard/assets/{sun-CWPmS6_O.js → sun-s_t6Zx3r.js} +1 -1
  63. package/static/dashboard/assets/switch-DbqX1mWE.js +1 -0
  64. package/static/dashboard/assets/{table-CiZO2uuW.js → table-CrWNqpP8.js} +1 -1
  65. package/static/dashboard/assets/{useAriaLabelledBy-oZFqC809.js → useAriaLabelledBy-CgL3SaJd.js} +1 -1
  66. package/static/dashboard/assets/{useBaseQuery-CHgLQD-M.js → useBaseQuery-CBTINzcX.js} +1 -1
  67. package/static/dashboard/assets/useQuery-BPuxCfhJ.js +1 -0
  68. package/static/dashboard/assets/useRender-BCpbp3WG.js +1 -0
  69. package/static/dashboard/assets/{useValueChanged-Dvr7PvAV.js → useValueChanged-B20GM2vK.js} +1 -1
  70. package/static/dashboard/assets/{users-Ci-ogubz.js → users-CQwkq_Tk.js} +1 -1
  71. package/static/dashboard/assets/{welcome-BDWw5Qlv.js → welcome-W1YxYlGW.js} +1 -1
  72. package/static/dashboard/assets/{you-DAUNHAFI.js → you-DXZRdbGS.js} +1 -1
  73. package/static/dashboard/assets/{zod-CtjFmGX5.js → zod-DWEgoEmm.js} +1 -1
  74. package/static/dashboard/index.html +2 -2
  75. package/static/widget/snag.capture.js +1 -1
  76. package/static/widget/snag.dock.js +1 -1
  77. package/static/widget/snag.runtime.js +2 -2
  78. package/static/widget/snag.screenshot.js +1 -1
  79. package/static/dashboard/assets/CompositeItem-Db_8iHSG.js +0 -1
  80. package/static/dashboard/assets/_app-qNVG5lsX.js +0 -56
  81. package/static/dashboard/assets/activity-CQLBFIV4.js +0 -1
  82. package/static/dashboard/assets/index-BR4Mnw-o.css +0 -1
  83. package/static/dashboard/assets/index-C6gyyLd5.js +0 -169
  84. package/static/dashboard/assets/me-CI9giaT9.js +0 -1
  85. package/static/dashboard/assets/s._site-D0HRtia-.js +0 -1
  86. package/static/dashboard/assets/s._site.comments-D3ACsr3a.js +0 -39
  87. package/static/dashboard/assets/s._site.settings-D0b6vJci.js +0 -1
  88. package/static/dashboard/assets/sites-QwJy87Y8.js +0 -1
  89. package/static/dashboard/assets/studio-B_ma5JGA.js +0 -1
  90. package/static/dashboard/assets/studio.index-DrtJF7DT.js +0 -1
  91. package/static/dashboard/assets/studio.integrations-Cv34ukzT.js +0 -1
  92. package/static/dashboard/assets/switch-CzijGUed.js +0 -1
  93. package/static/dashboard/assets/useQuery-C6FyjMS-.js +0 -1
  94. package/static/dashboard/assets/useRender-D5jZ1wns.js +0 -1
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Where a notification's link goes: the thread, open, in the dashboard (D120).
3
+ *
4
+ * Until D120 every mail linked `<address>/dashboard`, and the dashboard is
5
+ * served at the ROOT of the address (`app.ts`'s fallback, 08 §3), so that link
6
+ * landed on a route the SPA does not have. The comments page has always taken
7
+ * the thread as a search parameter — every view there is a link, D87 — and the
8
+ * round with it, because the list shows the latest round unless told
9
+ * otherwise, and a thread from an earlier round would open to nothing.
10
+ *
11
+ * One function for both mails, the per-comment one and the digest, so the two
12
+ * cannot drift into linking different places for the same thread.
13
+ */
14
+ export function threadUrl(baseUrl, site, key, round) {
15
+ const q = new URLSearchParams({ thread: key });
16
+ if (round !== null)
17
+ q.set("round", String(round));
18
+ return `${baseUrl}/s/${encodeURIComponent(site)}/comments?${q.toString()}`;
19
+ }
20
+ /** The dashboard itself — what a mail links to when it is about no one thread. */
21
+ export function dashboardUrl(baseUrl) {
22
+ return `${baseUrl}/`;
23
+ }
24
+ /**
25
+ * The link for a row that names a thread, read at SEND time: the round and
26
+ * the site's uid are not in the event, and a row queued before D120 has
27
+ * neither. A thread that has gone since falls back to the dashboard rather
28
+ * than to a link that opens nothing.
29
+ */
30
+ export async function threadLink(sql, threadId, baseUrl) {
31
+ if (!threadId)
32
+ return dashboardUrl(baseUrl);
33
+ const [t] = await sql `
34
+ SELECT p.uid, t.key, r.number AS round
35
+ FROM threads t
36
+ JOIN projects p ON p.id = t.project_id
37
+ LEFT JOIN rounds r ON r.id = t.round_id
38
+ WHERE t.id = ${threadId}::uuid AND t.deleted_at IS NULL`;
39
+ return t ? threadUrl(baseUrl, t.uid, t.key, t.round) : dashboardUrl(baseUrl);
40
+ }
41
+ //# sourceMappingURL=links.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"links.js","sourceRoot":"","sources":["../../src/mail/links.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,SAAS,CAAC,OAAe,EAAE,IAAY,EAAE,GAAW,EAAE,KAAoB;IACxF,MAAM,CAAC,GAAG,IAAI,eAAe,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;IAC/C,IAAI,KAAK,KAAK,IAAI;QAAE,CAAC,CAAC,GAAG,CAAC,OAAO,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;IAClD,OAAO,GAAG,OAAO,MAAM,kBAAkB,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC;AAC7E,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,YAAY,CAAC,OAAe;IAC1C,OAAO,GAAG,OAAO,GAAG,CAAC;AACvB,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAC9B,GAA2C,EAC3C,QAAuB,EACvB,OAAe;IAEf,IAAI,CAAC,QAAQ;QAAE,OAAO,YAAY,CAAC,OAAO,CAAC,CAAC;IAC5C,MAAM,CAAC,CAAC,CAAC,GAAG,MAAM,GAAG,CAAsD;;;;;oBAKzD,QAAQ,iCAAiC,CAAC;IAC5D,OAAO,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,OAAO,EAAE,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC;AAC/E,CAAC"}
@@ -4,6 +4,8 @@ import { redactText } from "../vendor/widget/predicates.js";
4
4
  import { noteQueued } from "../jobs/soon.js";
5
5
  import { isConfigured } from "./mailer.js";
6
6
  import { unsubscribeHeaders, unsubscribeToken } from "./unsubscribe.js";
7
+ import { DIGEST_TOPIC, pruneDigest, renderDigest } from "./digest.js";
8
+ import { threadLink } from "./links.js";
7
9
  /**
8
10
  * Notification email — L1.4, and deliberately ONE event.
9
11
  *
@@ -16,6 +18,13 @@ import { unsubscribeHeaders, unsubscribeToken } from "./unsubscribe.js";
16
18
  * does not exist yet. The rest lands with full M2-32, behind subscription and
17
19
  * unsubscribe.
18
20
  *
21
+ * Since D120 the owner's half arrives as a DAILY DIGEST by default
22
+ * (`SNAGSET_MAIL_NOTIFY=digest`, 08 §4.8): the rows below are queued exactly
23
+ * as before, the delivery job leaves them alone, and `digest.ts` folds a day's
24
+ * worth into one outbox row of its own. `each` restores one mail per comment.
25
+ * In both modes nobody is mailed about a comment they wrote — see
26
+ * `queueCommentNotice`.
27
+ *
19
28
  * ── HOW "A RETRY NEVER DOUBLE-SENDS" IS ACTUALLY ACHIEVED ──
20
29
  *
21
30
  * Two mechanisms, and both are needed:
@@ -58,18 +67,37 @@ export async function queueCommentNotice(tx, notice, id) {
58
67
  pageUrl: notice.pageUrl,
59
68
  commentId: notice.commentId,
60
69
  };
61
- // THE OWNER's row. No recipient in the event: the delivery job reads that
62
- // absence as "the project owner", which is what every row queued before
63
- // this fan-out existed also means. Backward compatibility by shape rather
64
- // than by version flag.
65
- await tx `
66
- INSERT INTO outbox (id, org_id, project_id, thread_id, provider, topic, dedupe_key, event)
67
- VALUES (
68
- ${id}, ${notice.orgId}, ${notice.projectId}, ${notice.threadId},
69
- 'email', ${TOPIC}, ${`${TOPIC}:${notice.commentId}`}, ${tx.json(payload)}
70
- )
71
- ON CONFLICT (provider, dedupe_key) DO NOTHING
72
- `;
70
+ const owner = await ownerOf(tx, notice.orgId);
71
+ const author = await reviewerIdentity(tx, notice.authorId);
72
+ /**
73
+ * THE OWNER's row — unless the owner wrote this comment (D120).
74
+ *
75
+ * "If there is a comment we made, we don't need to receive the email." The
76
+ * owner writes in two ways and both are theirs: from the dashboard, where
77
+ * the author is their studio reviewer row (`user_id` set), and through the
78
+ * widget on their own site, where the author is whatever reviewer identity
79
+ * they typed — "eurico-test" — with their own address in it. The first is
80
+ * matched by user id, the second by address, case-insensitively, because an
81
+ * address typed into a widget has whatever capitals the hand gave it.
82
+ *
83
+ * Decided HERE, at enqueue, rather than at send: the row that is never
84
+ * written is the one that cannot be sent in `each` mode and cannot be folded
85
+ * into a digest in `digest` mode, so one check covers both.
86
+ *
87
+ * No recipient in the event: the delivery job reads that absence as "the
88
+ * project owner", which is what every row queued before the fan-out existed
89
+ * also means. Backward compatibility by shape rather than by version flag.
90
+ */
91
+ if (!isSamePerson(author, owner)) {
92
+ await tx `
93
+ INSERT INTO outbox (id, org_id, project_id, thread_id, provider, topic, dedupe_key, event)
94
+ VALUES (
95
+ ${id}, ${notice.orgId}, ${notice.projectId}, ${notice.threadId},
96
+ 'email', ${TOPIC}, ${`${TOPIC}:${notice.commentId}`}, ${tx.json(payload)}
97
+ )
98
+ ON CONFLICT (provider, dedupe_key) DO NOTHING
99
+ `;
100
+ }
73
101
  /**
74
102
  * …and ONE ROW PER PARTICIPANT — B2's "created or replied".
75
103
  *
@@ -87,6 +115,12 @@ export async function queueCommentNotice(tx, notice, id) {
87
115
  * Excluded: the author of this very comment (nobody needs mail about their
88
116
  * own typing), anyone with no address, anyone who unsubscribed, and anyone
89
117
  * blocked or erased.
118
+ *
119
+ * …and THE OWNER, under any identity (D120). The owner already has the row
120
+ * above; if they have also replied on this thread from the dashboard, their
121
+ * studio reviewer row is a participant too, and without this they got the
122
+ * same comment twice — and in `digest` mode the participant copy would have
123
+ * arrived at once, around the digest that exists to stop exactly that.
90
124
  */
91
125
  await fanOut(tx, {
92
126
  orgId: notice.orgId,
@@ -95,6 +129,8 @@ export async function queueCommentNotice(tx, notice, id) {
95
129
  topic: TOPIC,
96
130
  keyPrefix: `${TOPIC}:${notice.commentId}`,
97
131
  exclude: notice.authorId,
132
+ excludeEmail: author?.email ?? null,
133
+ excludeOwner: owner,
98
134
  payload,
99
135
  });
100
136
  // D119: the request drains this once its response is ready.
@@ -109,6 +145,9 @@ export async function queueCommentNotice(tx, notice, id) {
109
145
  * somebody who did not ask.
110
146
  */
111
147
  async function fanOut(tx, o) {
148
+ const email = o.excludeEmail ?? null;
149
+ const ownerUser = o.excludeOwner?.userId ?? null;
150
+ const ownerEmail = o.excludeOwner?.email ?? null;
112
151
  const participants = await tx `
113
152
  SELECT DISTINCT r.id
114
153
  FROM comments c
@@ -120,6 +159,9 @@ async function fanOut(tx, o) {
120
159
  AND r.notifications_off_at IS NULL
121
160
  AND r.blocked_at IS NULL
122
161
  AND r.erased_at IS NULL
162
+ AND (${email}::text IS NULL OR lower(r.email) <> lower(${email}::text))
163
+ AND (${ownerUser}::uuid IS NULL OR r.user_id IS DISTINCT FROM ${ownerUser}::uuid)
164
+ AND (${ownerEmail}::text IS NULL OR lower(r.email) <> lower(${ownerEmail}::text))
123
165
  `;
124
166
  for (const p of participants) {
125
167
  await tx `
@@ -133,6 +175,40 @@ async function fanOut(tx, o) {
133
175
  `;
134
176
  }
135
177
  }
178
+ /**
179
+ * THE owner of an org — the first `owner` membership, as it has always been
180
+ * chosen for owner mail. One query in one place, because "who is the owner"
181
+ * answered differently at enqueue (am I mailing myself?) and at send (whom do
182
+ * I mail?) is how the self-check passes and the mail still arrives.
183
+ */
184
+ export async function ownerOf(sql, orgId) {
185
+ const [owner] = await sql `
186
+ SELECT u.id AS user_id, u.email
187
+ FROM memberships m
188
+ JOIN users u ON u.id = m.user_id
189
+ WHERE m.org_id = ${orgId} AND m.role = 'owner' AND u.deleted_at IS NULL
190
+ ORDER BY m.created_at
191
+ LIMIT 1`;
192
+ return owner ? { userId: owner.user_id, email: owner.email } : null;
193
+ }
194
+ /** A reviewer row's link to a person: the dashboard user behind it, and its address. */
195
+ async function reviewerIdentity(sql, reviewerId) {
196
+ const [r] = await sql `
197
+ SELECT user_id, email FROM reviewers WHERE id = ${reviewerId}::uuid`;
198
+ return r ? { userId: r.user_id, email: r.email } : null;
199
+ }
200
+ /**
201
+ * Is this reviewer identity the owner? By user id first — the studio row the
202
+ * dashboard writes with — and then by address, case-insensitively, which is
203
+ * what catches the owner reviewing their own site through the widget.
204
+ */
205
+ export function isSamePerson(who, owner) {
206
+ if (!who || !owner)
207
+ return false;
208
+ if (who.userId && who.userId === owner.userId)
209
+ return true;
210
+ return !!who.email && who.email.trim().toLowerCase() === owner.email.trim().toLowerCase();
211
+ }
136
212
  const RESOLVED = "thread.resolved";
137
213
  /**
138
214
  * "Somebody marked your snag resolved" — B2's second topic.
@@ -157,6 +233,8 @@ export async function queueResolvedNotice(tx, notice) {
157
233
  topic: RESOLVED,
158
234
  keyPrefix: `${RESOLVED}:${notice.threadId}:${notice.version}`,
159
235
  exclude: notice.actorId,
236
+ // The person who clicked, under any identity — the same rule as a comment.
237
+ excludeEmail: (await reviewerIdentity(tx, notice.actorId))?.email ?? null,
160
238
  payload: {
161
239
  threadKey: notice.threadKey,
162
240
  actorName: notice.actorName,
@@ -173,8 +251,14 @@ const BACKOFF_MIN = [1, 5, 15, 30, 60];
173
251
  /**
174
252
  * The delivery job. Registered only when a mailer is configured — with none,
175
253
  * there is nothing to drain and the absence is reported at boot instead.
254
+ *
255
+ * `notify` defaults to `each` HERE — the job's behaviour before D120, for a
256
+ * caller that does not say — while the PRODUCT's default is `digest`, which
257
+ * `jobs/set.ts` reads from the registry. The registry is where a default an
258
+ * operator can see belongs; a function's default is only what it does when
259
+ * nobody asked.
176
260
  */
177
- export function deliverEmail(mailer, address, keys) {
261
+ export function deliverEmail(mailer, address, keys, notify = "each") {
178
262
  return {
179
263
  name: "deliver-email",
180
264
  everyMs: 30_000,
@@ -187,6 +271,11 @@ export function deliverEmail(mailer, address, keys) {
187
271
  // that one provider timeout takes the whole batch's rows to `inflight`
188
272
  // with nothing sent. One row per pass, `more: true` to drain — the
189
273
  // engine's batching, reused rather than reinvented.
274
+ //
275
+ // In `digest` mode the owner's comment rows are NOT claimable: they wait
276
+ // for `digest.ts` to fold them. Excluded in the claim itself rather than
277
+ // claimed and put back, because a row moved to `inflight` and back is a
278
+ // row the "never twice" machinery has to reason about for nothing.
190
279
  const [row] = await sql `
191
280
  UPDATE outbox SET status = 'inflight', attempts = attempts + 1
192
281
  WHERE id = (
@@ -194,11 +283,12 @@ export function deliverEmail(mailer, address, keys) {
194
283
  WHERE provider = 'email'
195
284
  AND status = 'pending'
196
285
  AND next_attempt_at <= ${now}
286
+ ${notify === "digest" ? sql `AND NOT (topic = ${TOPIC} AND event->>'toReviewer' IS NULL)` : sql ``}
197
287
  ORDER BY next_attempt_at
198
288
  FOR UPDATE SKIP LOCKED
199
289
  LIMIT 1
200
290
  )
201
- RETURNING id, org_id, project_id, thread_id, attempts, event
291
+ RETURNING id, org_id, project_id, thread_id, topic, attempts, event
202
292
  `;
203
293
  if (!row)
204
294
  return { did: 0 };
@@ -217,6 +307,17 @@ export function deliverEmail(mailer, address, keys) {
217
307
  await sql `UPDATE outbox SET status = 'dropped', last_error = 'no deliverable recipient', completed_at = ${now} WHERE id = ${row.id}`;
218
308
  return { did: 0, more: true, note: "one notification had no recipient — dropped" };
219
309
  }
310
+ // A digest without the sites deleted since it was folded; with none
311
+ // left there is nothing to say, and it is dropped before it can count
312
+ // against the address's allowance.
313
+ let digest = null;
314
+ if (row.topic === DIGEST_TOPIC) {
315
+ digest = await pruneDigest(sql, row.org_id, row.event);
316
+ if (!digest) {
317
+ await sql `UPDATE outbox SET status = 'dropped', last_error = 'every site in the digest was deleted', completed_at = ${now} WHERE id = ${row.id}`;
318
+ return { did: 0, more: true, note: "one digest listed only deleted sites — dropped" };
319
+ }
320
+ }
220
321
  /**
221
322
  * PER-ADDRESS RATE LIMIT — B2's last piece.
222
323
  *
@@ -241,8 +342,14 @@ export function deliverEmail(mailer, address, keys) {
241
342
  WHERE id = ${row.id}`;
242
343
  return { did: 0, more: true, note: "one notification deferred — address over its hourly rate" };
243
344
  }
345
+ // The digest is its own message (`digest.ts`); every other row links
346
+ // to its thread, read now — outside the `try`, because a failing read
347
+ // is the database's problem, not a send to back off from.
348
+ const mail = digest
349
+ ? renderDigest(digest, to.email, baseUrl)
350
+ : render(row, to.email, await threadLink(sql, row.thread_id, baseUrl));
244
351
  try {
245
- await mailer.send({ ...render(row, to.email, baseUrl), ...(to.headers ? { headers: to.headers } : {}) });
352
+ await mailer.send({ ...mail, ...(to.headers ? { headers: to.headers } : {}) });
246
353
  await sql `
247
354
  UPDATE outbox SET status = 'done', completed_at = ${now}, last_error = NULL
248
355
  WHERE id = ${row.id}`;
@@ -341,13 +448,7 @@ async function recipient(sql, row, keys, baseUrl) {
341
448
  ? { email: r.email, headers: unsubscribeHeaders(baseUrl, unsubscribeToken(keys, reviewerId)) }
342
449
  : { email: r.email };
343
450
  }
344
- const [owner] = await sql `
345
- SELECT u.email
346
- FROM memberships m
347
- JOIN users u ON u.id = m.user_id
348
- WHERE m.org_id = ${row.org_id} AND m.role = 'owner' AND u.deleted_at IS NULL
349
- ORDER BY m.created_at
350
- LIMIT 1`;
451
+ const owner = await ownerOf(sql, row.org_id);
351
452
  return owner ? { email: owner.email } : null;
352
453
  }
353
454
  /**
@@ -363,7 +464,7 @@ async function recipient(sql, row, keys, baseUrl) {
363
464
  * the ones above, and the message is four lines: who, where, what, and a link.
364
465
  * There is nothing here that a `<div>` would make clearer.
365
466
  */
366
- function render(row, to, baseUrl) {
467
+ function render(row, to, link) {
367
468
  const key = sanitise(row.event.threadKey ?? "a snag", "plain", { singleLine: true, max: 24 });
368
469
  const author = sanitise(row.event.authorName ?? "Someone", "plain", { singleLine: true, max: 60 });
369
470
  const body = sanitiseWithRedactor(row.event.body ?? "", "plain", redactText, { max: 2000 });
@@ -383,7 +484,7 @@ function render(row, to, baseUrl) {
383
484
  `${actor} marked ${key} resolved.`,
384
485
  "",
385
486
  page ? `On the page: ${page}` : "",
386
- `See the round: ${baseUrl}/dashboard`,
487
+ `See the thread: ${link}`,
387
488
  "",
388
489
  "— Snagset",
389
490
  ]
@@ -400,7 +501,7 @@ function render(row, to, baseUrl) {
400
501
  body,
401
502
  "",
402
503
  page ? `On the page: ${page}` : "",
403
- `Open the review: ${baseUrl}/dashboard`,
504
+ `Open the thread: ${link}`,
404
505
  "",
405
506
  "— Snagset",
406
507
  ]
@@ -1 +1 @@
1
- {"version":3,"file":"notify.js","sourceRoot":"","sources":["../../src/mail/notify.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"notify.js","sourceRoot":"","sources":["../../src/mail/notify.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,202 @@
1
+ import { lookup as dnsLookup } from "node:dns";
2
+ import { BlockList, isIP } from "node:net";
3
+ import http from "node:http";
4
+ import https from "node:https";
5
+ /**
6
+ * Fetch an image from the public internet, and from nowhere else — the image
7
+ * proxy's half on the server (D-image-proxy, found reviewing Bringg).
8
+ *
9
+ * A server that fetches a URL a browser hands it is a server that can be
10
+ * pointed at itself: `http://localhost:5432`, the cloud metadata address
11
+ * `169.254.169.254`, a database on the private network beside it. So the
12
+ * check is on the ADDRESS, not the name, and it happens at connect time,
13
+ * inside the socket's own DNS lookup: checking the name first and connecting
14
+ * afterwards is exactly the gap DNS rebinding drives through, because the
15
+ * second resolution can answer differently from the first. Redirects are
16
+ * followed by hand for the same reason — each hop is a new connection and
17
+ * gets the same check.
18
+ *
19
+ * Node's built-in BlockList, not a dependency: the ranges are short, fixed
20
+ * and RFC-numbered, and a parser library is one more thing on the path of
21
+ * every request this route serves.
22
+ */
23
+ const BLOCKED = new BlockList();
24
+ for (const [net, bits] of [
25
+ ["0.0.0.0", 8], // "this network"
26
+ ["10.0.0.0", 8], // RFC 1918
27
+ ["100.64.0.0", 10], // carrier-grade NAT
28
+ ["127.0.0.0", 8], // loopback
29
+ ["169.254.0.0", 16], // link-local, and the cloud metadata address in it
30
+ ["172.16.0.0", 12], // RFC 1918
31
+ ["192.0.0.0", 24], // IETF protocol assignments
32
+ ["192.0.2.0", 24], // documentation
33
+ ["192.88.99.0", 24], // 6to4 relay
34
+ ["192.168.0.0", 16], // RFC 1918
35
+ ["198.18.0.0", 15], // benchmarking
36
+ ["198.51.100.0", 24], // documentation
37
+ ["203.0.113.0", 24], // documentation
38
+ ["224.0.0.0", 4], // multicast
39
+ ["240.0.0.0", 4], // reserved, and broadcast
40
+ ]) {
41
+ BLOCKED.addSubnet(net, bits, "ipv4");
42
+ }
43
+ for (const [net, bits] of [
44
+ ["::", 128], // unspecified
45
+ ["::1", 128], // loopback
46
+ ["64:ff9b::", 96], // NAT64, which can reach an IPv4 private address
47
+ ["64:ff9b:1::", 48], // NAT64 local use
48
+ ["100::", 64], // discard
49
+ ["2001:db8::", 32], // documentation
50
+ ["fc00::", 7], // unique local
51
+ ["fe80::", 10], // link-local
52
+ ["ff00::", 8], // multicast
53
+ ]) {
54
+ BLOCKED.addSubnet(net, bits, "ipv6");
55
+ }
56
+ // NOT `::ffff:0:0/96`. Node's BlockList matches an IPv4 address against an
57
+ // IPv4-mapped IPv6 subnet, so that one line blocked the whole IPv4 internet —
58
+ // found by the test that expects 8.8.8.8 to be public. Mapped addresses are
59
+ // unwrapped and judged as IPv4 in isPublicAddress instead.
60
+ /** True when an address is one the proxy may connect to. */
61
+ export function isPublicAddress(address) {
62
+ const family = isIP(address);
63
+ if (family === 4)
64
+ return !BLOCKED.check(address, "ipv4");
65
+ if (family === 6) {
66
+ // `::ffff:10.0.0.1` is 10.0.0.1. Judged as what it reaches, not as what
67
+ // it is written as — the IPv6 range alone would say only "mapped".
68
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address)?.[1];
69
+ if (mapped)
70
+ return !BLOCKED.check(mapped, "ipv4");
71
+ return !BLOCKED.check(address, "ipv6");
72
+ }
73
+ return false;
74
+ }
75
+ export class PublicFetchError extends Error {
76
+ // Assigned in the body: the server runs under --experimental-strip-types,
77
+ // which has no parameter properties.
78
+ code;
79
+ constructor(code, message) {
80
+ super(message);
81
+ this.code = code;
82
+ }
83
+ }
84
+ const PORTS = new Set(["", "80", "443"]);
85
+ export async function fetchPublicImage(raw, opts) {
86
+ let url;
87
+ try {
88
+ url = new URL(raw);
89
+ }
90
+ catch {
91
+ throw new PublicFetchError("bad_url", "That is not a URL.");
92
+ }
93
+ const deadline = Date.now() + opts.timeoutMs;
94
+ for (let hop = 0; hop <= (opts.maxRedirects ?? 3); hop++) {
95
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
96
+ throw new PublicFetchError("bad_url", "Only http and https images can be fetched.");
97
+ }
98
+ // Credentials in a URL are someone's, and nothing an image needs. A port
99
+ // beyond the web's two is how "fetch this image" becomes "probe this
100
+ // service" — the allowed origin's images are served on 80 and 443.
101
+ if (url.username || url.password || (!opts.allowPrivate && !PORTS.has(url.port))) {
102
+ throw new PublicFetchError("bad_url", "Only images on the standard web ports can be fetched.");
103
+ }
104
+ const res = await request(url, deadline, opts);
105
+ if (res.status >= 300 && res.status < 400 && res.location) {
106
+ url = new URL(res.location, url);
107
+ continue;
108
+ }
109
+ if (res.status !== 200)
110
+ throw new PublicFetchError("upstream", `The image's server answered ${res.status}.`);
111
+ return { bytes: res.bytes, contentType: res.contentType };
112
+ }
113
+ throw new PublicFetchError("too_many_redirects", "The image redirected too many times.");
114
+ }
115
+ function guardedLookup(allowPrivate) {
116
+ return (hostname, options, callback) => {
117
+ dnsLookup(hostname, { ...options, all: true }, (err, addresses) => {
118
+ if (err)
119
+ return callback(err, []);
120
+ const list = addresses;
121
+ // EVERY answer, not the first: a name that resolves to one public and
122
+ // one private address would otherwise be a coin toss on which the
123
+ // socket picks.
124
+ if (!allowPrivate && (list.length === 0 || !list.every((a) => isPublicAddress(a.address)))) {
125
+ return callback(Object.assign(new Error("blocked_address"), { code: "EBLOCKED" }), []);
126
+ }
127
+ const all = options.all;
128
+ if (all)
129
+ return callback(null, list);
130
+ callback(null, list[0].address, list[0].family);
131
+ });
132
+ };
133
+ }
134
+ function request(url, deadline, opts) {
135
+ const lib = url.protocol === "https:" ? https : http;
136
+ // A literal IP skips DNS, so it is checked here instead.
137
+ const host = url.hostname.replace(/^\[|\]$/g, "");
138
+ if (!opts.allowPrivate && isIP(host) && !isPublicAddress(host)) {
139
+ return Promise.reject(new PublicFetchError("blocked_address", "That address is not on the public internet."));
140
+ }
141
+ return new Promise((resolve, reject) => {
142
+ const remaining = deadline - Date.now();
143
+ if (remaining <= 0)
144
+ return reject(new PublicFetchError("timeout", "The image took too long."));
145
+ const req = lib.get(url, {
146
+ lookup: guardedLookup(!!opts.allowPrivate),
147
+ timeout: remaining,
148
+ headers: { accept: "image/*", "user-agent": "Snagset image proxy (+https://snagset.com)" },
149
+ }, (res) => {
150
+ const status = res.statusCode ?? 0;
151
+ if (status >= 300 && status < 400) {
152
+ res.resume();
153
+ return resolve({ status, ...(res.headers.location ? { location: res.headers.location } : {}) });
154
+ }
155
+ if (status !== 200) {
156
+ res.resume();
157
+ return resolve({ status });
158
+ }
159
+ const contentType = String(res.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
160
+ // An image or nothing. The proxy exists to put pixels in a canvas; a
161
+ // page, a script or a JSON document has no business coming back.
162
+ if (!contentType.startsWith("image/")) {
163
+ res.destroy();
164
+ return reject(new PublicFetchError("not_an_image", `Expected an image; got "${contentType || "nothing"}".`));
165
+ }
166
+ const declared = Number(res.headers["content-length"] ?? 0);
167
+ if (declared > opts.maxBytes) {
168
+ res.destroy();
169
+ return reject(new PublicFetchError("too_large", "The image is larger than the proxy carries."));
170
+ }
171
+ const chunks = [];
172
+ let size = 0;
173
+ res.on("data", (chunk) => {
174
+ size += chunk.length;
175
+ // Counted as it arrives: a Content-Length can be absent or a lie.
176
+ if (size > opts.maxBytes) {
177
+ res.destroy();
178
+ reject(new PublicFetchError("too_large", "The image is larger than the proxy carries."));
179
+ return;
180
+ }
181
+ chunks.push(chunk);
182
+ });
183
+ res.on("end", () => resolve({ status, bytes: new Uint8Array(Buffer.concat(chunks)), contentType }));
184
+ res.on("error", (e) => reject(new PublicFetchError("upstream", e.message)));
185
+ });
186
+ req.on("timeout", () => {
187
+ req.destroy();
188
+ reject(new PublicFetchError("timeout", "The image took too long."));
189
+ });
190
+ req.on("error", (e) => {
191
+ if (e.code === "EBLOCKED" || e.message === "blocked_address") {
192
+ reject(new PublicFetchError("blocked_address", "That address is not on the public internet."));
193
+ }
194
+ else if (!(e instanceof PublicFetchError)) {
195
+ reject(new PublicFetchError("upstream", e.message));
196
+ }
197
+ else
198
+ reject(e);
199
+ });
200
+ });
201
+ }
202
+ //# sourceMappingURL=publicFetch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"publicFetch.js","sourceRoot":"","sources":["../../src/net/publicFetch.ts"],"names":[],"mappings":"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"}
@@ -26,8 +26,8 @@ export function commentEditRoutes(deps) {
26
26
  const [row] = await maintenance(sql, (tx) => tx `
27
27
  SELECT c.id, c.org_id, c.project_id, c.thread_id, c.author_id, c.body, c.private,
28
28
  c.created_at, c.deleted_at,
29
- t.private AS thread_private,
30
- r.name AS author_name, r.kind AS author_kind
29
+ t.private AS thread_private, t.deleted_at AS thread_deleted_at,
30
+ r.name AS author_name, r.kind AS author_kind, r.user_id AS author_user_id
31
31
  FROM comments c
32
32
  JOIN threads t ON t.id = c.thread_id
33
33
  JOIN reviewers r ON r.id = c.author_id
@@ -46,7 +46,18 @@ export function commentEditRoutes(deps) {
46
46
  const owner = c.get("owner");
47
47
  const session = c.get("session");
48
48
  const asOwner = !!owner && owner.orgId === row.org_id;
49
- const isAuthor = !!session && session.sub === row.author_id;
49
+ /**
50
+ * TWO ways to be the author, because there are two identity systems.
51
+ *
52
+ * A reviewer session's `sub` IS a `reviewers.id`. An owner has no `sub`:
53
+ * a dashboard reply is written under the studio member's own reviewer row
54
+ * (`studioReviewerId`, `user_id` set), so the owner is the author when THAT
55
+ * row's `user_id` is theirs. This used to read only the session half, so
56
+ * an owner could never edit their own reply — the "edit only their own"
57
+ * rule below was "edit nothing" for every dashboard comment ever written.
58
+ */
59
+ const isAuthor = (!!session && session.sub === row.author_id) ||
60
+ (asOwner && !!row.author_user_id && row.author_user_id === owner.userId);
50
61
  if (!asOwner && !session) {
51
62
  return fail(c, 401, "unauthorized", "Sign in, or open your review link, and try again.");
52
63
  }
@@ -105,7 +116,10 @@ export function commentEditRoutes(deps) {
105
116
  /** Endpoint 12 — `PATCH /api/v1/comments/{id}`. */
106
117
  const patch = async (c) => {
107
118
  const row = await load(c.req.param("id"));
108
- if (!row || row.deleted_at)
119
+ // A comment in a DELETED thread is gone with it. Without this a stale tab
120
+ // could edit words nobody can read any more, and the edit would bump the
121
+ // dead thread's `updated_at` for nothing.
122
+ if (!row || row.deleted_at || row.thread_deleted_at)
109
123
  return fail(c, 404, "comment_not_found", "No such comment.");
110
124
  const refused = refuse(c, row, "edit");
111
125
  if (refused)
@@ -148,7 +162,8 @@ export function commentEditRoutes(deps) {
148
162
  /** Endpoint 13 — `DELETE /api/v1/comments/{id}`. */
149
163
  const remove = async (c) => {
150
164
  const row = await load(c.req.param("id"));
151
- if (!row)
165
+ // Same as the edit: the thread's deletion took its comments with it.
166
+ if (!row || row.thread_deleted_at)
152
167
  return fail(c, 404, "comment_not_found", "No such comment.");
153
168
  // Deleting twice is the same outcome as deleting once, and a reviewer who
154
169
  // taps twice on a slow connection should not see an error for getting what