@takosjp/yurucommu-core 3.0.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/LICENSE +16 -0
- package/README.md +82 -0
- package/migrations/0001_init.sql +495 -0
- package/migrations/0002_social_remote_actor_edges.sql +92 -0
- package/migrations/0003_activity_remote_object_edges.sql +68 -0
- package/migrations/0004_blocklist.sql +26 -0
- package/migrations/0005_story_community_scope.sql +13 -0
- package/migrations/0006_dm_community_read_status.sql +19 -0
- package/migrations/0007_moderation_reports.sql +22 -0
- package/migrations/0008_actor_fields_aka.sql +18 -0
- package/migrations/0009_object_tags.sql +13 -0
- package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
- package/migrations/0011_drop_remote_actor_fks.sql +205 -0
- package/migrations/0012_objects_content_fts.sql +39 -0
- package/migrations/0013_efficiency_indexes.sql +13 -0
- package/migrations/0014_inbox_actor_created_idx.sql +15 -0
- package/migrations/0015_community_bans.sql +16 -0
- package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
- package/migrations/0017_mobile_push_registrations.sql +22 -0
- package/migrations/README.md +122 -0
- package/package.json +75 -0
- package/packages/api/LICENSE +16 -0
- package/packages/api/package.json +30 -0
- package/packages/api/src/index.ts +4 -0
- package/packages/api/src/lib/api/account.ts +20 -0
- package/packages/api/src/lib/api/actors.ts +149 -0
- package/packages/api/src/lib/api/auth.ts +46 -0
- package/packages/api/src/lib/api/communities.ts +329 -0
- package/packages/api/src/lib/api/dm.test.ts +67 -0
- package/packages/api/src/lib/api/dm.ts +236 -0
- package/packages/api/src/lib/api/fetch.ts +111 -0
- package/packages/api/src/lib/api/follow.ts +30 -0
- package/packages/api/src/lib/api/media.ts +100 -0
- package/packages/api/src/lib/api/moderation.ts +98 -0
- package/packages/api/src/lib/api/normalize.ts +71 -0
- package/packages/api/src/lib/api/notifications.test.ts +63 -0
- package/packages/api/src/lib/api/notifications.ts +61 -0
- package/packages/api/src/lib/api/posts.test.ts +110 -0
- package/packages/api/src/lib/api/posts.ts +181 -0
- package/packages/api/src/lib/api/recommendations.ts +22 -0
- package/packages/api/src/lib/api/search.ts +88 -0
- package/packages/api/src/lib/api/stories.ts +80 -0
- package/packages/api/src/lib/api.ts +15 -0
- package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
- package/packages/api/src/lib/transport.ts +40 -0
- package/packages/api/src/social-server.ts +47 -0
- package/packages/api/src/types/index.ts +185 -0
- package/scripts/apply-takosumi-migrations.ts +621 -0
- package/src/backend/federation-helpers.ts +36 -0
- package/src/backend/index.ts +872 -0
- package/src/backend/lib/account-migration.ts +106 -0
- package/src/backend/lib/activitypub-actor-cache.ts +238 -0
- package/src/backend/lib/activitypub-helpers.ts +131 -0
- package/src/backend/lib/activitypub-validators.ts +323 -0
- package/src/backend/lib/ap-context.ts +16 -0
- package/src/backend/lib/ap-ids.ts +101 -0
- package/src/backend/lib/ap-response.ts +30 -0
- package/src/backend/lib/ap-signing.ts +87 -0
- package/src/backend/lib/ap-verify.ts +670 -0
- package/src/backend/lib/auth-lockout.ts +230 -0
- package/src/backend/lib/backend-paths.ts +34 -0
- package/src/backend/lib/base64.ts +30 -0
- package/src/backend/lib/blocklist-purge.ts +109 -0
- package/src/backend/lib/blocklist.ts +279 -0
- package/src/backend/lib/chunk.ts +33 -0
- package/src/backend/lib/client-ip.ts +169 -0
- package/src/backend/lib/community-visibility.ts +230 -0
- package/src/backend/lib/crypto.ts +424 -0
- package/src/backend/lib/delivery/circuit.ts +265 -0
- package/src/backend/lib/delivery/metrics.ts +30 -0
- package/src/backend/lib/delivery/planner.ts +190 -0
- package/src/backend/lib/delivery/queue-batching.ts +626 -0
- package/src/backend/lib/delivery/queue-delivery.ts +641 -0
- package/src/backend/lib/delivery/queue.ts +576 -0
- package/src/backend/lib/delivery/transformers.ts +56 -0
- package/src/backend/lib/delivery/types.ts +139 -0
- package/src/backend/lib/errors.ts +114 -0
- package/src/backend/lib/federation-fetch.ts +296 -0
- package/src/backend/lib/feed-cursor.ts +57 -0
- package/src/backend/lib/feed-exclude.ts +48 -0
- package/src/backend/lib/hex.ts +8 -0
- package/src/backend/lib/log-mask.ts +213 -0
- package/src/backend/lib/logger.ts +285 -0
- package/src/backend/lib/mobile-contract.ts +137 -0
- package/src/backend/lib/oauth-providers.ts +324 -0
- package/src/backend/lib/oauth-utils.ts +148 -0
- package/src/backend/lib/oidc-id-token.ts +151 -0
- package/src/backend/lib/parse-helpers.ts +31 -0
- package/src/backend/lib/post-visibility.ts +190 -0
- package/src/backend/lib/session-actor.ts +61 -0
- package/src/backend/lib/ssrf.ts +428 -0
- package/src/backend/lib/strip-image-metadata.ts +191 -0
- package/src/backend/middleware/bearer-auth.ts +70 -0
- package/src/backend/middleware/body-limit.ts +212 -0
- package/src/backend/middleware/cache.ts +429 -0
- package/src/backend/middleware/csrf.ts +130 -0
- package/src/backend/middleware/error-handler.ts +77 -0
- package/src/backend/middleware/rate-limit.ts +308 -0
- package/src/backend/public.ts +21 -0
- package/src/backend/routes/account-teardown.ts +430 -0
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
- package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
- package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
- package/src/backend/routes/activitypub/inbox-types.ts +74 -0
- package/src/backend/routes/activitypub/inbox.ts +1191 -0
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub/query-helpers.ts +227 -0
- package/src/backend/routes/activitypub.ts +616 -0
- package/src/backend/routes/actors-helpers.ts +487 -0
- package/src/backend/routes/actors.ts +1311 -0
- package/src/backend/routes/apps.ts +313 -0
- package/src/backend/routes/auth-helpers.ts +566 -0
- package/src/backend/routes/auth.ts +615 -0
- package/src/backend/routes/communities/membership-invites.ts +208 -0
- package/src/backend/routes/communities/membership-join.ts +335 -0
- package/src/backend/routes/communities/membership-members.ts +539 -0
- package/src/backend/routes/communities/membership-requests.ts +296 -0
- package/src/backend/routes/communities/membership-shared.ts +364 -0
- package/src/backend/routes/communities/messages.ts +479 -0
- package/src/backend/routes/communities/routes.ts +624 -0
- package/src/backend/routes/communities.ts +21 -0
- package/src/backend/routes/dm/contacts.ts +525 -0
- package/src/backend/routes/dm/conversations-helpers.ts +197 -0
- package/src/backend/routes/dm/conversations.ts +25 -0
- package/src/backend/routes/dm/messages.ts +658 -0
- package/src/backend/routes/dm/query-helpers.ts +85 -0
- package/src/backend/routes/dm/read-archive.ts +228 -0
- package/src/backend/routes/dm/requests.ts +222 -0
- package/src/backend/routes/dm/typing.ts +81 -0
- package/src/backend/routes/dm.ts +15 -0
- package/src/backend/routes/follow-helpers.ts +370 -0
- package/src/backend/routes/follow.ts +588 -0
- package/src/backend/routes/media.ts +692 -0
- package/src/backend/routes/mobile.ts +159 -0
- package/src/backend/routes/moderation.ts +373 -0
- package/src/backend/routes/notifications.ts +757 -0
- package/src/backend/routes/posts/delete-cascade.ts +330 -0
- package/src/backend/routes/posts/interactions.ts +795 -0
- package/src/backend/routes/posts/post-helpers.ts +847 -0
- package/src/backend/routes/posts/queries.ts +537 -0
- package/src/backend/routes/posts/routes.ts +865 -0
- package/src/backend/routes/posts/transformers.ts +161 -0
- package/src/backend/routes/posts.ts +17 -0
- package/src/backend/routes/recommendations.ts +88 -0
- package/src/backend/routes/search.ts +730 -0
- package/src/backend/routes/stories/interactions.ts +576 -0
- package/src/backend/routes/stories/query-helpers.ts +482 -0
- package/src/backend/routes/stories/routes.ts +906 -0
- package/src/backend/routes/stories.ts +13 -0
- package/src/backend/routes/takos-tools/dm.ts +249 -0
- package/src/backend/routes/takos-tools/follows.ts +225 -0
- package/src/backend/routes/takos-tools/posts.ts +292 -0
- package/src/backend/routes/takos-tools/search.ts +228 -0
- package/src/backend/routes/takos-tools/timeline.ts +132 -0
- package/src/backend/routes/takos-tools/types.ts +10 -0
- package/src/backend/routes/takos-tools-response.ts +178 -0
- package/src/backend/routes/takos-tools.ts +153 -0
- package/src/backend/routes/timeline.ts +755 -0
- package/src/backend/runtime/bun.ts +620 -0
- package/src/backend/runtime/cloudflare.ts +202 -0
- package/src/backend/runtime/compat-bun/types.ts +44 -0
- package/src/backend/runtime/memory-kv.ts +104 -0
- package/src/backend/runtime/shared.ts +142 -0
- package/src/backend/runtime/types.ts +205 -0
- package/src/backend/server.ts +636 -0
- package/src/backend/types.ts +143 -0
- package/src/db/index.ts +97 -0
- package/src/db/schema/actors.ts +129 -0
- package/src/db/schema/communities.ts +133 -0
- package/src/db/schema/date-utils.ts +17 -0
- package/src/db/schema/index.ts +17 -0
- package/src/db/schema/messaging.ts +241 -0
- package/src/db/schema/mobile.ts +37 -0
- package/src/db/schema/posts.ts +150 -0
- package/src/db/schema/relations.ts +266 -0
- package/src/db/schema/reports.ts +33 -0
- package/src/db/schema/social.ts +106 -0
- package/src/db/schema/stories.ts +70 -0
- package/src/db/schema.ts +15 -0
- package/src/plugin/public.ts +7 -0
- package/src/runtime/site-worker.ts +10 -0
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
// Notifications routes for Yurucommu backend
|
|
2
|
+
// AP Native: Notifications are derived from inbox (activities addressed to the actor)
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import {
|
|
5
|
+
and,
|
|
6
|
+
count,
|
|
7
|
+
desc,
|
|
8
|
+
eq,
|
|
9
|
+
exists,
|
|
10
|
+
inArray,
|
|
11
|
+
isNotNull,
|
|
12
|
+
isNull,
|
|
13
|
+
lt,
|
|
14
|
+
ne,
|
|
15
|
+
notExists,
|
|
16
|
+
or,
|
|
17
|
+
type SQL,
|
|
18
|
+
} from "drizzle-orm";
|
|
19
|
+
import type { Env, Variables } from "../types.ts";
|
|
20
|
+
import {
|
|
21
|
+
formatUsername,
|
|
22
|
+
parseLimit,
|
|
23
|
+
parseOffset,
|
|
24
|
+
} from "../federation-helpers.ts";
|
|
25
|
+
import type { Database } from "../../db/index.ts";
|
|
26
|
+
import {
|
|
27
|
+
activities,
|
|
28
|
+
follows,
|
|
29
|
+
inbox as inboxTable,
|
|
30
|
+
notificationArchived,
|
|
31
|
+
objects,
|
|
32
|
+
} from "../../db/index.ts";
|
|
33
|
+
import { batchLoadActorInfo } from "./communities/membership-shared.ts";
|
|
34
|
+
import { requireActor } from "./actors-helpers.ts";
|
|
35
|
+
import { communityReadableApIds } from "../lib/community-visibility.ts";
|
|
36
|
+
import { chunkForInClause } from "../lib/chunk.ts";
|
|
37
|
+
import { excludeBlockedMutedAuthors } from "../lib/feed-exclude.ts";
|
|
38
|
+
|
|
39
|
+
const notifications = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
40
|
+
|
|
41
|
+
const ARCHIVE_RETENTION_DAYS = 90;
|
|
42
|
+
const ARCHIVED_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
|
43
|
+
// Client-facing batch caps. <=90 because each id is re-queried via
|
|
44
|
+
// `inArray(..., body.ids)` plus an `eq()` param, and Cloudflare D1 caps a query
|
|
45
|
+
// at 100 bound parameters (libsql, used by tests, allows ~32k and hides this).
|
|
46
|
+
const MAX_ARCHIVE_BATCH_SIZE = 90;
|
|
47
|
+
const MAX_READ_BATCH_SIZE = 90;
|
|
48
|
+
// Multi-row INSERT chunk: each archive row binds 3 columns, so a chunk of N
|
|
49
|
+
// rows uses 3*N bound params. 30*3 = 90, under D1's 100-param ceiling.
|
|
50
|
+
const ARCHIVE_CREATE_BATCH_SIZE = 30;
|
|
51
|
+
// Bound the opportunistic retention-cleanup per run so a user with a very large
|
|
52
|
+
// archived backlog doesn't load + delete it all in one fired-from-read-path run;
|
|
53
|
+
// the rest drains on later runs.
|
|
54
|
+
const ARCHIVE_CLEANUP_BATCH = 200;
|
|
55
|
+
const ARCHIVE_ALL_CAP = 1000;
|
|
56
|
+
const NOTIFICATION_ACTIVITY_TYPES = ["Follow", "Like", "Announce", "Create"];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Tracks the last cleanup timestamp per actor so cleanup is throttled to one
|
|
60
|
+
* run per `ARCHIVED_CLEANUP_INTERVAL_MS`. Entries older than the interval no
|
|
61
|
+
* longer gate work and can be evicted. A hard cap also bounds worst-case
|
|
62
|
+
* growth when many unique actors hit notifications in a single window.
|
|
63
|
+
*/
|
|
64
|
+
const ARCHIVED_CLEANUP_TIMESTAMPS_MAX = 10_000;
|
|
65
|
+
const archivedCleanupTimestamps = new Map<string, number>();
|
|
66
|
+
|
|
67
|
+
function pruneArchivedCleanupTimestamps(now: number): void {
|
|
68
|
+
for (const [actor, lastRun] of archivedCleanupTimestamps) {
|
|
69
|
+
if (now - lastRun >= ARCHIVED_CLEANUP_INTERVAL_MS) {
|
|
70
|
+
archivedCleanupTimestamps.delete(actor);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (archivedCleanupTimestamps.size >= ARCHIVED_CLEANUP_TIMESTAMPS_MAX) {
|
|
74
|
+
// Last-resort: every entry is still fresh but cap is hit. Clear to bound
|
|
75
|
+
// memory; the worst that happens is duplicate cleanup work within the
|
|
76
|
+
// window for actors evicted here.
|
|
77
|
+
archivedCleanupTimestamps.clear();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @internal Test-only inspector for the cleanup-timestamps bookkeeping. */
|
|
82
|
+
export const __archivedCleanupInternals = {
|
|
83
|
+
size: () => archivedCleanupTimestamps.size,
|
|
84
|
+
clear: () => archivedCleanupTimestamps.clear(),
|
|
85
|
+
set: (key: string, value: number) =>
|
|
86
|
+
archivedCleanupTimestamps.set(key, value),
|
|
87
|
+
prune: pruneArchivedCleanupTimestamps,
|
|
88
|
+
maxEntries: ARCHIVED_CLEANUP_TIMESTAMPS_MAX,
|
|
89
|
+
intervalMs: ARCHIVED_CLEANUP_INTERVAL_MS,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Batch-insert archive rows with unique-constraint tolerance.
|
|
94
|
+
* Returns the number of rows actually inserted.
|
|
95
|
+
*/
|
|
96
|
+
async function batchArchiveInsert(
|
|
97
|
+
db: Database,
|
|
98
|
+
rows: Array<{ actorApId: string; activityApId: string; archivedAt: string }>,
|
|
99
|
+
batchSize: number,
|
|
100
|
+
): Promise<number> {
|
|
101
|
+
let inserted = 0;
|
|
102
|
+
|
|
103
|
+
for (let i = 0; i < rows.length; i += batchSize) {
|
|
104
|
+
const batch = rows.slice(i, i + batchSize);
|
|
105
|
+
const result = await db
|
|
106
|
+
.insert(notificationArchived)
|
|
107
|
+
.values(batch)
|
|
108
|
+
.onConflictDoNothing();
|
|
109
|
+
inserted += (result as { meta?: { changes?: number } }).meta?.changes ?? 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return inserted;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function cleanupArchivedNotifications(
|
|
116
|
+
db: Database,
|
|
117
|
+
actorApId: string,
|
|
118
|
+
): Promise<void> {
|
|
119
|
+
const retentionDate = new Date();
|
|
120
|
+
retentionDate.setDate(retentionDate.getDate() - ARCHIVE_RETENTION_DAYS);
|
|
121
|
+
const retentionDateStr = retentionDate.toISOString();
|
|
122
|
+
|
|
123
|
+
const archivedToDelete = await db
|
|
124
|
+
.select({ activityApId: notificationArchived.activityApId })
|
|
125
|
+
.from(notificationArchived)
|
|
126
|
+
.where(
|
|
127
|
+
and(
|
|
128
|
+
eq(notificationArchived.actorApId, actorApId),
|
|
129
|
+
lt(notificationArchived.archivedAt, retentionDateStr),
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
.limit(ARCHIVE_CLEANUP_BATCH);
|
|
133
|
+
|
|
134
|
+
if (archivedToDelete.length === 0) return;
|
|
135
|
+
|
|
136
|
+
const activityApIds = archivedToDelete.map((a) => a.activityApId);
|
|
137
|
+
|
|
138
|
+
// Delete the inbox rows AND the archived markers for EXACTLY the bounded set,
|
|
139
|
+
// chunked for D1's 100-bound-parameter cap. Both target the same activity ids
|
|
140
|
+
// (not a broad `archivedAt < retentionDate`): with the per-run limit, a broad
|
|
141
|
+
// archived-marker delete would drop markers for rows whose inbox entry wasn't
|
|
142
|
+
// deleted this run, re-surfacing an expired-archived notification as active.
|
|
143
|
+
// The inbox delete runs first so a crash can't leave a notification visible
|
|
144
|
+
// with its retention marker already gone.
|
|
145
|
+
for (const ids of chunkForInClause(activityApIds)) {
|
|
146
|
+
await db
|
|
147
|
+
.delete(inboxTable)
|
|
148
|
+
.where(
|
|
149
|
+
and(
|
|
150
|
+
eq(inboxTable.actorApId, actorApId),
|
|
151
|
+
inArray(inboxTable.activityApId, ids),
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
await db
|
|
155
|
+
.delete(notificationArchived)
|
|
156
|
+
.where(
|
|
157
|
+
and(
|
|
158
|
+
eq(notificationArchived.actorApId, actorApId),
|
|
159
|
+
inArray(notificationArchived.activityApId, ids),
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function maybeCleanupArchivedNotifications(
|
|
166
|
+
db: Database,
|
|
167
|
+
actorApId: string,
|
|
168
|
+
): Promise<void> {
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
const lastRun = archivedCleanupTimestamps.get(actorApId) ?? 0;
|
|
171
|
+
if (now - lastRun < ARCHIVED_CLEANUP_INTERVAL_MS) return;
|
|
172
|
+
|
|
173
|
+
pruneArchivedCleanupTimestamps(now);
|
|
174
|
+
archivedCleanupTimestamps.set(actorApId, now);
|
|
175
|
+
await cleanupArchivedNotifications(db, actorApId);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function activityToNotificationType(
|
|
179
|
+
activityType: string,
|
|
180
|
+
hasInReplyTo: boolean,
|
|
181
|
+
followStatus?: string | null,
|
|
182
|
+
): string | null {
|
|
183
|
+
switch (activityType) {
|
|
184
|
+
case "Follow":
|
|
185
|
+
return followStatus === "pending" ? "follow_request" : "follow";
|
|
186
|
+
case "Like":
|
|
187
|
+
return "like";
|
|
188
|
+
case "Announce":
|
|
189
|
+
return "announce";
|
|
190
|
+
case "Create":
|
|
191
|
+
return hasInReplyTo ? "reply" : "mention";
|
|
192
|
+
default:
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Notifications order by createdAt desc, but createdAt is NOT unique — several
|
|
198
|
+
// inbox rows can share a millisecond — so an exclusive cursor on createdAt alone
|
|
199
|
+
// would skip the rows on either side of a page boundary that share the cursor's
|
|
200
|
+
// timestamp. The cursor is therefore a composite of (createdAt, activityApId);
|
|
201
|
+
// activityApId is unique within an actor's inbox, so (createdAt desc,
|
|
202
|
+
// activityApId desc) is a total order. The two parts are encoded into the opaque
|
|
203
|
+
// `before` string with a NUL separator (NUL cannot appear in an ISO timestamp or
|
|
204
|
+
// an http(s) ap_id URL). A legacy plain-createdAt cursor (no separator) is still
|
|
205
|
+
// accepted — it is never wider than the composite form.
|
|
206
|
+
const NOTIF_CURSOR_SEP = "\u0000";
|
|
207
|
+
|
|
208
|
+
type NotifCursor = { createdAt: string; activityApId?: string };
|
|
209
|
+
|
|
210
|
+
function decodeNotifCursor(before: string): NotifCursor {
|
|
211
|
+
const idx = before.indexOf(NOTIF_CURSOR_SEP);
|
|
212
|
+
if (idx === -1) return { createdAt: before };
|
|
213
|
+
return {
|
|
214
|
+
createdAt: before.slice(0, idx),
|
|
215
|
+
activityApId: before.slice(idx + 1),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function notifCursorPredicate(cursor: NotifCursor): SQL {
|
|
220
|
+
if (cursor.activityApId === undefined) {
|
|
221
|
+
return lt(inboxTable.createdAt, cursor.createdAt);
|
|
222
|
+
}
|
|
223
|
+
return or(
|
|
224
|
+
lt(inboxTable.createdAt, cursor.createdAt),
|
|
225
|
+
and(
|
|
226
|
+
eq(inboxTable.createdAt, cursor.createdAt),
|
|
227
|
+
lt(inboxTable.activityApId, cursor.activityApId),
|
|
228
|
+
),
|
|
229
|
+
)!;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function encodeNotifCursor(row: { created_at: string; id: string }): string {
|
|
233
|
+
return `${row.created_at}${NOTIF_CURSOR_SEP}${row.id}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Routes
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
// GET / -- List notifications with type/archive filters
|
|
241
|
+
notifications.get("/", async (c) => {
|
|
242
|
+
const actor = requireActor(c);
|
|
243
|
+
if (actor instanceof Response) return actor;
|
|
244
|
+
|
|
245
|
+
const db = c.get("db");
|
|
246
|
+
await maybeCleanupArchivedNotifications(db, actor.ap_id);
|
|
247
|
+
|
|
248
|
+
// Max 90 (not 100): this page's activityApIds are re-queried via
|
|
249
|
+
// `inArray(inboxTable.activityApId, ...)` for the archived-state join, and
|
|
250
|
+
// Cloudflare D1 allows at most 100 bound parameters per query.
|
|
251
|
+
const limit = parseLimit(c.req.query("limit"), 20, 90);
|
|
252
|
+
const offset = parseOffset(c.req.query("offset"), 0, 10000);
|
|
253
|
+
const before = c.req.query("before");
|
|
254
|
+
const typeFilter = c.req.query("type");
|
|
255
|
+
const showArchived = c.req.query("archived") === "true";
|
|
256
|
+
|
|
257
|
+
const typeToActivityType: Record<string, string[]> = {
|
|
258
|
+
follow: ["Follow"],
|
|
259
|
+
like: ["Like"],
|
|
260
|
+
announce: ["Announce"],
|
|
261
|
+
reply: ["Create"],
|
|
262
|
+
mention: ["Create"],
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const activityTypes =
|
|
266
|
+
typeFilter && typeToActivityType[typeFilter]
|
|
267
|
+
? typeToActivityType[typeFilter]
|
|
268
|
+
: NOTIFICATION_ACTIVITY_TYPES;
|
|
269
|
+
|
|
270
|
+
// Archive partition pushed INTO SQL as a correlated EXISTS / NOT EXISTS, not a
|
|
271
|
+
// post-query filter. Filtering archived rows out in the result loop made two
|
|
272
|
+
// bugs: (1) `has_more` under-reported — a page whose limit+1 probe rows were
|
|
273
|
+
// mostly the wrong archive state returned < limit items yet there were older
|
|
274
|
+
// pages, so the client stopped loading; (2) every archived id for the actor
|
|
275
|
+
// was loaded into an unbounded in-memory Set per request. As an SQL predicate
|
|
276
|
+
// the limit+1 probe counts only rows that actually belong on the page.
|
|
277
|
+
const archivedCorrelation = and(
|
|
278
|
+
eq(notificationArchived.actorApId, inboxTable.actorApId),
|
|
279
|
+
eq(notificationArchived.activityApId, inboxTable.activityApId),
|
|
280
|
+
);
|
|
281
|
+
const archivedSubquery = db
|
|
282
|
+
.select({ activityApId: notificationArchived.activityApId })
|
|
283
|
+
.from(notificationArchived)
|
|
284
|
+
.where(archivedCorrelation);
|
|
285
|
+
const archiveCondition = showArchived
|
|
286
|
+
? exists(archivedSubquery)
|
|
287
|
+
: notExists(archivedSubquery);
|
|
288
|
+
|
|
289
|
+
// Build inbox query with JOIN to activities. A direct (DM) Note is delivered
|
|
290
|
+
// as a `Create` inbox row just like a mention, so without excluding it every
|
|
291
|
+
// DM would double-surface here as a "mention" (with its body) AND inflate the
|
|
292
|
+
// unread badge independently of the DM view. LEFT JOIN the object and drop
|
|
293
|
+
// direct-visibility Creates; the join is LEFT because Follow's object is an
|
|
294
|
+
// actor (no `objects` row) → NULL visibility must be kept.
|
|
295
|
+
const conditions = [
|
|
296
|
+
eq(inboxTable.actorApId, actor.ap_id),
|
|
297
|
+
ne(activities.actorApId, actor.ap_id),
|
|
298
|
+
inArray(activities.type, activityTypes),
|
|
299
|
+
or(isNull(objects.visibility), ne(objects.visibility, "direct"))!,
|
|
300
|
+
archiveCondition,
|
|
301
|
+
];
|
|
302
|
+
// reply vs mention both map to a Create; the split is whether the Create's
|
|
303
|
+
// object is a reply (`inReplyTo` set). Pushed into SQL too so a type-filtered
|
|
304
|
+
// page can't under-report has_more for the same reason as the archive split.
|
|
305
|
+
if (typeFilter === "reply") {
|
|
306
|
+
conditions.push(isNotNull(objects.inReplyTo));
|
|
307
|
+
} else if (typeFilter === "mention") {
|
|
308
|
+
conditions.push(isNull(objects.inReplyTo));
|
|
309
|
+
}
|
|
310
|
+
if (before) {
|
|
311
|
+
conditions.push(notifCursorPredicate(decodeNotifCursor(before)));
|
|
312
|
+
}
|
|
313
|
+
// Suppress notifications whose actor the recipient has blocked or muted. This
|
|
314
|
+
// is the read-time choke point: mutes are read-only everywhere, and not every
|
|
315
|
+
// notify WRITE path block-checks, so gating here covers like/repost/follow/
|
|
316
|
+
// reply/mention (local AND federated) for both blocks and mutes. Keyed on the
|
|
317
|
+
// activity actor (subquery-scoped → D1-param-safe).
|
|
318
|
+
const listBlockMute = excludeBlockedMutedAuthors(
|
|
319
|
+
db,
|
|
320
|
+
actor.ap_id,
|
|
321
|
+
activities.actorApId,
|
|
322
|
+
);
|
|
323
|
+
if (listBlockMute) conditions.push(listBlockMute);
|
|
324
|
+
|
|
325
|
+
const inboxEntries = await db
|
|
326
|
+
.select({
|
|
327
|
+
actorApId: inboxTable.actorApId,
|
|
328
|
+
activityApId: inboxTable.activityApId,
|
|
329
|
+
read: inboxTable.read,
|
|
330
|
+
createdAt: inboxTable.createdAt,
|
|
331
|
+
activityType: activities.type,
|
|
332
|
+
activityActorApId: activities.actorApId,
|
|
333
|
+
activityObjectApId: activities.objectApId,
|
|
334
|
+
})
|
|
335
|
+
.from(inboxTable)
|
|
336
|
+
.innerJoin(activities, eq(inboxTable.activityApId, activities.apId))
|
|
337
|
+
.leftJoin(objects, eq(activities.objectApId, objects.apId))
|
|
338
|
+
.where(and(...conditions))
|
|
339
|
+
.orderBy(desc(inboxTable.createdAt), desc(inboxTable.activityApId))
|
|
340
|
+
.limit(limit + 1);
|
|
341
|
+
|
|
342
|
+
// Batch fetch related data
|
|
343
|
+
const actorApIds = [...new Set(inboxEntries.map((i) => i.activityActorApId))];
|
|
344
|
+
const objectApIds = [
|
|
345
|
+
...new Set(
|
|
346
|
+
inboxEntries
|
|
347
|
+
.map((i) => i.activityObjectApId)
|
|
348
|
+
.filter((id): id is string => id !== null),
|
|
349
|
+
),
|
|
350
|
+
];
|
|
351
|
+
const activityApIdsArr = [
|
|
352
|
+
...new Set(inboxEntries.map((i) => i.activityApId)),
|
|
353
|
+
];
|
|
354
|
+
|
|
355
|
+
const [actorMap, objectRows, followRows] = await Promise.all([
|
|
356
|
+
batchLoadActorInfo(db, actorApIds),
|
|
357
|
+
objectApIds.length > 0
|
|
358
|
+
? db
|
|
359
|
+
.select({
|
|
360
|
+
apId: objects.apId,
|
|
361
|
+
content: objects.content,
|
|
362
|
+
inReplyTo: objects.inReplyTo,
|
|
363
|
+
audienceJson: objects.audienceJson,
|
|
364
|
+
communityApId: objects.communityApId,
|
|
365
|
+
visibility: objects.visibility,
|
|
366
|
+
attributedTo: objects.attributedTo,
|
|
367
|
+
})
|
|
368
|
+
.from(objects)
|
|
369
|
+
.where(inArray(objects.apId, objectApIds))
|
|
370
|
+
: Promise.resolve([]),
|
|
371
|
+
activityApIdsArr.length > 0
|
|
372
|
+
? db
|
|
373
|
+
.select({
|
|
374
|
+
activityApId: follows.activityApId,
|
|
375
|
+
status: follows.status,
|
|
376
|
+
})
|
|
377
|
+
.from(follows)
|
|
378
|
+
.where(inArray(follows.activityApId, activityApIdsArr))
|
|
379
|
+
: Promise.resolve([]),
|
|
380
|
+
]);
|
|
381
|
+
|
|
382
|
+
// Content read-gate. A restricted object can land in a NON-entitled inbox: a
|
|
383
|
+
// private-community post via an @-mention Create, or — since a local reply
|
|
384
|
+
// creates a notification for the parent author regardless of the reply's
|
|
385
|
+
// visibility (post-helpers.ts) — a FOLLOWERS-ONLY reply by someone the
|
|
386
|
+
// recipient neither follows nor authored. Projecting the body verbatim would
|
|
387
|
+
// leak it. Gate each object's content against the recipient (actor.ap_id):
|
|
388
|
+
// the community membership gate AND the followers-only gate (mirrors the
|
|
389
|
+
// post-detail / replies gate: own post or an accepted follow to the author).
|
|
390
|
+
// Direct posts are already dropped from the query above.
|
|
391
|
+
const followerGateAuthors = [
|
|
392
|
+
...new Set(
|
|
393
|
+
objectRows
|
|
394
|
+
.filter(
|
|
395
|
+
(o) => o.visibility === "followers" && o.attributedTo !== actor.ap_id,
|
|
396
|
+
)
|
|
397
|
+
.map((o) => o.attributedTo),
|
|
398
|
+
),
|
|
399
|
+
];
|
|
400
|
+
const followedAuthors =
|
|
401
|
+
followerGateAuthors.length > 0
|
|
402
|
+
? new Set(
|
|
403
|
+
(
|
|
404
|
+
await db
|
|
405
|
+
.select({ followingApId: follows.followingApId })
|
|
406
|
+
.from(follows)
|
|
407
|
+
.where(
|
|
408
|
+
and(
|
|
409
|
+
eq(follows.followerApId, actor.ap_id),
|
|
410
|
+
inArray(follows.followingApId, followerGateAuthors),
|
|
411
|
+
eq(follows.status, "accepted"),
|
|
412
|
+
),
|
|
413
|
+
)
|
|
414
|
+
).map((r) => r.followingApId),
|
|
415
|
+
)
|
|
416
|
+
: new Set<string>();
|
|
417
|
+
|
|
418
|
+
const followersGateAllows = (o: {
|
|
419
|
+
visibility: string;
|
|
420
|
+
attributedTo: string;
|
|
421
|
+
}): boolean => {
|
|
422
|
+
if (o.visibility !== "followers") return true;
|
|
423
|
+
return (
|
|
424
|
+
o.attributedTo === actor.ap_id || followedAuthors.has(o.attributedTo)
|
|
425
|
+
);
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
// Apply the synchronous followers-gate first, then resolve the community
|
|
429
|
+
// read-gate for all survivors in ONE batched call (2 queries) rather than
|
|
430
|
+
// 1-2 queries per row.
|
|
431
|
+
const readableObjectIds = await communityReadableApIds(
|
|
432
|
+
db,
|
|
433
|
+
objectRows.filter(followersGateAllows),
|
|
434
|
+
actor.ap_id,
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
const objectMap = new Map(
|
|
438
|
+
objectRows.map((o) => [
|
|
439
|
+
o.apId,
|
|
440
|
+
{
|
|
441
|
+
content: readableObjectIds.has(o.apId) ? o.content : "",
|
|
442
|
+
inReplyTo: o.inReplyTo,
|
|
443
|
+
},
|
|
444
|
+
]),
|
|
445
|
+
);
|
|
446
|
+
const followMap = new Map(
|
|
447
|
+
followRows
|
|
448
|
+
.filter((f) => f.activityApId)
|
|
449
|
+
.map((f) => [f.activityApId!, f.status]),
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
// Filter and transform inbox entries into notifications in a single pass
|
|
453
|
+
const notifications_list: Array<{
|
|
454
|
+
id: string;
|
|
455
|
+
type: string;
|
|
456
|
+
object_ap_id: string | null;
|
|
457
|
+
read: boolean;
|
|
458
|
+
created_at: string;
|
|
459
|
+
actor: {
|
|
460
|
+
ap_id: string;
|
|
461
|
+
username: string;
|
|
462
|
+
preferred_username: string | null;
|
|
463
|
+
name: string | null;
|
|
464
|
+
icon_url: string | null;
|
|
465
|
+
};
|
|
466
|
+
object_content: string;
|
|
467
|
+
}> = [];
|
|
468
|
+
|
|
469
|
+
for (const entry of inboxEntries) {
|
|
470
|
+
if (notifications_list.length > limit) break;
|
|
471
|
+
|
|
472
|
+
// Archive partition and reply/mention split are now enforced in SQL (see the
|
|
473
|
+
// conditions above), so this pass is a pure transform — no row is dropped
|
|
474
|
+
// here, which is what keeps `has_more` honest.
|
|
475
|
+
const objectData = entry.activityObjectApId
|
|
476
|
+
? objectMap.get(entry.activityObjectApId)
|
|
477
|
+
: null;
|
|
478
|
+
const inReplyTo = objectData?.inReplyTo ?? null;
|
|
479
|
+
|
|
480
|
+
const followStatus = followMap.get(entry.activityApId) ?? null;
|
|
481
|
+
const notifType = activityToNotificationType(
|
|
482
|
+
entry.activityType,
|
|
483
|
+
!!inReplyTo,
|
|
484
|
+
followStatus,
|
|
485
|
+
);
|
|
486
|
+
const actorInfo = actorMap.get(entry.activityActorApId);
|
|
487
|
+
|
|
488
|
+
notifications_list.push({
|
|
489
|
+
id: entry.activityApId,
|
|
490
|
+
type: notifType || entry.activityType.toLowerCase(),
|
|
491
|
+
object_ap_id: entry.activityObjectApId,
|
|
492
|
+
read: !!entry.read,
|
|
493
|
+
created_at: entry.createdAt,
|
|
494
|
+
actor: {
|
|
495
|
+
ap_id: entry.activityActorApId,
|
|
496
|
+
username: formatUsername(entry.activityActorApId),
|
|
497
|
+
preferred_username: actorInfo?.preferredUsername ?? null,
|
|
498
|
+
name: actorInfo?.name ?? null,
|
|
499
|
+
icon_url: actorInfo?.iconUrl ?? null,
|
|
500
|
+
},
|
|
501
|
+
object_content: objectData?.content ?? "",
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const has_more = notifications_list.length > limit;
|
|
506
|
+
if (has_more) notifications_list.length = limit;
|
|
507
|
+
|
|
508
|
+
// Composite keyset cursor for the next page — resume strictly after the last
|
|
509
|
+
// returned row. The client should prefer this over a bare created_at so
|
|
510
|
+
// same-millisecond rows straddling the page boundary are not skipped.
|
|
511
|
+
const last = notifications_list[notifications_list.length - 1];
|
|
512
|
+
const next_cursor = has_more && last ? encodeNotifCursor(last) : null;
|
|
513
|
+
|
|
514
|
+
return c.json({
|
|
515
|
+
notifications: notifications_list,
|
|
516
|
+
limit,
|
|
517
|
+
offset,
|
|
518
|
+
has_more,
|
|
519
|
+
next_cursor,
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// GET /unread/count
|
|
524
|
+
notifications.get("/unread/count", async (c) => {
|
|
525
|
+
const actor = requireActor(c);
|
|
526
|
+
if (actor instanceof Response) return actor;
|
|
527
|
+
|
|
528
|
+
const db = c.get("db");
|
|
529
|
+
await maybeCleanupArchivedNotifications(db, actor.ap_id);
|
|
530
|
+
|
|
531
|
+
// Mirror the list query's DM exclusion (see GET /): a direct Note's Create
|
|
532
|
+
// inbox row must not count toward the notification badge — the DM has its own
|
|
533
|
+
// unread badge, and marking it read never clears this inbox row.
|
|
534
|
+
// Mirror the default list view's archive exclusion: an archived notification
|
|
535
|
+
// is hidden from the inbox list, so it must NOT count toward the badge either —
|
|
536
|
+
// otherwise archiving an UNREAD notification (read stays 0) leaves a phantom
|
|
537
|
+
// count the client can never clear (its mark-read sweep only touches rows the
|
|
538
|
+
// inbox view returns, which no longer include the archived one).
|
|
539
|
+
const archivedSubquery = db
|
|
540
|
+
.select({ activityApId: notificationArchived.activityApId })
|
|
541
|
+
.from(notificationArchived)
|
|
542
|
+
.where(
|
|
543
|
+
and(
|
|
544
|
+
eq(notificationArchived.actorApId, inboxTable.actorApId),
|
|
545
|
+
eq(notificationArchived.activityApId, inboxTable.activityApId),
|
|
546
|
+
),
|
|
547
|
+
);
|
|
548
|
+
const result = await db
|
|
549
|
+
.select({ count: count() })
|
|
550
|
+
.from(inboxTable)
|
|
551
|
+
.innerJoin(activities, eq(inboxTable.activityApId, activities.apId))
|
|
552
|
+
.leftJoin(objects, eq(activities.objectApId, objects.apId))
|
|
553
|
+
.where(
|
|
554
|
+
and(
|
|
555
|
+
eq(inboxTable.actorApId, actor.ap_id),
|
|
556
|
+
eq(inboxTable.read, 0),
|
|
557
|
+
ne(activities.actorApId, actor.ap_id),
|
|
558
|
+
inArray(activities.type, NOTIFICATION_ACTIVITY_TYPES),
|
|
559
|
+
or(isNull(objects.visibility), ne(objects.visibility, "direct"))!,
|
|
560
|
+
notExists(archivedSubquery),
|
|
561
|
+
// Mirror the list query: don't count notifications from blocked/muted
|
|
562
|
+
// actors toward the unread badge.
|
|
563
|
+
excludeBlockedMutedAuthors(db, actor.ap_id, activities.actorApId),
|
|
564
|
+
),
|
|
565
|
+
)
|
|
566
|
+
.get();
|
|
567
|
+
|
|
568
|
+
return c.json({ count: result?.count ?? 0 });
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
// POST /read -- Mark notifications as read
|
|
572
|
+
notifications.post("/read", async (c) => {
|
|
573
|
+
const actor = requireActor(c);
|
|
574
|
+
if (actor instanceof Response) return actor;
|
|
575
|
+
|
|
576
|
+
const db = c.get("db");
|
|
577
|
+
// A literal `null`/primitive JSON body parses without throwing, then field
|
|
578
|
+
// access throws a TypeError that the global handler maps to 500 (not 400).
|
|
579
|
+
// Guard the body shape so a malformed request is a clean 400.
|
|
580
|
+
const body = await c.req
|
|
581
|
+
.json<{ ids?: string[]; read_all?: boolean }>()
|
|
582
|
+
.catch(() => null);
|
|
583
|
+
if (!body || typeof body !== "object") {
|
|
584
|
+
return c.json({ error: "Invalid request body" }, 400);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (body.read_all) {
|
|
588
|
+
await db
|
|
589
|
+
.update(inboxTable)
|
|
590
|
+
.set({ read: 1 })
|
|
591
|
+
.where(eq(inboxTable.actorApId, actor.ap_id));
|
|
592
|
+
} else if (body.ids && body.ids.length > 0) {
|
|
593
|
+
if (body.ids.length > MAX_READ_BATCH_SIZE) {
|
|
594
|
+
return c.json(
|
|
595
|
+
{
|
|
596
|
+
error: "array_too_long",
|
|
597
|
+
message: `Batch size exceeds maximum of ${MAX_READ_BATCH_SIZE}`,
|
|
598
|
+
},
|
|
599
|
+
400,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
await db
|
|
603
|
+
.update(inboxTable)
|
|
604
|
+
.set({ read: 1 })
|
|
605
|
+
.where(
|
|
606
|
+
and(
|
|
607
|
+
eq(inboxTable.actorApId, actor.ap_id),
|
|
608
|
+
inArray(inboxTable.activityApId, body.ids),
|
|
609
|
+
),
|
|
610
|
+
);
|
|
611
|
+
} else {
|
|
612
|
+
return c.json(
|
|
613
|
+
{ error: "Either ids array or read_all flag is required" },
|
|
614
|
+
400,
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return c.json({ success: true });
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
// POST /archive -- Archive specific notifications
|
|
622
|
+
notifications.post("/archive", async (c) => {
|
|
623
|
+
const actor = requireActor(c);
|
|
624
|
+
if (actor instanceof Response) return actor;
|
|
625
|
+
|
|
626
|
+
const db = c.get("db");
|
|
627
|
+
const body = await c.req.json<{ ids: string[] }>().catch(() => null);
|
|
628
|
+
if (!body || typeof body !== "object") {
|
|
629
|
+
return c.json({ error: "Invalid request body" }, 400);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (
|
|
633
|
+
!body.ids ||
|
|
634
|
+
!Array.isArray(body.ids) ||
|
|
635
|
+
body.ids.length === 0 ||
|
|
636
|
+
body.ids.some((id) => typeof id !== "string" || id.trim().length === 0)
|
|
637
|
+
) {
|
|
638
|
+
return c.json({ error: "ids array is required" }, 400);
|
|
639
|
+
}
|
|
640
|
+
if (body.ids.length > MAX_ARCHIVE_BATCH_SIZE) {
|
|
641
|
+
return c.json(
|
|
642
|
+
{
|
|
643
|
+
error: `Batch size exceeds maximum of ${MAX_ARCHIVE_BATCH_SIZE}`,
|
|
644
|
+
},
|
|
645
|
+
400,
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const now = new Date().toISOString();
|
|
650
|
+
const uniqueIds = [...new Set(body.ids.map((id) => id.trim()))];
|
|
651
|
+
|
|
652
|
+
const alreadyArchived = await db
|
|
653
|
+
.select({ activityApId: notificationArchived.activityApId })
|
|
654
|
+
.from(notificationArchived)
|
|
655
|
+
.where(
|
|
656
|
+
and(
|
|
657
|
+
eq(notificationArchived.actorApId, actor.ap_id),
|
|
658
|
+
inArray(notificationArchived.activityApId, uniqueIds),
|
|
659
|
+
),
|
|
660
|
+
);
|
|
661
|
+
const alreadyArchivedSet = new Set(
|
|
662
|
+
alreadyArchived.map((row) => row.activityApId),
|
|
663
|
+
);
|
|
664
|
+
const toArchive = uniqueIds.filter((id) => !alreadyArchivedSet.has(id));
|
|
665
|
+
|
|
666
|
+
const rows = toArchive.map((id) => ({
|
|
667
|
+
actorApId: actor.ap_id,
|
|
668
|
+
activityApId: id,
|
|
669
|
+
archivedAt: now,
|
|
670
|
+
}));
|
|
671
|
+
const archived_count = await batchArchiveInsert(
|
|
672
|
+
db,
|
|
673
|
+
rows,
|
|
674
|
+
ARCHIVE_CREATE_BATCH_SIZE,
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
return c.json({ success: true, archived_count });
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
// DELETE /archive -- Unarchive notifications
|
|
681
|
+
notifications.delete("/archive", async (c) => {
|
|
682
|
+
const actor = requireActor(c);
|
|
683
|
+
if (actor instanceof Response) return actor;
|
|
684
|
+
|
|
685
|
+
const db = c.get("db");
|
|
686
|
+
const body = await c.req.json<{ ids: string[] }>().catch(() => null);
|
|
687
|
+
if (!body || typeof body !== "object") {
|
|
688
|
+
return c.json({ error: "Invalid request body" }, 400);
|
|
689
|
+
}
|
|
690
|
+
if (!body.ids || body.ids.length === 0) {
|
|
691
|
+
return c.json({ error: "ids array is required" }, 400);
|
|
692
|
+
}
|
|
693
|
+
if (body.ids.length > MAX_ARCHIVE_BATCH_SIZE) {
|
|
694
|
+
return c.json(
|
|
695
|
+
{
|
|
696
|
+
error: "array_too_long",
|
|
697
|
+
message: `Batch size exceeds maximum of ${MAX_ARCHIVE_BATCH_SIZE}`,
|
|
698
|
+
},
|
|
699
|
+
400,
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
await db
|
|
704
|
+
.delete(notificationArchived)
|
|
705
|
+
.where(
|
|
706
|
+
and(
|
|
707
|
+
eq(notificationArchived.actorApId, actor.ap_id),
|
|
708
|
+
inArray(notificationArchived.activityApId, body.ids),
|
|
709
|
+
),
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
return c.json({ success: true });
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// POST /archive/all -- Archive all notifications
|
|
716
|
+
notifications.post("/archive/all", async (c) => {
|
|
717
|
+
const actor = requireActor(c);
|
|
718
|
+
if (actor instanceof Response) return actor;
|
|
719
|
+
|
|
720
|
+
const db = c.get("db");
|
|
721
|
+
const now = new Date().toISOString();
|
|
722
|
+
|
|
723
|
+
const [alreadyArchived, inboxItems] = await Promise.all([
|
|
724
|
+
db
|
|
725
|
+
.select({ activityApId: notificationArchived.activityApId })
|
|
726
|
+
.from(notificationArchived)
|
|
727
|
+
.where(eq(notificationArchived.actorApId, actor.ap_id))
|
|
728
|
+
.limit(ARCHIVE_ALL_CAP),
|
|
729
|
+
db
|
|
730
|
+
.select({ activityApId: inboxTable.activityApId })
|
|
731
|
+
.from(inboxTable)
|
|
732
|
+
.where(eq(inboxTable.actorApId, actor.ap_id))
|
|
733
|
+
.limit(ARCHIVE_ALL_CAP),
|
|
734
|
+
]);
|
|
735
|
+
|
|
736
|
+
const alreadyArchivedIds = new Set(
|
|
737
|
+
alreadyArchived.map((a) => a.activityApId),
|
|
738
|
+
);
|
|
739
|
+
const toArchive = inboxItems.filter(
|
|
740
|
+
(item) => !alreadyArchivedIds.has(item.activityApId),
|
|
741
|
+
);
|
|
742
|
+
|
|
743
|
+
const rows = toArchive.map((item) => ({
|
|
744
|
+
actorApId: actor.ap_id,
|
|
745
|
+
activityApId: item.activityApId,
|
|
746
|
+
archivedAt: now,
|
|
747
|
+
}));
|
|
748
|
+
|
|
749
|
+
const archived_count = await batchArchiveInsert(
|
|
750
|
+
db,
|
|
751
|
+
rows,
|
|
752
|
+
ARCHIVE_CREATE_BATCH_SIZE,
|
|
753
|
+
);
|
|
754
|
+
return c.json({ success: true, archived_count });
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
export default notifications;
|