@takosjp/yurucommu-core 3.2.0 → 3.2.1
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/migrations/0019_notification_push_delivery.sql +10 -7
- package/package.json +5 -2
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/browser-push.ts +11 -0
- package/packages/api/src/lib/api/normalize.ts +15 -4
- package/packages/api/src/lib/api/notification-target.ts +106 -0
- package/packages/api/src/lib/api/push-config.ts +132 -0
- package/packages/api/src/lib/api.ts +2 -0
- package/packages/api/src/social-server.ts +7 -0
- package/packages/api/src/types/index.ts +13 -4
- package/src/backend/index.ts +44 -9
- package/src/backend/lib/attachments.ts +52 -0
- package/src/backend/lib/delivery/queue.ts +55 -0
- package/src/backend/lib/notification-eligibility.ts +150 -0
- package/src/backend/lib/notification-push.ts +159 -146
- package/src/backend/lib/session-actor.ts +16 -1
- package/src/backend/lib/unread-counts.ts +79 -0
- package/src/backend/middleware/csrf.ts +11 -0
- package/src/backend/routes/account-teardown.ts +13 -0
- package/src/backend/routes/auth-helpers.ts +10 -7
- package/src/backend/routes/auth.ts +124 -2
- package/src/backend/routes/communities/messages.ts +16 -33
- package/src/backend/routes/dm/contacts.ts +6 -44
- package/src/backend/routes/dm/messages.ts +5 -39
- package/src/backend/routes/notifications.ts +27 -70
- package/src/backend/routes/posts/transformers.ts +8 -10
- package/src/db/index.ts +11 -10
- package/src/db/schema/mobile.ts +7 -9
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared social-notification eligibility predicates.
|
|
3
|
+
*
|
|
4
|
+
* SINGLE owner of the WHERE fragments that decide whether an inbox row is a
|
|
5
|
+
* user-facing notification. Consumed by ALL of:
|
|
6
|
+
* - GET /api/notifications (list),
|
|
7
|
+
* - GET /api/notifications/unread/count (badge),
|
|
8
|
+
* - the push outbox processor (delivery-time re-check + push unread badge)
|
|
9
|
+
* so a change to eligibility (a new activity type, a new suppression rule)
|
|
10
|
+
* cannot silently drift between the list, the badge, and push delivery — the
|
|
11
|
+
* dispersion that past audits repeatedly flagged for visibility logic.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
and,
|
|
16
|
+
eq,
|
|
17
|
+
exists,
|
|
18
|
+
inArray,
|
|
19
|
+
isNull,
|
|
20
|
+
ne,
|
|
21
|
+
notExists,
|
|
22
|
+
or,
|
|
23
|
+
type SQL,
|
|
24
|
+
} from "drizzle-orm";
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
activities,
|
|
28
|
+
dmArchivedConversations,
|
|
29
|
+
inbox,
|
|
30
|
+
notificationArchived,
|
|
31
|
+
objects,
|
|
32
|
+
type Database,
|
|
33
|
+
} from "../../db/index.ts";
|
|
34
|
+
import { excludeBlockedMutedAuthors } from "./feed-exclude.ts";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The activity types that surface as notifications. Any addition must hold for
|
|
38
|
+
* the list, the unread badge, AND push delivery — they all read this constant.
|
|
39
|
+
*/
|
|
40
|
+
export const NOTIFICATION_ACTIVITY_TYPES = [
|
|
41
|
+
"Follow",
|
|
42
|
+
"Like",
|
|
43
|
+
"Announce",
|
|
44
|
+
"Create",
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
export interface NotificationEligibilityOptions {
|
|
48
|
+
/**
|
|
49
|
+
* How direct-visibility (DM) Creates are treated:
|
|
50
|
+
* - "exclude" (list/badge): a DM surfaces in the DM view, never as a
|
|
51
|
+
* notification row, so drop it entirely.
|
|
52
|
+
* - "unless-dm-archived" (push): a direct Create IS push-eligible (routed to
|
|
53
|
+
* Yurume), unless the recipient archived the conversation — archiving is a
|
|
54
|
+
* delivery preference, not merely a presentation filter.
|
|
55
|
+
*/
|
|
56
|
+
readonly direct: "exclude" | "unless-dm-archived";
|
|
57
|
+
/**
|
|
58
|
+
* Archive partition. "exclude" (default: badge/push and the default list
|
|
59
|
+
* view) hides archived rows; "only" (the list's archived view) shows ONLY
|
|
60
|
+
* archived rows.
|
|
61
|
+
*/
|
|
62
|
+
readonly archived?: "exclude" | "only";
|
|
63
|
+
/**
|
|
64
|
+
* Restrict to a subset of NOTIFICATION_ACTIVITY_TYPES (the list's type
|
|
65
|
+
* filter). Defaults to the full set; callers must not widen it.
|
|
66
|
+
*/
|
|
67
|
+
readonly activityTypes?: readonly string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Eligibility conditions for a query shaped as
|
|
72
|
+
* `inbox JOIN activities LEFT JOIN objects` scoped to `actorApId`'s inbox.
|
|
73
|
+
* Returns the shared conditions only; callers add their own paging/read/type
|
|
74
|
+
* filters on top.
|
|
75
|
+
*/
|
|
76
|
+
export function notificationEligibilityWhere(
|
|
77
|
+
db: Database,
|
|
78
|
+
actorApId: string,
|
|
79
|
+
options: NotificationEligibilityOptions,
|
|
80
|
+
): SQL[] {
|
|
81
|
+
// Never notify an actor about their own activity.
|
|
82
|
+
const notSelf = ne(activities.actorApId, actorApId);
|
|
83
|
+
|
|
84
|
+
// Only user-facing activity types.
|
|
85
|
+
const userFacingType = inArray(activities.type, [
|
|
86
|
+
...(options.activityTypes ?? NOTIFICATION_ACTIVITY_TYPES),
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
// Archive partition. Archived notifications are hidden from the default
|
|
90
|
+
// list, excluded from the badge, and must not push; the list's archived view
|
|
91
|
+
// inverts the predicate.
|
|
92
|
+
const archivedSubquery = db
|
|
93
|
+
.select({ activityApId: notificationArchived.activityApId })
|
|
94
|
+
.from(notificationArchived)
|
|
95
|
+
.where(
|
|
96
|
+
and(
|
|
97
|
+
eq(notificationArchived.actorApId, inbox.actorApId),
|
|
98
|
+
eq(notificationArchived.activityApId, inbox.activityApId),
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
const notArchived =
|
|
102
|
+
options.archived === "only"
|
|
103
|
+
? exists(archivedSubquery)
|
|
104
|
+
: notExists(archivedSubquery);
|
|
105
|
+
|
|
106
|
+
// Direct (DM) handling. The object join is LEFT (Follow's object is an
|
|
107
|
+
// actor, not an objects row), so NULL visibility must be kept.
|
|
108
|
+
let directCondition: SQL;
|
|
109
|
+
if (options.direct === "exclude") {
|
|
110
|
+
directCondition = or(
|
|
111
|
+
isNull(objects.visibility),
|
|
112
|
+
ne(objects.visibility, "direct"),
|
|
113
|
+
)!;
|
|
114
|
+
} else {
|
|
115
|
+
const archivedDmSubquery = db
|
|
116
|
+
.select({ conversationId: dmArchivedConversations.conversationId })
|
|
117
|
+
.from(dmArchivedConversations)
|
|
118
|
+
.where(
|
|
119
|
+
and(
|
|
120
|
+
eq(dmArchivedConversations.actorApId, actorApId),
|
|
121
|
+
eq(dmArchivedConversations.conversationId, objects.conversation),
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
directCondition = or(
|
|
125
|
+
isNull(objects.visibility),
|
|
126
|
+
ne(objects.visibility, "direct"),
|
|
127
|
+
notExists(archivedDmSubquery),
|
|
128
|
+
)!;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const conditions: SQL[] = [
|
|
132
|
+
notSelf,
|
|
133
|
+
userFacingType,
|
|
134
|
+
notArchived,
|
|
135
|
+
directCondition,
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
// Suppress notifications whose actor the recipient has blocked or muted.
|
|
139
|
+
// This is the read-time choke point: mutes are read-only everywhere, and not
|
|
140
|
+
// every notify WRITE path block-checks, so gating here covers like/repost/
|
|
141
|
+
// follow/reply/mention (local AND federated) for both blocks and mutes.
|
|
142
|
+
const blockMute = excludeBlockedMutedAuthors(
|
|
143
|
+
db,
|
|
144
|
+
actorApId,
|
|
145
|
+
activities.actorApId,
|
|
146
|
+
);
|
|
147
|
+
if (blockMute) conditions.push(blockMute);
|
|
148
|
+
|
|
149
|
+
return conditions;
|
|
150
|
+
}
|
|
@@ -1,25 +1,11 @@
|
|
|
1
1
|
import type { Message } from "@cloudflare/workers-types";
|
|
2
|
-
import {
|
|
3
|
-
and,
|
|
4
|
-
asc,
|
|
5
|
-
eq,
|
|
6
|
-
exists,
|
|
7
|
-
inArray,
|
|
8
|
-
isNull,
|
|
9
|
-
lte,
|
|
10
|
-
ne,
|
|
11
|
-
notExists,
|
|
12
|
-
or,
|
|
13
|
-
sql,
|
|
14
|
-
} from "drizzle-orm";
|
|
2
|
+
import { and, asc, eq, exists, inArray, lte, ne, sql } from "drizzle-orm";
|
|
15
3
|
|
|
16
4
|
import {
|
|
17
5
|
activities,
|
|
18
6
|
affectedRowCount,
|
|
19
7
|
communityMembers,
|
|
20
|
-
dmArchivedConversations,
|
|
21
8
|
inbox,
|
|
22
|
-
notificationArchived,
|
|
23
9
|
notificationPushers,
|
|
24
10
|
notificationPushJobs,
|
|
25
11
|
objectRecipients,
|
|
@@ -41,6 +27,11 @@ import {
|
|
|
41
27
|
type DeliveryQueueMessageV1,
|
|
42
28
|
} from "./delivery/types.ts";
|
|
43
29
|
import { excludeBlockedMutedAuthors } from "./feed-exclude.ts";
|
|
30
|
+
import {
|
|
31
|
+
NOTIFICATION_ACTIVITY_TYPES,
|
|
32
|
+
notificationEligibilityWhere,
|
|
33
|
+
} from "./notification-eligibility.ts";
|
|
34
|
+
import { yurumeUnreadCounts } from "./unread-counts.ts";
|
|
44
35
|
import { logger } from "./logger.ts";
|
|
45
36
|
import { generateId } from "./oauth-utils.ts";
|
|
46
37
|
|
|
@@ -53,17 +44,21 @@ export const MAX_NOTIFICATION_PUSH_ATTEMPTS = 5;
|
|
|
53
44
|
export const NOTIFICATION_PUSHER_RETENTION_DAYS = 90;
|
|
54
45
|
export const NOTIFICATION_PUSH_JOB_RETENTION_DAYS = 90;
|
|
55
46
|
export const MAX_NOTIFICATION_PUSH_JOB_PURGE = 50;
|
|
47
|
+
export const MAX_NOTIFICATION_PUSHER_PURGE = 50;
|
|
56
48
|
|
|
57
49
|
const DEFAULT_GATEWAY_TIMEOUT_MS = 10_000;
|
|
58
50
|
const MAX_GATEWAY_RESPONSE_BYTES = 64 * 1024;
|
|
59
51
|
const MAX_QUEUE_SCAN = 50;
|
|
60
52
|
const STALE_PROCESSING_MS = 2 * 60 * 1000;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
53
|
+
// A 'queued'/'processing' row whose queue message is GONE (auto-dead-lettered
|
|
54
|
+
// after max retries with the raw body, dropped after queue retention, consumer
|
|
55
|
+
// down long enough) is otherwise unreachable: the sweep sends only
|
|
56
|
+
// pending/retry_wait and message-based recovery needs a message. Reclaim such
|
|
57
|
+
// rows to 'pending' once they are far staler than any live in-flight state —
|
|
58
|
+
// a live processing owner refreshes its lease before every gateway call
|
|
59
|
+
// (≤ ~40s between touches) and a queued message is consumed or dead-lettered
|
|
60
|
+
// within minutes.
|
|
61
|
+
const STALE_INFLIGHT_RECLAIM_MS = 15 * 60 * 1000;
|
|
67
62
|
|
|
68
63
|
export interface NotificationPusherRegistrationResponse {
|
|
69
64
|
readonly id: string;
|
|
@@ -330,22 +325,58 @@ export function buildNotificationPushMessage(
|
|
|
330
325
|
export async function enqueuePendingNotificationPushJobs(
|
|
331
326
|
env: Env,
|
|
332
327
|
): Promise<number> {
|
|
333
|
-
|
|
328
|
+
const db = env.DB_INSTANCE;
|
|
329
|
+
// Expired rows are retained long enough to preserve the deterministic job
|
|
334
330
|
// idempotency window, then removed opportunistically in a bounded batch.
|
|
335
331
|
// This runs even without a Queue binding so disabled push delivery cannot
|
|
336
|
-
// turn the outbox ledger into unbounded storage.
|
|
337
|
-
|
|
332
|
+
// turn the outbox ledger into unbounded storage. Stale pushers are purged
|
|
333
|
+
// here too (bounded), NOT inside per-job processing.
|
|
334
|
+
await purgeExpiredNotificationPushJobs(db);
|
|
335
|
+
await purgeExpiredNotificationPushers(db);
|
|
338
336
|
if (!env.DELIVERY_QUEUE) return 0;
|
|
339
337
|
const now = new Date().toISOString();
|
|
340
|
-
|
|
338
|
+
|
|
339
|
+
// Reclaim in-flight rows whose queue message is gone (see
|
|
340
|
+
// STALE_INFLIGHT_RECLAIM_MS). The reclaim consumes one attempt so a
|
|
341
|
+
// crash-looping job terminates at the same MAX_NOTIFICATION_PUSH_ATTEMPTS
|
|
342
|
+
// budget instead of ping-ponging forever.
|
|
343
|
+
const staleCutoff = new Date(
|
|
344
|
+
Date.now() - STALE_INFLIGHT_RECLAIM_MS,
|
|
345
|
+
).toISOString();
|
|
346
|
+
await db
|
|
347
|
+
.update(notificationPushJobs)
|
|
348
|
+
.set({
|
|
349
|
+
status: sql`CASE WHEN ${notificationPushJobs.attempts} + 1 >= ${MAX_NOTIFICATION_PUSH_ATTEMPTS} THEN 'failed' ELSE 'pending' END`,
|
|
350
|
+
attempts: sql`${notificationPushJobs.attempts} + 1`,
|
|
351
|
+
processingToken: null,
|
|
352
|
+
lastError: sql`COALESCE(${notificationPushJobs.lastError}, 'reclaimed stale in-flight push job')`,
|
|
353
|
+
updatedAt: now,
|
|
354
|
+
})
|
|
355
|
+
.where(
|
|
356
|
+
and(
|
|
357
|
+
inArray(notificationPushJobs.status, ["queued", "processing"]),
|
|
358
|
+
lte(notificationPushJobs.updatedAt, staleCutoff),
|
|
359
|
+
),
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
// The trigger creates a job for EVERY unread inbox insert, including
|
|
363
|
+
// recipients with zero pushers. Only enqueue jobs that can actually deliver;
|
|
364
|
+
// pusher-less rows stay pending (no queue traffic, no processing cycle) and
|
|
365
|
+
// age out through the retention purge above.
|
|
366
|
+
const actorHasPusher = exists(
|
|
367
|
+
db
|
|
368
|
+
.select({ id: notificationPushers.id })
|
|
369
|
+
.from(notificationPushers)
|
|
370
|
+
.where(eq(notificationPushers.actorApId, notificationPushJobs.actorApId)),
|
|
371
|
+
);
|
|
372
|
+
const rows = await db
|
|
373
|
+
.select({ id: notificationPushJobs.id })
|
|
341
374
|
.from(notificationPushJobs)
|
|
342
375
|
.where(
|
|
343
376
|
and(
|
|
344
|
-
|
|
345
|
-
eq(notificationPushJobs.status, "pending"),
|
|
346
|
-
eq(notificationPushJobs.status, "retry_wait"),
|
|
347
|
-
),
|
|
377
|
+
inArray(notificationPushJobs.status, ["pending", "retry_wait"]),
|
|
348
378
|
lte(notificationPushJobs.nextAttemptAt, now),
|
|
379
|
+
actorHasPusher,
|
|
349
380
|
),
|
|
350
381
|
)
|
|
351
382
|
.orderBy(asc(notificationPushJobs.createdAt))
|
|
@@ -355,7 +386,8 @@ export async function enqueuePendingNotificationPushJobs(
|
|
|
355
386
|
await env.DELIVERY_QUEUE.sendBatch(
|
|
356
387
|
rows.map((row) => ({ body: buildNotificationPushMessage(row.id) })),
|
|
357
388
|
);
|
|
358
|
-
await
|
|
389
|
+
await db
|
|
390
|
+
.update(notificationPushJobs)
|
|
359
391
|
.set({ status: "queued", processingToken: null, updatedAt: now })
|
|
360
392
|
.where(
|
|
361
393
|
and(
|
|
@@ -363,16 +395,82 @@ export async function enqueuePendingNotificationPushJobs(
|
|
|
363
395
|
notificationPushJobs.id,
|
|
364
396
|
rows.map((row) => row.id),
|
|
365
397
|
),
|
|
366
|
-
|
|
367
|
-
eq(notificationPushJobs.status, "pending"),
|
|
368
|
-
eq(notificationPushJobs.status, "retry_wait"),
|
|
369
|
-
),
|
|
398
|
+
inArray(notificationPushJobs.status, ["pending", "retry_wait"]),
|
|
370
399
|
),
|
|
371
400
|
);
|
|
372
401
|
return rows.length;
|
|
373
402
|
}
|
|
374
403
|
|
|
375
|
-
/**
|
|
404
|
+
/**
|
|
405
|
+
* Reset a dead-lettered push job so the durable outbox can retry it. Called by
|
|
406
|
+
* the DLQ consumer when a `notification_push` message exhausted its Cloudflare
|
|
407
|
+
* Queue retries with the RAW body (automatic dead-lettering) — without this,
|
|
408
|
+
* the job row would be stranded in 'queued'/'processing' forever. Consumes one
|
|
409
|
+
* attempt so a permanently failing job still terminates at the attempts budget.
|
|
410
|
+
*/
|
|
411
|
+
export async function recoverDeadLetteredNotificationPushJob(
|
|
412
|
+
db: Database,
|
|
413
|
+
jobId: string,
|
|
414
|
+
): Promise<boolean> {
|
|
415
|
+
const now = new Date().toISOString();
|
|
416
|
+
const recovered = await db
|
|
417
|
+
.update(notificationPushJobs)
|
|
418
|
+
.set({
|
|
419
|
+
status: sql`CASE WHEN ${notificationPushJobs.attempts} + 1 >= ${MAX_NOTIFICATION_PUSH_ATTEMPTS} THEN 'failed' ELSE 'retry_wait' END`,
|
|
420
|
+
attempts: sql`${notificationPushJobs.attempts} + 1`,
|
|
421
|
+
processingToken: null,
|
|
422
|
+
nextAttemptAt: now,
|
|
423
|
+
lastError: sql`COALESCE(${notificationPushJobs.lastError}, 'queue message dead-lettered')`,
|
|
424
|
+
updatedAt: now,
|
|
425
|
+
})
|
|
426
|
+
.where(
|
|
427
|
+
and(
|
|
428
|
+
eq(notificationPushJobs.id, jobId),
|
|
429
|
+
inArray(notificationPushJobs.status, [
|
|
430
|
+
"pending",
|
|
431
|
+
"queued",
|
|
432
|
+
"processing",
|
|
433
|
+
"retry_wait",
|
|
434
|
+
]),
|
|
435
|
+
),
|
|
436
|
+
);
|
|
437
|
+
return affectedRowCount(recovered) > 0;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Remove at most one bounded batch of pushers idle past the retention window. */
|
|
441
|
+
export async function purgeExpiredNotificationPushers(
|
|
442
|
+
db: Database,
|
|
443
|
+
now = new Date(),
|
|
444
|
+
): Promise<number> {
|
|
445
|
+
const cutoff = new Date(
|
|
446
|
+
now.getTime() - NOTIFICATION_PUSHER_RETENTION_DAYS * 86_400_000,
|
|
447
|
+
).toISOString();
|
|
448
|
+
const stale = await db
|
|
449
|
+
.select({ id: notificationPushers.id })
|
|
450
|
+
.from(notificationPushers)
|
|
451
|
+
.where(lte(notificationPushers.lastSeenAt, cutoff))
|
|
452
|
+
.orderBy(asc(notificationPushers.lastSeenAt), asc(notificationPushers.id))
|
|
453
|
+
.limit(MAX_NOTIFICATION_PUSHER_PURGE);
|
|
454
|
+
if (stale.length === 0) return 0;
|
|
455
|
+
const deleted = await db.delete(notificationPushers).where(
|
|
456
|
+
and(
|
|
457
|
+
inArray(
|
|
458
|
+
notificationPushers.id,
|
|
459
|
+
stale.map((row) => row.id),
|
|
460
|
+
),
|
|
461
|
+
lte(notificationPushers.lastSeenAt, cutoff),
|
|
462
|
+
),
|
|
463
|
+
);
|
|
464
|
+
return affectedRowCount(deleted);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Remove at most one bounded batch of jobs past the retention window. ANY
|
|
469
|
+
* status qualifies: terminal rows have served their idempotency window, a
|
|
470
|
+
* pending row that old belongs to a pusher-less recipient (never enqueued —
|
|
471
|
+
* see the sweep's actorHasPusher filter), and no live in-flight state survives
|
|
472
|
+
* 90 days (stale queued/processing rows are reclaimed within minutes).
|
|
473
|
+
*/
|
|
376
474
|
export async function purgeExpiredNotificationPushJobs(
|
|
377
475
|
db: Database,
|
|
378
476
|
now = new Date(),
|
|
@@ -380,16 +478,10 @@ export async function purgeExpiredNotificationPushJobs(
|
|
|
380
478
|
const cutoff = new Date(
|
|
381
479
|
now.getTime() - NOTIFICATION_PUSH_JOB_RETENTION_DAYS * 86_400_000,
|
|
382
480
|
).toISOString();
|
|
383
|
-
const terminalStatuses = ["delivered", "failed"] as const;
|
|
384
481
|
const expired = await db
|
|
385
482
|
.select({ id: notificationPushJobs.id })
|
|
386
483
|
.from(notificationPushJobs)
|
|
387
|
-
.where(
|
|
388
|
-
and(
|
|
389
|
-
inArray(notificationPushJobs.status, terminalStatuses),
|
|
390
|
-
lte(notificationPushJobs.updatedAt, cutoff),
|
|
391
|
-
),
|
|
392
|
-
)
|
|
484
|
+
.where(lte(notificationPushJobs.updatedAt, cutoff))
|
|
393
485
|
.orderBy(asc(notificationPushJobs.updatedAt), asc(notificationPushJobs.id))
|
|
394
486
|
.limit(MAX_NOTIFICATION_PUSH_JOB_PURGE);
|
|
395
487
|
if (expired.length === 0) return 0;
|
|
@@ -400,7 +492,6 @@ export async function purgeExpiredNotificationPushJobs(
|
|
|
400
492
|
notificationPushJobs.id,
|
|
401
493
|
expired.map((row) => row.id),
|
|
402
494
|
),
|
|
403
|
-
inArray(notificationPushJobs.status, terminalStatuses),
|
|
404
495
|
lte(notificationPushJobs.updatedAt, cutoff),
|
|
405
496
|
),
|
|
406
497
|
);
|
|
@@ -470,12 +561,17 @@ export async function processNotificationPushJob(
|
|
|
470
561
|
eq(notificationPushJobs.id, job.id),
|
|
471
562
|
eq(notificationPushJobs.status, job.status),
|
|
472
563
|
eq(notificationPushJobs.updatedAt, job.updatedAt),
|
|
564
|
+
// Honor the backoff schedule: a duplicate message that survived a
|
|
565
|
+
// double-enqueue race must not claim a retry_wait row before its
|
|
566
|
+
// nextAttemptAt, which would bypass exponential backoff / Retry-After
|
|
567
|
+
// and hammer a gateway that is actively rate-limiting us.
|
|
568
|
+
lte(notificationPushJobs.nextAttemptAt, now),
|
|
473
569
|
),
|
|
474
570
|
);
|
|
475
571
|
if (affectedRowCount(claimed) === 0) {
|
|
476
|
-
// Another Queue delivery owns this job now
|
|
477
|
-
// message permanently: a retry lets it observe the
|
|
478
|
-
// if that owner crashes after claiming.
|
|
572
|
+
// Another Queue delivery owns this job now, or it is not yet due. Do not
|
|
573
|
+
// ack the competing message permanently: a retry lets it observe the
|
|
574
|
+
// terminal row, become due, or recover if that owner crashes after claiming.
|
|
479
575
|
message.retry({ delaySeconds: 30 });
|
|
480
576
|
return;
|
|
481
577
|
}
|
|
@@ -503,12 +599,10 @@ export async function processNotificationPushJob(
|
|
|
503
599
|
(event.visibility === "direct" ? "yurume" : "yurucommu");
|
|
504
600
|
let pendingIds = parsePendingIds(job.pendingPusherIdsJson);
|
|
505
601
|
if (pendingIds === null) {
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
.delete(notificationPushers)
|
|
511
|
-
.where(lte(notificationPushers.lastSeenAt, cutoff));
|
|
602
|
+
// Retention purge of idle pushers is a bounded sweep in
|
|
603
|
+
// enqueuePendingNotificationPushJobs, NOT an unbounded all-actor DELETE
|
|
604
|
+
// on this hot per-job path. Stale rows simply resolve to zero deliveries
|
|
605
|
+
// here and get reaped by the sweep.
|
|
512
606
|
pendingIds = (
|
|
513
607
|
await db
|
|
514
608
|
.select({ id: notificationPushers.id })
|
|
@@ -900,7 +994,7 @@ async function loadPushEvent(
|
|
|
900
994
|
and(
|
|
901
995
|
eq(activities.apId, activityApId),
|
|
902
996
|
ne(activities.actorApId, actorApId),
|
|
903
|
-
inArray(activities.type,
|
|
997
|
+
inArray(activities.type, [...NOTIFICATION_ACTIVITY_TYPES]),
|
|
904
998
|
exists(currentCommunityMembership),
|
|
905
999
|
excludeBlockedMutedAuthors(db, actorApId, activities.actorApId),
|
|
906
1000
|
),
|
|
@@ -910,27 +1004,9 @@ async function loadPushEvent(
|
|
|
910
1004
|
|
|
911
1005
|
// The inbox trigger intentionally captures every unread insert so it cannot
|
|
912
1006
|
// lose a notification in a route-specific crash window. Eligibility is
|
|
913
|
-
// therefore re-checked here immediately before external delivery
|
|
914
|
-
//
|
|
915
|
-
//
|
|
916
|
-
// senders. Direct Creates remain eligible and are routed to Yurume below.
|
|
917
|
-
const archivedCorrelation = and(
|
|
918
|
-
eq(notificationArchived.actorApId, inbox.actorApId),
|
|
919
|
-
eq(notificationArchived.activityApId, inbox.activityApId),
|
|
920
|
-
);
|
|
921
|
-
const archivedSubquery = db
|
|
922
|
-
.select({ activityApId: notificationArchived.activityApId })
|
|
923
|
-
.from(notificationArchived)
|
|
924
|
-
.where(archivedCorrelation);
|
|
925
|
-
const archivedDmSubquery = db
|
|
926
|
-
.select({ conversationId: dmArchivedConversations.conversationId })
|
|
927
|
-
.from(dmArchivedConversations)
|
|
928
|
-
.where(
|
|
929
|
-
and(
|
|
930
|
-
eq(dmArchivedConversations.actorApId, actorApId),
|
|
931
|
-
eq(dmArchivedConversations.conversationId, objects.conversation),
|
|
932
|
-
),
|
|
933
|
-
);
|
|
1007
|
+
// therefore re-checked here immediately before external delivery, using the
|
|
1008
|
+
// SAME shared predicate builder as the notification list/count. Direct
|
|
1009
|
+
// Creates remain eligible (archived DMs excepted) and route to Yurume below.
|
|
934
1010
|
return selectEvent()
|
|
935
1011
|
.from(inbox)
|
|
936
1012
|
.innerJoin(activities, eq(inbox.activityApId, activities.apId))
|
|
@@ -939,18 +1015,9 @@ async function loadPushEvent(
|
|
|
939
1015
|
and(
|
|
940
1016
|
eq(inbox.actorApId, actorApId),
|
|
941
1017
|
eq(inbox.activityApId, activityApId),
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
// Archiving a Yurume conversation is a current delivery preference,
|
|
946
|
-
// not merely a presentation filter. A queued direct event must stop at
|
|
947
|
-
// this choke point if the recipient archived it after enqueue.
|
|
948
|
-
or(
|
|
949
|
-
isNull(objects.visibility),
|
|
950
|
-
ne(objects.visibility, "direct"),
|
|
951
|
-
notExists(archivedDmSubquery),
|
|
952
|
-
)!,
|
|
953
|
-
excludeBlockedMutedAuthors(db, actorApId, activities.actorApId),
|
|
1018
|
+
...notificationEligibilityWhere(db, actorApId, {
|
|
1019
|
+
direct: "unless-dm-archived",
|
|
1020
|
+
}),
|
|
954
1021
|
),
|
|
955
1022
|
)
|
|
956
1023
|
.get();
|
|
@@ -963,63 +1030,13 @@ async function unreadCountForProduct(
|
|
|
963
1030
|
product: SocialNotificationProduct,
|
|
964
1031
|
): Promise<number> {
|
|
965
1032
|
if (product === "yurume") {
|
|
966
|
-
//
|
|
967
|
-
//
|
|
968
|
-
|
|
969
|
-
const dmRow = await db.get<{ c: number }>(sql`
|
|
970
|
-
SELECT COUNT(*) AS c
|
|
971
|
-
FROM objects o
|
|
972
|
-
JOIN object_recipients orp
|
|
973
|
-
ON orp.object_ap_id = o.ap_id
|
|
974
|
-
AND orp.recipient_ap_id = ${actorApId}
|
|
975
|
-
AND orp.type = 'to'
|
|
976
|
-
LEFT JOIN dm_read_status r
|
|
977
|
-
ON r.conversation_id = o.conversation
|
|
978
|
-
AND r.actor_ap_id = ${actorApId}
|
|
979
|
-
WHERE o.visibility = 'direct'
|
|
980
|
-
AND o.type = 'Note'
|
|
981
|
-
AND o.conversation IS NOT NULL
|
|
982
|
-
AND o.attributed_to != ${actorApId}
|
|
983
|
-
AND o.published > COALESCE(r.last_read_at, '1970-01-01T00:00:00Z')
|
|
984
|
-
AND o.conversation NOT IN (
|
|
985
|
-
SELECT conversation_id FROM dm_archived_conversations
|
|
986
|
-
WHERE actor_ap_id = ${actorApId}
|
|
987
|
-
)
|
|
988
|
-
`);
|
|
989
|
-
const communityRow = await db.get<{ c: number }>(sql`
|
|
990
|
-
SELECT COUNT(*) AS c
|
|
991
|
-
FROM community_members cm
|
|
992
|
-
JOIN object_recipients orp
|
|
993
|
-
ON orp.recipient_ap_id = cm.community_ap_id
|
|
994
|
-
AND orp.type = 'audience'
|
|
995
|
-
JOIN objects o
|
|
996
|
-
ON o.ap_id = orp.object_ap_id
|
|
997
|
-
AND o.type = 'Note'
|
|
998
|
-
AND o.community_ap_id IS NULL
|
|
999
|
-
AND o.attributed_to != ${actorApId}
|
|
1000
|
-
LEFT JOIN dm_community_read_status r
|
|
1001
|
-
ON r.community_ap_id = cm.community_ap_id
|
|
1002
|
-
AND r.actor_ap_id = ${actorApId}
|
|
1003
|
-
WHERE cm.actor_ap_id = ${actorApId}
|
|
1004
|
-
AND o.published > COALESCE(
|
|
1005
|
-
r.last_read_at,
|
|
1006
|
-
cm.joined_at,
|
|
1007
|
-
'1970-01-01T00:00:00Z'
|
|
1008
|
-
)
|
|
1009
|
-
`);
|
|
1010
|
-
return Number(dmRow?.c ?? 0) + Number(communityRow?.c ?? 0);
|
|
1033
|
+
// Reuse the SAME helper as GET /api/dm/unread/count so the badge a push
|
|
1034
|
+
// sets can never drift from the badge the client computes on open.
|
|
1035
|
+
return (await yurumeUnreadCounts(db, actorApId)).total;
|
|
1011
1036
|
}
|
|
1012
1037
|
|
|
1013
|
-
//
|
|
1014
|
-
// Yurume DM row must never inflate the Yurucommu app badge.
|
|
1015
|
-
const archivedCorrelation = and(
|
|
1016
|
-
eq(notificationArchived.actorApId, inbox.actorApId),
|
|
1017
|
-
eq(notificationArchived.activityApId, inbox.activityApId),
|
|
1018
|
-
);
|
|
1019
|
-
const archivedSubquery = db
|
|
1020
|
-
.select({ activityApId: notificationArchived.activityApId })
|
|
1021
|
-
.from(notificationArchived)
|
|
1022
|
-
.where(archivedCorrelation);
|
|
1038
|
+
// Reuse the SAME eligibility predicate as the notification list/badge. A
|
|
1039
|
+
// Yurume DM row must never inflate the Yurucommu app badge (direct: exclude).
|
|
1023
1040
|
const row = await db
|
|
1024
1041
|
.select({ count: sql<number>`COUNT(*)` })
|
|
1025
1042
|
.from(inbox)
|
|
@@ -1029,11 +1046,7 @@ async function unreadCountForProduct(
|
|
|
1029
1046
|
and(
|
|
1030
1047
|
eq(inbox.actorApId, actorApId),
|
|
1031
1048
|
eq(inbox.read, 0),
|
|
1032
|
-
|
|
1033
|
-
inArray(activities.type, SOCIAL_NOTIFICATION_ACTIVITY_TYPES),
|
|
1034
|
-
or(isNull(objects.visibility), ne(objects.visibility, "direct"))!,
|
|
1035
|
-
notExists(archivedSubquery),
|
|
1036
|
-
excludeBlockedMutedAuthors(db, actorApId, activities.actorApId),
|
|
1049
|
+
...notificationEligibilityWhere(db, actorApId, { direct: "exclude" }),
|
|
1037
1050
|
),
|
|
1038
1051
|
)
|
|
1039
1052
|
.get();
|
|
@@ -17,7 +17,7 @@ function isExpired(expiresAt: string): boolean {
|
|
|
17
17
|
export async function extractActorFromSession(
|
|
18
18
|
c: Context<{ Bindings: Env; Variables: Variables }>,
|
|
19
19
|
): Promise<void> {
|
|
20
|
-
const sessionId =
|
|
20
|
+
const sessionId = rawSessionCredential(c);
|
|
21
21
|
if (!sessionId) return;
|
|
22
22
|
|
|
23
23
|
const db = c.get("db");
|
|
@@ -59,3 +59,18 @@ export async function extractActorFromSession(
|
|
|
59
59
|
};
|
|
60
60
|
c.set("actor", actor);
|
|
61
61
|
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the host-owned session credential used by browser and native clients.
|
|
65
|
+
* Cookie auth wins when both are present so adding an Authorization header to a
|
|
66
|
+
* browser request never changes its CSRF/session identity semantics.
|
|
67
|
+
*/
|
|
68
|
+
export function rawSessionCredential(
|
|
69
|
+
c: Context<{ Bindings: Env; Variables: Variables }>,
|
|
70
|
+
): string | undefined {
|
|
71
|
+
const cookie = getCookie(c, "session")?.trim();
|
|
72
|
+
if (cookie) return cookie;
|
|
73
|
+
const authorization = c.req.header("Authorization")?.trim();
|
|
74
|
+
const match = authorization?.match(/^Bearer\s+([^\s]+)$/i);
|
|
75
|
+
return match?.[1];
|
|
76
|
+
}
|