@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,906 @@
|
|
|
1
|
+
// Story routes for Yurucommu backend
|
|
2
|
+
// v2: 1 Story = 1 Media (Instagram style)
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import { and, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
|
|
5
|
+
import type { Database } from "../../../db/index.ts";
|
|
6
|
+
import {
|
|
7
|
+
activities,
|
|
8
|
+
actors,
|
|
9
|
+
communityMembers,
|
|
10
|
+
follows,
|
|
11
|
+
likes,
|
|
12
|
+
objects,
|
|
13
|
+
storyViews,
|
|
14
|
+
} from "../../../db/index.ts";
|
|
15
|
+
import {
|
|
16
|
+
deleteObjectCascade,
|
|
17
|
+
purgeMediaBlobs,
|
|
18
|
+
} from "../posts/delete-cascade.ts";
|
|
19
|
+
import type { Env, Variables } from "../../types.ts";
|
|
20
|
+
import type { IObjectStorage } from "../../runtime/types.ts";
|
|
21
|
+
import {
|
|
22
|
+
activityApId,
|
|
23
|
+
actorApId,
|
|
24
|
+
formatUsername,
|
|
25
|
+
generateId,
|
|
26
|
+
objectApId,
|
|
27
|
+
} from "../../federation-helpers.ts";
|
|
28
|
+
import { storyToActivityPub } from "../../lib/activitypub-helpers.ts";
|
|
29
|
+
import { excludeBlockedMutedAuthors } from "../../lib/feed-exclude.ts";
|
|
30
|
+
import { maybeReapDrainedTombstones } from "../actors.ts";
|
|
31
|
+
import { checkCommunityPostPermission } from "../posts/post-helpers.ts";
|
|
32
|
+
import { rateLimit, RateLimitConfigs } from "../../middleware/rate-limit.ts";
|
|
33
|
+
import {
|
|
34
|
+
cleanupExpiredStories,
|
|
35
|
+
fetchActorCache,
|
|
36
|
+
fetchBatchVotes,
|
|
37
|
+
fetchBlockedAndMutedIds,
|
|
38
|
+
sumVotes,
|
|
39
|
+
transformStoryData,
|
|
40
|
+
validateOverlays,
|
|
41
|
+
} from "./query-helpers.ts";
|
|
42
|
+
import {
|
|
43
|
+
enqueueFanoutToCommunity,
|
|
44
|
+
enqueueFanoutToFollowers,
|
|
45
|
+
} from "../../lib/delivery/queue.ts";
|
|
46
|
+
import { logger } from "../../lib/logger.ts";
|
|
47
|
+
|
|
48
|
+
const log = logger.child({ component: "stories.routes" });
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Narrow view over the concrete D1/libsql drizzle client's atomic batch API.
|
|
52
|
+
* The shared `Database` union type does not surface `batch` (it lives on the
|
|
53
|
+
* concrete subclasses), so we reach it through a structural cast at the call
|
|
54
|
+
* site that needs an atomic multi-statement write.
|
|
55
|
+
*/
|
|
56
|
+
type Batchable = {
|
|
57
|
+
batch(statements: readonly unknown[]): Promise<unknown>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const stories = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
61
|
+
|
|
62
|
+
// Best-effort, opportunistic retention of expired stories.
|
|
63
|
+
//
|
|
64
|
+
// This is NOT a substitute for a scheduled job: this Worker has no `scheduled`
|
|
65
|
+
// handler / cron trigger, so expiry cleanup is triggered probabilistically on
|
|
66
|
+
// the read path. Expired stories are already excluded from every read query
|
|
67
|
+
// (the feed/single-story handlers filter on `endTime`), so the only impact of
|
|
68
|
+
// a missed sweep is storage growth, not stale data leaking to users. The guard
|
|
69
|
+
// below ensures at most one sweep runs at a time per isolate, so a burst of
|
|
70
|
+
// feed requests cannot kick off several concurrent full-table delete sweeps.
|
|
71
|
+
let expiredStoryCleanupInFlight = false;
|
|
72
|
+
|
|
73
|
+
function maybeCleanupExpiredStories(
|
|
74
|
+
db: Database,
|
|
75
|
+
media?: IObjectStorage,
|
|
76
|
+
): void {
|
|
77
|
+
if (expiredStoryCleanupInFlight) return;
|
|
78
|
+
if (Math.random() >= 0.01) return; // ~1% of feed requests per isolate
|
|
79
|
+
|
|
80
|
+
expiredStoryCleanupInFlight = true;
|
|
81
|
+
// Pass the MEDIA binding so expired-story cleanup also purges the R2 blobs,
|
|
82
|
+
// not just the media_uploads DB rows (otherwise expired-story media leaks).
|
|
83
|
+
cleanupExpiredStories(db, media)
|
|
84
|
+
.catch((err) => {
|
|
85
|
+
log.warn("Failed to cleanup expired stories", {
|
|
86
|
+
event: "stories.cleanup.failed",
|
|
87
|
+
error: err,
|
|
88
|
+
});
|
|
89
|
+
})
|
|
90
|
+
.finally(() => {
|
|
91
|
+
expiredStoryCleanupInFlight = false;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Rate-limit story write paths (publish-like / fanout) per-actor, instead of
|
|
96
|
+
// letting them share the generous general read bucket. Registered as POST
|
|
97
|
+
// middleware ahead of the handlers below so it runs before each write.
|
|
98
|
+
const storyWriteLimiter = rateLimit(RateLimitConfigs.storyWrite);
|
|
99
|
+
stories.post("/", storyWriteLimiter);
|
|
100
|
+
stories.post("/delete", storyWriteLimiter);
|
|
101
|
+
|
|
102
|
+
type VoteResults = Record<number, number>;
|
|
103
|
+
|
|
104
|
+
type StoryAuthor = {
|
|
105
|
+
ap_id: string;
|
|
106
|
+
username: string;
|
|
107
|
+
preferred_username: string | null;
|
|
108
|
+
name: string | null;
|
|
109
|
+
icon_url: string | null;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
type StoryResponse = {
|
|
113
|
+
ap_id: string;
|
|
114
|
+
author: StoryAuthor;
|
|
115
|
+
attachment: ReturnType<typeof transformStoryData>["attachment"];
|
|
116
|
+
caption?: string;
|
|
117
|
+
displayDuration: string;
|
|
118
|
+
overlays?: ReturnType<typeof transformStoryData>["overlays"];
|
|
119
|
+
end_time: string;
|
|
120
|
+
published: string;
|
|
121
|
+
viewed: boolean;
|
|
122
|
+
like_count: number;
|
|
123
|
+
share_count: number;
|
|
124
|
+
liked: boolean;
|
|
125
|
+
votes?: VoteResults;
|
|
126
|
+
votes_total?: number;
|
|
127
|
+
user_vote?: number;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
type StoryCreateBody = {
|
|
131
|
+
attachment: {
|
|
132
|
+
r2_key: string;
|
|
133
|
+
content_type: string;
|
|
134
|
+
width?: number;
|
|
135
|
+
height?: number;
|
|
136
|
+
};
|
|
137
|
+
displayDuration: string;
|
|
138
|
+
// Optional caption/text shown over the story.
|
|
139
|
+
caption?: string;
|
|
140
|
+
overlays?: unknown[];
|
|
141
|
+
// Optional community scope. When set, the story is scoped to this community
|
|
142
|
+
// (members-only visibility) instead of the author's personal story feed.
|
|
143
|
+
community_ap_id?: string;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Caption is user-authored free text; cap it so a malformed/huge body can't
|
|
147
|
+
// bloat the stored attachments JSON.
|
|
148
|
+
const MAX_STORY_CAPTION_LENGTH = 500;
|
|
149
|
+
// Hard ceiling on a story-feed query. Stories are a non-paginated "bar", and
|
|
150
|
+
// inbound Create(Story) has no per-author cap, so a hostile followed host could
|
|
151
|
+
// accumulate tens of thousands of live (24h) Story rows and make this
|
|
152
|
+
// authenticated read path (hit on every app open) load an unbounded set into
|
|
153
|
+
// Worker memory. Bound it like every other feed query.
|
|
154
|
+
//
|
|
155
|
+
// Capped at 90 (not 500): the returned story ids are re-queried via
|
|
156
|
+
// `inArray(storyApIds)` for view/like/author enrichment, and Cloudflare D1
|
|
157
|
+
// allows at most 100 bound parameters per query — a 500-item feed would throw
|
|
158
|
+
// "too many SQL variables" on production D1 (libsql, which the tests run on,
|
|
159
|
+
// allows ~32k and hides this). 90 active stories is ample for a single-user
|
|
160
|
+
// feed page; a busy instance simply shows the 90 most recent.
|
|
161
|
+
const MAX_STORY_FEED_ITEMS = 90;
|
|
162
|
+
|
|
163
|
+
/** Build a StoryAuthor from available data sources. */
|
|
164
|
+
function buildAuthor(
|
|
165
|
+
apId: string,
|
|
166
|
+
data:
|
|
167
|
+
| {
|
|
168
|
+
preferredUsername?: string | null;
|
|
169
|
+
name?: string | null;
|
|
170
|
+
iconUrl?: string | null;
|
|
171
|
+
}
|
|
172
|
+
| null
|
|
173
|
+
| undefined,
|
|
174
|
+
): StoryAuthor {
|
|
175
|
+
return {
|
|
176
|
+
ap_id: apId,
|
|
177
|
+
username: formatUsername(apId),
|
|
178
|
+
preferred_username: data?.preferredUsername || null,
|
|
179
|
+
name: data?.name || null,
|
|
180
|
+
icon_url: data?.iconUrl || null,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Build a StoryResponse from a story object row and pre-fetched data. */
|
|
185
|
+
function buildStoryResponse(
|
|
186
|
+
s: {
|
|
187
|
+
apId: string;
|
|
188
|
+
attributedTo: string;
|
|
189
|
+
attachmentsJson: string;
|
|
190
|
+
endTime: string | null;
|
|
191
|
+
published: string;
|
|
192
|
+
likeCount: number;
|
|
193
|
+
shareCount: number | null;
|
|
194
|
+
viewedByUser?: boolean;
|
|
195
|
+
likedByUser?: boolean;
|
|
196
|
+
},
|
|
197
|
+
author: StoryAuthor,
|
|
198
|
+
allVotes: Record<string, VoteResults>,
|
|
199
|
+
userVotes: Record<string, number>,
|
|
200
|
+
): StoryResponse {
|
|
201
|
+
const storyData = transformStoryData(s.attachmentsJson);
|
|
202
|
+
const storyVotesData = allVotes[s.apId] || {};
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
ap_id: s.apId,
|
|
206
|
+
author,
|
|
207
|
+
attachment: storyData.attachment,
|
|
208
|
+
caption: storyData.caption,
|
|
209
|
+
displayDuration: storyData.displayDuration,
|
|
210
|
+
overlays: storyData.overlays,
|
|
211
|
+
end_time: s.endTime || "",
|
|
212
|
+
published: s.published,
|
|
213
|
+
viewed: s.viewedByUser ?? false,
|
|
214
|
+
like_count: s.likeCount,
|
|
215
|
+
share_count: s.shareCount || 0,
|
|
216
|
+
liked: s.likedByUser ?? false,
|
|
217
|
+
votes: storyVotesData,
|
|
218
|
+
votes_total: sumVotes(storyVotesData),
|
|
219
|
+
user_vote: userVotes[s.apId],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Resolve remote author info for stories missing a joined author relation. */
|
|
224
|
+
async function resolveRemoteAuthors(
|
|
225
|
+
db: Database,
|
|
226
|
+
storiesData: Array<{ author?: unknown; attributedTo: string }>,
|
|
227
|
+
): Promise<
|
|
228
|
+
Record<
|
|
229
|
+
string,
|
|
230
|
+
{
|
|
231
|
+
preferredUsername: string | null;
|
|
232
|
+
name: string | null;
|
|
233
|
+
iconUrl: string | null;
|
|
234
|
+
}
|
|
235
|
+
>
|
|
236
|
+
> {
|
|
237
|
+
const remoteIds = [
|
|
238
|
+
...new Set(storiesData.filter((s) => !s.author).map((s) => s.attributedTo)),
|
|
239
|
+
];
|
|
240
|
+
return fetchActorCache(db, remoteIds);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Create an outbound activity record and enqueue fanout to followers. */
|
|
244
|
+
async function createAndFanoutActivity(
|
|
245
|
+
db: Database,
|
|
246
|
+
env: Env,
|
|
247
|
+
actorApIdStr: string,
|
|
248
|
+
objectApIdStr: string,
|
|
249
|
+
activity: Record<string, unknown>,
|
|
250
|
+
communityApId?: string | null,
|
|
251
|
+
): Promise<void> {
|
|
252
|
+
const id = activity.id as string;
|
|
253
|
+
await db.insert(activities).values({
|
|
254
|
+
apId: id,
|
|
255
|
+
type: activity.type as string,
|
|
256
|
+
actorApId: actorApIdStr,
|
|
257
|
+
objectApId: objectApIdStr,
|
|
258
|
+
rawJson: JSON.stringify(activity),
|
|
259
|
+
direction: "outbound",
|
|
260
|
+
});
|
|
261
|
+
// A community-scoped story has reach == community: fan its activity out to the
|
|
262
|
+
// community's members/followers (the same audience posts use), NOT the
|
|
263
|
+
// author's personal follower graph. A personal story keeps author-follower
|
|
264
|
+
// reach.
|
|
265
|
+
if (communityApId) {
|
|
266
|
+
await enqueueFanoutToCommunity(env, id, communityApId);
|
|
267
|
+
} else {
|
|
268
|
+
await enqueueFanoutToFollowers(env, id, actorApIdStr);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Delete all related data for a story, then the story object itself.
|
|
274
|
+
*
|
|
275
|
+
* Delegates to `deleteObjectCascade` (the SAME teardown the expiry path
|
|
276
|
+
* `cleanupExpiredStories` runs) so it also reaps the story's mandatory R2 blob
|
|
277
|
+
* and its `media_uploads` row — child-row-only deletion here would orphan the
|
|
278
|
+
* image in R2 forever (there is no orphan-key sweep). The cascade covers
|
|
279
|
+
* storyViews/Votes/Shares + likes + announces + bookmarks + objectRecipients +
|
|
280
|
+
* media; it reads `attachments_json` off the still-present object row, so the
|
|
281
|
+
* object row is dropped afterwards.
|
|
282
|
+
*/
|
|
283
|
+
/**
|
|
284
|
+
* Returns true when THIS call actually removed the objects row (false if it was
|
|
285
|
+
* already gone). Callers gate the author's postCount decrement on this so a
|
|
286
|
+
* concurrent duplicate delete — or a race with the opportunistic expiry sweep —
|
|
287
|
+
* decrements at most once for the single +1 the story counted at create time.
|
|
288
|
+
*/
|
|
289
|
+
async function deleteStoryAndRelatedData(
|
|
290
|
+
db: Database,
|
|
291
|
+
apId: string,
|
|
292
|
+
media?: IObjectStorage,
|
|
293
|
+
): Promise<boolean> {
|
|
294
|
+
const mediaKeys = await deleteObjectCascade(db, apId, media);
|
|
295
|
+
const deleted = await db
|
|
296
|
+
.delete(objects)
|
|
297
|
+
.where(eq(objects.apId, apId))
|
|
298
|
+
.returning({ apId: objects.apId });
|
|
299
|
+
// Irreversible R2 purge LAST — after the objects row is gone.
|
|
300
|
+
await purgeMediaBlobs(media, mediaKeys);
|
|
301
|
+
return deleted.length > 0;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Resolve the scope of a stories read request.
|
|
306
|
+
*
|
|
307
|
+
* - No `community` query param -> personal scope (self + followed, and any
|
|
308
|
+
* community-scoped story is excluded so it never leaks into the personal feed).
|
|
309
|
+
* - `community=<apId>` -> community scope; the viewer must be an accepted member
|
|
310
|
+
* of that community, otherwise the scope resolves to "denied" and no stories
|
|
311
|
+
* are returned.
|
|
312
|
+
*/
|
|
313
|
+
async function resolveStoryScope(
|
|
314
|
+
db: Database,
|
|
315
|
+
viewerApId: string,
|
|
316
|
+
communityParam: string | undefined,
|
|
317
|
+
): Promise<
|
|
318
|
+
| { kind: "personal" }
|
|
319
|
+
| { kind: "community"; communityApId: string }
|
|
320
|
+
| { kind: "denied" }
|
|
321
|
+
> {
|
|
322
|
+
if (!communityParam) return { kind: "personal" };
|
|
323
|
+
|
|
324
|
+
const member = await db
|
|
325
|
+
.select({ actorApId: communityMembers.actorApId })
|
|
326
|
+
.from(communityMembers)
|
|
327
|
+
.where(
|
|
328
|
+
and(
|
|
329
|
+
eq(communityMembers.communityApId, communityParam),
|
|
330
|
+
eq(communityMembers.actorApId, viewerApId),
|
|
331
|
+
),
|
|
332
|
+
)
|
|
333
|
+
.get();
|
|
334
|
+
|
|
335
|
+
if (!member) return { kind: "denied" };
|
|
336
|
+
return { kind: "community", communityApId: communityParam };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Get active stories from followed users and self (grouped by author)
|
|
340
|
+
stories.get("/", async (c) => {
|
|
341
|
+
const actor = c.get("actor");
|
|
342
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
343
|
+
|
|
344
|
+
const db = c.get("db");
|
|
345
|
+
const now = new Date().toISOString();
|
|
346
|
+
|
|
347
|
+
// Resolve the requested scope. `?community=<apId>` switches to community scope
|
|
348
|
+
// (members only); absence keeps the personal self+followed feed.
|
|
349
|
+
const communityParam = c.req.query("community") || undefined;
|
|
350
|
+
const scope = await resolveStoryScope(db, actor.ap_id, communityParam);
|
|
351
|
+
if (scope.kind === "denied") {
|
|
352
|
+
return c.json({ actor_stories: [] });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Opportunistic, best-effort expiry cleanup (see maybeCleanupExpiredStories).
|
|
356
|
+
maybeCleanupExpiredStories(db, c.env.MEDIA);
|
|
357
|
+
// Opportunistically reap drained account tombstones on this hot read path
|
|
358
|
+
// (the Worker has no scheduled handler; see maybeReapDrainedTombstones).
|
|
359
|
+
maybeReapDrainedTombstones(db);
|
|
360
|
+
|
|
361
|
+
// Personal scope shows self + accepted-follows. Express the follow set as a
|
|
362
|
+
// subquery (`attributed_to IN (SELECT ...)`) so the feed stays lossless for
|
|
363
|
+
// any follow count without splicing every followed id into the query as a
|
|
364
|
+
// bound parameter — Cloudflare D1 caps a query at 100, so the old
|
|
365
|
+
// `inArray(followedIds)` 500'd the story feed for anyone following >~100
|
|
366
|
+
// accounts (libsql, which the tests run on, allows ~32k and hid this).
|
|
367
|
+
const followingSubquery = db
|
|
368
|
+
.select({ id: follows.followingApId })
|
|
369
|
+
.from(follows)
|
|
370
|
+
.where(
|
|
371
|
+
and(
|
|
372
|
+
eq(follows.followerApId, actor.ap_id),
|
|
373
|
+
eq(follows.status, "accepted"),
|
|
374
|
+
),
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
// Scope filter:
|
|
378
|
+
// - community scope: stories whose communityApId = the target community.
|
|
379
|
+
// Author membership in the personal follow graph is irrelevant here; the
|
|
380
|
+
// viewer's accepted membership (verified above) is what grants visibility.
|
|
381
|
+
// - personal scope: self + followed authors, and NO community-scoped story
|
|
382
|
+
// (communityApId IS NULL) so community stories never leak into the feed.
|
|
383
|
+
let storiesWhere =
|
|
384
|
+
scope.kind === "community"
|
|
385
|
+
? and(
|
|
386
|
+
eq(objects.type, "Story"),
|
|
387
|
+
gt(objects.endTime, now),
|
|
388
|
+
eq(objects.communityApId, scope.communityApId),
|
|
389
|
+
)
|
|
390
|
+
: and(
|
|
391
|
+
eq(objects.type, "Story"),
|
|
392
|
+
gt(objects.endTime, now),
|
|
393
|
+
isNull(objects.communityApId),
|
|
394
|
+
or(
|
|
395
|
+
eq(objects.attributedTo, actor.ap_id),
|
|
396
|
+
inArray(objects.attributedTo, followingSubquery),
|
|
397
|
+
),
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
const excludeAuthors = excludeBlockedMutedAuthors(db, actor.ap_id);
|
|
401
|
+
if (excludeAuthors) {
|
|
402
|
+
storiesWhere = and(storiesWhere, excludeAuthors);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const storiesData = await db
|
|
406
|
+
.select()
|
|
407
|
+
.from(objects)
|
|
408
|
+
.where(storiesWhere!)
|
|
409
|
+
.orderBy(desc(objects.endTime))
|
|
410
|
+
.limit(MAX_STORY_FEED_ITEMS);
|
|
411
|
+
|
|
412
|
+
// Batch fetch views and likes for the current user
|
|
413
|
+
const storyApIds = storiesData.map((s) => s.apId);
|
|
414
|
+
|
|
415
|
+
const [viewedRows, likedRows] = await Promise.all([
|
|
416
|
+
storyApIds.length > 0
|
|
417
|
+
? db
|
|
418
|
+
.select({ storyApId: storyViews.storyApId })
|
|
419
|
+
.from(storyViews)
|
|
420
|
+
.where(
|
|
421
|
+
and(
|
|
422
|
+
eq(storyViews.actorApId, actor.ap_id),
|
|
423
|
+
inArray(storyViews.storyApId, storyApIds),
|
|
424
|
+
),
|
|
425
|
+
)
|
|
426
|
+
: [],
|
|
427
|
+
storyApIds.length > 0
|
|
428
|
+
? db
|
|
429
|
+
.select({ objectApId: likes.objectApId })
|
|
430
|
+
.from(likes)
|
|
431
|
+
.where(
|
|
432
|
+
and(
|
|
433
|
+
eq(likes.actorApId, actor.ap_id),
|
|
434
|
+
inArray(likes.objectApId, storyApIds),
|
|
435
|
+
),
|
|
436
|
+
)
|
|
437
|
+
: [],
|
|
438
|
+
]);
|
|
439
|
+
|
|
440
|
+
const viewedSet = new Set(viewedRows.map((v) => v.storyApId));
|
|
441
|
+
const likedSet = new Set(likedRows.map((l) => l.objectApId));
|
|
442
|
+
|
|
443
|
+
// Batch fetch author info
|
|
444
|
+
const authorApIds = [...new Set(storiesData.map((s) => s.attributedTo))];
|
|
445
|
+
const [localAuthors, remoteAuthorCache] = await Promise.all([
|
|
446
|
+
authorApIds.length > 0
|
|
447
|
+
? db
|
|
448
|
+
.select({
|
|
449
|
+
apId: actors.apId,
|
|
450
|
+
preferredUsername: actors.preferredUsername,
|
|
451
|
+
name: actors.name,
|
|
452
|
+
iconUrl: actors.iconUrl,
|
|
453
|
+
})
|
|
454
|
+
.from(actors)
|
|
455
|
+
.where(inArray(actors.apId, authorApIds))
|
|
456
|
+
: [],
|
|
457
|
+
Promise.resolve().then(async () => {
|
|
458
|
+
// We'll resolve after we know which are remote
|
|
459
|
+
return {} as Record<
|
|
460
|
+
string,
|
|
461
|
+
{
|
|
462
|
+
preferredUsername: string | null;
|
|
463
|
+
name: string | null;
|
|
464
|
+
iconUrl: string | null;
|
|
465
|
+
}
|
|
466
|
+
>;
|
|
467
|
+
}),
|
|
468
|
+
]);
|
|
469
|
+
|
|
470
|
+
const authorMap = new Map(localAuthors.map((a) => [a.apId, a]));
|
|
471
|
+
const missingAuthorIds = authorApIds.filter((id) => !authorMap.has(id));
|
|
472
|
+
const actorCacheMap = await fetchActorCache(db, missingAuthorIds);
|
|
473
|
+
|
|
474
|
+
const [{ allVotes, userVotes }] = await Promise.all([
|
|
475
|
+
fetchBatchVotes(db, storyApIds, actor.ap_id),
|
|
476
|
+
]);
|
|
477
|
+
|
|
478
|
+
// Group by author
|
|
479
|
+
const grouped: Record<
|
|
480
|
+
string,
|
|
481
|
+
{ actor: StoryAuthor; stories: StoryResponse[]; has_unviewed: boolean }
|
|
482
|
+
> = {};
|
|
483
|
+
const authorOrder: string[] = [];
|
|
484
|
+
|
|
485
|
+
for (const s of storiesData) {
|
|
486
|
+
const authorApId = s.attributedTo;
|
|
487
|
+
const authorData = authorMap.get(authorApId) || actorCacheMap[authorApId];
|
|
488
|
+
const authorInfo = buildAuthor(authorApId, authorData);
|
|
489
|
+
|
|
490
|
+
if (!grouped[authorApId]) {
|
|
491
|
+
grouped[authorApId] = {
|
|
492
|
+
actor: authorInfo,
|
|
493
|
+
stories: [],
|
|
494
|
+
has_unviewed: false,
|
|
495
|
+
};
|
|
496
|
+
if (authorApId === actor.ap_id) {
|
|
497
|
+
authorOrder.unshift(authorApId);
|
|
498
|
+
} else {
|
|
499
|
+
authorOrder.push(authorApId);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const response = buildStoryResponse(
|
|
504
|
+
{
|
|
505
|
+
...s,
|
|
506
|
+
viewedByUser: viewedSet.has(s.apId),
|
|
507
|
+
likedByUser: likedSet.has(s.apId),
|
|
508
|
+
},
|
|
509
|
+
authorInfo,
|
|
510
|
+
allVotes,
|
|
511
|
+
userVotes,
|
|
512
|
+
);
|
|
513
|
+
if (!response.viewed) grouped[authorApId].has_unviewed = true;
|
|
514
|
+
grouped[authorApId].stories.push(response);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Sort stories within each group: unviewed first, then by end_time desc
|
|
518
|
+
for (const group of Object.values(grouped)) {
|
|
519
|
+
group.stories.sort((a, b) => {
|
|
520
|
+
if (!a.viewed && b.viewed) return -1;
|
|
521
|
+
if (a.viewed && !b.viewed) return 1;
|
|
522
|
+
return b.end_time.localeCompare(a.end_time);
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Sort author groups: self first, then those with unviewed stories
|
|
527
|
+
authorOrder.sort((a, b) => {
|
|
528
|
+
if (a === actor.ap_id) return -1;
|
|
529
|
+
if (b === actor.ap_id) return 1;
|
|
530
|
+
if (grouped[a].has_unviewed && !grouped[b].has_unviewed) return -1;
|
|
531
|
+
if (!grouped[a].has_unviewed && grouped[b].has_unviewed) return 1;
|
|
532
|
+
return 0;
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
return c.json({ actor_stories: authorOrder.map((apId) => grouped[apId]) });
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// Get stories for a specific user
|
|
539
|
+
stories.get("/:actorId", async (c) => {
|
|
540
|
+
const targetActorId = c.req.param("actorId");
|
|
541
|
+
const actor = c.get("actor");
|
|
542
|
+
const db = c.get("db");
|
|
543
|
+
const baseUrl = c.env.APP_URL;
|
|
544
|
+
const now = new Date().toISOString();
|
|
545
|
+
|
|
546
|
+
// Find the actor by username or full ap_id
|
|
547
|
+
const targetApId = targetActorId.startsWith("http")
|
|
548
|
+
? targetActorId
|
|
549
|
+
: actorApId(baseUrl, targetActorId);
|
|
550
|
+
|
|
551
|
+
// Resolve scope. Community scope requires an authenticated, accepted member.
|
|
552
|
+
const communityParam = c.req.query("community") || undefined;
|
|
553
|
+
if (communityParam) {
|
|
554
|
+
if (!actor) return c.json({ stories: [] });
|
|
555
|
+
const scope = await resolveStoryScope(db, actor.ap_id, communityParam);
|
|
556
|
+
if (scope.kind === "denied") return c.json({ stories: [] });
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Check blocked/muted (if authenticated)
|
|
560
|
+
if (actor) {
|
|
561
|
+
const { blockedIds, mutedIds } = await fetchBlockedAndMutedIds(
|
|
562
|
+
db,
|
|
563
|
+
actor.ap_id,
|
|
564
|
+
);
|
|
565
|
+
if (blockedIds.includes(targetApId) || mutedIds.includes(targetApId)) {
|
|
566
|
+
return c.json({ stories: [] });
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Personal stories are follower-scoped: the home feed (`GET /`) only surfaces
|
|
571
|
+
// self + accepted-follows, and the federated Create addresses them to the
|
|
572
|
+
// author's /followers — so a personal-scope read of another actor's stories is
|
|
573
|
+
// gated to the target or an accepted follower (and never anonymous). Community
|
|
574
|
+
// scope is already gated above by `resolveStoryScope`.
|
|
575
|
+
if (!communityParam) {
|
|
576
|
+
if (!actor) return c.json({ stories: [] });
|
|
577
|
+
if (actor.ap_id !== targetApId) {
|
|
578
|
+
const follow = await db
|
|
579
|
+
.select({ followerApId: follows.followerApId })
|
|
580
|
+
.from(follows)
|
|
581
|
+
.where(
|
|
582
|
+
and(
|
|
583
|
+
eq(follows.followerApId, actor.ap_id),
|
|
584
|
+
eq(follows.followingApId, targetApId),
|
|
585
|
+
eq(follows.status, "accepted"),
|
|
586
|
+
),
|
|
587
|
+
)
|
|
588
|
+
.get();
|
|
589
|
+
if (!follow) return c.json({ stories: [] });
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Get stories for the target user, filtered by scope:
|
|
594
|
+
// - community scope: only that community's stories,
|
|
595
|
+
// - personal scope: only NON-community (personal) stories.
|
|
596
|
+
const scopeCondition = communityParam
|
|
597
|
+
? eq(objects.communityApId, communityParam)
|
|
598
|
+
: isNull(objects.communityApId);
|
|
599
|
+
|
|
600
|
+
const userStories = await db
|
|
601
|
+
.select()
|
|
602
|
+
.from(objects)
|
|
603
|
+
.where(
|
|
604
|
+
and(
|
|
605
|
+
eq(objects.type, "Story"),
|
|
606
|
+
eq(objects.attributedTo, targetApId),
|
|
607
|
+
gt(objects.endTime, now),
|
|
608
|
+
scopeCondition,
|
|
609
|
+
),
|
|
610
|
+
)
|
|
611
|
+
.orderBy(desc(objects.published))
|
|
612
|
+
.limit(MAX_STORY_FEED_ITEMS);
|
|
613
|
+
|
|
614
|
+
const storyApIds = userStories.map((s) => s.apId);
|
|
615
|
+
|
|
616
|
+
// Batch fetch views and likes for current user
|
|
617
|
+
const [viewedRows, likedRows] = await Promise.all([
|
|
618
|
+
actor && storyApIds.length > 0
|
|
619
|
+
? db
|
|
620
|
+
.select({ storyApId: storyViews.storyApId })
|
|
621
|
+
.from(storyViews)
|
|
622
|
+
.where(
|
|
623
|
+
and(
|
|
624
|
+
eq(storyViews.actorApId, actor.ap_id),
|
|
625
|
+
inArray(storyViews.storyApId, storyApIds),
|
|
626
|
+
),
|
|
627
|
+
)
|
|
628
|
+
: [],
|
|
629
|
+
actor && storyApIds.length > 0
|
|
630
|
+
? db
|
|
631
|
+
.select({ objectApId: likes.objectApId })
|
|
632
|
+
.from(likes)
|
|
633
|
+
.where(
|
|
634
|
+
and(
|
|
635
|
+
eq(likes.actorApId, actor.ap_id),
|
|
636
|
+
inArray(likes.objectApId, storyApIds),
|
|
637
|
+
),
|
|
638
|
+
)
|
|
639
|
+
: [],
|
|
640
|
+
]);
|
|
641
|
+
|
|
642
|
+
const viewedSet = new Set((viewedRows || []).map((v) => v.storyApId));
|
|
643
|
+
const likedSet = new Set((likedRows || []).map((l) => l.objectApId));
|
|
644
|
+
|
|
645
|
+
// Batch fetch author info
|
|
646
|
+
const authorApIds = [...new Set(userStories.map((s) => s.attributedTo))];
|
|
647
|
+
const localAuthors =
|
|
648
|
+
authorApIds.length > 0
|
|
649
|
+
? await db
|
|
650
|
+
.select({
|
|
651
|
+
apId: actors.apId,
|
|
652
|
+
preferredUsername: actors.preferredUsername,
|
|
653
|
+
name: actors.name,
|
|
654
|
+
iconUrl: actors.iconUrl,
|
|
655
|
+
})
|
|
656
|
+
.from(actors)
|
|
657
|
+
.where(inArray(actors.apId, authorApIds))
|
|
658
|
+
: [];
|
|
659
|
+
|
|
660
|
+
const authorLocalMap = new Map(localAuthors.map((a) => [a.apId, a]));
|
|
661
|
+
const missingIds = authorApIds.filter((id) => !authorLocalMap.has(id));
|
|
662
|
+
const actorCacheMap = await fetchActorCache(db, missingIds);
|
|
663
|
+
|
|
664
|
+
const [{ allVotes, userVotes }] = await Promise.all([
|
|
665
|
+
fetchBatchVotes(db, storyApIds, actor?.ap_id),
|
|
666
|
+
]);
|
|
667
|
+
|
|
668
|
+
const result = userStories.map((s) => {
|
|
669
|
+
const authorData =
|
|
670
|
+
authorLocalMap.get(s.attributedTo) || actorCacheMap[s.attributedTo];
|
|
671
|
+
const author = buildAuthor(s.attributedTo, authorData);
|
|
672
|
+
|
|
673
|
+
return buildStoryResponse(
|
|
674
|
+
{
|
|
675
|
+
...s,
|
|
676
|
+
viewedByUser: viewedSet.has(s.apId),
|
|
677
|
+
likedByUser: likedSet.has(s.apId),
|
|
678
|
+
},
|
|
679
|
+
author,
|
|
680
|
+
allVotes,
|
|
681
|
+
userVotes,
|
|
682
|
+
);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
return c.json({ stories: result });
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
// Create story (v2: single attachment format)
|
|
689
|
+
stories.post("/", async (c) => {
|
|
690
|
+
const actor = c.get("actor");
|
|
691
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
692
|
+
|
|
693
|
+
const db = c.get("db");
|
|
694
|
+
const body = await c.req.json<StoryCreateBody>();
|
|
695
|
+
|
|
696
|
+
if (!body.attachment || !body.attachment.r2_key) {
|
|
697
|
+
return c.json({ error: "attachment with r2_key required" }, 400);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (body.overlays && body.overlays.length > 0) {
|
|
701
|
+
const validation = validateOverlays(body.overlays);
|
|
702
|
+
if (!validation.valid) {
|
|
703
|
+
return c.json({ error: validation.error }, 400);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Normalize the optional caption: trim, drop when empty, and reject overlong
|
|
708
|
+
// input rather than silently truncating.
|
|
709
|
+
let caption: string | undefined;
|
|
710
|
+
if (typeof body.caption === "string") {
|
|
711
|
+
const trimmed = body.caption.trim();
|
|
712
|
+
if (trimmed.length > MAX_STORY_CAPTION_LENGTH) {
|
|
713
|
+
return c.json(
|
|
714
|
+
{
|
|
715
|
+
error: `caption must be at most ${MAX_STORY_CAPTION_LENGTH} characters`,
|
|
716
|
+
},
|
|
717
|
+
400,
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
if (trimmed.length > 0) caption = trimmed;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Optional community scope. Reuse the same post-permission policy as posts so
|
|
724
|
+
// story scope and post scope stay consistent (membership + postPolicy). A
|
|
725
|
+
// personal story leaves communityApId NULL.
|
|
726
|
+
const communityCheck = await checkCommunityPostPermission(
|
|
727
|
+
db,
|
|
728
|
+
actor.ap_id,
|
|
729
|
+
body.community_ap_id,
|
|
730
|
+
);
|
|
731
|
+
if (!communityCheck.allowed) {
|
|
732
|
+
return c.json({ error: communityCheck.error }, communityCheck.status);
|
|
733
|
+
}
|
|
734
|
+
const communityApIdValue = communityCheck.communityId;
|
|
735
|
+
const communityFollowersUrl = communityCheck.community?.followersUrl ?? null;
|
|
736
|
+
|
|
737
|
+
const baseUrl = c.env.APP_URL;
|
|
738
|
+
const apId = objectApId(baseUrl, generateId());
|
|
739
|
+
const now = new Date().toISOString();
|
|
740
|
+
const endTime = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
|
741
|
+
|
|
742
|
+
const storyData = {
|
|
743
|
+
attachment: {
|
|
744
|
+
...body.attachment,
|
|
745
|
+
width: body.attachment.width || 1080,
|
|
746
|
+
height: body.attachment.height || 1920,
|
|
747
|
+
},
|
|
748
|
+
displayDuration: body.displayDuration || "PT5S",
|
|
749
|
+
caption,
|
|
750
|
+
overlays: body.overlays || undefined,
|
|
751
|
+
};
|
|
752
|
+
const attachmentsJson = JSON.stringify(storyData);
|
|
753
|
+
|
|
754
|
+
// Insert the story object and bump the author's denormalized postCount in a
|
|
755
|
+
// single atomic batch. D1 has no interactive transactions, so doing these as
|
|
756
|
+
// two separate writes could drift the counter relative to the stored stories
|
|
757
|
+
// on a mid-request failure. The `Database` union type does not surface `batch`
|
|
758
|
+
// (it is only on the concrete D1/libsql subclasses), so reach it through a
|
|
759
|
+
// narrow structural cast.
|
|
760
|
+
const storyInsert = db.insert(objects).values({
|
|
761
|
+
apId,
|
|
762
|
+
type: "Story",
|
|
763
|
+
attributedTo: actor.ap_id,
|
|
764
|
+
content: "",
|
|
765
|
+
attachmentsJson,
|
|
766
|
+
communityApId: communityApIdValue,
|
|
767
|
+
endTime,
|
|
768
|
+
published: now,
|
|
769
|
+
isLocal: 1,
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
const postCountBump = db
|
|
773
|
+
.update(actors)
|
|
774
|
+
.set({ postCount: sql`${actors.postCount} + 1` })
|
|
775
|
+
.where(eq(actors.apId, actor.ap_id));
|
|
776
|
+
|
|
777
|
+
await (db as unknown as Batchable).batch([storyInsert, postCountBump]);
|
|
778
|
+
|
|
779
|
+
const responseData = transformStoryData(attachmentsJson);
|
|
780
|
+
const authorInfo = buildAuthor(actor.ap_id, {
|
|
781
|
+
preferredUsername: actor.preferred_username,
|
|
782
|
+
name: actor.name,
|
|
783
|
+
iconUrl: actor.icon_url,
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
const story = {
|
|
787
|
+
ap_id: apId,
|
|
788
|
+
author: authorInfo,
|
|
789
|
+
attachment: responseData.attachment,
|
|
790
|
+
caption: responseData.caption,
|
|
791
|
+
displayDuration: responseData.displayDuration,
|
|
792
|
+
overlays: responseData.overlays,
|
|
793
|
+
end_time: endTime,
|
|
794
|
+
published: now,
|
|
795
|
+
viewed: false,
|
|
796
|
+
like_count: 0,
|
|
797
|
+
liked: false,
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
// Send Create(Story) activity to followers
|
|
801
|
+
const storyObject = storyToActivityPub(
|
|
802
|
+
{
|
|
803
|
+
apId,
|
|
804
|
+
attributedTo: actor.ap_id,
|
|
805
|
+
attachment: responseData.attachment,
|
|
806
|
+
displayDuration: responseData.displayDuration,
|
|
807
|
+
caption: responseData.caption,
|
|
808
|
+
overlays: responseData.overlays,
|
|
809
|
+
endTime,
|
|
810
|
+
published: now,
|
|
811
|
+
},
|
|
812
|
+
actor,
|
|
813
|
+
baseUrl,
|
|
814
|
+
);
|
|
815
|
+
// Address a community-scoped story to the community's followers collection;
|
|
816
|
+
// a personal story stays addressed to the author's own followers.
|
|
817
|
+
const storyTo =
|
|
818
|
+
communityApIdValue && communityFollowersUrl
|
|
819
|
+
? [communityFollowersUrl]
|
|
820
|
+
: [`${actor.ap_id}/followers`];
|
|
821
|
+
await createAndFanoutActivity(
|
|
822
|
+
db,
|
|
823
|
+
c.env,
|
|
824
|
+
actor.ap_id,
|
|
825
|
+
apId,
|
|
826
|
+
{
|
|
827
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
828
|
+
id: activityApId(baseUrl, generateId()),
|
|
829
|
+
type: "Create",
|
|
830
|
+
actor: actor.ap_id,
|
|
831
|
+
published: now,
|
|
832
|
+
to: storyTo,
|
|
833
|
+
object: storyObject,
|
|
834
|
+
},
|
|
835
|
+
communityApIdValue,
|
|
836
|
+
);
|
|
837
|
+
|
|
838
|
+
return c.json({ story }, 201);
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
// Delete story
|
|
842
|
+
stories.post("/delete", async (c) => {
|
|
843
|
+
const actor = c.get("actor");
|
|
844
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
845
|
+
|
|
846
|
+
const db = c.get("db");
|
|
847
|
+
const body = await c.req.json<{ ap_id: string }>();
|
|
848
|
+
if (!body.ap_id) return c.json({ error: "ap_id required" }, 400);
|
|
849
|
+
const apId = body.ap_id;
|
|
850
|
+
|
|
851
|
+
// Verify ownership
|
|
852
|
+
const story = await db
|
|
853
|
+
.select()
|
|
854
|
+
.from(objects)
|
|
855
|
+
.where(eq(objects.apId, apId))
|
|
856
|
+
.get();
|
|
857
|
+
if (!story) return c.json({ error: "Story not found" }, 404);
|
|
858
|
+
if (story.attributedTo !== actor.ap_id) {
|
|
859
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// Enqueue Delete(Story) activity before deleting. Outbound delivery MUST NOT
|
|
863
|
+
// run in request path; enqueue is the sync boundary. A community-scoped story
|
|
864
|
+
// tombstone is addressed to and fanned out to the community (reach ==
|
|
865
|
+
// community), mirroring its Create; a personal story keeps author-follower
|
|
866
|
+
// reach.
|
|
867
|
+
const baseUrl = c.env.APP_URL;
|
|
868
|
+
const deleteTo = story.communityApId
|
|
869
|
+
? [`${story.communityApId}/followers`]
|
|
870
|
+
: ["https://www.w3.org/ns/activitystreams#Public"];
|
|
871
|
+
await createAndFanoutActivity(
|
|
872
|
+
db,
|
|
873
|
+
c.env,
|
|
874
|
+
actor.ap_id,
|
|
875
|
+
apId,
|
|
876
|
+
{
|
|
877
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
878
|
+
id: activityApId(baseUrl, generateId()),
|
|
879
|
+
type: "Delete",
|
|
880
|
+
actor: actor.ap_id,
|
|
881
|
+
to: deleteTo,
|
|
882
|
+
object: apId,
|
|
883
|
+
},
|
|
884
|
+
story.communityApId,
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
const removed = await deleteStoryAndRelatedData(db, apId, c.env.MEDIA);
|
|
888
|
+
|
|
889
|
+
// Decrement the author's postCount ONLY when THIS request actually removed the
|
|
890
|
+
// row. The early 404 above guards SEQUENTIAL duplicates, but two concurrent
|
|
891
|
+
// deletes (double-click / retry) — or a manual delete racing the opportunistic
|
|
892
|
+
// expiry sweep (cleanupExpiredStories) — can both pass the SELECT before either
|
|
893
|
+
// delete commits, then both reach here; an unconditional decrement would then
|
|
894
|
+
// subtract 2 for one +1. Gating on the actual delete keeps the count exact
|
|
895
|
+
// (gt > 0 still guards underflow). Mirrors the EXISTS-guarded post-delete path.
|
|
896
|
+
if (removed) {
|
|
897
|
+
await db
|
|
898
|
+
.update(actors)
|
|
899
|
+
.set({ postCount: sql`${actors.postCount} - 1` })
|
|
900
|
+
.where(and(eq(actors.apId, actor.ap_id), gt(actors.postCount, 0)));
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
return c.json({ success: true });
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
export default stories;
|