@takosjp/yurucommu-core 3.4.3 → 3.4.4
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/0023_delivery_resolution_outbox.sql +33 -0
- package/migrations/0024_delivery_fanout_outbox.sql +35 -0
- package/migrations/0025_delivery_endpoint_terminal_retention.sql +6 -0
- package/migrations/0026_remote_actor_fetch_failures.sql +29 -0
- package/migrations/0027_remote_actor_tombstones.sql +12 -0
- package/migrations/0028_remote_actor_delivery_fence.sql +30 -0
- package/migrations/0029_delivery_endpoint_recipients.sql +47 -0
- package/package.json +2 -1
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/communities.ts +2 -0
- package/packages/api/src/lib/api/fetch.ts +69 -13
- package/packages/api/src/lib/api/normalize.ts +32 -10
- package/packages/api/src/lib/api/notifications.ts +4 -1
- package/packages/api/src/lib/rtc-client.ts +127 -29
- package/src/backend/federation-helpers.ts +1 -0
- package/src/backend/index.ts +16 -3
- package/src/backend/lib/account-migration.ts +340 -0
- package/src/backend/lib/activity-delete-cascade.ts +193 -0
- package/src/backend/lib/activitypub-actor-cache.ts +615 -48
- package/src/backend/lib/activitypub-actor-identity-sql.ts +179 -0
- package/src/backend/lib/activitypub-actor-identity.ts +39 -0
- package/src/backend/lib/activitypub-validators.ts +62 -4
- package/src/backend/lib/ap-ids.ts +36 -6
- package/src/backend/lib/ap-verify.ts +7 -17
- package/src/backend/lib/blocklist-purge.ts +676 -42
- package/src/backend/lib/blocklist.ts +278 -39
- package/src/backend/lib/community-visibility.ts +47 -33
- package/src/backend/lib/delivery/fanout-outbox.ts +336 -0
- package/src/backend/lib/delivery/planner.ts +16 -12
- package/src/backend/lib/delivery/queue-batching.ts +389 -145
- package/src/backend/lib/delivery/queue-delivery.ts +47 -25
- package/src/backend/lib/delivery/queue.ts +479 -73
- package/src/backend/lib/delivery/resolution-outbox.ts +456 -0
- package/src/backend/lib/delivery/types.ts +31 -7
- package/src/backend/lib/feed-exclude.ts +40 -28
- package/src/backend/lib/follow-edge-mutations.ts +217 -0
- package/src/backend/lib/notification-eligibility.ts +15 -9
- package/src/backend/lib/notification-push.ts +2 -2
- package/src/backend/lib/oidc-id-token.ts +66 -0
- package/src/backend/lib/personal-actor-moderation.ts +239 -0
- package/src/backend/lib/post-visibility.ts +79 -16
- package/src/backend/lib/remote-activity-id.ts +61 -0
- package/src/backend/lib/unread-counts.ts +3 -0
- package/src/backend/retention.ts +38 -1
- package/src/backend/routes/account-teardown.ts +422 -156
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
- package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
- package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
- package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
- package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
- package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
- package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
- package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
- package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
- package/src/backend/routes/activitypub/inbox-types.ts +14 -2
- package/src/backend/routes/activitypub/inbox.ts +81 -105
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub.ts +6 -5
- package/src/backend/routes/actors-helpers.ts +34 -8
- package/src/backend/routes/actors.ts +328 -164
- package/src/backend/routes/auth-helpers.ts +57 -9
- package/src/backend/routes/auth.ts +10 -2
- package/src/backend/routes/communities/membership-invites.ts +4 -1
- package/src/backend/routes/communities/membership-members.ts +201 -73
- package/src/backend/routes/communities/membership-requests.ts +141 -76
- package/src/backend/routes/communities/membership-shared.ts +228 -21
- package/src/backend/routes/communities/messages.ts +9 -2
- package/src/backend/routes/communities/routes.ts +48 -13
- package/src/backend/routes/dm/contacts.ts +29 -41
- package/src/backend/routes/dm/conversations-helpers.ts +9 -1
- package/src/backend/routes/dm/messages.ts +36 -42
- package/src/backend/routes/dm/read-archive.ts +4 -2
- package/src/backend/routes/dm/requests.ts +62 -54
- package/src/backend/routes/follow-helpers.ts +200 -60
- package/src/backend/routes/follow.ts +146 -140
- package/src/backend/routes/media.ts +20 -94
- package/src/backend/routes/moderation.ts +72 -4
- package/src/backend/routes/notes.ts +3 -4
- package/src/backend/routes/notifications.ts +209 -108
- package/src/backend/routes/posts/delete-cascade.ts +253 -89
- package/src/backend/routes/posts/federation.ts +373 -0
- package/src/backend/routes/posts/interactions.ts +160 -184
- package/src/backend/routes/posts/like-mutation.ts +240 -0
- package/src/backend/routes/posts/post-helpers.ts +112 -162
- package/src/backend/routes/posts/queries.ts +150 -54
- package/src/backend/routes/posts/routes.ts +169 -329
- package/src/backend/routes/posts/transformers.ts +61 -6
- package/src/backend/routes/recommendations.ts +37 -44
- package/src/backend/routes/rtc/index.ts +26 -6
- package/src/backend/routes/search.ts +39 -55
- package/src/backend/routes/stories/interactions.ts +22 -6
- package/src/backend/routes/stories/query-helpers.ts +28 -41
- package/src/backend/routes/stories/routes.ts +125 -108
- package/src/backend/routes/takos-tools/dm.ts +17 -17
- package/src/backend/routes/takos-tools/posts.ts +189 -106
- package/src/backend/routes/takos-tools/search.ts +13 -4
- package/src/backend/routes/takos-tools/timeline.ts +35 -27
- package/src/backend/routes/takos-tools-response.ts +6 -2
- package/src/backend/routes/timeline.ts +5 -5
- package/src/backend/runtime/call-signaling-do.ts +35 -4
- package/src/backend/runtime/one-time-ticket.ts +116 -0
- package/src/backend/runtime/realtime-stream-do.ts +5 -61
- package/src/backend/runtime/signaling-hub.ts +36 -3
- package/src/backend/server.ts +95 -80
- package/src/db/d1-write.ts +67 -43
- package/src/db/schema/federation.ts +42 -1
- package/src/db/schema/messaging.ts +110 -2
- package/src/db/schema.ts +1 -1
|
@@ -28,6 +28,8 @@ import {
|
|
|
28
28
|
import {
|
|
29
29
|
blockActor,
|
|
30
30
|
blockDomain,
|
|
31
|
+
domainSuffixCandidates,
|
|
32
|
+
normalizeDomain,
|
|
31
33
|
unblockActor,
|
|
32
34
|
unblockDomain,
|
|
33
35
|
} from "../lib/blocklist.ts";
|
|
@@ -91,6 +93,29 @@ function readReason(body: unknown): string | null {
|
|
|
91
93
|
return typeof reason === "string" && reason.length > 0 ? reason : null;
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
function domainBlockCoversLocalHost(
|
|
97
|
+
domainOrUrl: string,
|
|
98
|
+
appUrl: string,
|
|
99
|
+
): boolean {
|
|
100
|
+
const requestedDomain = normalizeDomain(domainOrUrl);
|
|
101
|
+
const localDomain = normalizeDomain(appUrl);
|
|
102
|
+
return (
|
|
103
|
+
requestedDomain !== null &&
|
|
104
|
+
localDomain !== null &&
|
|
105
|
+
domainSuffixCandidates(localDomain).includes(requestedDomain)
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function actorBlockTargetsLocalHost(
|
|
110
|
+
actorApId: string,
|
|
111
|
+
appUrl: string,
|
|
112
|
+
): boolean {
|
|
113
|
+
if (isLocal(actorApId, appUrl)) return true;
|
|
114
|
+
const actorDomain = normalizeDomain(actorApId);
|
|
115
|
+
const localDomain = normalizeDomain(appUrl);
|
|
116
|
+
return actorDomain !== null && actorDomain === localDomain;
|
|
117
|
+
}
|
|
118
|
+
|
|
94
119
|
// ---------------------------------------------------------------------------
|
|
95
120
|
// Blocked domains
|
|
96
121
|
// ---------------------------------------------------------------------------
|
|
@@ -125,6 +150,14 @@ moderationRoutes.post("/domains", async (c) => {
|
|
|
125
150
|
|
|
126
151
|
const parsed = await readApIdBody(c, "domain");
|
|
127
152
|
if ("error" in parsed) return parsed.error;
|
|
153
|
+
// A domain block immediately purges already-ingested content. Blocking the
|
|
154
|
+
// local host (or one of its parent domains) would therefore turn an operator
|
|
155
|
+
// typo into irreversible deletion of local posts and Activity rows. Reject
|
|
156
|
+
// before writing the blocklist row; this is a destructive safety boundary,
|
|
157
|
+
// not merely a delivery preference.
|
|
158
|
+
if (domainBlockCoversLocalHost(parsed.value, c.env.APP_URL)) {
|
|
159
|
+
return c.json({ error: "Cannot block the local domain" }, 400);
|
|
160
|
+
}
|
|
128
161
|
|
|
129
162
|
try {
|
|
130
163
|
await blockDomain(c.get("db"), parsed.value, readReason(parsed.body));
|
|
@@ -133,8 +166,24 @@ moderationRoutes.post("/domains", async (c) => {
|
|
|
133
166
|
}
|
|
134
167
|
// Defederation also purges the domain's already-ingested content so it stops
|
|
135
168
|
// being served (the blocklist is otherwise ingest/delivery-only).
|
|
136
|
-
await purgeDomainContent(
|
|
137
|
-
|
|
169
|
+
const purge = await purgeDomainContent(
|
|
170
|
+
c.get("db"),
|
|
171
|
+
parsed.value,
|
|
172
|
+
c.env.MEDIA,
|
|
173
|
+
);
|
|
174
|
+
if (!purge.complete) {
|
|
175
|
+
c.header("Retry-After", "1");
|
|
176
|
+
return c.json(
|
|
177
|
+
{
|
|
178
|
+
error:
|
|
179
|
+
"Domain block is active, but retained content cleanup did not finish. Retry this block to complete cleanup.",
|
|
180
|
+
block_applied: true,
|
|
181
|
+
cleanup_complete: false,
|
|
182
|
+
},
|
|
183
|
+
503,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return c.json({ success: true, cleanup_complete: true });
|
|
138
187
|
});
|
|
139
188
|
|
|
140
189
|
moderationRoutes.delete("/domains", async (c) => {
|
|
@@ -186,6 +235,13 @@ moderationRoutes.post("/actors", async (c) => {
|
|
|
186
235
|
|
|
187
236
|
const parsed = await readApIdBody(c, "ap_id");
|
|
188
237
|
if ("error" in parsed) return parsed.error;
|
|
238
|
+
// Operator actor blocks use the same destructive retained-content purge.
|
|
239
|
+
// They are only for remote actors; never let a local AP ID reach the block
|
|
240
|
+
// mutation or purge path. The normalized-host check also catches cosmetic
|
|
241
|
+
// trailing-root-dot spellings that URL-origin comparison alone misses.
|
|
242
|
+
if (actorBlockTargetsLocalHost(parsed.value, c.env.APP_URL)) {
|
|
243
|
+
return c.json({ error: "Cannot block a local actor" }, 400);
|
|
244
|
+
}
|
|
189
245
|
|
|
190
246
|
try {
|
|
191
247
|
await blockActor(c.get("db"), parsed.value, readReason(parsed.body));
|
|
@@ -193,8 +249,20 @@ moderationRoutes.post("/actors", async (c) => {
|
|
|
193
249
|
return c.json({ error: "Invalid actor" }, 400);
|
|
194
250
|
}
|
|
195
251
|
// Purge the blocked actor's already-ingested content so it stops being served.
|
|
196
|
-
await purgeActorContent(c.get("db"), parsed.value, c.env.MEDIA);
|
|
197
|
-
|
|
252
|
+
const purge = await purgeActorContent(c.get("db"), parsed.value, c.env.MEDIA);
|
|
253
|
+
if (!purge.complete) {
|
|
254
|
+
c.header("Retry-After", "1");
|
|
255
|
+
return c.json(
|
|
256
|
+
{
|
|
257
|
+
error:
|
|
258
|
+
"Actor block is active, but retained content cleanup did not finish. Retry this block to complete cleanup.",
|
|
259
|
+
block_applied: true,
|
|
260
|
+
cleanup_complete: false,
|
|
261
|
+
},
|
|
262
|
+
503,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
return c.json({ success: true, cleanup_complete: true });
|
|
198
266
|
});
|
|
199
267
|
|
|
200
268
|
moderationRoutes.delete("/actors", async (c) => {
|
|
@@ -5,7 +5,7 @@ import type { SQL } from "drizzle-orm";
|
|
|
5
5
|
import { actorNotes, actors, follows, type Database } from "../../db/index.ts";
|
|
6
6
|
import type { Env, Variables } from "../types.ts";
|
|
7
7
|
import { formatUsername } from "../federation-helpers.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { excludeModeratedActors } from "../lib/feed-exclude.ts";
|
|
9
9
|
|
|
10
10
|
const notes = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
11
11
|
|
|
@@ -64,7 +64,7 @@ function formatNote(row: NoteRow, viewerApId: string): NoteResponse {
|
|
|
64
64
|
return {
|
|
65
65
|
actor: {
|
|
66
66
|
ap_id: row.actorApId,
|
|
67
|
-
username: formatUsername(row.actorApId),
|
|
67
|
+
username: formatUsername(row.actorApId, row.preferredUsername),
|
|
68
68
|
preferred_username: row.preferredUsername,
|
|
69
69
|
name: row.name,
|
|
70
70
|
icon_url: row.iconUrl,
|
|
@@ -98,8 +98,7 @@ async function loadActiveNotes(
|
|
|
98
98
|
inArray(actorNotes.actorApId, followingSubquery),
|
|
99
99
|
)!,
|
|
100
100
|
];
|
|
101
|
-
const excludeAuthors =
|
|
102
|
-
db,
|
|
101
|
+
const excludeAuthors = excludeModeratedActors(
|
|
103
102
|
viewerApId,
|
|
104
103
|
actorNotes.actorApId,
|
|
105
104
|
);
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
isNull,
|
|
12
12
|
lt,
|
|
13
13
|
or,
|
|
14
|
+
sql,
|
|
14
15
|
type SQL,
|
|
15
16
|
} from "drizzle-orm";
|
|
16
17
|
import type { Env, Variables } from "../types.ts";
|
|
@@ -22,9 +23,11 @@ import {
|
|
|
22
23
|
import type { Database } from "../../db/index.ts";
|
|
23
24
|
import {
|
|
24
25
|
activities,
|
|
26
|
+
affectedRowCount,
|
|
25
27
|
follows,
|
|
26
28
|
inbox as inboxTable,
|
|
27
29
|
notificationArchived,
|
|
30
|
+
objectRecipients,
|
|
28
31
|
objects,
|
|
29
32
|
} from "../../db/index.ts";
|
|
30
33
|
import { batchLoadActorInfo } from "./communities/membership-shared.ts";
|
|
@@ -39,6 +42,7 @@ import {
|
|
|
39
42
|
emitUnreadSnapshot,
|
|
40
43
|
runRealtimeAfterResponse,
|
|
41
44
|
} from "../runtime/realtime-hub.ts";
|
|
45
|
+
import { passesPostVisibilitySync } from "../lib/post-visibility.ts";
|
|
42
46
|
|
|
43
47
|
const notifications = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
44
48
|
|
|
@@ -49,14 +53,43 @@ const ARCHIVED_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
|
|
49
53
|
// at 100 bound parameters (libsql, used by tests, allows ~32k and hides this).
|
|
50
54
|
const MAX_ARCHIVE_BATCH_SIZE = 90;
|
|
51
55
|
const MAX_READ_BATCH_SIZE = 90;
|
|
52
|
-
//
|
|
53
|
-
//
|
|
56
|
+
// INSERT ... SELECT chunk: each requested id is one bind, plus actor + archive
|
|
57
|
+
// timestamp. Keep the established 30-id chunk comfortably under D1's
|
|
58
|
+
// 100-parameter ceiling.
|
|
54
59
|
const ARCHIVE_CREATE_BATCH_SIZE = 30;
|
|
55
60
|
// Bound the opportunistic retention-cleanup per run so a user with a very large
|
|
56
61
|
// archived backlog doesn't load + delete it all in one fired-from-read-path run;
|
|
57
62
|
// the rest drains on later runs.
|
|
58
63
|
const ARCHIVE_CLEANUP_BATCH = 200;
|
|
59
|
-
|
|
64
|
+
|
|
65
|
+
type ParsedNotificationIds =
|
|
66
|
+
| { readonly ok: true; readonly ids: string[] }
|
|
67
|
+
| { readonly ok: false; readonly reason: "invalid" | "too_long" };
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Runtime owner for notification mutation ID lists. JSON generic arguments do
|
|
71
|
+
* not validate input, and passing a string or mixed array to Drizzle's
|
|
72
|
+
* `inArray` can become a 500 or a partially applied write. Normalizing here
|
|
73
|
+
* keeps read, archive, and unarchive on one pre-write contract.
|
|
74
|
+
*/
|
|
75
|
+
function parseNotificationIds(
|
|
76
|
+
value: unknown,
|
|
77
|
+
maxSize: number,
|
|
78
|
+
): ParsedNotificationIds {
|
|
79
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
80
|
+
return { ok: false, reason: "invalid" };
|
|
81
|
+
}
|
|
82
|
+
if (value.length > maxSize) {
|
|
83
|
+
return { ok: false, reason: "too_long" };
|
|
84
|
+
}
|
|
85
|
+
if (value.some((id) => typeof id !== "string" || id.trim().length === 0)) {
|
|
86
|
+
return { ok: false, reason: "invalid" };
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
ok: true,
|
|
90
|
+
ids: [...new Set(value.map((id) => (id as string).trim()))],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
60
93
|
|
|
61
94
|
/**
|
|
62
95
|
* Tracks the last cleanup timestamp per actor so cleanup is throttled to one
|
|
@@ -93,28 +126,111 @@ export const __archivedCleanupInternals = {
|
|
|
93
126
|
};
|
|
94
127
|
|
|
95
128
|
/**
|
|
96
|
-
*
|
|
129
|
+
* Materialize archive markers only from rows in the authenticated actor's
|
|
130
|
+
* retained inbox. Keeping eligibility and insertion in the same statement
|
|
131
|
+
* prevents foreign, missing, or concurrently removed inbox rows from becoming
|
|
132
|
+
* durable markers. The unique constraint makes retries idempotent.
|
|
133
|
+
*
|
|
97
134
|
* Returns the number of rows actually inserted.
|
|
98
135
|
*/
|
|
99
136
|
async function batchArchiveInsert(
|
|
100
137
|
db: Database,
|
|
101
|
-
|
|
138
|
+
actorApId: string,
|
|
139
|
+
activityApIds: string[],
|
|
140
|
+
archivedAt: string,
|
|
102
141
|
batchSize: number,
|
|
103
142
|
): Promise<number> {
|
|
104
143
|
let inserted = 0;
|
|
105
144
|
|
|
106
|
-
for (let i = 0; i <
|
|
107
|
-
const batch =
|
|
145
|
+
for (let i = 0; i < activityApIds.length; i += batchSize) {
|
|
146
|
+
const batch = activityApIds.slice(i, i + batchSize);
|
|
147
|
+
const candidates = db
|
|
148
|
+
.select({
|
|
149
|
+
actorApId: inboxTable.actorApId,
|
|
150
|
+
activityApId: inboxTable.activityApId,
|
|
151
|
+
archivedAt: sql<string>`${archivedAt}`.as("archived_at"),
|
|
152
|
+
})
|
|
153
|
+
.from(inboxTable)
|
|
154
|
+
.where(
|
|
155
|
+
and(
|
|
156
|
+
eq(inboxTable.actorApId, actorApId),
|
|
157
|
+
inArray(inboxTable.activityApId, batch),
|
|
158
|
+
),
|
|
159
|
+
);
|
|
108
160
|
const result = await db
|
|
109
161
|
.insert(notificationArchived)
|
|
110
|
-
.
|
|
162
|
+
.select(candidates)
|
|
111
163
|
.onConflictDoNothing();
|
|
112
|
-
inserted += (result
|
|
164
|
+
inserted += affectedRowCount(result);
|
|
113
165
|
}
|
|
114
166
|
|
|
115
167
|
return inserted;
|
|
116
168
|
}
|
|
117
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Archive the authenticated actor's complete retained inbox as one set-based
|
|
172
|
+
* authority transition. A client-side or server-side page loop would expose a
|
|
173
|
+
* partial-success state between batches; INSERT ... SELECT gives SQLite/D1 one
|
|
174
|
+
* atomic statement, binds only actor + timestamp, and stays idempotent under
|
|
175
|
+
* retries through the archive primary key.
|
|
176
|
+
*/
|
|
177
|
+
async function archiveAllInboxRows(
|
|
178
|
+
db: Database,
|
|
179
|
+
actorApId: string,
|
|
180
|
+
archivedAt: string,
|
|
181
|
+
): Promise<number> {
|
|
182
|
+
const candidates = db
|
|
183
|
+
.select({
|
|
184
|
+
actorApId: inboxTable.actorApId,
|
|
185
|
+
activityApId: inboxTable.activityApId,
|
|
186
|
+
archivedAt: sql<string>`${archivedAt}`.as("archived_at"),
|
|
187
|
+
})
|
|
188
|
+
.from(inboxTable)
|
|
189
|
+
.where(eq(inboxTable.actorApId, actorApId));
|
|
190
|
+
const result = await db
|
|
191
|
+
.insert(notificationArchived)
|
|
192
|
+
.select(candidates)
|
|
193
|
+
.onConflictDoNothing();
|
|
194
|
+
return affectedRowCount(result);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Mark every currently visible social notification read using the same
|
|
199
|
+
* authority predicate as the list and unread badge. Inbox also retains rows
|
|
200
|
+
* for DMs, archived history, self activity, and non-user-facing ActivityPub
|
|
201
|
+
* types; a broad actor-only UPDATE would silently mutate those other domains.
|
|
202
|
+
*/
|
|
203
|
+
async function markAllEligibleNotificationsRead(
|
|
204
|
+
db: Database,
|
|
205
|
+
actorApId: string,
|
|
206
|
+
): Promise<void> {
|
|
207
|
+
const eligibleActivityIds = db
|
|
208
|
+
.select({ activityApId: inboxTable.activityApId })
|
|
209
|
+
.from(inboxTable)
|
|
210
|
+
.innerJoin(activities, eq(inboxTable.activityApId, activities.apId))
|
|
211
|
+
.leftJoin(objects, eq(activities.objectApId, objects.apId))
|
|
212
|
+
.where(
|
|
213
|
+
and(
|
|
214
|
+
eq(inboxTable.actorApId, actorApId),
|
|
215
|
+
eq(inboxTable.read, 0),
|
|
216
|
+
...notificationEligibilityWhere(db, actorApId, {
|
|
217
|
+
direct: "exclude",
|
|
218
|
+
}),
|
|
219
|
+
),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
await db
|
|
223
|
+
.update(inboxTable)
|
|
224
|
+
.set({ read: 1 })
|
|
225
|
+
.where(
|
|
226
|
+
and(
|
|
227
|
+
eq(inboxTable.actorApId, actorApId),
|
|
228
|
+
eq(inboxTable.read, 0),
|
|
229
|
+
inArray(inboxTable.activityApId, eligibleActivityIds),
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
118
234
|
async function cleanupArchivedNotifications(
|
|
119
235
|
db: Database,
|
|
120
236
|
actorApId: string,
|
|
@@ -383,6 +499,9 @@ notifications.get("/", async (c) => {
|
|
|
383
499
|
communityApId: objects.communityApId,
|
|
384
500
|
visibility: objects.visibility,
|
|
385
501
|
attributedTo: objects.attributedTo,
|
|
502
|
+
toJson: objects.toJson,
|
|
503
|
+
ccJson: objects.ccJson,
|
|
504
|
+
endTime: objects.endTime,
|
|
386
505
|
})
|
|
387
506
|
.from(objects)
|
|
388
507
|
.where(inArray(objects.apId, objectApIds))
|
|
@@ -411,7 +530,10 @@ notifications.get("/", async (c) => {
|
|
|
411
530
|
...new Set(
|
|
412
531
|
objectRows
|
|
413
532
|
.filter(
|
|
414
|
-
(o) =>
|
|
533
|
+
(o) =>
|
|
534
|
+
o.attributedTo !== actor.ap_id &&
|
|
535
|
+
((o.type === "Story" && !o.communityApId) ||
|
|
536
|
+
(o.type !== "Story" && o.visibility === "followers")),
|
|
415
537
|
)
|
|
416
538
|
.map((o) => o.attributedTo),
|
|
417
539
|
),
|
|
@@ -434,22 +556,46 @@ notifications.get("/", async (c) => {
|
|
|
434
556
|
)
|
|
435
557
|
: new Set<string>();
|
|
436
558
|
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
559
|
+
const projectionCandidates = objectRows
|
|
560
|
+
.filter(
|
|
561
|
+
(o) =>
|
|
562
|
+
o.attributedTo !== actor.ap_id &&
|
|
563
|
+
(o.visibility === "followers" || o.visibility === "direct"),
|
|
564
|
+
)
|
|
565
|
+
.map((o) => o.apId);
|
|
566
|
+
const projectedRecipientIds =
|
|
567
|
+
projectionCandidates.length > 0
|
|
568
|
+
? new Set(
|
|
569
|
+
(
|
|
570
|
+
await db
|
|
571
|
+
.select({ objectApId: objectRecipients.objectApId })
|
|
572
|
+
.from(objectRecipients)
|
|
573
|
+
.where(
|
|
574
|
+
and(
|
|
575
|
+
eq(objectRecipients.recipientApId, actor.ap_id),
|
|
576
|
+
eq(objectRecipients.type, "to"),
|
|
577
|
+
inArray(objectRecipients.objectApId, projectionCandidates),
|
|
578
|
+
),
|
|
579
|
+
)
|
|
580
|
+
).map((row) => row.objectApId),
|
|
581
|
+
)
|
|
582
|
+
: new Set<string>();
|
|
583
|
+
|
|
584
|
+
const visibilityReadableRows = objectRows.filter((object) =>
|
|
585
|
+
passesPostVisibilitySync(
|
|
586
|
+
object,
|
|
587
|
+
actor.ap_id,
|
|
588
|
+
(authorApId) => followedAuthors.has(authorApId),
|
|
589
|
+
(objectApId) => projectedRecipientIds.has(objectApId),
|
|
590
|
+
),
|
|
591
|
+
);
|
|
446
592
|
|
|
447
593
|
// Apply the synchronous followers-gate first, then resolve the community
|
|
448
594
|
// read-gate for all survivors in ONE batched call (2 queries) rather than
|
|
449
595
|
// 1-2 queries per row.
|
|
450
596
|
const readableObjectIds = await communityReadableApIds(
|
|
451
597
|
db,
|
|
452
|
-
|
|
598
|
+
visibilityReadableRows,
|
|
453
599
|
actor.ap_id,
|
|
454
600
|
);
|
|
455
601
|
|
|
@@ -522,7 +668,10 @@ notifications.get("/", async (c) => {
|
|
|
522
668
|
created_at: entry.createdAt,
|
|
523
669
|
actor: {
|
|
524
670
|
ap_id: entry.activityActorApId,
|
|
525
|
-
username: formatUsername(
|
|
671
|
+
username: formatUsername(
|
|
672
|
+
entry.activityActorApId,
|
|
673
|
+
actorInfo?.preferredUsername,
|
|
674
|
+
),
|
|
526
675
|
preferred_username: actorInfo?.preferredUsername ?? null,
|
|
527
676
|
name: actorInfo?.name ?? null,
|
|
528
677
|
icon_url: actorInfo?.iconUrl ?? null,
|
|
@@ -585,23 +734,31 @@ notifications.post("/read", async (c) => {
|
|
|
585
734
|
if (actor instanceof Response) return actor;
|
|
586
735
|
|
|
587
736
|
const db = c.get("db");
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
const body = await c.req
|
|
592
|
-
|
|
593
|
-
.catch(() => null);
|
|
594
|
-
if (!body || typeof body !== "object") {
|
|
737
|
+
// JSON's static type parameter is not runtime validation. Accept exactly one
|
|
738
|
+
// operation and validate every value before any write, so truthy strings or
|
|
739
|
+
// mixed arrays cannot trigger a broad or partially applied update.
|
|
740
|
+
const body = await c.req.json<Record<string, unknown>>().catch(() => null);
|
|
741
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
595
742
|
return c.json({ error: "Invalid request body" }, 400);
|
|
596
743
|
}
|
|
597
744
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
745
|
+
const hasReadAll = Object.hasOwn(body, "read_all");
|
|
746
|
+
const hasIds = Object.hasOwn(body, "ids");
|
|
747
|
+
if (hasReadAll === hasIds) {
|
|
748
|
+
return c.json({ error: "Exactly one of ids or read_all is required" }, 400);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (hasReadAll) {
|
|
752
|
+
if (body.read_all !== true) {
|
|
753
|
+
return c.json({ error: "read_all must be true" }, 400);
|
|
754
|
+
}
|
|
755
|
+
await markAllEligibleNotificationsRead(db, actor.ap_id);
|
|
756
|
+
} else {
|
|
757
|
+
const parsedIds = parseNotificationIds(body.ids, MAX_READ_BATCH_SIZE);
|
|
758
|
+
if (!parsedIds.ok && parsedIds.reason === "invalid") {
|
|
759
|
+
return c.json({ error: "ids must be a non-empty array of strings" }, 400);
|
|
760
|
+
}
|
|
761
|
+
if (!parsedIds.ok) {
|
|
605
762
|
return c.json(
|
|
606
763
|
{
|
|
607
764
|
error: "array_too_long",
|
|
@@ -616,14 +773,9 @@ notifications.post("/read", async (c) => {
|
|
|
616
773
|
.where(
|
|
617
774
|
and(
|
|
618
775
|
eq(inboxTable.actorApId, actor.ap_id),
|
|
619
|
-
inArray(inboxTable.activityApId,
|
|
776
|
+
inArray(inboxTable.activityApId, parsedIds.ids),
|
|
620
777
|
),
|
|
621
778
|
);
|
|
622
|
-
} else {
|
|
623
|
-
return c.json(
|
|
624
|
-
{ error: "Either ids array or read_all flag is required" },
|
|
625
|
-
400,
|
|
626
|
-
);
|
|
627
779
|
}
|
|
628
780
|
|
|
629
781
|
// Sync the reader's OTHER tabs/devices: push the fresh authoritative badge.
|
|
@@ -640,20 +792,16 @@ notifications.post("/archive", async (c) => {
|
|
|
640
792
|
if (actor instanceof Response) return actor;
|
|
641
793
|
|
|
642
794
|
const db = c.get("db");
|
|
643
|
-
const body = await c.req.json<
|
|
644
|
-
if (!body || typeof body !== "object") {
|
|
795
|
+
const body = await c.req.json<Record<string, unknown>>().catch(() => null);
|
|
796
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
645
797
|
return c.json({ error: "Invalid request body" }, 400);
|
|
646
798
|
}
|
|
647
799
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
!Array.isArray(body.ids) ||
|
|
651
|
-
body.ids.length === 0 ||
|
|
652
|
-
body.ids.some((id) => typeof id !== "string" || id.trim().length === 0)
|
|
653
|
-
) {
|
|
800
|
+
const parsedIds = parseNotificationIds(body.ids, MAX_ARCHIVE_BATCH_SIZE);
|
|
801
|
+
if (!parsedIds.ok && parsedIds.reason === "invalid") {
|
|
654
802
|
return c.json({ error: "ids array is required" }, 400);
|
|
655
803
|
}
|
|
656
|
-
if (
|
|
804
|
+
if (!parsedIds.ok) {
|
|
657
805
|
return c.json(
|
|
658
806
|
{
|
|
659
807
|
error: `Batch size exceeds maximum of ${MAX_ARCHIVE_BATCH_SIZE}`,
|
|
@@ -663,30 +811,12 @@ notifications.post("/archive", async (c) => {
|
|
|
663
811
|
}
|
|
664
812
|
|
|
665
813
|
const now = new Date().toISOString();
|
|
666
|
-
const uniqueIds = [...new Set(body.ids.map((id) => id.trim()))];
|
|
667
814
|
|
|
668
|
-
const alreadyArchived = await db
|
|
669
|
-
.select({ activityApId: notificationArchived.activityApId })
|
|
670
|
-
.from(notificationArchived)
|
|
671
|
-
.where(
|
|
672
|
-
and(
|
|
673
|
-
eq(notificationArchived.actorApId, actor.ap_id),
|
|
674
|
-
inArray(notificationArchived.activityApId, uniqueIds),
|
|
675
|
-
),
|
|
676
|
-
);
|
|
677
|
-
const alreadyArchivedSet = new Set(
|
|
678
|
-
alreadyArchived.map((row) => row.activityApId),
|
|
679
|
-
);
|
|
680
|
-
const toArchive = uniqueIds.filter((id) => !alreadyArchivedSet.has(id));
|
|
681
|
-
|
|
682
|
-
const rows = toArchive.map((id) => ({
|
|
683
|
-
actorApId: actor.ap_id,
|
|
684
|
-
activityApId: id,
|
|
685
|
-
archivedAt: now,
|
|
686
|
-
}));
|
|
687
815
|
const archived_count = await batchArchiveInsert(
|
|
688
816
|
db,
|
|
689
|
-
|
|
817
|
+
actor.ap_id,
|
|
818
|
+
parsedIds.ids,
|
|
819
|
+
now,
|
|
690
820
|
ARCHIVE_CREATE_BATCH_SIZE,
|
|
691
821
|
);
|
|
692
822
|
|
|
@@ -704,14 +834,16 @@ notifications.delete("/archive", async (c) => {
|
|
|
704
834
|
if (actor instanceof Response) return actor;
|
|
705
835
|
|
|
706
836
|
const db = c.get("db");
|
|
707
|
-
const body = await c.req.json<
|
|
708
|
-
if (!body || typeof body !== "object") {
|
|
837
|
+
const body = await c.req.json<Record<string, unknown>>().catch(() => null);
|
|
838
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
709
839
|
return c.json({ error: "Invalid request body" }, 400);
|
|
710
840
|
}
|
|
711
|
-
|
|
841
|
+
|
|
842
|
+
const parsedIds = parseNotificationIds(body.ids, MAX_ARCHIVE_BATCH_SIZE);
|
|
843
|
+
if (!parsedIds.ok && parsedIds.reason === "invalid") {
|
|
712
844
|
return c.json({ error: "ids array is required" }, 400);
|
|
713
845
|
}
|
|
714
|
-
if (
|
|
846
|
+
if (!parsedIds.ok) {
|
|
715
847
|
return c.json(
|
|
716
848
|
{
|
|
717
849
|
error: "array_too_long",
|
|
@@ -726,7 +858,7 @@ notifications.delete("/archive", async (c) => {
|
|
|
726
858
|
.where(
|
|
727
859
|
and(
|
|
728
860
|
eq(notificationArchived.actorApId, actor.ap_id),
|
|
729
|
-
inArray(notificationArchived.activityApId,
|
|
861
|
+
inArray(notificationArchived.activityApId, parsedIds.ids),
|
|
730
862
|
),
|
|
731
863
|
);
|
|
732
864
|
|
|
@@ -745,38 +877,7 @@ notifications.post("/archive/all", async (c) => {
|
|
|
745
877
|
|
|
746
878
|
const db = c.get("db");
|
|
747
879
|
const now = new Date().toISOString();
|
|
748
|
-
|
|
749
|
-
const [alreadyArchived, inboxItems] = await Promise.all([
|
|
750
|
-
db
|
|
751
|
-
.select({ activityApId: notificationArchived.activityApId })
|
|
752
|
-
.from(notificationArchived)
|
|
753
|
-
.where(eq(notificationArchived.actorApId, actor.ap_id))
|
|
754
|
-
.limit(ARCHIVE_ALL_CAP),
|
|
755
|
-
db
|
|
756
|
-
.select({ activityApId: inboxTable.activityApId })
|
|
757
|
-
.from(inboxTable)
|
|
758
|
-
.where(eq(inboxTable.actorApId, actor.ap_id))
|
|
759
|
-
.limit(ARCHIVE_ALL_CAP),
|
|
760
|
-
]);
|
|
761
|
-
|
|
762
|
-
const alreadyArchivedIds = new Set(
|
|
763
|
-
alreadyArchived.map((a) => a.activityApId),
|
|
764
|
-
);
|
|
765
|
-
const toArchive = inboxItems.filter(
|
|
766
|
-
(item) => !alreadyArchivedIds.has(item.activityApId),
|
|
767
|
-
);
|
|
768
|
-
|
|
769
|
-
const rows = toArchive.map((item) => ({
|
|
770
|
-
actorApId: actor.ap_id,
|
|
771
|
-
activityApId: item.activityApId,
|
|
772
|
-
archivedAt: now,
|
|
773
|
-
}));
|
|
774
|
-
|
|
775
|
-
const archived_count = await batchArchiveInsert(
|
|
776
|
-
db,
|
|
777
|
-
rows,
|
|
778
|
-
ARCHIVE_CREATE_BATCH_SIZE,
|
|
779
|
-
);
|
|
880
|
+
const archived_count = await archiveAllInboxRows(db, actor.ap_id, now);
|
|
780
881
|
|
|
781
882
|
// Archive-all clears the whole badge; sync the reader's other tabs/devices.
|
|
782
883
|
await runRealtimeAfterResponse(c, () =>
|