@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,482 @@
|
|
|
1
|
+
import { and, count, eq, inArray, lt, sql } from "drizzle-orm";
|
|
2
|
+
import type { Database } from "../../../db/index.ts";
|
|
3
|
+
import {
|
|
4
|
+
actorCache,
|
|
5
|
+
actors,
|
|
6
|
+
blocks,
|
|
7
|
+
communityMembers,
|
|
8
|
+
follows,
|
|
9
|
+
mutes,
|
|
10
|
+
objects,
|
|
11
|
+
storyVotes,
|
|
12
|
+
} from "../../../db/index.ts";
|
|
13
|
+
import type { IObjectStorage } from "../../runtime/types.ts";
|
|
14
|
+
import { objectApId, safeJsonParse } from "../../federation-helpers.ts";
|
|
15
|
+
import {
|
|
16
|
+
deleteObjectCascade,
|
|
17
|
+
purgeMediaBlobs,
|
|
18
|
+
} from "../posts/delete-cascade.ts";
|
|
19
|
+
import { chunkForInClause } from "../../lib/chunk.ts";
|
|
20
|
+
|
|
21
|
+
// Bound the per-run expired-story reap. The cleanup is opportunistic (fired,
|
|
22
|
+
// unawaited, from the hot story-read path), so a hostile followed host that
|
|
23
|
+
// accumulated thousands of now-expired stories must not make a single run load
|
|
24
|
+
// and sequentially cascade the whole backlog. Each run drains up to this many;
|
|
25
|
+
// the rest are reaped on subsequent runs.
|
|
26
|
+
const STORY_CLEANUP_BATCH = 200;
|
|
27
|
+
|
|
28
|
+
interface VoteResults {
|
|
29
|
+
[optionIndex: number]: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type OverlayPosition = {
|
|
33
|
+
x: number;
|
|
34
|
+
y: number;
|
|
35
|
+
width: number;
|
|
36
|
+
height: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type Overlay = {
|
|
40
|
+
type: string;
|
|
41
|
+
position: OverlayPosition;
|
|
42
|
+
oneOf?: unknown[];
|
|
43
|
+
href?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type StoryAttachment = {
|
|
47
|
+
type: string;
|
|
48
|
+
mediaType: string;
|
|
49
|
+
url: string;
|
|
50
|
+
r2_key: string;
|
|
51
|
+
width: number;
|
|
52
|
+
height: number;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type StoredStoryData = {
|
|
56
|
+
attachment?: {
|
|
57
|
+
r2_key?: string;
|
|
58
|
+
content_type?: string;
|
|
59
|
+
url?: string;
|
|
60
|
+
width?: number;
|
|
61
|
+
height?: number;
|
|
62
|
+
};
|
|
63
|
+
displayDuration?: string;
|
|
64
|
+
caption?: string;
|
|
65
|
+
overlays?: Overlay[];
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
type ActorCacheEntry = {
|
|
69
|
+
preferredUsername: string | null;
|
|
70
|
+
name: string | null;
|
|
71
|
+
iconUrl: string | null;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Story lookup helpers
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/** Resolve a story param (short ID or full URL) to a full ap_id. */
|
|
79
|
+
export function resolveStoryApId(storyId: string, baseUrl: string): string {
|
|
80
|
+
return storyId.startsWith("http") ? storyId : objectApId(baseUrl, storyId);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Find a single Story object by ap_id. Returns null/undefined when not found. */
|
|
84
|
+
export function findStory(db: Database, apId: string) {
|
|
85
|
+
return db
|
|
86
|
+
.select()
|
|
87
|
+
.from(objects)
|
|
88
|
+
.where(and(eq(objects.apId, apId), eq(objects.type, "Story")))
|
|
89
|
+
.get();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether `viewerApId` may read `story` — and therefore interact with it. A
|
|
94
|
+
* story's reach mirrors the story read endpoints: the author always; a
|
|
95
|
+
* community story → an accepted member of that community; a personal story → an
|
|
96
|
+
* accepted follower of the author. Gates view / vote / like / share so a
|
|
97
|
+
* non-follower / non-member cannot interact with (notify the author about, or
|
|
98
|
+
* re-share) a story they were never shown. Anonymous (`null`) never passes.
|
|
99
|
+
* Fails closed.
|
|
100
|
+
*/
|
|
101
|
+
export async function canViewerReadStory(
|
|
102
|
+
db: Database,
|
|
103
|
+
story: { attributedTo: string; communityApId: string | null },
|
|
104
|
+
viewerApId: string | null | undefined,
|
|
105
|
+
): Promise<boolean> {
|
|
106
|
+
if (!viewerApId) return false;
|
|
107
|
+
if (story.attributedTo === viewerApId) return true;
|
|
108
|
+
if (story.communityApId) {
|
|
109
|
+
const member = await db
|
|
110
|
+
.select({ actorApId: communityMembers.actorApId })
|
|
111
|
+
.from(communityMembers)
|
|
112
|
+
.where(
|
|
113
|
+
and(
|
|
114
|
+
eq(communityMembers.communityApId, story.communityApId),
|
|
115
|
+
eq(communityMembers.actorApId, viewerApId),
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
.get();
|
|
119
|
+
return Boolean(member);
|
|
120
|
+
}
|
|
121
|
+
const follow = await db
|
|
122
|
+
.select({ followerApId: follows.followerApId })
|
|
123
|
+
.from(follows)
|
|
124
|
+
.where(
|
|
125
|
+
and(
|
|
126
|
+
eq(follows.followerApId, viewerApId),
|
|
127
|
+
eq(follows.followingApId, story.attributedTo),
|
|
128
|
+
eq(follows.status, "accepted"),
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
.get();
|
|
132
|
+
return Boolean(follow);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Blocked / muted helpers
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/** Fetch blocked and muted ap_ids for the given actor. */
|
|
140
|
+
export async function fetchBlockedAndMutedIds(
|
|
141
|
+
db: Database,
|
|
142
|
+
actorApId: string,
|
|
143
|
+
): Promise<{ blockedIds: string[]; mutedIds: string[] }> {
|
|
144
|
+
const [blockRows, muteRows] = await Promise.all([
|
|
145
|
+
db
|
|
146
|
+
.select({ blockedApId: blocks.blockedApId })
|
|
147
|
+
.from(blocks)
|
|
148
|
+
.where(eq(blocks.blockerApId, actorApId)),
|
|
149
|
+
db
|
|
150
|
+
.select({ mutedApId: mutes.mutedApId })
|
|
151
|
+
.from(mutes)
|
|
152
|
+
.where(eq(mutes.muterApId, actorApId)),
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
blockedIds: blockRows.map((b) => b.blockedApId),
|
|
157
|
+
mutedIds: muteRows.map((m) => m.mutedApId),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Vote helpers
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/** Get vote counts for a single story, keyed by option index. */
|
|
166
|
+
export async function getVoteCounts(
|
|
167
|
+
db: Database,
|
|
168
|
+
storyApId: string,
|
|
169
|
+
): Promise<VoteResults> {
|
|
170
|
+
const votes = await db
|
|
171
|
+
.select({
|
|
172
|
+
optionIndex: storyVotes.optionIndex,
|
|
173
|
+
count: count(),
|
|
174
|
+
})
|
|
175
|
+
.from(storyVotes)
|
|
176
|
+
.where(eq(storyVotes.storyApId, storyApId))
|
|
177
|
+
.groupBy(storyVotes.optionIndex);
|
|
178
|
+
|
|
179
|
+
return Object.fromEntries(votes.map((v) => [v.optionIndex, v.count]));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Sum all vote counts in a VoteResults record. */
|
|
183
|
+
export function sumVotes(votes: VoteResults): number {
|
|
184
|
+
return Object.values(votes).reduce((sum, count) => sum + count, 0);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Batch-fetch vote counts and (optionally) the current user's votes
|
|
189
|
+
* for a list of story ap_ids.
|
|
190
|
+
*/
|
|
191
|
+
export async function fetchBatchVotes(
|
|
192
|
+
db: Database,
|
|
193
|
+
storyApIds: string[],
|
|
194
|
+
actorApId?: string,
|
|
195
|
+
): Promise<{
|
|
196
|
+
allVotes: Record<string, VoteResults>;
|
|
197
|
+
userVotes: Record<string, number>;
|
|
198
|
+
}> {
|
|
199
|
+
if (storyApIds.length === 0) {
|
|
200
|
+
return { allVotes: {}, userVotes: {} };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const voteCounts = await db
|
|
204
|
+
.select({
|
|
205
|
+
storyApId: storyVotes.storyApId,
|
|
206
|
+
optionIndex: storyVotes.optionIndex,
|
|
207
|
+
count: count(),
|
|
208
|
+
})
|
|
209
|
+
.from(storyVotes)
|
|
210
|
+
.where(inArray(storyVotes.storyApId, storyApIds))
|
|
211
|
+
.groupBy(storyVotes.storyApId, storyVotes.optionIndex);
|
|
212
|
+
|
|
213
|
+
const allVotes: Record<string, VoteResults> = {};
|
|
214
|
+
for (const v of voteCounts) {
|
|
215
|
+
if (!allVotes[v.storyApId]) allVotes[v.storyApId] = {};
|
|
216
|
+
allVotes[v.storyApId][v.optionIndex] = v.count;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let userVotes: Record<string, number> = {};
|
|
220
|
+
if (actorApId) {
|
|
221
|
+
const rows = await db
|
|
222
|
+
.select({
|
|
223
|
+
storyApId: storyVotes.storyApId,
|
|
224
|
+
optionIndex: storyVotes.optionIndex,
|
|
225
|
+
})
|
|
226
|
+
.from(storyVotes)
|
|
227
|
+
.where(
|
|
228
|
+
and(
|
|
229
|
+
inArray(storyVotes.storyApId, storyApIds),
|
|
230
|
+
eq(storyVotes.actorApId, actorApId),
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
userVotes = Object.fromEntries(
|
|
234
|
+
rows.map((r) => [r.storyApId, r.optionIndex]),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return { allVotes, userVotes };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Actor cache helper
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
/** Fetch cached actor info for remote authors missing a local actor row. */
|
|
246
|
+
export async function fetchActorCache(
|
|
247
|
+
db: Database,
|
|
248
|
+
remoteApIds: string[],
|
|
249
|
+
): Promise<Record<string, ActorCacheEntry>> {
|
|
250
|
+
if (remoteApIds.length === 0) return {};
|
|
251
|
+
|
|
252
|
+
const cached = await db
|
|
253
|
+
.select({
|
|
254
|
+
apId: actorCache.apId,
|
|
255
|
+
preferredUsername: actorCache.preferredUsername,
|
|
256
|
+
name: actorCache.name,
|
|
257
|
+
iconUrl: actorCache.iconUrl,
|
|
258
|
+
})
|
|
259
|
+
.from(actorCache)
|
|
260
|
+
.where(inArray(actorCache.apId, remoteApIds));
|
|
261
|
+
|
|
262
|
+
return Object.fromEntries(
|
|
263
|
+
cached.map((a) => [
|
|
264
|
+
a.apId,
|
|
265
|
+
{
|
|
266
|
+
preferredUsername: a.preferredUsername,
|
|
267
|
+
name: a.name,
|
|
268
|
+
iconUrl: a.iconUrl,
|
|
269
|
+
},
|
|
270
|
+
]),
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// Story data cleanup & transformation
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
export async function cleanupExpiredStories(
|
|
279
|
+
db: Database,
|
|
280
|
+
media?: IObjectStorage,
|
|
281
|
+
): Promise<number> {
|
|
282
|
+
const now = new Date().toISOString();
|
|
283
|
+
|
|
284
|
+
const expiredStories = await db
|
|
285
|
+
.select({ apId: objects.apId, attributedTo: objects.attributedTo })
|
|
286
|
+
.from(objects)
|
|
287
|
+
.where(and(eq(objects.type, "Story"), lt(objects.endTime, now)))
|
|
288
|
+
.limit(STORY_CLEANUP_BATCH);
|
|
289
|
+
|
|
290
|
+
if (expiredStories.length === 0) return 0;
|
|
291
|
+
|
|
292
|
+
const expiredApIds = expiredStories.map((s) => s.apId);
|
|
293
|
+
|
|
294
|
+
// Reap every child edge for each expired story through the shared object
|
|
295
|
+
// cascade (likes / announces / bookmarks / object_recipients / story_views /
|
|
296
|
+
// story_votes / story_shares + attached media_uploads), so expiry can't
|
|
297
|
+
// orphan rows the hand-rolled list previously missed (announces, bookmarks,
|
|
298
|
+
// media). The helper reads each object row, so run it before deleting them.
|
|
299
|
+
// When a `media` binding is threaded in, the backing R2 blobs are best-effort
|
|
300
|
+
// purged too so expired-story storage does not leak.
|
|
301
|
+
const mediaKeys: string[] = [];
|
|
302
|
+
for (const apId of expiredApIds) {
|
|
303
|
+
mediaKeys.push(...(await deleteObjectCascade(db, apId, media)));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Delete EXACTLY the cascaded set (chunked for D1's 100-bound-param cap), not a
|
|
307
|
+
// broad `endTime < now` — with the per-run limit a broad delete could remove a
|
|
308
|
+
// story whose children weren't reaped this run, orphaning them. Capture the
|
|
309
|
+
// rows THIS sweep actually removed via RETURNING so the postCount decrement
|
|
310
|
+
// below is keyed on the real delete, not the earlier SELECT.
|
|
311
|
+
const deletedAuthors: (string | null)[] = [];
|
|
312
|
+
for (const chunk of chunkForInClause(expiredApIds)) {
|
|
313
|
+
const removed = await db
|
|
314
|
+
.delete(objects)
|
|
315
|
+
.where(inArray(objects.apId, chunk))
|
|
316
|
+
.returning({ attributedTo: objects.attributedTo });
|
|
317
|
+
for (const row of removed) deletedAuthors.push(row.attributedTo);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Mirror story CREATION's postCount +1 (routes.ts) at expiry — the DOMINANT
|
|
321
|
+
// end-of-life path. Without this, every story a user posts permanently inflates
|
|
322
|
+
// their profile post_count by 1 once it expires (create bumps, explicit delete
|
|
323
|
+
// decrements, but expiry forgot to). Decrement each LOCAL author by the number
|
|
324
|
+
// of THEIR stories THIS sweep actually DELETED (from RETURNING), not the
|
|
325
|
+
// pre-delete SELECT: D1 serializes writes, so when concurrent isolates race the
|
|
326
|
+
// same expiry batch only the winning DELETE returns a given row and a losing
|
|
327
|
+
// isolate's DELETE matches 0 rows — so each story is counted exactly once and
|
|
328
|
+
// postCount never double-decrements. (The per-isolate in-flight boolean in
|
|
329
|
+
// routes.ts cannot serialize across Workers isolates.) MAX(0, …) guards
|
|
330
|
+
// underflow; remote authors have no `actors` row so the update is a no-op.
|
|
331
|
+
const perAuthor = new Map<string, number>();
|
|
332
|
+
for (const author of deletedAuthors) {
|
|
333
|
+
if (author) perAuthor.set(author, (perAuthor.get(author) ?? 0) + 1);
|
|
334
|
+
}
|
|
335
|
+
for (const [author, n] of perAuthor) {
|
|
336
|
+
await db
|
|
337
|
+
.update(actors)
|
|
338
|
+
.set({ postCount: sql`MAX(0, ${actors.postCount} - ${n})` })
|
|
339
|
+
.where(eq(actors.apId, author));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Irreversible R2 purge LAST — after the objects rows are gone.
|
|
343
|
+
await purgeMediaBlobs(media, mediaKeys);
|
|
344
|
+
|
|
345
|
+
return expiredApIds.length;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
// Overlay validation
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
const POSITION_FIELDS = ["x", "y", "width", "height"] as const;
|
|
353
|
+
|
|
354
|
+
function isOverlayRecord(value: unknown): value is Record<string, unknown> {
|
|
355
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Bound the overlays payload (count + serialized size). The caption is
|
|
359
|
+
// length-capped at the caller; without this an overlays blob could carry up to
|
|
360
|
+
// the global 1 MiB body cap into the stored + federated story.
|
|
361
|
+
const MAX_OVERLAYS = 20;
|
|
362
|
+
const MAX_OVERLAYS_JSON_LENGTH = 16 * 1024;
|
|
363
|
+
|
|
364
|
+
export function validateOverlays(overlays: unknown[]): {
|
|
365
|
+
valid: boolean;
|
|
366
|
+
error?: string;
|
|
367
|
+
} {
|
|
368
|
+
if (!Array.isArray(overlays)) {
|
|
369
|
+
return { valid: false, error: "overlays must be an array" };
|
|
370
|
+
}
|
|
371
|
+
if (overlays.length > MAX_OVERLAYS) {
|
|
372
|
+
return { valid: false, error: `Too many overlays (max ${MAX_OVERLAYS})` };
|
|
373
|
+
}
|
|
374
|
+
if (JSON.stringify(overlays).length > MAX_OVERLAYS_JSON_LENGTH) {
|
|
375
|
+
return { valid: false, error: "overlays payload too large" };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
let questionCount = 0;
|
|
379
|
+
for (const [i, raw] of overlays.entries()) {
|
|
380
|
+
if (!isOverlayRecord(raw)) {
|
|
381
|
+
return { valid: false, error: `overlay[${i}] must be an object` };
|
|
382
|
+
}
|
|
383
|
+
const overlay = raw;
|
|
384
|
+
|
|
385
|
+
if (typeof overlay.type !== "string" || overlay.type === "") {
|
|
386
|
+
return { valid: false, error: `overlay[${i}].type is required` };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (!isOverlayRecord(overlay.position)) {
|
|
390
|
+
return { valid: false, error: `overlay[${i}].position is required` };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const position = overlay.position;
|
|
394
|
+
for (const field of POSITION_FIELDS) {
|
|
395
|
+
const val = position[field];
|
|
396
|
+
if (typeof val !== "number" || val < 0 || val > 1) {
|
|
397
|
+
return {
|
|
398
|
+
valid: false,
|
|
399
|
+
error: `overlay[${i}].position.${field} must be 0.0-1.0`,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (overlay.type === "Question") {
|
|
405
|
+
// At most ONE Question (poll) per story: votes are keyed only by
|
|
406
|
+
// (storyApId, actorApId) and the tally aggregates purely by optionIndex
|
|
407
|
+
// with no question dimension, so a second Question would conflate tallies
|
|
408
|
+
// and be unvoteable. Reject multi-poll stories at creation.
|
|
409
|
+
questionCount += 1;
|
|
410
|
+
if (questionCount > 1) {
|
|
411
|
+
return {
|
|
412
|
+
valid: false,
|
|
413
|
+
error: "A story may have at most one Question (poll) overlay",
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
const oneOf = overlay.oneOf;
|
|
417
|
+
if (!Array.isArray(oneOf) || oneOf.length < 2 || oneOf.length > 4) {
|
|
418
|
+
return {
|
|
419
|
+
valid: false,
|
|
420
|
+
error: `overlay[${i}].oneOf must have 2-4 options`,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (overlay.type === "Link") {
|
|
426
|
+
if (typeof overlay.href !== "string" || overlay.href === "") {
|
|
427
|
+
return { valid: false, error: `overlay[${i}].href is required` };
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
new URL(overlay.href);
|
|
431
|
+
} catch {
|
|
432
|
+
return { valid: false, error: `overlay[${i}].href is invalid URL` };
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return { valid: true };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
// Story data transformation
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
export function transformStoryData(attachmentsJson: string): {
|
|
445
|
+
attachment: StoryAttachment;
|
|
446
|
+
caption?: string;
|
|
447
|
+
displayDuration: string;
|
|
448
|
+
overlays?: Overlay[];
|
|
449
|
+
} {
|
|
450
|
+
const stored = safeJsonParse<StoredStoryData>(attachmentsJson, {});
|
|
451
|
+
const r2Key = stored.attachment?.r2_key;
|
|
452
|
+
const contentType = stored.attachment?.content_type || "image/jpeg";
|
|
453
|
+
const externalUrl = stored.attachment?.url;
|
|
454
|
+
|
|
455
|
+
let url = "";
|
|
456
|
+
if (r2Key) {
|
|
457
|
+
// Strip the known leading `uploads/` prefix explicitly. `String.replace`
|
|
458
|
+
// with a string argument only removes the first, anywhere-matching
|
|
459
|
+
// occurrence, which would mangle keys whose remainder contains the token.
|
|
460
|
+
const UPLOADS_PREFIX = "uploads/";
|
|
461
|
+
const publicPath = r2Key.startsWith(UPLOADS_PREFIX)
|
|
462
|
+
? r2Key.slice(UPLOADS_PREFIX.length)
|
|
463
|
+
: r2Key;
|
|
464
|
+
url = `/media/${publicPath}`;
|
|
465
|
+
} else if (externalUrl) {
|
|
466
|
+
url = externalUrl;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
attachment: {
|
|
471
|
+
type: contentType.startsWith("video/") ? "Video" : "Document",
|
|
472
|
+
mediaType: contentType,
|
|
473
|
+
url,
|
|
474
|
+
r2_key: r2Key || "",
|
|
475
|
+
width: stored.attachment?.width || 1080,
|
|
476
|
+
height: stored.attachment?.height || 1920,
|
|
477
|
+
},
|
|
478
|
+
caption: stored.caption || undefined,
|
|
479
|
+
displayDuration: stored.displayDuration || "PT5S",
|
|
480
|
+
overlays: stored.overlays || undefined,
|
|
481
|
+
};
|
|
482
|
+
}
|