@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,354 @@
|
|
|
1
|
+
import { and, eq, isNull, or, sql } from "drizzle-orm";
|
|
2
|
+
import {
|
|
3
|
+
activities,
|
|
4
|
+
communities,
|
|
5
|
+
communityMembers,
|
|
6
|
+
follows,
|
|
7
|
+
objectRecipients,
|
|
8
|
+
objects,
|
|
9
|
+
} from "../../../../db/index.ts";
|
|
10
|
+
import {
|
|
11
|
+
activityApId,
|
|
12
|
+
generateId,
|
|
13
|
+
getDomain,
|
|
14
|
+
isLocal,
|
|
15
|
+
objectApId,
|
|
16
|
+
} from "../../../federation-helpers.ts";
|
|
17
|
+
import { enqueueDeliveryToActor } from "../../../lib/delivery/queue.ts";
|
|
18
|
+
import { communityRequiresMembership } from "../../../lib/community-visibility.ts";
|
|
19
|
+
import { isMemberBanned } from "../../communities/membership-shared.ts";
|
|
20
|
+
import {
|
|
21
|
+
boundAttachmentsJson,
|
|
22
|
+
boundInboundContent,
|
|
23
|
+
boundInboundSummary,
|
|
24
|
+
} from "../../posts/transformers.ts";
|
|
25
|
+
import { normalizeInboundTimestamp } from "./inbound-timestamp.ts";
|
|
26
|
+
import type { InstanceActorResult } from "../query-helpers.ts";
|
|
27
|
+
import {
|
|
28
|
+
type Activity,
|
|
29
|
+
type ActivityContext,
|
|
30
|
+
getActivityObject,
|
|
31
|
+
getActivityObjectId,
|
|
32
|
+
typeIncludes,
|
|
33
|
+
} from "../inbox-types.ts";
|
|
34
|
+
|
|
35
|
+
const AS_CONTEXT = "https://www.w3.org/ns/activitystreams";
|
|
36
|
+
|
|
37
|
+
const JOIN_POLICY_STATUS: Record<string, "accepted" | "pending" | "rejected"> =
|
|
38
|
+
{
|
|
39
|
+
approval: "pending",
|
|
40
|
+
invite: "rejected",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Minimal shape of a Group-style actor whose inbox accepts Follows: the
|
|
45
|
+
* instance actor AND any community Group share this handler. Only the apId
|
|
46
|
+
* (the thing being followed / signed as) and the joinPolicy (auto-accept vs
|
|
47
|
+
* hold pending) are needed, so both `InstanceActorResult` and a loaded
|
|
48
|
+
* community satisfy it structurally.
|
|
49
|
+
*/
|
|
50
|
+
export interface GroupFollowTarget {
|
|
51
|
+
apId: string;
|
|
52
|
+
joinPolicy?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function handleGroupFollow(
|
|
56
|
+
c: ActivityContext,
|
|
57
|
+
_activity: Activity,
|
|
58
|
+
group: GroupFollowTarget,
|
|
59
|
+
actorApIdStr: string,
|
|
60
|
+
baseUrl: string,
|
|
61
|
+
activityId: string,
|
|
62
|
+
) {
|
|
63
|
+
const db = c.get("db");
|
|
64
|
+
const followerKey = {
|
|
65
|
+
followerApId: actorApIdStr,
|
|
66
|
+
followingApId: group.apId,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const existing = await db.query.follows.findFirst({
|
|
70
|
+
where: and(
|
|
71
|
+
eq(follows.followerApId, followerKey.followerApId),
|
|
72
|
+
eq(follows.followingApId, followerKey.followingApId),
|
|
73
|
+
),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// A banned actor re-Following an OPEN community must NOT auto-rejoin: force a
|
|
77
|
+
// Reject and record no edge (durable kick — mirrors the local open-join ban
|
|
78
|
+
// gate). Only the AUTO-ACCEPT path is blocked; an approval community still
|
|
79
|
+
// lets the Follow go pending so a moderator can re-admit (which lifts the ban).
|
|
80
|
+
const policyStatus = JOIN_POLICY_STATUS[group.joinPolicy ?? ""] ?? "accepted";
|
|
81
|
+
const banned =
|
|
82
|
+
!existing &&
|
|
83
|
+
policyStatus === "accepted" &&
|
|
84
|
+
(await isMemberBanned(db, group.apId, actorApIdStr));
|
|
85
|
+
|
|
86
|
+
const status = existing
|
|
87
|
+
? existing.status
|
|
88
|
+
: banned
|
|
89
|
+
? "rejected"
|
|
90
|
+
: policyStatus;
|
|
91
|
+
|
|
92
|
+
if (!existing && !banned) {
|
|
93
|
+
const now = new Date().toISOString();
|
|
94
|
+
// onConflictDoNothing: the same Follow delivered concurrently to two inbox
|
|
95
|
+
// endpoints (shared + per-actor) can re-claim an in-flight dispatch and run
|
|
96
|
+
// this insert twice; without it the concurrent loser throws a PK violation on
|
|
97
|
+
// (follower_ap_id, following_ap_id) that is caught + logged as noise (#8). The
|
|
98
|
+
// follows edge is idempotent, so silently skipping the duplicate is correct.
|
|
99
|
+
await db
|
|
100
|
+
.insert(follows)
|
|
101
|
+
.values({
|
|
102
|
+
...followerKey,
|
|
103
|
+
status,
|
|
104
|
+
activityApId: activityId,
|
|
105
|
+
acceptedAt: status === "accepted" ? now : null,
|
|
106
|
+
})
|
|
107
|
+
.onConflictDoNothing();
|
|
108
|
+
// An approval-policy community holds the follow PENDING. The pending follows
|
|
109
|
+
// edge IS the remote join request (it cannot be mirrored into
|
|
110
|
+
// community_join_requests, whose actor_ap_id FKs to the local `actors` table a
|
|
111
|
+
// remote follower has no row in). The manager approval surface
|
|
112
|
+
// (GET /requests + POST /requests/accept) therefore also reads pending
|
|
113
|
+
// follows edges to the community.
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (isLocal(actorApIdStr, baseUrl)) return;
|
|
117
|
+
if (status === "pending") return;
|
|
118
|
+
|
|
119
|
+
const responseType = status === "accepted" ? "Accept" : "Reject";
|
|
120
|
+
|
|
121
|
+
// Idempotent (re-)emit of the Accept/Reject. We do NOT early-return on an
|
|
122
|
+
// existing follow: if the follow row was recorded on a prior attempt but the
|
|
123
|
+
// response was LOST (a transient failure between the follow insert and the
|
|
124
|
+
// enqueue left the activity uncommitted, so the remote retried), the old
|
|
125
|
+
// `if (existing) return` suppressed the Accept forever — leaving the remote
|
|
126
|
+
// stuck pending while we record them as accepted. Instead, if a response for
|
|
127
|
+
// THIS Follow already exists just re-enqueue its (dedup'd, durable) delivery;
|
|
128
|
+
// otherwise create + enqueue it.
|
|
129
|
+
const existingResponse = await db.query.activities.findFirst({
|
|
130
|
+
where: and(
|
|
131
|
+
eq(activities.type, responseType),
|
|
132
|
+
eq(activities.actorApId, group.apId),
|
|
133
|
+
eq(activities.objectApId, activityId),
|
|
134
|
+
),
|
|
135
|
+
});
|
|
136
|
+
if (existingResponse) {
|
|
137
|
+
await enqueueDeliveryToActor(c.env, existingResponse.apId, actorApIdStr);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const responseId = activityApId(baseUrl, generateId());
|
|
142
|
+
const responseActivity = {
|
|
143
|
+
"@context": AS_CONTEXT,
|
|
144
|
+
id: responseId,
|
|
145
|
+
type: responseType,
|
|
146
|
+
actor: group.apId,
|
|
147
|
+
object: activityId,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
await db.insert(activities).values({
|
|
151
|
+
apId: responseId,
|
|
152
|
+
type: responseType,
|
|
153
|
+
actorApId: group.apId,
|
|
154
|
+
objectApId: activityId,
|
|
155
|
+
rawJson: JSON.stringify(responseActivity),
|
|
156
|
+
direction: "outbound",
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Outbound delivery must be async (no remote POST in request path). The
|
|
160
|
+
// Accept is signed by `group.apId` — queue-delivery resolves the community /
|
|
161
|
+
// instance-actor key for that apId.
|
|
162
|
+
await enqueueDeliveryToActor(c.env, responseId, actorApIdStr);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function handleGroupUndo(
|
|
166
|
+
c: ActivityContext,
|
|
167
|
+
activity: Activity,
|
|
168
|
+
group: GroupFollowTarget,
|
|
169
|
+
actorApIdStr: string,
|
|
170
|
+
) {
|
|
171
|
+
const db = c.get("db");
|
|
172
|
+
const objectId = getActivityObjectId(activity);
|
|
173
|
+
if (!objectId) return;
|
|
174
|
+
|
|
175
|
+
// Try exact match by activity AP ID first.
|
|
176
|
+
const follow = await db.query.follows.findFirst({
|
|
177
|
+
where: and(
|
|
178
|
+
eq(follows.activityApId, objectId),
|
|
179
|
+
eq(follows.followingApId, group.apId),
|
|
180
|
+
),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (follow) {
|
|
184
|
+
// Bind to the VERIFIED signer: only the follow's own follower may undo it.
|
|
185
|
+
// The activity id is public (it appears in the follower's outbox), so
|
|
186
|
+
// without this check a remote attacker who signs an Undo as any actor on
|
|
187
|
+
// their own domain could resolve a VICTIM's follow by that id and sever the
|
|
188
|
+
// victim's follow/membership edge — the same cross-actor forgery already
|
|
189
|
+
// guarded in the user-inbox undoFollow handler.
|
|
190
|
+
if (follow.followerApId !== actorApIdStr) return;
|
|
191
|
+
await db
|
|
192
|
+
.delete(follows)
|
|
193
|
+
.where(
|
|
194
|
+
and(
|
|
195
|
+
eq(follows.followerApId, follow.followerApId),
|
|
196
|
+
eq(follows.followingApId, follow.followingApId),
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Fallback: if the undone object is a Follow, delete by actor pair — keyed on
|
|
203
|
+
// the verified signer, so it can only remove the signer's OWN follow.
|
|
204
|
+
if (getActivityObject(activity)?.type !== "Follow") return;
|
|
205
|
+
|
|
206
|
+
await db
|
|
207
|
+
.delete(follows)
|
|
208
|
+
.where(
|
|
209
|
+
and(
|
|
210
|
+
eq(follows.followerApId, actorApIdStr),
|
|
211
|
+
eq(follows.followingApId, group.apId),
|
|
212
|
+
),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function handleGroupCreate(
|
|
217
|
+
c: ActivityContext,
|
|
218
|
+
activity: Activity,
|
|
219
|
+
_instanceActor: InstanceActorResult,
|
|
220
|
+
actorApIdStr: string,
|
|
221
|
+
baseUrl: string,
|
|
222
|
+
) {
|
|
223
|
+
const db = c.get("db");
|
|
224
|
+
const object = getActivityObject(activity);
|
|
225
|
+
if (!object || !typeIncludes(object.type, "Note")) return;
|
|
226
|
+
|
|
227
|
+
if (object.id) {
|
|
228
|
+
try {
|
|
229
|
+
if (
|
|
230
|
+
isLocal(object.id, baseUrl) ||
|
|
231
|
+
getDomain(object.id) !== getDomain(actorApIdStr)
|
|
232
|
+
) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const roomUrl = object.room || activity.room;
|
|
241
|
+
if (!roomUrl || typeof roomUrl !== "string") return;
|
|
242
|
+
const match = roomUrl.match(/\/ap\/rooms\/([^\/]+)$/);
|
|
243
|
+
if (!match) return;
|
|
244
|
+
const roomId = match[1];
|
|
245
|
+
|
|
246
|
+
// Resolve the TARGET community (filtered to a live, non-deleted row) and
|
|
247
|
+
// authorize the post against THAT community — not the instance actor. The old
|
|
248
|
+
// gate checked postPolicy + a follow of the single instance Group actor, which
|
|
249
|
+
// every open-policy instance-follower satisfied for EVERY community, letting a
|
|
250
|
+
// non-member inject chat messages into ANY community — including private and
|
|
251
|
+
// soft-deleted ones — that then surfaced to that community's members. Mirror
|
|
252
|
+
// checkCommunityPostPermission's semantics, but with the FEDERATION membership
|
|
253
|
+
// model: a remote member is an accepted Follow of the community Group actor
|
|
254
|
+
// (handleGroupFollow records follows.followingApId = community.apId); a local
|
|
255
|
+
// member additionally has a communityMembers row carrying a role.
|
|
256
|
+
const community = await db
|
|
257
|
+
.select({
|
|
258
|
+
apId: communities.apId,
|
|
259
|
+
postPolicy: communities.postPolicy,
|
|
260
|
+
visibility: communities.visibility,
|
|
261
|
+
})
|
|
262
|
+
.from(communities)
|
|
263
|
+
.where(
|
|
264
|
+
and(
|
|
265
|
+
or(
|
|
266
|
+
eq(communities.preferredUsername, roomId),
|
|
267
|
+
eq(communities.apId, roomId),
|
|
268
|
+
),
|
|
269
|
+
isNull(communities.deletedAt),
|
|
270
|
+
),
|
|
271
|
+
)
|
|
272
|
+
.get();
|
|
273
|
+
if (!community) return;
|
|
274
|
+
|
|
275
|
+
const memberFollow = await db.query.follows.findFirst({
|
|
276
|
+
where: and(
|
|
277
|
+
eq(follows.followerApId, actorApIdStr),
|
|
278
|
+
eq(follows.followingApId, community.apId),
|
|
279
|
+
eq(follows.status, "accepted"),
|
|
280
|
+
),
|
|
281
|
+
});
|
|
282
|
+
const memberRow = await db.query.communityMembers.findFirst({
|
|
283
|
+
where: and(
|
|
284
|
+
eq(communityMembers.communityApId, community.apId),
|
|
285
|
+
eq(communityMembers.actorApId, actorApIdStr),
|
|
286
|
+
),
|
|
287
|
+
columns: { role: true },
|
|
288
|
+
});
|
|
289
|
+
const isMember = Boolean(memberFollow) || Boolean(memberRow);
|
|
290
|
+
const role = memberRow?.role;
|
|
291
|
+
const isManager = role === "owner" || role === "moderator";
|
|
292
|
+
const policy = community.postPolicy || "members";
|
|
293
|
+
|
|
294
|
+
// A non-public community requires membership to post regardless of policy.
|
|
295
|
+
if (communityRequiresMembership(community.visibility) && !isMember) return;
|
|
296
|
+
if (policy !== "anyone" && !isMember) return;
|
|
297
|
+
if (policy === "mods" && !isManager) return;
|
|
298
|
+
if (policy === "owners" && role !== "owner") return;
|
|
299
|
+
|
|
300
|
+
const newObjectId = object.id || objectApId(baseUrl, generateId());
|
|
301
|
+
const existingObj = await db.query.objects.findFirst({
|
|
302
|
+
where: eq(objects.apId, newObjectId),
|
|
303
|
+
});
|
|
304
|
+
if (existingObj) return;
|
|
305
|
+
|
|
306
|
+
const attachments = object.attachment
|
|
307
|
+
? JSON.stringify(object.attachment)
|
|
308
|
+
: "[]";
|
|
309
|
+
// Clamp + normalize the remote-controlled `published` exactly like the other
|
|
310
|
+
// inbound Note paths (handleCreate / insertDirectNote / handleCreateStory). The
|
|
311
|
+
// community chat reader sorts + keyset-paginates on `desc(objects.published)`
|
|
312
|
+
// and the unread count compares against `object_recipients.created_at`, so a
|
|
313
|
+
// verbatim far-future / malformed value would pin the message atop the chat
|
|
314
|
+
// forever and corrupt unread baselines.
|
|
315
|
+
const now = normalizeInboundTimestamp(
|
|
316
|
+
object.published,
|
|
317
|
+
new Date().toISOString(),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
// This is a federated room (group-CHAT) message, addressed to the community
|
|
321
|
+
// via the object_recipients audience link below. The chat reader and the
|
|
322
|
+
// chat unread count (communities/messages.ts, dm/contacts.ts) treat the chat
|
|
323
|
+
// object-set as audience-linked Notes with `communityApId IS NULL`, disjoint
|
|
324
|
+
// from the feed object-set (which carries `communityApId`). Leave
|
|
325
|
+
// `communityApId` NULL here so the message surfaces in the group chat and
|
|
326
|
+
// unread count rather than being misrouted into the community feed.
|
|
327
|
+
await db.insert(objects).values({
|
|
328
|
+
apId: newObjectId,
|
|
329
|
+
type: "Note",
|
|
330
|
+
attributedTo: actorApIdStr,
|
|
331
|
+
content: boundInboundContent(object.content),
|
|
332
|
+
summary: boundInboundSummary(object.summary),
|
|
333
|
+
attachmentsJson: boundAttachmentsJson(attachments),
|
|
334
|
+
visibility: "group",
|
|
335
|
+
// Record the community in audienceJson too (not just the object_recipients
|
|
336
|
+
// row): the canonical single-object read-gate (canViewerReadObject) keys on
|
|
337
|
+
// audienceJson/communityApId and does NOT consult object_recipients, so
|
|
338
|
+
// without this a federated chat message in a PRIVATE community was served to
|
|
339
|
+
// ANY (even anonymous) caller via GET /api/posts/:id. The local chat POST sets
|
|
340
|
+
// this for the same reason. Safe for the chat reader (joins on
|
|
341
|
+
// object_recipients + communityApId IS NULL, not audienceJson) and the feed
|
|
342
|
+
// (audienceJson != "[]" already excludes it).
|
|
343
|
+
audienceJson: JSON.stringify([community.apId]),
|
|
344
|
+
communityApId: null,
|
|
345
|
+
published: now,
|
|
346
|
+
isLocal: 0,
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Using raw SQL with INSERT OR IGNORE since ObjectRecipient FK expects Actor, not Community
|
|
350
|
+
await db.run(sql`
|
|
351
|
+
INSERT OR IGNORE INTO object_recipients (object_ap_id, recipient_ap_id, type, created_at)
|
|
352
|
+
VALUES (${newObjectId}, ${community.apId}, 'audience', ${now})
|
|
353
|
+
`);
|
|
354
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Clamp + normalize a remote-controlled timestamp (inbound AP `published`).
|
|
2
|
+
// Stored verbatim it poisons the `desc(published)` feed/chat sort + the
|
|
3
|
+
// `lt(published, cursor)` paginator two ways: (1) a non-ISO / space-separated /
|
|
4
|
+
// non-`Z` value lexically mis-sorts under SQLite BINARY collation; (2) a VALID
|
|
5
|
+
// but far-FUTURE value ("9999-…") legitimately parses yet lexically dominates
|
|
6
|
+
// every real `2026-…` row, pinning the row to the top of every feed forever. So:
|
|
7
|
+
// reformat to the canonical ISO-8601 UTC shape every comparison operand uses,
|
|
8
|
+
// AND clamp a future date down to `now` (a post cannot be published in the
|
|
9
|
+
// future; a small skew slack is allowed). Garbage / missing → `now`. We do NOT
|
|
10
|
+
// clamp the past — legitimately old (backfilled) posts exist and merely sink.
|
|
11
|
+
// (Mirrors the `endTime` clamp already applied to inbound stories.)
|
|
12
|
+
//
|
|
13
|
+
// Shared by every inbound Note path — handleCreate / insertDirectNote /
|
|
14
|
+
// handleCreateStory (inbox-content-handlers) AND handleGroupCreate
|
|
15
|
+
// (actor-inbox-handlers, the federated community-chat path) — so none can skip it.
|
|
16
|
+
export const FUTURE_PUBLISHED_SKEW_MS = 5 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
export function normalizeInboundTimestamp(
|
|
19
|
+
raw: string | null | undefined,
|
|
20
|
+
fallbackNow: string,
|
|
21
|
+
): string {
|
|
22
|
+
const nowMs = Date.parse(fallbackNow);
|
|
23
|
+
const ms = raw ? Date.parse(raw) : NaN;
|
|
24
|
+
if (!Number.isFinite(ms)) return fallbackNow;
|
|
25
|
+
if (Number.isFinite(nowMs) && ms > nowMs + FUTURE_PUBLISHED_SKEW_MS) {
|
|
26
|
+
return fallbackNow;
|
|
27
|
+
}
|
|
28
|
+
return new Date(ms).toISOString();
|
|
29
|
+
}
|