@takosjp/yurucommu-core 3.2.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/migrations/0019_notification_push_delivery.sql +10 -7
- package/migrations/0020_call_sessions.sql +23 -0
- package/package.json +6 -2
- package/packages/api/package.json +1 -1
- package/packages/api/src/index.ts +1 -0
- 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/lib/rtc-client.ts +542 -0
- package/packages/api/src/social-server.ts +7 -0
- package/packages/api/src/types/call.ts +306 -0
- package/packages/api/src/types/index.ts +16 -4
- package/src/backend/index.ts +65 -10
- 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/rtc/call-store.ts +101 -0
- package/src/backend/lib/rtc/provider.ts +135 -0
- package/src/backend/lib/rtc/signal-transport.ts +125 -0
- 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/public.ts +3 -0
- package/src/backend/routes/account-teardown.ts +13 -0
- package/src/backend/routes/activitypub.ts +5 -1
- package/src/backend/routes/auth-helpers.ts +10 -7
- package/src/backend/routes/auth.ts +131 -3
- 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/backend/routes/rtc/index.ts +147 -0
- package/src/backend/runtime/call-hub-core.ts +470 -0
- package/src/backend/runtime/call-hub-port.ts +78 -0
- package/src/backend/runtime/call-signaling-do.ts +242 -0
- package/src/backend/runtime/signaling-hub.ts +187 -0
- package/src/backend/types.ts +28 -0
- package/src/db/index.ts +11 -10
- package/src/db/schema/calls.ts +46 -0
- package/src/db/schema/index.ts +1 -0
- package/src/db/schema/mobile.ts +7 -9
|
@@ -18,9 +18,9 @@ import {
|
|
|
18
18
|
} from "../../federation-helpers.ts";
|
|
19
19
|
import { feedCursorWhere } from "../../lib/feed-cursor.ts";
|
|
20
20
|
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
} from "
|
|
21
|
+
type ChatAttachment,
|
|
22
|
+
validateChatAttachments,
|
|
23
|
+
} from "../../lib/attachments.ts";
|
|
24
24
|
import { communityRequiresMembership } from "../../lib/community-visibility.ts";
|
|
25
25
|
import { rateLimit, RateLimitConfigs } from "../../middleware/rate-limit.ts";
|
|
26
26
|
import {
|
|
@@ -38,30 +38,9 @@ import {
|
|
|
38
38
|
|
|
39
39
|
const MAX_COMMUNITY_MESSAGE_LENGTH = 5000;
|
|
40
40
|
const MAX_COMMUNITY_MESSAGES_LIMIT = 100;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Validate a chat message's attachments array (mirrors the post-create and DM
|
|
46
|
-
* bounds: records only, capped count + serialized size). Returns the validated
|
|
47
|
-
* array ([] when absent) or an error message.
|
|
48
|
-
*/
|
|
49
|
-
function validateAttachments(
|
|
50
|
-
raw: unknown,
|
|
51
|
-
): ChatAttachment[] | { error: string } {
|
|
52
|
-
if (raw === undefined || raw === null) return [];
|
|
53
|
-
if (!Array.isArray(raw)) return { error: "attachments must be an array" };
|
|
54
|
-
if (raw.some((a) => !a || typeof a !== "object" || Array.isArray(a))) {
|
|
55
|
-
return { error: "attachments must be objects" };
|
|
56
|
-
}
|
|
57
|
-
if (raw.length > MAX_ATTACHMENTS) {
|
|
58
|
-
return { error: `Too many attachments (max ${MAX_ATTACHMENTS})` };
|
|
59
|
-
}
|
|
60
|
-
if (JSON.stringify(raw).length > MAX_ATTACHMENTS_JSON_LENGTH) {
|
|
61
|
-
return { error: "attachments payload too large" };
|
|
62
|
-
}
|
|
63
|
-
return raw as ChatAttachment[];
|
|
64
|
-
}
|
|
41
|
+
// Cap the per-member read receipts returned alongside a page so a very large
|
|
42
|
+
// community's chat reader stays bounded regardless of local-member count.
|
|
43
|
+
const MAX_COMMUNITY_READ_STATES = 200;
|
|
65
44
|
|
|
66
45
|
// D1's batch() (atomic multi-statement) is only on the concrete D1/libsql
|
|
67
46
|
// driver, not the shared `Database` union; reach it through a narrow cast.
|
|
@@ -220,7 +199,9 @@ messagesRouter.get("/:identifier/messages", async (c) => {
|
|
|
220
199
|
// Per-member read positions (LOCAL-ONLY read receipts): rows exist only for
|
|
221
200
|
// local members that marked the chat read — read state is never federated,
|
|
222
201
|
// so remote members simply never appear here. Restricted to CURRENT members
|
|
223
|
-
// so a kicked member's stale row doesn't leak into the read count
|
|
202
|
+
// so a kicked member's stale row doesn't leak into the read count, and capped
|
|
203
|
+
// (most-recently-read first) so a huge community can't return an unbounded
|
|
204
|
+
// receipt list on every page fetch.
|
|
224
205
|
const readStates = await db
|
|
225
206
|
.select({
|
|
226
207
|
actorApId: dmCommunityReadStatus.actorApId,
|
|
@@ -234,7 +215,9 @@ messagesRouter.get("/:identifier/messages", async (c) => {
|
|
|
234
215
|
eq(communityMembers.actorApId, dmCommunityReadStatus.actorApId),
|
|
235
216
|
),
|
|
236
217
|
)
|
|
237
|
-
.where(eq(dmCommunityReadStatus.communityApId, community.apId))
|
|
218
|
+
.where(eq(dmCommunityReadStatus.communityApId, community.apId))
|
|
219
|
+
.orderBy(desc(dmCommunityReadStatus.lastReadAt))
|
|
220
|
+
.limit(MAX_COMMUNITY_READ_STATES);
|
|
238
221
|
|
|
239
222
|
return c.json({
|
|
240
223
|
messages: result,
|
|
@@ -264,11 +247,11 @@ messagesRouter.post(
|
|
|
264
247
|
attachments?: unknown;
|
|
265
248
|
}>();
|
|
266
249
|
|
|
267
|
-
const
|
|
268
|
-
if (!
|
|
269
|
-
return c.json({ error:
|
|
250
|
+
const attachmentsResult = validateChatAttachments(body.attachments);
|
|
251
|
+
if (!attachmentsResult.ok) {
|
|
252
|
+
return c.json({ error: attachmentsResult.error }, 400);
|
|
270
253
|
}
|
|
271
|
-
const attachments =
|
|
254
|
+
const attachments = attachmentsResult.attachments;
|
|
272
255
|
|
|
273
256
|
// Guard non-string content before .trim() (else TypeError → 500). An
|
|
274
257
|
// attachment-only message (image send) carries no text.
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from "../../../db/index.ts";
|
|
24
24
|
import { formatUsername } from "../../federation-helpers.ts";
|
|
25
25
|
import { chunkForInClause } from "../../lib/chunk.ts";
|
|
26
|
+
import { yurumeUnreadCounts } from "../../lib/unread-counts.ts";
|
|
26
27
|
import {
|
|
27
28
|
buildActorInfoMap,
|
|
28
29
|
byTimeDesc,
|
|
@@ -476,50 +477,11 @@ contacts.get("/unread/count", async (c) => {
|
|
|
476
477
|
const actor = c.get("actor");
|
|
477
478
|
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
478
479
|
const db = c.get("db");
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
JOIN object_recipients orp
|
|
485
|
-
ON orp.object_ap_id = o.ap_id
|
|
486
|
-
AND orp.recipient_ap_id = ${me}
|
|
487
|
-
AND orp.type = 'to'
|
|
488
|
-
LEFT JOIN dm_read_status r
|
|
489
|
-
ON r.conversation_id = o.conversation
|
|
490
|
-
AND r.actor_ap_id = ${me}
|
|
491
|
-
WHERE o.visibility = 'direct'
|
|
492
|
-
AND o.type = 'Note'
|
|
493
|
-
AND o.conversation IS NOT NULL
|
|
494
|
-
AND o.attributed_to != ${me}
|
|
495
|
-
AND o.published > COALESCE(r.last_read_at, '1970-01-01T00:00:00Z')
|
|
496
|
-
AND o.conversation NOT IN (
|
|
497
|
-
SELECT conversation_id FROM dm_archived_conversations
|
|
498
|
-
WHERE actor_ap_id = ${me}
|
|
499
|
-
)
|
|
500
|
-
`);
|
|
501
|
-
|
|
502
|
-
const communityRow = await db.get<{ c: number }>(sql`
|
|
503
|
-
SELECT COUNT(*) AS c
|
|
504
|
-
FROM community_members cm
|
|
505
|
-
JOIN object_recipients orp
|
|
506
|
-
ON orp.recipient_ap_id = cm.community_ap_id
|
|
507
|
-
AND orp.type = 'audience'
|
|
508
|
-
JOIN objects o
|
|
509
|
-
ON o.ap_id = orp.object_ap_id
|
|
510
|
-
AND o.type = 'Note'
|
|
511
|
-
AND o.community_ap_id IS NULL
|
|
512
|
-
AND o.attributed_to != ${me}
|
|
513
|
-
LEFT JOIN dm_community_read_status r
|
|
514
|
-
ON r.community_ap_id = cm.community_ap_id
|
|
515
|
-
AND r.actor_ap_id = ${me}
|
|
516
|
-
WHERE cm.actor_ap_id = ${me}
|
|
517
|
-
AND o.published > COALESCE(r.last_read_at, cm.joined_at, '1970-01-01T00:00:00Z')
|
|
518
|
-
`);
|
|
519
|
-
|
|
520
|
-
const dm = Number(dmRow?.c ?? 0);
|
|
521
|
-
const community = Number(communityRow?.c ?? 0);
|
|
522
|
-
return c.json({ total: dm + community, dm, community });
|
|
480
|
+
|
|
481
|
+
// Single owner of the DM + community-chat unread SQL (lib/unread-counts.ts),
|
|
482
|
+
// shared with the notification push payload's badge so the two cannot drift.
|
|
483
|
+
const { total, dm, community } = await yurumeUnreadCounts(db, actor.ap_id);
|
|
484
|
+
return c.json({ total, dm, community });
|
|
523
485
|
});
|
|
524
486
|
|
|
525
487
|
export default contacts;
|
|
@@ -37,10 +37,7 @@ import {
|
|
|
37
37
|
import { enqueueDeliveryToActor } from "../../lib/delivery/queue.ts";
|
|
38
38
|
import { feedCursorWhere } from "../../lib/feed-cursor.ts";
|
|
39
39
|
import { toApAttachments } from "../../lib/activitypub-helpers.ts";
|
|
40
|
-
import {
|
|
41
|
-
MAX_ATTACHMENTS,
|
|
42
|
-
MAX_ATTACHMENTS_JSON_LENGTH,
|
|
43
|
-
} from "../posts/transformers.ts";
|
|
40
|
+
import { validateChatAttachments } from "../../lib/attachments.ts";
|
|
44
41
|
import { logger } from "../../lib/logger.ts";
|
|
45
42
|
|
|
46
43
|
const log = logger.child({ component: "dm.messages" });
|
|
@@ -127,34 +124,6 @@ function validateContent(
|
|
|
127
124
|
return content;
|
|
128
125
|
}
|
|
129
126
|
|
|
130
|
-
/**
|
|
131
|
-
* Validate a chat message's attachments array (mirrors the post-create bounds:
|
|
132
|
-
* records only, capped count + serialized size — the size cap bounds row and
|
|
133
|
-
* federated-doc bloat regardless of internal shape). Returns the validated
|
|
134
|
-
* array ([] when absent) or an error response.
|
|
135
|
-
*/
|
|
136
|
-
function validateAttachments(
|
|
137
|
-
raw: unknown,
|
|
138
|
-
): Attachment[] | { error: string; status: 400 } {
|
|
139
|
-
if (raw === undefined || raw === null) return [];
|
|
140
|
-
if (!Array.isArray(raw)) {
|
|
141
|
-
return { error: "attachments must be an array", status: 400 };
|
|
142
|
-
}
|
|
143
|
-
if (raw.some((a) => !a || typeof a !== "object" || Array.isArray(a))) {
|
|
144
|
-
return { error: "attachments must be objects", status: 400 };
|
|
145
|
-
}
|
|
146
|
-
if (raw.length > MAX_ATTACHMENTS) {
|
|
147
|
-
return {
|
|
148
|
-
error: `Too many attachments (max ${MAX_ATTACHMENTS})`,
|
|
149
|
-
status: 400,
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
if (JSON.stringify(raw).length > MAX_ATTACHMENTS_JSON_LENGTH) {
|
|
153
|
-
return { error: "attachments payload too large", status: 400 };
|
|
154
|
-
}
|
|
155
|
-
return raw as Attachment[];
|
|
156
|
-
}
|
|
157
|
-
|
|
158
127
|
/**
|
|
159
128
|
* Resolve a `user@domain` handle for a DM recipient. Prefers the stored
|
|
160
129
|
* preferredUsername paired with the recipient's host, falling back to
|
|
@@ -462,14 +431,11 @@ dm.post("/user/:encodedApId/messages", async (c) => {
|
|
|
462
431
|
}>();
|
|
463
432
|
const baseUrl = c.env.APP_URL;
|
|
464
433
|
|
|
465
|
-
const
|
|
466
|
-
if (!
|
|
467
|
-
return c.json(
|
|
468
|
-
{ error: attachmentsOrError.error },
|
|
469
|
-
attachmentsOrError.status,
|
|
470
|
-
);
|
|
434
|
+
const attachmentsResult = validateChatAttachments(body.attachments);
|
|
435
|
+
if (!attachmentsResult.ok) {
|
|
436
|
+
return c.json({ error: attachmentsResult.error }, 400);
|
|
471
437
|
}
|
|
472
|
-
const attachments =
|
|
438
|
+
const attachments = attachmentsResult.attachments as Attachment[];
|
|
473
439
|
|
|
474
440
|
// An attachment-only message (LINE-style image send) carries no text.
|
|
475
441
|
const contentOrError = validateContent(body.content, attachments.length > 0);
|
|
@@ -6,13 +6,10 @@ import {
|
|
|
6
6
|
count,
|
|
7
7
|
desc,
|
|
8
8
|
eq,
|
|
9
|
-
exists,
|
|
10
9
|
inArray,
|
|
11
10
|
isNotNull,
|
|
12
11
|
isNull,
|
|
13
12
|
lt,
|
|
14
|
-
ne,
|
|
15
|
-
notExists,
|
|
16
13
|
or,
|
|
17
14
|
type SQL,
|
|
18
15
|
} from "drizzle-orm";
|
|
@@ -34,7 +31,10 @@ import { batchLoadActorInfo } from "./communities/membership-shared.ts";
|
|
|
34
31
|
import { requireActor } from "./actors-helpers.ts";
|
|
35
32
|
import { communityReadableApIds } from "../lib/community-visibility.ts";
|
|
36
33
|
import { chunkForInClause } from "../lib/chunk.ts";
|
|
37
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
NOTIFICATION_ACTIVITY_TYPES,
|
|
36
|
+
notificationEligibilityWhere,
|
|
37
|
+
} from "../lib/notification-eligibility.ts";
|
|
38
38
|
|
|
39
39
|
const notifications = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
40
40
|
|
|
@@ -53,7 +53,6 @@ const ARCHIVE_CREATE_BATCH_SIZE = 30;
|
|
|
53
53
|
// the rest drains on later runs.
|
|
54
54
|
const ARCHIVE_CLEANUP_BATCH = 200;
|
|
55
55
|
const ARCHIVE_ALL_CAP = 1000;
|
|
56
|
-
const NOTIFICATION_ACTIVITY_TYPES = ["Follow", "Like", "Announce", "Create"];
|
|
57
56
|
|
|
58
57
|
/**
|
|
59
58
|
* Tracks the last cleanup timestamp per actor so cleanup is throttled to one
|
|
@@ -241,6 +240,9 @@ function notificationTarget(
|
|
|
241
240
|
): {
|
|
242
241
|
target_kind: "post" | "story" | "profile" | "notifications";
|
|
243
242
|
target_id: string | null;
|
|
243
|
+
// Same-origin in-app path shaped for the yurucommu web client's routing.
|
|
244
|
+
// Other clients (e.g. yurume's distinct IA) must treat target_kind/target_id
|
|
245
|
+
// as authoritative and build their own path, not follow target_url blindly.
|
|
244
246
|
target_url: string;
|
|
245
247
|
} {
|
|
246
248
|
if (type === "follow" || type === "follow_request") {
|
|
@@ -305,37 +307,22 @@ notifications.get("/", async (c) => {
|
|
|
305
307
|
? typeToActivityType[typeFilter]
|
|
306
308
|
: NOTIFICATION_ACTIVITY_TYPES;
|
|
307
309
|
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
eq(notificationArchived.activityApId, inboxTable.activityApId),
|
|
318
|
-
);
|
|
319
|
-
const archivedSubquery = db
|
|
320
|
-
.select({ activityApId: notificationArchived.activityApId })
|
|
321
|
-
.from(notificationArchived)
|
|
322
|
-
.where(archivedCorrelation);
|
|
323
|
-
const archiveCondition = showArchived
|
|
324
|
-
? exists(archivedSubquery)
|
|
325
|
-
: notExists(archivedSubquery);
|
|
326
|
-
|
|
327
|
-
// Build inbox query with JOIN to activities. A direct (DM) Note is delivered
|
|
328
|
-
// as a `Create` inbox row just like a mention, so without excluding it every
|
|
329
|
-
// DM would double-surface here as a "mention" (with its body) AND inflate the
|
|
330
|
-
// unread badge independently of the DM view. LEFT JOIN the object and drop
|
|
331
|
-
// direct-visibility Creates; the join is LEFT because Follow's object is an
|
|
332
|
-
// actor (no `objects` row) → NULL visibility must be kept.
|
|
310
|
+
// Build inbox query with JOIN to activities. Shared eligibility predicate
|
|
311
|
+
// (lib/notification-eligibility.ts) — the SAME builder used by the unread
|
|
312
|
+
// badge and push delivery — supplies: not-self, user-facing types, the
|
|
313
|
+
// archive partition (pushed into SQL as a correlated EXISTS/NOT EXISTS so the
|
|
314
|
+
// limit+1 probe counts only rows that belong on the page, not a post-query
|
|
315
|
+
// filter), the direct-DM exclusion (a DM's `Create` inbox row must not
|
|
316
|
+
// double-surface here as a mention), and block/mute suppression. Direct
|
|
317
|
+
// Creates are dropped via LEFT JOIN + NULL-visibility keep (Follow's object
|
|
318
|
+
// is an actor, no `objects` row).
|
|
333
319
|
const conditions = [
|
|
334
320
|
eq(inboxTable.actorApId, actor.ap_id),
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
321
|
+
...notificationEligibilityWhere(db, actor.ap_id, {
|
|
322
|
+
direct: "exclude",
|
|
323
|
+
archived: showArchived ? "only" : "exclude",
|
|
324
|
+
activityTypes,
|
|
325
|
+
}),
|
|
339
326
|
];
|
|
340
327
|
// reply vs mention both map to a Create; the split is whether the Create's
|
|
341
328
|
// object is a reply (`inReplyTo` set). Pushed into SQL too so a type-filtered
|
|
@@ -348,17 +335,6 @@ notifications.get("/", async (c) => {
|
|
|
348
335
|
if (before) {
|
|
349
336
|
conditions.push(notifCursorPredicate(decodeNotifCursor(before)));
|
|
350
337
|
}
|
|
351
|
-
// Suppress notifications whose actor the recipient has blocked or muted. This
|
|
352
|
-
// is the read-time choke point: mutes are read-only everywhere, and not every
|
|
353
|
-
// notify WRITE path block-checks, so gating here covers like/repost/follow/
|
|
354
|
-
// reply/mention (local AND federated) for both blocks and mutes. Keyed on the
|
|
355
|
-
// activity actor (subquery-scoped → D1-param-safe).
|
|
356
|
-
const listBlockMute = excludeBlockedMutedAuthors(
|
|
357
|
-
db,
|
|
358
|
-
actor.ap_id,
|
|
359
|
-
activities.actorApId,
|
|
360
|
-
);
|
|
361
|
-
if (listBlockMute) conditions.push(listBlockMute);
|
|
362
338
|
|
|
363
339
|
const inboxEntries = await db
|
|
364
340
|
.select({
|
|
@@ -504,7 +480,7 @@ notifications.get("/", async (c) => {
|
|
|
504
480
|
icon_url: string | null;
|
|
505
481
|
};
|
|
506
482
|
object_content: string;
|
|
507
|
-
target_kind: "post" | "story" | "profile" | "
|
|
483
|
+
target_kind: "post" | "story" | "profile" | "notifications";
|
|
508
484
|
target_id: string | null;
|
|
509
485
|
target_url: string;
|
|
510
486
|
}> = [];
|
|
@@ -578,23 +554,10 @@ notifications.get("/unread/count", async (c) => {
|
|
|
578
554
|
const db = c.get("db");
|
|
579
555
|
await maybeCleanupArchivedNotifications(db, actor.ap_id);
|
|
580
556
|
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
// is hidden from the inbox list, so it must NOT count toward the badge either —
|
|
586
|
-
// otherwise archiving an UNREAD notification (read stays 0) leaves a phantom
|
|
587
|
-
// count the client can never clear (its mark-read sweep only touches rows the
|
|
588
|
-
// inbox view returns, which no longer include the archived one).
|
|
589
|
-
const archivedSubquery = db
|
|
590
|
-
.select({ activityApId: notificationArchived.activityApId })
|
|
591
|
-
.from(notificationArchived)
|
|
592
|
-
.where(
|
|
593
|
-
and(
|
|
594
|
-
eq(notificationArchived.actorApId, inboxTable.actorApId),
|
|
595
|
-
eq(notificationArchived.activityApId, inboxTable.activityApId),
|
|
596
|
-
),
|
|
597
|
-
);
|
|
557
|
+
// SAME shared eligibility builder as the list and push delivery: not-self,
|
|
558
|
+
// user-facing types, archive exclusion (an archived UNREAD notification must
|
|
559
|
+
// not leave a phantom count the client can never clear), the direct-DM
|
|
560
|
+
// exclusion (a DM has its own badge), and block/mute suppression.
|
|
598
561
|
const result = await db
|
|
599
562
|
.select({ count: count() })
|
|
600
563
|
.from(inboxTable)
|
|
@@ -604,13 +567,7 @@ notifications.get("/unread/count", async (c) => {
|
|
|
604
567
|
and(
|
|
605
568
|
eq(inboxTable.actorApId, actor.ap_id),
|
|
606
569
|
eq(inboxTable.read, 0),
|
|
607
|
-
|
|
608
|
-
inArray(activities.type, NOTIFICATION_ACTIVITY_TYPES),
|
|
609
|
-
or(isNull(objects.visibility), ne(objects.visibility, "direct"))!,
|
|
610
|
-
notExists(archivedSubquery),
|
|
611
|
-
// Mirror the list query: don't count notifications from blocked/muted
|
|
612
|
-
// actors toward the unread badge.
|
|
613
|
-
excludeBlockedMutedAuthors(db, actor.ap_id, activities.actorApId),
|
|
570
|
+
...notificationEligibilityWhere(db, actor.ap_id, { direct: "exclude" }),
|
|
614
571
|
),
|
|
615
572
|
)
|
|
616
573
|
.get();
|
|
@@ -2,12 +2,14 @@ import { formatUsername, safeJsonParse } from "../../federation-helpers.ts";
|
|
|
2
2
|
|
|
3
3
|
export const MAX_POST_CONTENT_LENGTH = 5000;
|
|
4
4
|
export const MAX_POST_SUMMARY_LENGTH = 500;
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
// Attachment bounds live in lib/attachments.ts (shared with the DM and
|
|
6
|
+
// community-chat validators); re-exported here for the existing post-route and
|
|
7
|
+
// inbound-handler importers.
|
|
8
|
+
export {
|
|
9
|
+
boundAttachmentsJson,
|
|
10
|
+
MAX_ATTACHMENTS,
|
|
11
|
+
MAX_ATTACHMENTS_JSON_LENGTH,
|
|
12
|
+
} from "../../lib/attachments.ts";
|
|
11
13
|
|
|
12
14
|
/** Truncate a string to `max` characters (no-op when already within bounds). */
|
|
13
15
|
export function truncate(s: string, max: number): string {
|
|
@@ -28,10 +30,6 @@ export function boundInboundSummary(summary: unknown): string | null {
|
|
|
28
30
|
? truncate(summary, MAX_POST_SUMMARY_LENGTH)
|
|
29
31
|
: null;
|
|
30
32
|
}
|
|
31
|
-
/** Drop an oversized inbound attachments blob to "[]" rather than store it. */
|
|
32
|
-
export function boundAttachmentsJson(json: string): string {
|
|
33
|
-
return json.length > MAX_ATTACHMENTS_JSON_LENGTH ? "[]" : json;
|
|
34
|
-
}
|
|
35
33
|
// Capped at 90 (not 100): a page's object ids are re-queried via
|
|
36
34
|
// `inArray(col, objectApIds)` for like/bookmark enrichment, and Cloudflare D1
|
|
37
35
|
// allows at most 100 bound parameters per query. 90 leaves headroom for the
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Call feature routes (WebRTC voice + video).
|
|
3
|
+
*
|
|
4
|
+
* POST /ap/rtc/signal server-to-server signaling ingest (HTTP-Signature)
|
|
5
|
+
* GET /api/rtc/socket browser WebSocket upgrade -> per-user signaling hub
|
|
6
|
+
* GET /api/rtc/ice mint short-lived ICE (STUN/TURN) servers
|
|
7
|
+
* POST /api/rtc/calls start a call (block-list gate + callId + ICE)
|
|
8
|
+
* GET /api/rtc/calls call history (missed / recent)
|
|
9
|
+
* GET /api/rtc/calls/:id current state of one call
|
|
10
|
+
*
|
|
11
|
+
* Signaling is intentionally OUTSIDE the ActivityPub inbox pipeline: the
|
|
12
|
+
* `/ap/rtc/signal` endpoint bypasses `claimActivityForDispatch` /
|
|
13
|
+
* `parseActivity` (which would strip SDP/ICE and persist ephemeral frames to the
|
|
14
|
+
* `activities` ledger). It reuses the same HTTP-Signature auth every inbox uses.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Hono } from "hono";
|
|
18
|
+
import { eq } from "drizzle-orm";
|
|
19
|
+
import type { Env, Variables } from "../../types.ts";
|
|
20
|
+
import { actors } from "../../../db/index.ts";
|
|
21
|
+
import { verifyHttpSignature } from "../../lib/ap-verify.ts";
|
|
22
|
+
import {
|
|
23
|
+
isActorMismatch,
|
|
24
|
+
signingActorFromKeyId,
|
|
25
|
+
} from "../activitypub/inbox.ts";
|
|
26
|
+
import { isActorBlocked } from "../../lib/blocklist.ts";
|
|
27
|
+
import {
|
|
28
|
+
getSignalingHub,
|
|
29
|
+
isSignalingAvailable,
|
|
30
|
+
} from "../../runtime/signaling-hub.ts";
|
|
31
|
+
import { createRtcProvider } from "../../lib/rtc/provider.ts";
|
|
32
|
+
import { getCallSession, listCallSessions } from "../../lib/rtc/call-store.ts";
|
|
33
|
+
import type {
|
|
34
|
+
CallMediaKind,
|
|
35
|
+
StartCallRequest,
|
|
36
|
+
} from "../../../../packages/api/src/types/call.ts";
|
|
37
|
+
import { parseRtcSignalEnvelope } from "../../../../packages/api/src/types/call.ts";
|
|
38
|
+
|
|
39
|
+
const rtc = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
40
|
+
|
|
41
|
+
function normalizeMedia(input: unknown): CallMediaKind {
|
|
42
|
+
if (input && typeof input === "object") {
|
|
43
|
+
const m = input as Partial<CallMediaKind>;
|
|
44
|
+
return { audio: m.audio !== false, video: Boolean(m.video) };
|
|
45
|
+
}
|
|
46
|
+
return { audio: true, video: false };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- Server-to-server signaling ingest -------------------------------------
|
|
50
|
+
rtc.post("/ap/rtc/signal", async (c) => {
|
|
51
|
+
const db = c.get("db");
|
|
52
|
+
const body = await c.req.text();
|
|
53
|
+
const sig = await verifyHttpSignature(c.req.raw, db, body);
|
|
54
|
+
if (!sig.valid) return c.json({ error: "invalid_signature" }, 401);
|
|
55
|
+
|
|
56
|
+
let parsed: unknown;
|
|
57
|
+
try {
|
|
58
|
+
parsed = JSON.parse(body);
|
|
59
|
+
} catch {
|
|
60
|
+
return c.json({ error: "bad_json" }, 400);
|
|
61
|
+
}
|
|
62
|
+
const envelope = parseRtcSignalEnvelope(parsed);
|
|
63
|
+
if (!envelope) return c.json({ error: "bad_envelope" }, 400);
|
|
64
|
+
|
|
65
|
+
// The HTTP-Signature signer must own the claimed `from` actor.
|
|
66
|
+
if (isActorMismatch(signingActorFromKeyId(sig.keyId), envelope.from)) {
|
|
67
|
+
return c.json({ error: "signer_mismatch" }, 403);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The recipient must be a local actor served by this instance.
|
|
71
|
+
const local = await db.query.actors.findFirst({
|
|
72
|
+
where: eq(actors.apId, envelope.to),
|
|
73
|
+
columns: { apId: true },
|
|
74
|
+
});
|
|
75
|
+
if (!local) return c.json({ error: "unknown_recipient" }, 404);
|
|
76
|
+
|
|
77
|
+
// Never ring for a sender the local owner has blocked; drop silently.
|
|
78
|
+
if (await isActorBlocked(db, envelope.from)) return c.body(null, 204);
|
|
79
|
+
|
|
80
|
+
await getSignalingHub(c.env).deliver(envelope.to, envelope);
|
|
81
|
+
return c.body(null, 204);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// --- Browser WebSocket upgrade ---------------------------------------------
|
|
85
|
+
rtc.get("/api/rtc/socket", async (c) => {
|
|
86
|
+
const actor = c.get("actor");
|
|
87
|
+
if (!actor) return c.json({ error: "unauthorized" }, 401);
|
|
88
|
+
if (!isSignalingAvailable(c.env)) {
|
|
89
|
+
return c.json({ error: "signaling_unavailable" }, 503);
|
|
90
|
+
}
|
|
91
|
+
return getSignalingHub(c.env).upgrade(c.req.raw, actor.ap_id);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// --- ICE servers ------------------------------------------------------------
|
|
95
|
+
rtc.get("/api/rtc/ice", async (c) => {
|
|
96
|
+
const actor = c.get("actor");
|
|
97
|
+
if (!actor) return c.json({ error: "unauthorized" }, 401);
|
|
98
|
+
const iceServers = await createRtcProvider(c.env).getIceServers();
|
|
99
|
+
return c.json({ iceServers });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// --- Start a call -----------------------------------------------------------
|
|
103
|
+
rtc.post("/api/rtc/calls", async (c) => {
|
|
104
|
+
const actor = c.get("actor");
|
|
105
|
+
if (!actor) return c.json({ error: "unauthorized" }, 401);
|
|
106
|
+
if (!isSignalingAvailable(c.env)) {
|
|
107
|
+
return c.json({ error: "signaling_unavailable" }, 503);
|
|
108
|
+
}
|
|
109
|
+
const db = c.get("db");
|
|
110
|
+
let payload: Partial<StartCallRequest>;
|
|
111
|
+
try {
|
|
112
|
+
payload = (await c.req.json()) as Partial<StartCallRequest>;
|
|
113
|
+
} catch {
|
|
114
|
+
return c.json({ error: "bad_json" }, 400);
|
|
115
|
+
}
|
|
116
|
+
const to = typeof payload.to === "string" ? payload.to.trim() : "";
|
|
117
|
+
if (!to || to === actor.ap_id) return c.json({ error: "bad_target" }, 400);
|
|
118
|
+
const media = normalizeMedia(payload.media);
|
|
119
|
+
|
|
120
|
+
// Do not let the local owner place a call to a contact they have blocked.
|
|
121
|
+
if (await isActorBlocked(db, to)) return c.json({ error: "blocked" }, 403);
|
|
122
|
+
|
|
123
|
+
const provider = createRtcProvider(c.env);
|
|
124
|
+
const [iceServers, sfuFocus] = await Promise.all([
|
|
125
|
+
provider.getIceServers(),
|
|
126
|
+
provider.getSfuFocus(media),
|
|
127
|
+
]);
|
|
128
|
+
return c.json({ callId: crypto.randomUUID(), iceServers, sfuFocus });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// --- Call history + state ---------------------------------------------------
|
|
132
|
+
rtc.get("/api/rtc/calls", async (c) => {
|
|
133
|
+
const actor = c.get("actor");
|
|
134
|
+
if (!actor) return c.json({ error: "unauthorized" }, 401);
|
|
135
|
+
const calls = await listCallSessions(c.get("db"), actor.ap_id);
|
|
136
|
+
return c.json({ calls });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
rtc.get("/api/rtc/calls/:id", async (c) => {
|
|
140
|
+
const actor = c.get("actor");
|
|
141
|
+
if (!actor) return c.json({ error: "unauthorized" }, 401);
|
|
142
|
+
const call = await getCallSession(c.get("db"), actor.ap_id, c.req.param("id"));
|
|
143
|
+
if (!call) return c.json({ error: "not_found" }, 404);
|
|
144
|
+
return c.json({ call });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
export default rtc;
|