@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,479 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { and, desc, eq, isNull, sql } from "drizzle-orm";
|
|
3
|
+
import {
|
|
4
|
+
activities,
|
|
5
|
+
communities,
|
|
6
|
+
communityMembers,
|
|
7
|
+
objectRecipients,
|
|
8
|
+
objects,
|
|
9
|
+
} from "../../../db/index.ts";
|
|
10
|
+
import type { Env, Variables } from "../../types.ts";
|
|
11
|
+
import { formatUsername, generateId } from "../../federation-helpers.ts";
|
|
12
|
+
import { feedCursorWhere } from "../../lib/feed-cursor.ts";
|
|
13
|
+
import { communityRequiresMembership } from "../../lib/community-visibility.ts";
|
|
14
|
+
import { rateLimit, RateLimitConfigs } from "../../middleware/rate-limit.ts";
|
|
15
|
+
import {
|
|
16
|
+
deleteObjectCascade,
|
|
17
|
+
purgeMediaBlobs,
|
|
18
|
+
} from "../posts/delete-cascade.ts";
|
|
19
|
+
import {
|
|
20
|
+
batchLoadActorInfo,
|
|
21
|
+
communityWhere,
|
|
22
|
+
fetchCommunityId,
|
|
23
|
+
managerRoles,
|
|
24
|
+
memberWhere,
|
|
25
|
+
resolveCommunityApId,
|
|
26
|
+
} from "./membership-shared.ts";
|
|
27
|
+
|
|
28
|
+
const MAX_COMMUNITY_MESSAGE_LENGTH = 5000;
|
|
29
|
+
const MAX_COMMUNITY_MESSAGES_LIMIT = 100;
|
|
30
|
+
|
|
31
|
+
// D1's batch() (atomic multi-statement) is only on the concrete D1/libsql
|
|
32
|
+
// driver, not the shared `Database` union; reach it through a narrow cast.
|
|
33
|
+
type Batchable = {
|
|
34
|
+
batch(statements: readonly unknown[]): Promise<unknown>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const messagesRouter = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Enforce post policy against the actor's membership and role.
|
|
41
|
+
* Returns an error message string if denied, or null if allowed.
|
|
42
|
+
*
|
|
43
|
+
* This is a WRITE policy and must only gate POST/PATCH. Read access is
|
|
44
|
+
* governed separately by `checkReadAccess` (membership / `visibility`), so
|
|
45
|
+
* that e.g. a `post_policy: "mods"` community does not lock ordinary members
|
|
46
|
+
* out of reading channels they are entitled to see.
|
|
47
|
+
*/
|
|
48
|
+
function checkPostPolicy(
|
|
49
|
+
policy: string,
|
|
50
|
+
visibility: string,
|
|
51
|
+
membership: { role: string } | null,
|
|
52
|
+
): string | null {
|
|
53
|
+
const role = membership?.role;
|
|
54
|
+
const isManager = role != null && managerRoles.has(role);
|
|
55
|
+
|
|
56
|
+
// A non-public community requires membership to WRITE regardless of policy:
|
|
57
|
+
// read is membership-gated (checkReadAccess), so a private community with
|
|
58
|
+
// post_policy="anyone" must not let a non-member who cannot read it post.
|
|
59
|
+
if (communityRequiresMembership(visibility) && !membership)
|
|
60
|
+
return "Not a community member";
|
|
61
|
+
if (policy !== "anyone" && !membership) return "Not a community member";
|
|
62
|
+
if (policy === "mods" && !isManager) return "Moderator role required";
|
|
63
|
+
if (policy === "owners" && role !== "owner") return "Owner role required";
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Authorize READING community messages based on `visibility` and membership,
|
|
69
|
+
* independent of who may post. Public communities are readable by anyone;
|
|
70
|
+
* private communities require membership. Returns an error message string if
|
|
71
|
+
* denied, or null if allowed.
|
|
72
|
+
*/
|
|
73
|
+
function checkReadAccess(
|
|
74
|
+
visibility: string,
|
|
75
|
+
membership: { role: string } | null,
|
|
76
|
+
): string | null {
|
|
77
|
+
if (!communityRequiresMembership(visibility)) return null;
|
|
78
|
+
if (!membership) return "Not a community member";
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// GET /api/communities/:name/messages - Get chat messages
|
|
83
|
+
messagesRouter.get("/:identifier/messages", async (c) => {
|
|
84
|
+
const actor = c.get("actor");
|
|
85
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
86
|
+
|
|
87
|
+
const identifier = c.req.param("identifier")!;
|
|
88
|
+
const db = c.get("db");
|
|
89
|
+
const baseUrl = c.env.APP_URL;
|
|
90
|
+
const apId = resolveCommunityApId(baseUrl, identifier);
|
|
91
|
+
const rawLimit = parseInt(c.req.query("limit") || "50", 10);
|
|
92
|
+
const limit = Number.isFinite(rawLimit)
|
|
93
|
+
? Math.min(Math.max(rawLimit, 1), MAX_COMMUNITY_MESSAGES_LIMIT)
|
|
94
|
+
: 50;
|
|
95
|
+
const before = c.req.query("before");
|
|
96
|
+
|
|
97
|
+
const community = await db
|
|
98
|
+
.select()
|
|
99
|
+
.from(communities)
|
|
100
|
+
.where(communityWhere(apId, identifier))
|
|
101
|
+
.get();
|
|
102
|
+
if (!community) {
|
|
103
|
+
return c.json({ error: "Community not found" }, 404);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const membership = await db
|
|
107
|
+
.select()
|
|
108
|
+
.from(communityMembers)
|
|
109
|
+
.where(memberWhere(community.apId, actor.ap_id))
|
|
110
|
+
.get();
|
|
111
|
+
|
|
112
|
+
// Read access is governed by community visibility + membership, NOT by the
|
|
113
|
+
// post policy (which only controls who may write).
|
|
114
|
+
const readError = checkReadAccess(
|
|
115
|
+
community.visibility || "public",
|
|
116
|
+
membership ?? null,
|
|
117
|
+
);
|
|
118
|
+
if (readError) {
|
|
119
|
+
return c.json({ error: readError }, 403);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Query objects addressed to this community (via object_recipients) with a
|
|
123
|
+
// single INNER JOIN, NOT a two-step "load every recipient id then IN (...)".
|
|
124
|
+
// The old shape materialized the community's ENTIRE chat history into an
|
|
125
|
+
// `objectApIds` array and an `inArray` bound-parameter list every request:
|
|
126
|
+
// memory O(all messages) and — past SQLite's bound-variable ceiling — a hard
|
|
127
|
+
// error on a busy channel. The join filters on the indexed `recipient_ap_id`
|
|
128
|
+
// and pages with LIMIT, so the work is bounded by the page size.
|
|
129
|
+
//
|
|
130
|
+
// The group-chat reader must return CHAT messages only, not community feed
|
|
131
|
+
// posts. Feed posts are stored with `communityApId` set (and are surfaced by
|
|
132
|
+
// the community-scoped feed), whereas chat messages are addressed purely via
|
|
133
|
+
// object_recipients and leave `communityApId` NULL. Filtering on
|
|
134
|
+
// `communityApId IS NULL` keeps the chat object-set disjoint from the feed
|
|
135
|
+
// object-set, matching the unread count in GET /dm/contacts.
|
|
136
|
+
const whereConditions = [
|
|
137
|
+
eq(objectRecipients.recipientApId, community.apId),
|
|
138
|
+
eq(objectRecipients.type, "audience"),
|
|
139
|
+
eq(objects.type, "Note"),
|
|
140
|
+
isNull(objects.communityApId),
|
|
141
|
+
];
|
|
142
|
+
// Composite (published, apId) cursor so same-millisecond messages aren't
|
|
143
|
+
// skipped on a load-older boundary (see lib/feed-cursor.ts).
|
|
144
|
+
const chatCursor = feedCursorWhere(objects.published, objects.apId, before);
|
|
145
|
+
if (chatCursor) whereConditions.push(chatCursor);
|
|
146
|
+
|
|
147
|
+
// Fetch one extra to detect whether an OLDER page exists (powers the thread's
|
|
148
|
+
// "load older" affordance; `before` is the oldest shown message's cursor).
|
|
149
|
+
const fetched = await db
|
|
150
|
+
.select({
|
|
151
|
+
apId: objects.apId,
|
|
152
|
+
attributedTo: objects.attributedTo,
|
|
153
|
+
content: objects.content,
|
|
154
|
+
published: objects.published,
|
|
155
|
+
})
|
|
156
|
+
.from(objectRecipients)
|
|
157
|
+
.innerJoin(objects, eq(objectRecipients.objectApId, objects.apId))
|
|
158
|
+
.where(and(...whereConditions))
|
|
159
|
+
.orderBy(desc(objects.published), desc(objects.apId))
|
|
160
|
+
.limit(limit + 1);
|
|
161
|
+
const hasMore = fetched.length > limit;
|
|
162
|
+
const messages = hasMore ? fetched.slice(0, limit) : fetched;
|
|
163
|
+
|
|
164
|
+
const senderApIds = [...new Set(messages.map((msg) => msg.attributedTo))];
|
|
165
|
+
const actorInfoMap = await batchLoadActorInfo(db, senderApIds);
|
|
166
|
+
|
|
167
|
+
const result = messages.reverse().map((msg) => {
|
|
168
|
+
const senderInfo = actorInfoMap.get(msg.attributedTo);
|
|
169
|
+
return {
|
|
170
|
+
id: msg.apId,
|
|
171
|
+
sender: {
|
|
172
|
+
ap_id: msg.attributedTo,
|
|
173
|
+
username: formatUsername(msg.attributedTo),
|
|
174
|
+
preferred_username: senderInfo?.preferredUsername || null,
|
|
175
|
+
name: senderInfo?.name || null,
|
|
176
|
+
icon_url: senderInfo?.iconUrl || null,
|
|
177
|
+
},
|
|
178
|
+
content: msg.content,
|
|
179
|
+
created_at: msg.published,
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
return c.json({ messages: result, has_more: hasMore });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// POST /api/communities/:name/messages - Send a chat message
|
|
187
|
+
// Rate-limited as a publish-like write (per-actor), not under the general bucket.
|
|
188
|
+
messagesRouter.post(
|
|
189
|
+
"/:identifier/messages",
|
|
190
|
+
rateLimit(RateLimitConfigs.communityMessage),
|
|
191
|
+
async (c) => {
|
|
192
|
+
const actor = c.get("actor");
|
|
193
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
194
|
+
|
|
195
|
+
const identifier = c.req.param("identifier")!;
|
|
196
|
+
const db = c.get("db");
|
|
197
|
+
const baseUrl = c.env.APP_URL;
|
|
198
|
+
const apId = resolveCommunityApId(baseUrl, identifier);
|
|
199
|
+
const body = await c.req.json<{ content: string }>();
|
|
200
|
+
|
|
201
|
+
// Guard non-string content before .trim() (else TypeError → 500).
|
|
202
|
+
if (typeof body.content !== "string") {
|
|
203
|
+
return c.json({ error: "Message content is required" }, 400);
|
|
204
|
+
}
|
|
205
|
+
const content = body.content.trim();
|
|
206
|
+
if (!content) {
|
|
207
|
+
return c.json({ error: "Message content is required" }, 400);
|
|
208
|
+
}
|
|
209
|
+
if (content.length > MAX_COMMUNITY_MESSAGE_LENGTH) {
|
|
210
|
+
return c.json(
|
|
211
|
+
{
|
|
212
|
+
error: `Message too long (max ${MAX_COMMUNITY_MESSAGE_LENGTH} chars)`,
|
|
213
|
+
},
|
|
214
|
+
400,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const community = await db
|
|
219
|
+
.select()
|
|
220
|
+
.from(communities)
|
|
221
|
+
.where(communityWhere(apId, identifier))
|
|
222
|
+
.get();
|
|
223
|
+
if (!community) {
|
|
224
|
+
return c.json({ error: "Community not found" }, 404);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const membership = await db
|
|
228
|
+
.select()
|
|
229
|
+
.from(communityMembers)
|
|
230
|
+
.where(memberWhere(community.apId, actor.ap_id))
|
|
231
|
+
.get();
|
|
232
|
+
|
|
233
|
+
const policyError = checkPostPolicy(
|
|
234
|
+
community.postPolicy || "members",
|
|
235
|
+
community.visibility || "public",
|
|
236
|
+
membership ?? null,
|
|
237
|
+
);
|
|
238
|
+
if (policyError) {
|
|
239
|
+
return c.json(
|
|
240
|
+
{
|
|
241
|
+
error:
|
|
242
|
+
policyError === "Not a community member"
|
|
243
|
+
? "Not a member"
|
|
244
|
+
: policyError,
|
|
245
|
+
},
|
|
246
|
+
403,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const objectId = generateId();
|
|
251
|
+
const objectApId = `${baseUrl}/ap/objects/${objectId}`;
|
|
252
|
+
const now = new Date().toISOString();
|
|
253
|
+
|
|
254
|
+
const toJson = JSON.stringify([community.apId]);
|
|
255
|
+
const audienceJson = JSON.stringify([community.apId]);
|
|
256
|
+
|
|
257
|
+
const activityId = generateId();
|
|
258
|
+
const activityApIdVal = `${baseUrl}/ap/activities/${activityId}`;
|
|
259
|
+
|
|
260
|
+
// Persist the chat message atomically: the Note, its community-audience
|
|
261
|
+
// recipient row (which the GET-messages reader joins on), the Create
|
|
262
|
+
// activity, and the community's lastMessageAt. D1 has no interactive
|
|
263
|
+
// transactions; batch() is atomic, so a mid-write failure can no longer
|
|
264
|
+
// leave an orphan Note with no recipient row. The recipient row is a plain
|
|
265
|
+
// Drizzle insert now that recipient_ap_id no longer has a (wrong) FK to
|
|
266
|
+
// actors — a community apId is a valid recipient (migration 0010).
|
|
267
|
+
await (db as unknown as Batchable).batch([
|
|
268
|
+
db.insert(objects).values({
|
|
269
|
+
apId: objectApId,
|
|
270
|
+
type: "Note",
|
|
271
|
+
attributedTo: actor.ap_id,
|
|
272
|
+
content,
|
|
273
|
+
toJson,
|
|
274
|
+
audienceJson,
|
|
275
|
+
visibility: "unlisted",
|
|
276
|
+
published: now,
|
|
277
|
+
isLocal: 1,
|
|
278
|
+
}),
|
|
279
|
+
db.insert(objectRecipients).values({
|
|
280
|
+
objectApId,
|
|
281
|
+
recipientApId: community.apId,
|
|
282
|
+
type: "audience",
|
|
283
|
+
createdAt: now,
|
|
284
|
+
}),
|
|
285
|
+
db.insert(activities).values({
|
|
286
|
+
apId: activityApIdVal,
|
|
287
|
+
type: "Create",
|
|
288
|
+
actorApId: actor.ap_id,
|
|
289
|
+
objectApId,
|
|
290
|
+
rawJson: JSON.stringify({ to: JSON.parse(toJson) }),
|
|
291
|
+
}),
|
|
292
|
+
db
|
|
293
|
+
.update(communities)
|
|
294
|
+
.set({ lastMessageAt: now })
|
|
295
|
+
.where(eq(communities.apId, community.apId)),
|
|
296
|
+
]);
|
|
297
|
+
|
|
298
|
+
return c.json(
|
|
299
|
+
{
|
|
300
|
+
message: {
|
|
301
|
+
id: objectApId,
|
|
302
|
+
sender: {
|
|
303
|
+
ap_id: actor.ap_id,
|
|
304
|
+
username: formatUsername(actor.ap_id),
|
|
305
|
+
preferred_username: actor.preferred_username,
|
|
306
|
+
name: actor.name,
|
|
307
|
+
icon_url: actor.icon_url,
|
|
308
|
+
},
|
|
309
|
+
content,
|
|
310
|
+
created_at: now,
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
201,
|
|
314
|
+
);
|
|
315
|
+
},
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
// PATCH /api/communities/:identifier/messages/:messageId - Edit a message
|
|
319
|
+
messagesRouter.patch("/:identifier/messages/:messageId", async (c) => {
|
|
320
|
+
const actor = c.get("actor");
|
|
321
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
322
|
+
|
|
323
|
+
const identifier = c.req.param("identifier")!;
|
|
324
|
+
const messageId = decodeURIComponent(c.req.param("messageId")!);
|
|
325
|
+
const db = c.get("db");
|
|
326
|
+
|
|
327
|
+
const { community } = await fetchCommunityId(c, identifier);
|
|
328
|
+
if (!community) {
|
|
329
|
+
return c.json({ error: "Community not found" }, 404);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const body = await c.req.json<{ content: string }>();
|
|
333
|
+
// Guard non-string content before .trim() (else TypeError → 500).
|
|
334
|
+
if (typeof body.content !== "string") {
|
|
335
|
+
return c.json({ error: "Message content is required" }, 400);
|
|
336
|
+
}
|
|
337
|
+
const content = body.content.trim();
|
|
338
|
+
if (!content) {
|
|
339
|
+
return c.json({ error: "Message content is required" }, 400);
|
|
340
|
+
}
|
|
341
|
+
if (content.length > MAX_COMMUNITY_MESSAGE_LENGTH) {
|
|
342
|
+
return c.json(
|
|
343
|
+
{
|
|
344
|
+
error: `Message too long (max ${MAX_COMMUNITY_MESSAGE_LENGTH} chars)`,
|
|
345
|
+
},
|
|
346
|
+
400,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Check message exists and belongs to community (using raw SQL since ObjectRecipient FK expects Actor)
|
|
351
|
+
const recipientRows = await db.all<{ object_ap_id: string }>(sql`
|
|
352
|
+
SELECT object_ap_id FROM object_recipients
|
|
353
|
+
WHERE object_ap_id = ${messageId} AND recipient_ap_id = ${community.apId} AND type = 'audience'
|
|
354
|
+
LIMIT 1
|
|
355
|
+
`);
|
|
356
|
+
if (recipientRows.length === 0) {
|
|
357
|
+
return c.json({ error: "Message not found" }, 404);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const message = await db
|
|
361
|
+
.select({
|
|
362
|
+
apId: objects.apId,
|
|
363
|
+
attributedTo: objects.attributedTo,
|
|
364
|
+
})
|
|
365
|
+
.from(objects)
|
|
366
|
+
.where(eq(objects.apId, messageId))
|
|
367
|
+
.get();
|
|
368
|
+
if (!message) {
|
|
369
|
+
return c.json({ error: "Message not found" }, 404);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Membership / read gate: a kicked or never-member actor must NOT mutate chat
|
|
373
|
+
// content. The author check below is not sufficient — a removed author keeps
|
|
374
|
+
// their message id + authorship and could otherwise keep rewriting a private
|
|
375
|
+
// community's history. Mirrors the GET reader (checkReadAccess) + POST gate.
|
|
376
|
+
const communityRow = await db
|
|
377
|
+
.select({ visibility: communities.visibility })
|
|
378
|
+
.from(communities)
|
|
379
|
+
.where(eq(communities.apId, community.apId))
|
|
380
|
+
.get();
|
|
381
|
+
const membership = await db
|
|
382
|
+
.select({ role: communityMembers.role })
|
|
383
|
+
.from(communityMembers)
|
|
384
|
+
.where(memberWhere(community.apId, actor.ap_id))
|
|
385
|
+
.get();
|
|
386
|
+
const readError = checkReadAccess(
|
|
387
|
+
communityRow?.visibility || "public",
|
|
388
|
+
membership ?? null,
|
|
389
|
+
);
|
|
390
|
+
if (readError) return c.json({ error: readError }, 403);
|
|
391
|
+
|
|
392
|
+
if (message.attributedTo !== actor.ap_id) {
|
|
393
|
+
return c.json({ error: "Only the author can edit this message" }, 403);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
await db
|
|
397
|
+
.update(objects)
|
|
398
|
+
.set({ content, updated: new Date().toISOString() })
|
|
399
|
+
.where(eq(objects.apId, messageId));
|
|
400
|
+
|
|
401
|
+
return c.json({ success: true });
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// DELETE /api/communities/:identifier/messages/:messageId - Delete a message
|
|
405
|
+
messagesRouter.delete("/:identifier/messages/:messageId", async (c) => {
|
|
406
|
+
const actor = c.get("actor");
|
|
407
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
408
|
+
|
|
409
|
+
const identifier = c.req.param("identifier")!;
|
|
410
|
+
const messageId = decodeURIComponent(c.req.param("messageId")!);
|
|
411
|
+
const db = c.get("db");
|
|
412
|
+
|
|
413
|
+
const { community } = await fetchCommunityId(c, identifier);
|
|
414
|
+
if (!community) {
|
|
415
|
+
return c.json({ error: "Community not found" }, 404);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Check message exists and belongs to community
|
|
419
|
+
const recipientsForDelete = await db.all<{ object_ap_id: string }>(sql`
|
|
420
|
+
SELECT object_ap_id FROM object_recipients
|
|
421
|
+
WHERE object_ap_id = ${messageId} AND recipient_ap_id = ${community.apId} AND type = 'audience'
|
|
422
|
+
LIMIT 1
|
|
423
|
+
`);
|
|
424
|
+
if (recipientsForDelete.length === 0) {
|
|
425
|
+
return c.json({ error: "Message not found" }, 404);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const message = await db
|
|
429
|
+
.select({
|
|
430
|
+
apId: objects.apId,
|
|
431
|
+
attributedTo: objects.attributedTo,
|
|
432
|
+
})
|
|
433
|
+
.from(objects)
|
|
434
|
+
.where(eq(objects.apId, messageId))
|
|
435
|
+
.get();
|
|
436
|
+
if (!message) {
|
|
437
|
+
return c.json({ error: "Message not found" }, 404);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Check permission: author can delete, or moderator/owner can delete any
|
|
441
|
+
const membership = await db
|
|
442
|
+
.select()
|
|
443
|
+
.from(communityMembers)
|
|
444
|
+
.where(memberWhere(community.apId, actor.ap_id))
|
|
445
|
+
.get();
|
|
446
|
+
|
|
447
|
+
// Membership / read gate first: a kicked or never-member author must not keep
|
|
448
|
+
// deleting a private community's history. (A manager is always a member.)
|
|
449
|
+
const communityRow = await db
|
|
450
|
+
.select({ visibility: communities.visibility })
|
|
451
|
+
.from(communities)
|
|
452
|
+
.where(eq(communities.apId, community.apId))
|
|
453
|
+
.get();
|
|
454
|
+
const readError = checkReadAccess(
|
|
455
|
+
communityRow?.visibility || "public",
|
|
456
|
+
membership ?? null,
|
|
457
|
+
);
|
|
458
|
+
if (readError) return c.json({ error: readError }, 403);
|
|
459
|
+
|
|
460
|
+
const isAuthor = message.attributedTo === actor.ap_id;
|
|
461
|
+
const isManager = membership && managerRoles.has(membership.role);
|
|
462
|
+
|
|
463
|
+
if (!isAuthor && !isManager) {
|
|
464
|
+
return c.json({ error: "Permission denied" }, 403);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Route through the shared object cascade so a chat message's interaction rows
|
|
468
|
+
// (likes/announces/bookmarks/object_recipients/story_*) and any attached R2
|
|
469
|
+
// blob + media_uploads row are reaped deterministically — the runtime's
|
|
470
|
+
// ON DELETE CASCADE is not relied upon (self-host libsql may run with FK
|
|
471
|
+
// enforcement off). Then drop the object row and purge blobs last.
|
|
472
|
+
const mediaKeys = await deleteObjectCascade(db, messageId, c.env.MEDIA);
|
|
473
|
+
await db.delete(objects).where(eq(objects.apId, messageId));
|
|
474
|
+
await purgeMediaBlobs(c.env.MEDIA, mediaKeys);
|
|
475
|
+
|
|
476
|
+
return c.json({ success: true });
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
export default messagesRouter;
|