@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,576 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { and, eq, gt, sql } from "drizzle-orm";
|
|
3
|
+
import type { Env, Variables } from "../../types.ts";
|
|
4
|
+
import {
|
|
5
|
+
activities,
|
|
6
|
+
blocks,
|
|
7
|
+
inbox,
|
|
8
|
+
likes,
|
|
9
|
+
objects,
|
|
10
|
+
storyShares,
|
|
11
|
+
storyViews,
|
|
12
|
+
storyVotes,
|
|
13
|
+
} from "../../../db/index.ts";
|
|
14
|
+
import {
|
|
15
|
+
activityApId,
|
|
16
|
+
generateId,
|
|
17
|
+
isLocal,
|
|
18
|
+
safeJsonParse,
|
|
19
|
+
} from "../../federation-helpers.ts";
|
|
20
|
+
import {
|
|
21
|
+
canViewerReadStory,
|
|
22
|
+
findStory,
|
|
23
|
+
getVoteCounts,
|
|
24
|
+
resolveStoryApId,
|
|
25
|
+
sumVotes,
|
|
26
|
+
} from "./query-helpers.ts";
|
|
27
|
+
import { enqueueDeliveryToActor } from "../../lib/delivery/queue.ts";
|
|
28
|
+
import { actorIsBlockedBy } from "../../lib/post-visibility.ts";
|
|
29
|
+
import { rateLimit, RateLimitConfigs } from "../../middleware/rate-limit.ts";
|
|
30
|
+
import { logger } from "../../lib/logger.ts";
|
|
31
|
+
import { isUniqueConstraintError } from "../../lib/parse-helpers.ts";
|
|
32
|
+
|
|
33
|
+
const log = logger.child({ component: "stories.interactions" });
|
|
34
|
+
|
|
35
|
+
// `.batch` lives only on the concrete D1/libsql subclasses, not the Database
|
|
36
|
+
// union; reach it through a narrow structural cast (matching the other routes).
|
|
37
|
+
type Batchable = { batch: (stmts: unknown[]) => Promise<unknown> };
|
|
38
|
+
|
|
39
|
+
const stories = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
40
|
+
|
|
41
|
+
// Rate-limit story interaction write paths per-actor (same budget as story
|
|
42
|
+
// create), rather than leaving them in the general read bucket. Registered as
|
|
43
|
+
// POST middleware ahead of the handlers below. `/:id/votes` and `/:id/shares`
|
|
44
|
+
// are reads and are intentionally not limited here.
|
|
45
|
+
const storyWriteLimiter = rateLimit(RateLimitConfigs.storyWrite);
|
|
46
|
+
stories.post("/view", storyWriteLimiter);
|
|
47
|
+
stories.post("/vote", storyWriteLimiter);
|
|
48
|
+
stories.post("/:id/like", storyWriteLimiter);
|
|
49
|
+
stories.delete("/:id/like", storyWriteLimiter);
|
|
50
|
+
stories.post("/:id/share", storyWriteLimiter);
|
|
51
|
+
|
|
52
|
+
type StoryOverlay = {
|
|
53
|
+
type?: string;
|
|
54
|
+
oneOf?: unknown[];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type StoryData = {
|
|
58
|
+
overlays?: StoryOverlay[];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Mark story as viewed
|
|
62
|
+
stories.post("/view", async (c) => {
|
|
63
|
+
const actor = c.get("actor");
|
|
64
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
65
|
+
|
|
66
|
+
const db = c.get("db");
|
|
67
|
+
const body = await c.req.json<{ ap_id: string }>();
|
|
68
|
+
if (!body.ap_id) return c.json({ error: "ap_id required" }, 400);
|
|
69
|
+
const apId = body.ap_id;
|
|
70
|
+
|
|
71
|
+
const story = await findStory(db, apId);
|
|
72
|
+
if (!story) return c.json({ error: "Story not found" }, 404);
|
|
73
|
+
|
|
74
|
+
const now = new Date().toISOString();
|
|
75
|
+
if (story.endTime && story.endTime < now) {
|
|
76
|
+
return c.json({ error: "Story has expired" }, 410);
|
|
77
|
+
}
|
|
78
|
+
// Only someone who can actually see the story may register a view (the author
|
|
79
|
+
// sees who viewed) — a non-follower / non-member must not be able to.
|
|
80
|
+
if (!(await canViewerReadStory(db, story, actor.ap_id))) {
|
|
81
|
+
return c.json({ error: "Story not found" }, 404);
|
|
82
|
+
}
|
|
83
|
+
// The author has blocked this actor: refuse (404, not leaking the block) so a
|
|
84
|
+
// blocked actor can't register a view (or, on vote/share below, manipulate the
|
|
85
|
+
// tally / share count) — parity with the like handler.
|
|
86
|
+
if (await actorIsBlockedBy(db, story.attributedTo, actor.ap_id)) {
|
|
87
|
+
return c.json({ error: "Story not found" }, 404);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
// Upsert: check existence then insert if not found
|
|
92
|
+
const existing = await db
|
|
93
|
+
.select()
|
|
94
|
+
.from(storyViews)
|
|
95
|
+
.where(
|
|
96
|
+
and(
|
|
97
|
+
eq(storyViews.actorApId, actor.ap_id),
|
|
98
|
+
eq(storyViews.storyApId, apId),
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
.get();
|
|
102
|
+
|
|
103
|
+
if (!existing) {
|
|
104
|
+
await db.insert(storyViews).values({
|
|
105
|
+
actorApId: actor.ap_id,
|
|
106
|
+
storyApId: apId,
|
|
107
|
+
viewedAt: now,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// Ignore duplicate key errors
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return c.json({ success: true });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Vote on a story poll
|
|
118
|
+
stories.post("/vote", async (c) => {
|
|
119
|
+
const actor = c.get("actor");
|
|
120
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
121
|
+
|
|
122
|
+
const db = c.get("db");
|
|
123
|
+
const body = await c.req.json<{ ap_id: string; option_index: number }>();
|
|
124
|
+
if (!body.ap_id) return c.json({ error: "ap_id required" }, 400);
|
|
125
|
+
const apId = body.ap_id;
|
|
126
|
+
|
|
127
|
+
if (typeof body.option_index !== "number" || body.option_index < 0) {
|
|
128
|
+
return c.json({ error: "Invalid option_index" }, 400);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const story = await findStory(db, apId);
|
|
132
|
+
if (!story) return c.json({ error: "Story not found" }, 404);
|
|
133
|
+
|
|
134
|
+
if (story.attributedTo === actor.ap_id) {
|
|
135
|
+
return c.json({ error: "Cannot vote on your own story" }, 403);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const now = new Date().toISOString();
|
|
139
|
+
if (story.endTime && story.endTime < now) {
|
|
140
|
+
return c.json({ error: "Story has expired" }, 410);
|
|
141
|
+
}
|
|
142
|
+
// A blocked actor must not be able to vote (skewing the author's poll tally)
|
|
143
|
+
// — a local block does NOT sever follow/community membership, so canViewerRead
|
|
144
|
+
// below still passes for a still-following/co-member blocked actor.
|
|
145
|
+
if (await actorIsBlockedBy(db, story.attributedTo, actor.ap_id)) {
|
|
146
|
+
return c.json({ error: "Story not found" }, 404);
|
|
147
|
+
}
|
|
148
|
+
// A non-follower / non-member must not be able to vote on a story they cannot
|
|
149
|
+
// see (the author would otherwise receive poll votes from unentitled actors).
|
|
150
|
+
if (!(await canViewerReadStory(db, story, actor.ap_id))) {
|
|
151
|
+
return c.json({ error: "Story not found" }, 404);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Validate option_index against the first Question overlay
|
|
155
|
+
const storyData = safeJsonParse<StoryData>(story.attachmentsJson, {});
|
|
156
|
+
const questionOverlays = (storyData.overlays || []).filter(
|
|
157
|
+
(o) => o.type === "Question",
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
if (questionOverlays.length === 0) {
|
|
161
|
+
return c.json({ error: "Story has no poll" }, 400);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const maxOptionIndex = questionOverlays[0].oneOf?.length || 0;
|
|
165
|
+
if (body.option_index >= maxOptionIndex) {
|
|
166
|
+
return c.json(
|
|
167
|
+
{ error: `option_index must be 0-${maxOptionIndex - 1}` },
|
|
168
|
+
400,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Upsert vote atomically. A concurrent double-vote previously raced the
|
|
173
|
+
// check-then-insert and 500'd on the (storyApId, actorApId) unique index;
|
|
174
|
+
// letting the DB resolve the conflict makes re-voting idempotent without a
|
|
175
|
+
// pre-read. On conflict we keep the existing row's id and only update the
|
|
176
|
+
// chosen option / timestamp.
|
|
177
|
+
await db
|
|
178
|
+
.insert(storyVotes)
|
|
179
|
+
.values({
|
|
180
|
+
id: generateId(),
|
|
181
|
+
storyApId: apId,
|
|
182
|
+
actorApId: actor.ap_id,
|
|
183
|
+
optionIndex: body.option_index,
|
|
184
|
+
createdAt: now,
|
|
185
|
+
})
|
|
186
|
+
.onConflictDoUpdate({
|
|
187
|
+
target: [storyVotes.storyApId, storyVotes.actorApId],
|
|
188
|
+
set: { optionIndex: body.option_index, createdAt: now },
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const votes = await getVoteCounts(db, apId);
|
|
192
|
+
return c.json({
|
|
193
|
+
success: true,
|
|
194
|
+
votes,
|
|
195
|
+
total: sumVotes(votes),
|
|
196
|
+
user_vote: body.option_index,
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Like a story
|
|
201
|
+
stories.post("/:id/like", async (c) => {
|
|
202
|
+
const actor = c.get("actor");
|
|
203
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
204
|
+
|
|
205
|
+
const db = c.get("db");
|
|
206
|
+
const baseUrl = c.env.APP_URL;
|
|
207
|
+
const apId = resolveStoryApId(c.req.param("id"), baseUrl);
|
|
208
|
+
|
|
209
|
+
const story = await findStory(db, apId);
|
|
210
|
+
if (!story) return c.json({ error: "Story not found" }, 404);
|
|
211
|
+
|
|
212
|
+
// Reject if the story author has blocked the liker, so a blocked actor
|
|
213
|
+
// cannot insert into the author's inbox. Respond with 404 (matching a
|
|
214
|
+
// non-existent story) so the block is not leaked to the liker.
|
|
215
|
+
const blockedBy = await db
|
|
216
|
+
.select({ blockerApId: blocks.blockerApId })
|
|
217
|
+
.from(blocks)
|
|
218
|
+
.where(
|
|
219
|
+
and(
|
|
220
|
+
eq(blocks.blockerApId, story.attributedTo),
|
|
221
|
+
eq(blocks.blockedApId, actor.ap_id),
|
|
222
|
+
),
|
|
223
|
+
)
|
|
224
|
+
.get();
|
|
225
|
+
if (blockedBy) return c.json({ error: "Story not found" }, 404);
|
|
226
|
+
|
|
227
|
+
const nowIso = new Date().toISOString();
|
|
228
|
+
if (story.endTime && story.endTime < nowIso) {
|
|
229
|
+
return c.json({ error: "Story has expired" }, 410);
|
|
230
|
+
}
|
|
231
|
+
// Only someone who can see the story may like it (otherwise a non-follower /
|
|
232
|
+
// non-member notifies the author + bumps the count on a story never shown).
|
|
233
|
+
if (!(await canViewerReadStory(db, story, actor.ap_id))) {
|
|
234
|
+
return c.json({ error: "Story not found" }, 404);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const existing = await db
|
|
238
|
+
.select()
|
|
239
|
+
.from(likes)
|
|
240
|
+
.where(and(eq(likes.actorApId, actor.ap_id), eq(likes.objectApId, apId)))
|
|
241
|
+
.get();
|
|
242
|
+
if (existing) {
|
|
243
|
+
return c.json({ success: true, liked: true, like_count: story.likeCount });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const likeId = generateId();
|
|
247
|
+
const likeActivityApId = activityApId(baseUrl, likeId);
|
|
248
|
+
const now = new Date().toISOString();
|
|
249
|
+
|
|
250
|
+
// Co-commit the like edge + count bump in one batch so a crash between them
|
|
251
|
+
// can't drift likeCount; the bare insert throws on a concurrent duplicate,
|
|
252
|
+
// rolling back the whole batch (no double-count).
|
|
253
|
+
try {
|
|
254
|
+
await (db as unknown as Batchable).batch([
|
|
255
|
+
db.insert(likes).values({
|
|
256
|
+
actorApId: actor.ap_id,
|
|
257
|
+
objectApId: apId,
|
|
258
|
+
activityApId: likeActivityApId,
|
|
259
|
+
createdAt: now,
|
|
260
|
+
}),
|
|
261
|
+
db
|
|
262
|
+
.update(objects)
|
|
263
|
+
.set({ likeCount: sql`${objects.likeCount} + 1` })
|
|
264
|
+
.where(eq(objects.apId, apId)),
|
|
265
|
+
]);
|
|
266
|
+
} catch (e) {
|
|
267
|
+
// A concurrent like won the race (TOCTOU past the existing-check); treat as
|
|
268
|
+
// idempotent success instead of surfacing a 500 unique-constraint error. The
|
|
269
|
+
// like now provably exists (the winner committed +1), so echo the
|
|
270
|
+
// incremented count — matching the success path — not the pre-read value.
|
|
271
|
+
if (isUniqueConstraintError(e)) {
|
|
272
|
+
return c.json({
|
|
273
|
+
success: true,
|
|
274
|
+
liked: true,
|
|
275
|
+
like_count: story.likeCount + 1,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
throw e;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const likeActivityRaw = {
|
|
282
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
283
|
+
id: likeActivityApId,
|
|
284
|
+
type: "Like",
|
|
285
|
+
actor: actor.ap_id,
|
|
286
|
+
object: apId,
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
await db.insert(activities).values({
|
|
290
|
+
apId: likeActivityApId,
|
|
291
|
+
type: "Like",
|
|
292
|
+
actorApId: actor.ap_id,
|
|
293
|
+
objectApId: apId,
|
|
294
|
+
rawJson: JSON.stringify(likeActivityRaw),
|
|
295
|
+
createdAt: now,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
if (
|
|
299
|
+
story.attributedTo !== actor.ap_id &&
|
|
300
|
+
isLocal(story.attributedTo, baseUrl)
|
|
301
|
+
) {
|
|
302
|
+
await db.insert(inbox).values({
|
|
303
|
+
actorApId: story.attributedTo,
|
|
304
|
+
activityApId: likeActivityApId,
|
|
305
|
+
read: 0,
|
|
306
|
+
createdAt: now,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!isLocal(apId, baseUrl)) {
|
|
311
|
+
try {
|
|
312
|
+
await enqueueDeliveryToActor(c.env, likeActivityApId, story.attributedTo);
|
|
313
|
+
} catch (e) {
|
|
314
|
+
log.error("Failed to enqueue Like activity for story", {
|
|
315
|
+
event: "stories.like.enqueue_failed",
|
|
316
|
+
storyApId: apId,
|
|
317
|
+
recipient: story.attributedTo,
|
|
318
|
+
error: e,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return c.json({
|
|
324
|
+
success: true,
|
|
325
|
+
liked: true,
|
|
326
|
+
like_count: story.likeCount + 1,
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// Unlike a story
|
|
331
|
+
stories.delete("/:id/like", async (c) => {
|
|
332
|
+
const actor = c.get("actor");
|
|
333
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
334
|
+
|
|
335
|
+
const db = c.get("db");
|
|
336
|
+
const baseUrl = c.env.APP_URL;
|
|
337
|
+
const apId = resolveStoryApId(c.req.param("id"), baseUrl);
|
|
338
|
+
|
|
339
|
+
const story = await findStory(db, apId);
|
|
340
|
+
if (!story) return c.json({ error: "Story not found" }, 404);
|
|
341
|
+
|
|
342
|
+
const like = await db
|
|
343
|
+
.select()
|
|
344
|
+
.from(likes)
|
|
345
|
+
.where(and(eq(likes.actorApId, actor.ap_id), eq(likes.objectApId, apId)))
|
|
346
|
+
.get();
|
|
347
|
+
if (!like) return c.json({ error: "Not liked" }, 400);
|
|
348
|
+
|
|
349
|
+
// Co-commit the count decrement (guarded gt>0 against underflow) + edge delete
|
|
350
|
+
// in one batch so a crash between them can't leave likeCount drifted. Also reap
|
|
351
|
+
// the original like's notification: a story like mints a FRESH activity id each
|
|
352
|
+
// time and the re-like dedup guard only checks the `likes` edge (removed here),
|
|
353
|
+
// so without this a like→unlike→like cycle would leave the first inbox/activity
|
|
354
|
+
// rows behind and add new ones — duplicate "X liked your story" + a phantom
|
|
355
|
+
// unread +1. Mirrors the post-path unlike reap.
|
|
356
|
+
const reapLikeNotification = like.activityApId
|
|
357
|
+
? [
|
|
358
|
+
db.delete(inbox).where(eq(inbox.activityApId, like.activityApId)),
|
|
359
|
+
db.delete(activities).where(eq(activities.apId, like.activityApId)),
|
|
360
|
+
]
|
|
361
|
+
: [];
|
|
362
|
+
await (db as unknown as Batchable).batch([
|
|
363
|
+
db
|
|
364
|
+
.update(objects)
|
|
365
|
+
.set({ likeCount: sql`${objects.likeCount} - 1` })
|
|
366
|
+
.where(and(eq(objects.apId, apId), gt(objects.likeCount, 0))),
|
|
367
|
+
db
|
|
368
|
+
.delete(likes)
|
|
369
|
+
.where(and(eq(likes.actorApId, actor.ap_id), eq(likes.objectApId, apId))),
|
|
370
|
+
...reapLikeNotification,
|
|
371
|
+
]);
|
|
372
|
+
|
|
373
|
+
if (!isLocal(apId, baseUrl)) {
|
|
374
|
+
const undoObject = like.activityApId
|
|
375
|
+
? like.activityApId
|
|
376
|
+
: { type: "Like", actor: actor.ap_id, object: apId };
|
|
377
|
+
const undoLikeActivity = {
|
|
378
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
379
|
+
id: activityApId(baseUrl, generateId()),
|
|
380
|
+
type: "Undo",
|
|
381
|
+
actor: actor.ap_id,
|
|
382
|
+
object: undoObject,
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// Store activity first (queue consumer loads rawJson by activityId).
|
|
386
|
+
// Upsert: check existence then insert if not found
|
|
387
|
+
const existingActivity = await db
|
|
388
|
+
.select()
|
|
389
|
+
.from(activities)
|
|
390
|
+
.where(eq(activities.apId, undoLikeActivity.id))
|
|
391
|
+
.get();
|
|
392
|
+
|
|
393
|
+
if (!existingActivity) {
|
|
394
|
+
await db.insert(activities).values({
|
|
395
|
+
apId: undoLikeActivity.id,
|
|
396
|
+
type: "Undo",
|
|
397
|
+
actorApId: actor.ap_id,
|
|
398
|
+
objectApId: apId,
|
|
399
|
+
rawJson: JSON.stringify(undoLikeActivity),
|
|
400
|
+
direction: "outbound",
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
await enqueueDeliveryToActor(
|
|
406
|
+
c.env,
|
|
407
|
+
undoLikeActivity.id,
|
|
408
|
+
story.attributedTo,
|
|
409
|
+
);
|
|
410
|
+
} catch (e) {
|
|
411
|
+
log.error("Failed to enqueue Undo Like for story", {
|
|
412
|
+
event: "stories.unlike.enqueue_failed",
|
|
413
|
+
storyApId: apId,
|
|
414
|
+
recipient: story.attributedTo,
|
|
415
|
+
error: e,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return c.json({
|
|
421
|
+
success: true,
|
|
422
|
+
liked: false,
|
|
423
|
+
like_count: Math.max(0, story.likeCount - 1),
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// Share a story (track that user shared it)
|
|
428
|
+
stories.post("/:id/share", async (c) => {
|
|
429
|
+
const actor = c.get("actor");
|
|
430
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
431
|
+
|
|
432
|
+
const db = c.get("db");
|
|
433
|
+
const baseUrl = c.env.APP_URL;
|
|
434
|
+
const apId = resolveStoryApId(c.req.param("id"), baseUrl);
|
|
435
|
+
|
|
436
|
+
const story = await findStory(db, apId);
|
|
437
|
+
if (!story) return c.json({ error: "Story not found" }, 404);
|
|
438
|
+
|
|
439
|
+
const nowIso = new Date().toISOString();
|
|
440
|
+
if (story.endTime && story.endTime < nowIso) {
|
|
441
|
+
return c.json({ error: "Story has expired" }, 410);
|
|
442
|
+
}
|
|
443
|
+
// A blocked actor must not be able to share (inflating the author's shareCount)
|
|
444
|
+
// — parity with the like/vote/view gates.
|
|
445
|
+
if (await actorIsBlockedBy(db, story.attributedTo, actor.ap_id)) {
|
|
446
|
+
return c.json({ error: "Story not found" }, 404);
|
|
447
|
+
}
|
|
448
|
+
// Re-sharing is only for a story the actor can see — never a followers-only /
|
|
449
|
+
// community story they were not shown.
|
|
450
|
+
if (!(await canViewerReadStory(db, story, actor.ap_id))) {
|
|
451
|
+
return c.json({ error: "Story not found" }, 404);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const existing = await db
|
|
455
|
+
.select()
|
|
456
|
+
.from(storyShares)
|
|
457
|
+
.where(
|
|
458
|
+
and(
|
|
459
|
+
eq(storyShares.storyApId, apId),
|
|
460
|
+
eq(storyShares.actorApId, actor.ap_id),
|
|
461
|
+
),
|
|
462
|
+
)
|
|
463
|
+
.get();
|
|
464
|
+
if (existing) {
|
|
465
|
+
return c.json({
|
|
466
|
+
success: true,
|
|
467
|
+
shared: true,
|
|
468
|
+
share_count: story.shareCount || 0,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const now = new Date().toISOString();
|
|
473
|
+
|
|
474
|
+
// Co-commit the share edge + count bump in one batch (crash-safe; the bare
|
|
475
|
+
// insert rolls the batch back on a duplicate).
|
|
476
|
+
try {
|
|
477
|
+
await (db as unknown as Batchable).batch([
|
|
478
|
+
db.insert(storyShares).values({
|
|
479
|
+
id: generateId(),
|
|
480
|
+
storyApId: apId,
|
|
481
|
+
actorApId: actor.ap_id,
|
|
482
|
+
sharedAt: now,
|
|
483
|
+
}),
|
|
484
|
+
db
|
|
485
|
+
.update(objects)
|
|
486
|
+
.set({ shareCount: sql`${objects.shareCount} + 1` })
|
|
487
|
+
.where(eq(objects.apId, apId)),
|
|
488
|
+
]);
|
|
489
|
+
} catch (e) {
|
|
490
|
+
// Concurrent share won the race past the existing-check; idempotent success.
|
|
491
|
+
// The share now provably exists, so echo the incremented count (matching the
|
|
492
|
+
// success path) rather than the pre-read value.
|
|
493
|
+
if (isUniqueConstraintError(e)) {
|
|
494
|
+
return c.json({
|
|
495
|
+
success: true,
|
|
496
|
+
shared: true,
|
|
497
|
+
share_count: (story.shareCount || 0) + 1,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
throw e;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return c.json({
|
|
504
|
+
success: true,
|
|
505
|
+
shared: true,
|
|
506
|
+
share_count: (story.shareCount || 0) + 1,
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// Get share count for a story
|
|
511
|
+
stories.get("/:id/shares", async (c) => {
|
|
512
|
+
const db = c.get("db");
|
|
513
|
+
const actor = c.get("actor");
|
|
514
|
+
const baseUrl = c.env.APP_URL;
|
|
515
|
+
const apId = resolveStoryApId(c.req.param("id"), baseUrl);
|
|
516
|
+
|
|
517
|
+
// Full row (not just shareCount) so the visibility gate can read
|
|
518
|
+
// attributedTo / communityApId, mirroring the view/vote/like handlers.
|
|
519
|
+
const story = await findStory(db, apId);
|
|
520
|
+
if (!story) return c.json({ error: "Story not found" }, 404);
|
|
521
|
+
if (!(await canViewerReadStory(db, story, actor?.ap_id))) {
|
|
522
|
+
return c.json({ error: "Story not found" }, 404);
|
|
523
|
+
}
|
|
524
|
+
// Don't serve aggregates for an expired story before the reaper runs,
|
|
525
|
+
// mirroring the write handlers' expiry gate.
|
|
526
|
+
if (story.endTime && story.endTime < new Date().toISOString()) {
|
|
527
|
+
return c.json({ error: "Story has expired" }, 410);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return c.json({ share_count: story.shareCount || 0 });
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// Get votes for a story
|
|
534
|
+
stories.get("/:id/votes", async (c) => {
|
|
535
|
+
const db = c.get("db");
|
|
536
|
+
const actor = c.get("actor");
|
|
537
|
+
const baseUrl = c.env.APP_URL;
|
|
538
|
+
// Resolve the id the same way every sibling /:id/* route does, so a full
|
|
539
|
+
// story ap_id (the form the like/share routes accept) is matched instead of
|
|
540
|
+
// being double-prefixed into a non-existent objects row.
|
|
541
|
+
const apId = resolveStoryApId(c.req.param("id"), baseUrl);
|
|
542
|
+
|
|
543
|
+
const story = await findStory(db, apId);
|
|
544
|
+
if (!story) return c.json({ error: "Story not found" }, 404);
|
|
545
|
+
// Gate the poll tally behind the same visibility rule as the view/vote
|
|
546
|
+
// handlers — a non-follower / non-member (or anonymous) must not read the
|
|
547
|
+
// tally of a followers-only / private-community story. 404 (not 403) so the
|
|
548
|
+
// gate isn't a story-existence oracle.
|
|
549
|
+
if (!(await canViewerReadStory(db, story, actor?.ap_id))) {
|
|
550
|
+
return c.json({ error: "Story not found" }, 404);
|
|
551
|
+
}
|
|
552
|
+
if (story.endTime && story.endTime < new Date().toISOString()) {
|
|
553
|
+
return c.json({ error: "Story has expired" }, 410);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const votes = await getVoteCounts(db, apId);
|
|
557
|
+
|
|
558
|
+
let user_vote: number | undefined;
|
|
559
|
+
if (actor) {
|
|
560
|
+
const userVote = await db
|
|
561
|
+
.select({ optionIndex: storyVotes.optionIndex })
|
|
562
|
+
.from(storyVotes)
|
|
563
|
+
.where(
|
|
564
|
+
and(
|
|
565
|
+
eq(storyVotes.storyApId, apId),
|
|
566
|
+
eq(storyVotes.actorApId, actor.ap_id),
|
|
567
|
+
),
|
|
568
|
+
)
|
|
569
|
+
.get();
|
|
570
|
+
if (userVote) user_vote = userVote.optionIndex;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return c.json({ votes, total: sumVotes(votes), user_vote });
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
export default stories;
|