@takosjp/yurucommu-core 3.3.0 → 3.4.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/0022_inbound_dispatch_claims.sql +17 -0
- package/package.json +3 -2
- package/packages/api/package.json +1 -1
- package/packages/api/src/index.ts +1 -0
- package/packages/api/src/lib/api/notifications.ts +1 -0
- package/packages/api/src/lib/api/posts.ts +1 -0
- package/packages/api/src/lib/rtc-client.ts +1 -3
- package/packages/api/src/types/call.ts +2 -10
- package/packages/api/src/types/index.ts +3 -0
- package/packages/api/src/types/realtime.ts +139 -0
- package/src/backend/index.ts +54 -12
- package/src/backend/lib/delivery/queue-batching.ts +53 -36
- package/src/backend/lib/delivery/queue-delivery.ts +3 -3
- package/src/backend/lib/delivery/queue.ts +17 -9
- package/src/backend/lib/delivery/types.ts +12 -0
- package/src/backend/lib/notification-push.ts +2 -2
- package/src/backend/lib/oauth-providers.ts +9 -0
- package/src/backend/lib/strip-image-metadata.ts +50 -30
- package/src/backend/lib/unread-counts.ts +63 -2
- package/src/backend/middleware/bearer-auth.ts +24 -9
- package/src/backend/public.ts +25 -1
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +4 -3
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +204 -5
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +78 -53
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +66 -2
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +35 -12
- package/src/backend/routes/activitypub/inbox-addressing.ts +236 -0
- package/src/backend/routes/activitypub/inbox-types.ts +8 -0
- package/src/backend/routes/activitypub/inbox.ts +410 -205
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/auth.ts +2 -1
- package/src/backend/routes/communities/messages.ts +53 -17
- package/src/backend/routes/dm/messages.ts +47 -7
- package/src/backend/routes/dm/read-archive.ts +27 -0
- package/src/backend/routes/dm/typing.ts +16 -0
- package/src/backend/routes/notifications.ts +25 -0
- package/src/backend/routes/posts/post-helpers.ts +42 -23
- package/src/backend/routes/realtime/index.ts +67 -0
- package/src/backend/routes/rtc/index.ts +5 -1
- package/src/backend/runtime/call-hub-core.ts +13 -3
- package/src/backend/runtime/cloudflare.ts +63 -2
- package/src/backend/runtime/managed-relational.ts +197 -0
- package/src/backend/runtime/managed-runtime.ts +631 -0
- package/src/backend/runtime/queue.ts +40 -0
- package/src/backend/runtime/realtime-hub.ts +257 -0
- package/src/backend/runtime/realtime-stream-do.ts +323 -0
- package/src/backend/server.ts +15 -18
- package/src/backend/types.ts +13 -2
- package/src/db/d1-write.ts +270 -0
- package/src/db/index.ts +17 -0
- package/src/db/schema/federation.ts +19 -0
- package/src/db/schema/index.ts +1 -0
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
* and the batch handler that dispatches to sub-modules.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type { Message, MessageBatch, Queue } from "@cloudflare/workers-types";
|
|
7
6
|
import type { Env } from "../../types.ts";
|
|
7
|
+
import type {
|
|
8
|
+
IQueueBatch,
|
|
9
|
+
IQueueMessage,
|
|
10
|
+
IQueueProducer,
|
|
11
|
+
} from "../../runtime/queue.ts";
|
|
8
12
|
import type { Database } from "../../../db/index.ts";
|
|
9
13
|
import { and, eq, notInArray, or, sql } from "drizzle-orm";
|
|
10
14
|
import { actorCache, deliveryQueue } from "../../../db/index.ts";
|
|
@@ -119,8 +123,8 @@ export function nowIso(): string {
|
|
|
119
123
|
}
|
|
120
124
|
|
|
121
125
|
export type QueueEnv = Env & {
|
|
122
|
-
DELIVERY_QUEUE:
|
|
123
|
-
DELIVERY_DLQ:
|
|
126
|
+
DELIVERY_QUEUE: IQueueProducer<DeliveryQueueMessageV1>;
|
|
127
|
+
DELIVERY_DLQ: IQueueProducer<DeliveryDlqMessageV1>;
|
|
124
128
|
};
|
|
125
129
|
|
|
126
130
|
function queueAvailable(env: Env): env is QueueEnv {
|
|
@@ -130,7 +134,7 @@ function queueAvailable(env: Env): env is QueueEnv {
|
|
|
130
134
|
export function requireQueue(
|
|
131
135
|
env: Env,
|
|
132
136
|
label: string,
|
|
133
|
-
message:
|
|
137
|
+
message: IQueueMessage<DeliveryQueueMessageV1>,
|
|
134
138
|
): env is QueueEnv {
|
|
135
139
|
if (queueAvailable(env)) return true;
|
|
136
140
|
log.warn("Missing DELIVERY_QUEUE/DELIVERY_DLQ bindings; dropping job", {
|
|
@@ -426,7 +430,7 @@ export async function enqueueFanoutToCommunity(
|
|
|
426
430
|
// ---------------------------------------------------------------------------
|
|
427
431
|
|
|
428
432
|
export async function handleDeliveryQueueBatch(
|
|
429
|
-
batch:
|
|
433
|
+
batch: IQueueBatch<DeliveryQueueMessageV1>,
|
|
430
434
|
env: Env,
|
|
431
435
|
): Promise<void> {
|
|
432
436
|
const db = env.DB_INSTANCE;
|
|
@@ -494,13 +498,13 @@ export async function handleDeliveryQueueBatch(
|
|
|
494
498
|
|
|
495
499
|
// Deliver endpoint messages with bulkhead+concurrency.
|
|
496
500
|
const deliveryMessages = batch.messages.filter(
|
|
497
|
-
(m:
|
|
501
|
+
(m: IQueueMessage<DeliveryQueueMessageV1>) =>
|
|
498
502
|
isDeliveryQueueMessageV1(m.body) && m.body.type === "deliver_endpoint",
|
|
499
|
-
) as Array<
|
|
503
|
+
) as Array<IQueueMessage<DeliveryQueueMessageV1>>;
|
|
500
504
|
await runWithConcurrency(
|
|
501
505
|
deliveryMessages,
|
|
502
506
|
BULKHEAD_GLOBAL_CONCURRENCY,
|
|
503
|
-
async (m:
|
|
507
|
+
async (m: IQueueMessage<DeliveryQueueMessageV1>) => {
|
|
504
508
|
try {
|
|
505
509
|
await processDeliverEndpoint(
|
|
506
510
|
db,
|
|
@@ -531,10 +535,14 @@ export async function handleDeliveryQueueBatch(
|
|
|
531
535
|
error,
|
|
532
536
|
});
|
|
533
537
|
}
|
|
538
|
+
// Same choke point feeds the realtime stream (federated + fanout inserts).
|
|
539
|
+
const { sweepRealtimeNotifications } =
|
|
540
|
+
await import("../../runtime/realtime-hub.ts");
|
|
541
|
+
await sweepRealtimeNotifications(env);
|
|
534
542
|
}
|
|
535
543
|
|
|
536
544
|
export async function handleDeliveryDlqBatch(
|
|
537
|
-
batch:
|
|
545
|
+
batch: IQueueBatch<DeliveryDlqMessageV1>,
|
|
538
546
|
env: Env,
|
|
539
547
|
): Promise<void> {
|
|
540
548
|
for (const message of batch.messages) {
|
|
@@ -5,6 +5,8 @@ export type DeliveryFanoutFollowersMessageV1 = {
|
|
|
5
5
|
type: "fanout_followers";
|
|
6
6
|
activityId: string;
|
|
7
7
|
followeeApId: string;
|
|
8
|
+
/** Stable keyset cursor used when a large graph spans queue invocations. */
|
|
9
|
+
cursor?: string;
|
|
8
10
|
scheduledAt: string; // ISO8601 UTC
|
|
9
11
|
};
|
|
10
12
|
|
|
@@ -24,6 +26,10 @@ export type DeliveryFanoutCommunityMessageV1 = {
|
|
|
24
26
|
// receive `activityId`. Absent for non-Create activities (edit/delete relay
|
|
25
27
|
// the activity directly).
|
|
26
28
|
announceActivityId?: string;
|
|
29
|
+
/** Current bounded fan-out phase; absent means start with local members. */
|
|
30
|
+
stage?: "local_members" | "remote_members" | "remote_followers";
|
|
31
|
+
/** Stable actor-id keyset cursor within `stage`. */
|
|
32
|
+
cursor?: string;
|
|
27
33
|
scheduledAt: string; // ISO8601 UTC
|
|
28
34
|
};
|
|
29
35
|
|
|
@@ -106,12 +112,18 @@ export function isDeliveryQueueMessageV1(
|
|
|
106
112
|
return (
|
|
107
113
|
typeof v.activityId === "string" &&
|
|
108
114
|
typeof v.followeeApId === "string" &&
|
|
115
|
+
(v.cursor === undefined || typeof v.cursor === "string") &&
|
|
109
116
|
typeof v.scheduledAt === "string"
|
|
110
117
|
);
|
|
111
118
|
case "fanout_community":
|
|
112
119
|
return (
|
|
113
120
|
typeof v.activityId === "string" &&
|
|
114
121
|
typeof v.communityApId === "string" &&
|
|
122
|
+
(v.stage === undefined ||
|
|
123
|
+
v.stage === "local_members" ||
|
|
124
|
+
v.stage === "remote_members" ||
|
|
125
|
+
v.stage === "remote_followers") &&
|
|
126
|
+
(v.cursor === undefined || typeof v.cursor === "string") &&
|
|
115
127
|
typeof v.scheduledAt === "string"
|
|
116
128
|
);
|
|
117
129
|
case "resolve_actor":
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { IQueueMessage } from "../runtime/queue.ts";
|
|
2
2
|
import { and, asc, eq, exists, inArray, lte, ne, sql } from "drizzle-orm";
|
|
3
3
|
|
|
4
4
|
import {
|
|
@@ -501,7 +501,7 @@ export async function purgeExpiredNotificationPushJobs(
|
|
|
501
501
|
export async function processNotificationPushJob(
|
|
502
502
|
env: Env,
|
|
503
503
|
body: DeliveryNotificationPushMessageV1,
|
|
504
|
-
message:
|
|
504
|
+
message: IQueueMessage<DeliveryQueueMessageV1>,
|
|
505
505
|
): Promise<void> {
|
|
506
506
|
const db = env.DB_INSTANCE;
|
|
507
507
|
let job = await db
|
|
@@ -61,6 +61,15 @@ export function getOidcClientCredentials(env: Env): {
|
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Audience advertised to native clients and accepted by the host exchange.
|
|
66
|
+
* A Capsule has one public OIDC client with multiple exact redirect URIs; web
|
|
67
|
+
* and native clients must therefore converge on this same client id.
|
|
68
|
+
*/
|
|
69
|
+
export function getMobileOidcAudience(env: Env): string {
|
|
70
|
+
return getOidcClientCredentials(env).clientId;
|
|
71
|
+
}
|
|
72
|
+
|
|
64
73
|
export function issuerEndpoint(issuer: string, path: string): string {
|
|
65
74
|
return `${issuer.replace(/\/$/, "")}${path}`;
|
|
66
75
|
}
|
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
* re-encoding the pixels, so the visible image is unchanged.
|
|
11
11
|
*
|
|
12
12
|
* Pure byte-surgery (no native image library): it walks the container structure
|
|
13
|
-
* and drops only metadata segments/chunks, copying everything else verbatim.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* and drops only metadata segments/chunks, copying everything else verbatim.
|
|
14
|
+
* Supported containers fail closed on structural surprises: returning their
|
|
15
|
+
* original bytes would silently publish the metadata the parser failed to
|
|
16
|
+
* understand.
|
|
16
17
|
*
|
|
17
18
|
* Covered: JPEG (APP1 EXIF/XMP, APP13 IPTC, COM), PNG (tEXt/zTXt/iTXt/eXIf/tIME),
|
|
18
19
|
* WebP (EXIF / XMP chunks). GIF and video are passed through unchanged (GIF
|
|
@@ -24,36 +25,37 @@ export function stripImageMetadata(
|
|
|
24
25
|
bytes: Uint8Array,
|
|
25
26
|
mimeType: string,
|
|
26
27
|
): Uint8Array {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return bytes; // gif / video / unknown: unchanged
|
|
37
|
-
}
|
|
38
|
-
} catch {
|
|
39
|
-
// Never let a parsing surprise corrupt or drop the upload.
|
|
40
|
-
return bytes;
|
|
28
|
+
switch (mimeType) {
|
|
29
|
+
case "image/jpeg":
|
|
30
|
+
return stripJpeg(bytes);
|
|
31
|
+
case "image/png":
|
|
32
|
+
return stripPng(bytes);
|
|
33
|
+
case "image/webp":
|
|
34
|
+
return stripWebp(bytes);
|
|
35
|
+
default:
|
|
36
|
+
return bytes; // gif / video / unknown: unchanged
|
|
41
37
|
}
|
|
42
38
|
}
|
|
43
39
|
|
|
40
|
+
function malformed(format: "JPEG" | "PNG" | "WebP"): never {
|
|
41
|
+
throw new Error(`Malformed ${format} image`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
44
|
// ---------------------------------------------------------------------------
|
|
45
45
|
// JPEG: a stream of marker segments. Drop APP1 (EXIF + XMP), APP13 (IPTC /
|
|
46
46
|
// Photoshop) and COM (comment); keep APP0 (JFIF), APP2 (ICC), APP14 (Adobe
|
|
47
47
|
// color transform), the quantization/Huffman tables, and the scan data verbatim.
|
|
48
48
|
// ---------------------------------------------------------------------------
|
|
49
49
|
function stripJpeg(bytes: Uint8Array): Uint8Array {
|
|
50
|
-
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8)
|
|
50
|
+
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) {
|
|
51
|
+
return malformed("JPEG");
|
|
52
|
+
}
|
|
51
53
|
|
|
52
54
|
const out: number[] = [0xff, 0xd8]; // SOI
|
|
53
55
|
let i = 2;
|
|
54
56
|
|
|
55
57
|
while (i + 1 < bytes.length) {
|
|
56
|
-
if (bytes[i] !== 0xff) return
|
|
58
|
+
if (bytes[i] !== 0xff) return malformed("JPEG");
|
|
57
59
|
const marker = bytes[i + 1];
|
|
58
60
|
|
|
59
61
|
// Start of Scan: entropy-coded data runs to EOI — copy the rest verbatim.
|
|
@@ -72,9 +74,9 @@ function stripJpeg(bytes: Uint8Array): Uint8Array {
|
|
|
72
74
|
i += 2;
|
|
73
75
|
continue;
|
|
74
76
|
}
|
|
75
|
-
if (i + 3 >= bytes.length) return
|
|
77
|
+
if (i + 3 >= bytes.length) return malformed("JPEG");
|
|
76
78
|
const len = (bytes[i + 2] << 8) | bytes[i + 3]; // includes the 2 length bytes
|
|
77
|
-
if (len < 2 || i + 2 + len > bytes.length) return
|
|
79
|
+
if (len < 2 || i + 2 + len > bytes.length) return malformed("JPEG");
|
|
78
80
|
|
|
79
81
|
const drop =
|
|
80
82
|
marker === 0xe1 || // APP1: EXIF + XMP (the GPS carriers)
|
|
@@ -85,7 +87,7 @@ function stripJpeg(bytes: Uint8Array): Uint8Array {
|
|
|
85
87
|
}
|
|
86
88
|
i += 2 + len;
|
|
87
89
|
}
|
|
88
|
-
return
|
|
90
|
+
return malformed("JPEG");
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
// ---------------------------------------------------------------------------
|
|
@@ -96,8 +98,10 @@ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
|
96
98
|
const PNG_DROP_CHUNKS = new Set(["tEXt", "zTXt", "iTXt", "eXIf", "tIME"]);
|
|
97
99
|
|
|
98
100
|
function stripPng(bytes: Uint8Array): Uint8Array {
|
|
99
|
-
if (bytes.length < 8) return
|
|
100
|
-
for (let i = 0; i < 8; i++)
|
|
101
|
+
if (bytes.length < 8) return malformed("PNG");
|
|
102
|
+
for (let i = 0; i < 8; i++) {
|
|
103
|
+
if (bytes[i] !== PNG_SIGNATURE[i]) return malformed("PNG");
|
|
104
|
+
}
|
|
101
105
|
|
|
102
106
|
const out: number[] = [...PNG_SIGNATURE];
|
|
103
107
|
let i = 8;
|
|
@@ -107,7 +111,7 @@ function stripPng(bytes: Uint8Array): Uint8Array {
|
|
|
107
111
|
(bytes[i + 1] << 16) |
|
|
108
112
|
(bytes[i + 2] << 8) |
|
|
109
113
|
bytes[i + 3];
|
|
110
|
-
if (len < 0) return
|
|
114
|
+
if (len < 0) return malformed("PNG");
|
|
111
115
|
const type = String.fromCharCode(
|
|
112
116
|
bytes[i + 4],
|
|
113
117
|
bytes[i + 5],
|
|
@@ -115,7 +119,7 @@ function stripPng(bytes: Uint8Array): Uint8Array {
|
|
|
115
119
|
bytes[i + 7],
|
|
116
120
|
);
|
|
117
121
|
const chunkEnd = i + 12 + len; // length(4) + type(4) + data(len) + crc(4)
|
|
118
|
-
if (chunkEnd > bytes.length) return
|
|
122
|
+
if (chunkEnd > bytes.length) return malformed("PNG");
|
|
119
123
|
|
|
120
124
|
if (!PNG_DROP_CHUNKS.has(type)) {
|
|
121
125
|
for (let k = i; k < chunkEnd; k++) out.push(bytes[k]);
|
|
@@ -123,9 +127,22 @@ function stripPng(bytes: Uint8Array): Uint8Array {
|
|
|
123
127
|
i = chunkEnd;
|
|
124
128
|
if (type === "IEND") break;
|
|
125
129
|
}
|
|
130
|
+
if (i !== bytes.length || !containsPngChunk(out, "IEND")) {
|
|
131
|
+
return malformed("PNG");
|
|
132
|
+
}
|
|
126
133
|
return Uint8Array.from(out);
|
|
127
134
|
}
|
|
128
135
|
|
|
136
|
+
function containsPngChunk(bytes: readonly number[], type: string): boolean {
|
|
137
|
+
const needle = [...type].map((character) => character.charCodeAt(0));
|
|
138
|
+
for (let i = 8; i + needle.length <= bytes.length; i++) {
|
|
139
|
+
if (needle.every((value, offset) => bytes[i + offset] === value)) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
129
146
|
// ---------------------------------------------------------------------------
|
|
130
147
|
// WebP: a RIFF container ("RIFF" <size> "WEBP" <chunks>). Drop the "EXIF" and
|
|
131
148
|
// "XMP " chunks, clear the matching VP8X feature-flag bits, and rewrite the RIFF
|
|
@@ -142,8 +159,10 @@ function fourCC(bytes: Uint8Array, off: number): string {
|
|
|
142
159
|
}
|
|
143
160
|
|
|
144
161
|
function stripWebp(bytes: Uint8Array): Uint8Array {
|
|
145
|
-
if (bytes.length < 16) return
|
|
146
|
-
if (fourCC(bytes, 0) !== "RIFF" || fourCC(bytes, 8) !== "WEBP")
|
|
162
|
+
if (bytes.length < 16) return malformed("WebP");
|
|
163
|
+
if (fourCC(bytes, 0) !== "RIFF" || fourCC(bytes, 8) !== "WEBP") {
|
|
164
|
+
return malformed("WebP");
|
|
165
|
+
}
|
|
147
166
|
|
|
148
167
|
const head: number[] = [];
|
|
149
168
|
for (let k = 0; k < 12; k++) head.push(bytes[k]); // RIFF + size + WEBP
|
|
@@ -158,10 +177,10 @@ function stripWebp(bytes: Uint8Array): Uint8Array {
|
|
|
158
177
|
(bytes[i + 5] << 8) |
|
|
159
178
|
(bytes[i + 6] << 16) |
|
|
160
179
|
(bytes[i + 7] << 24);
|
|
161
|
-
if (size < 0) return
|
|
180
|
+
if (size < 0) return malformed("WebP");
|
|
162
181
|
const padded = size + (size & 1); // chunks pad to an even length
|
|
163
182
|
const chunkEnd = i + 8 + padded;
|
|
164
|
-
if (chunkEnd > bytes.length) return
|
|
183
|
+
if (chunkEnd > bytes.length) return malformed("WebP");
|
|
165
184
|
|
|
166
185
|
if (cc === "EXIF" || cc === "XMP ") {
|
|
167
186
|
removed = true; // skip this chunk entirely
|
|
@@ -170,6 +189,7 @@ function stripWebp(bytes: Uint8Array): Uint8Array {
|
|
|
170
189
|
}
|
|
171
190
|
i = chunkEnd;
|
|
172
191
|
}
|
|
192
|
+
if (i !== bytes.length) return malformed("WebP");
|
|
173
193
|
if (!removed) return bytes; // nothing to strip — keep original bytes
|
|
174
194
|
|
|
175
195
|
// Clear the EXIF (bit 3) / XMP (bit 2) feature flags in a VP8X header so the
|
|
@@ -17,8 +17,14 @@
|
|
|
17
17
|
* own, after the later of the per-community read time and the join time.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { sql } from "drizzle-orm";
|
|
21
|
-
import
|
|
20
|
+
import { and, count, eq, sql } from "drizzle-orm";
|
|
21
|
+
import {
|
|
22
|
+
activities,
|
|
23
|
+
inbox as inboxTable,
|
|
24
|
+
objects,
|
|
25
|
+
type Database,
|
|
26
|
+
} from "../../db/index.ts";
|
|
27
|
+
import { notificationEligibilityWhere } from "./notification-eligibility.ts";
|
|
22
28
|
|
|
23
29
|
export interface YurumeUnreadCounts {
|
|
24
30
|
readonly dm: number;
|
|
@@ -77,3 +83,58 @@ export async function yurumeUnreadCounts(
|
|
|
77
83
|
const community = Number(communityRow?.c ?? 0);
|
|
78
84
|
return { dm, community, total: dm + community };
|
|
79
85
|
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Unread social-notification count. SAME shared eligibility builder as the
|
|
89
|
+
* notifications list, the badge endpoint, and push delivery (not-self,
|
|
90
|
+
* user-facing types, archive exclusion, direct-DM exclusion, block/mute
|
|
91
|
+
* suppression) so a realtime-pushed badge can never drift from the badge the
|
|
92
|
+
* client fetches.
|
|
93
|
+
*/
|
|
94
|
+
export async function notificationUnreadCount(
|
|
95
|
+
db: Database,
|
|
96
|
+
actorApId: string,
|
|
97
|
+
): Promise<number> {
|
|
98
|
+
const result = await db
|
|
99
|
+
.select({ count: count() })
|
|
100
|
+
.from(inboxTable)
|
|
101
|
+
.innerJoin(activities, eq(inboxTable.activityApId, activities.apId))
|
|
102
|
+
.leftJoin(objects, eq(activities.objectApId, objects.apId))
|
|
103
|
+
.where(
|
|
104
|
+
and(
|
|
105
|
+
eq(inboxTable.actorApId, actorApId),
|
|
106
|
+
eq(inboxTable.read, 0),
|
|
107
|
+
...notificationEligibilityWhere(db, actorApId, { direct: "exclude" }),
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
.get();
|
|
111
|
+
return Number(result?.count ?? 0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface UnreadSnapshot {
|
|
115
|
+
readonly dm: number;
|
|
116
|
+
readonly community: number;
|
|
117
|
+
readonly talkTotal: number;
|
|
118
|
+
readonly notifications: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* One authoritative unread snapshot (talk + notifications) for the realtime
|
|
123
|
+
* `unread` event. Server-computed on every emit so clients never derive or
|
|
124
|
+
* increment counters themselves.
|
|
125
|
+
*/
|
|
126
|
+
export async function computeUnreadSnapshot(
|
|
127
|
+
db: Database,
|
|
128
|
+
actorApId: string,
|
|
129
|
+
): Promise<UnreadSnapshot> {
|
|
130
|
+
const [talk, notifications] = await Promise.all([
|
|
131
|
+
yurumeUnreadCounts(db, actorApId),
|
|
132
|
+
notificationUnreadCount(db, actorApId),
|
|
133
|
+
]);
|
|
134
|
+
return {
|
|
135
|
+
dm: talk.dm,
|
|
136
|
+
community: talk.community,
|
|
137
|
+
talkTotal: talk.total,
|
|
138
|
+
notifications,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
issuerEndpoint,
|
|
7
7
|
} from "../lib/oauth-providers.ts";
|
|
8
8
|
|
|
9
|
+
const INTROSPECTION_TIMEOUT_MS = 5_000;
|
|
10
|
+
|
|
9
11
|
export function requireBearerAuth(
|
|
10
12
|
requiredScope: string,
|
|
11
13
|
): MiddlewareHandler<{ Bindings: Env; Variables: Variables }> {
|
|
@@ -27,15 +29,28 @@ export function requireBearerAuth(
|
|
|
27
29
|
);
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
let res: Response;
|
|
33
|
+
try {
|
|
34
|
+
res = await fetch(issuerEndpoint(issuer, "/oauth/introspect"), {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
37
|
+
body: new URLSearchParams({
|
|
38
|
+
token,
|
|
39
|
+
client_id: clientId,
|
|
40
|
+
client_secret: clientSecret,
|
|
41
|
+
}).toString(),
|
|
42
|
+
signal: AbortSignal.timeout(INTROSPECTION_TIMEOUT_MS),
|
|
43
|
+
});
|
|
44
|
+
} catch {
|
|
45
|
+
c.header("Retry-After", "5");
|
|
46
|
+
return c.json(
|
|
47
|
+
{
|
|
48
|
+
error: "temporarily_unavailable",
|
|
49
|
+
error_description: "Introspection request failed",
|
|
50
|
+
},
|
|
51
|
+
503,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
39
54
|
if (!res.ok) {
|
|
40
55
|
return c.json(
|
|
41
56
|
{
|
package/src/backend/public.ts
CHANGED
|
@@ -12,10 +12,34 @@ export {
|
|
|
12
12
|
export { default } from "./index.ts";
|
|
13
13
|
export { default as app } from "./index.ts";
|
|
14
14
|
export { type Database, getDb, getDbSQLite } from "../db/index.ts";
|
|
15
|
-
export {
|
|
15
|
+
export {
|
|
16
|
+
wrapCloudflareBindings,
|
|
17
|
+
wrapCloudflareMessageBatch,
|
|
18
|
+
wrapCloudflareQueue,
|
|
19
|
+
} from "./runtime/cloudflare.ts";
|
|
20
|
+
export {
|
|
21
|
+
ManagedRuntimeGatewayError,
|
|
22
|
+
createManagedRuntimeQueueProducer,
|
|
23
|
+
type ManagedRuntimeGateway,
|
|
24
|
+
type ManagedRuntimeQueueProducerOptions,
|
|
25
|
+
} from "./runtime/managed-runtime.ts";
|
|
26
|
+
export {
|
|
27
|
+
createManagedRelationalDatabase,
|
|
28
|
+
type ManagedRelationalDatabaseOptions,
|
|
29
|
+
} from "./runtime/managed-relational.ts";
|
|
30
|
+
export type {
|
|
31
|
+
IQueueBatch,
|
|
32
|
+
IQueueMessage,
|
|
33
|
+
IQueueProducer,
|
|
34
|
+
QueueBatchItem,
|
|
35
|
+
QueueSendOptions,
|
|
36
|
+
} from "./runtime/queue.ts";
|
|
16
37
|
// Call feature: the signaling Durable Object class each product's generated
|
|
17
38
|
// worker entry must re-export so Wrangler can bind CALL_SIGNALING to it.
|
|
18
39
|
export { CallSignalingDurableObject } from "./runtime/call-signaling-do.ts";
|
|
40
|
+
// Realtime stream: the per-user fanout Durable Object class each product's
|
|
41
|
+
// generated worker entry must re-export so Wrangler can bind REALTIME_STREAM.
|
|
42
|
+
export { RealtimeStreamDO } from "./runtime/realtime-stream-do.ts";
|
|
19
43
|
export type { Env, EnvVars } from "./types.ts";
|
|
20
44
|
export type {
|
|
21
45
|
DeliveryDlqMessageV1,
|
|
@@ -59,6 +59,7 @@ export async function handleGroupFollow(
|
|
|
59
59
|
actorApIdStr: string,
|
|
60
60
|
baseUrl: string,
|
|
61
61
|
activityId: string,
|
|
62
|
+
sourceActivityId: string = activityId,
|
|
62
63
|
) {
|
|
63
64
|
const db = c.get("db");
|
|
64
65
|
const followerKey = {
|
|
@@ -130,7 +131,7 @@ export async function handleGroupFollow(
|
|
|
130
131
|
where: and(
|
|
131
132
|
eq(activities.type, responseType),
|
|
132
133
|
eq(activities.actorApId, group.apId),
|
|
133
|
-
eq(activities.objectApId,
|
|
134
|
+
eq(activities.objectApId, sourceActivityId),
|
|
134
135
|
),
|
|
135
136
|
});
|
|
136
137
|
if (existingResponse) {
|
|
@@ -144,14 +145,14 @@ export async function handleGroupFollow(
|
|
|
144
145
|
id: responseId,
|
|
145
146
|
type: responseType,
|
|
146
147
|
actor: group.apId,
|
|
147
|
-
object:
|
|
148
|
+
object: sourceActivityId,
|
|
148
149
|
};
|
|
149
150
|
|
|
150
151
|
await db.insert(activities).values({
|
|
151
152
|
apId: responseId,
|
|
152
153
|
type: responseType,
|
|
153
154
|
actorApId: group.apId,
|
|
154
|
-
objectApId:
|
|
155
|
+
objectApId: sourceActivityId,
|
|
155
156
|
rawJson: JSON.stringify(responseActivity),
|
|
156
157
|
direction: "outbound",
|
|
157
158
|
});
|