@takosjp/yurucommu-core 3.4.3 → 3.4.4
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/migrations/0023_delivery_resolution_outbox.sql +33 -0
- package/migrations/0024_delivery_fanout_outbox.sql +35 -0
- package/migrations/0025_delivery_endpoint_terminal_retention.sql +6 -0
- package/migrations/0026_remote_actor_fetch_failures.sql +29 -0
- package/migrations/0027_remote_actor_tombstones.sql +12 -0
- package/migrations/0028_remote_actor_delivery_fence.sql +30 -0
- package/migrations/0029_delivery_endpoint_recipients.sql +47 -0
- package/package.json +2 -1
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/communities.ts +2 -0
- package/packages/api/src/lib/api/fetch.ts +69 -13
- package/packages/api/src/lib/api/normalize.ts +32 -10
- package/packages/api/src/lib/api/notifications.ts +4 -1
- package/packages/api/src/lib/rtc-client.ts +127 -29
- package/src/backend/federation-helpers.ts +1 -0
- package/src/backend/index.ts +16 -3
- package/src/backend/lib/account-migration.ts +340 -0
- package/src/backend/lib/activity-delete-cascade.ts +193 -0
- package/src/backend/lib/activitypub-actor-cache.ts +615 -48
- package/src/backend/lib/activitypub-actor-identity-sql.ts +179 -0
- package/src/backend/lib/activitypub-actor-identity.ts +39 -0
- package/src/backend/lib/activitypub-validators.ts +62 -4
- package/src/backend/lib/ap-ids.ts +36 -6
- package/src/backend/lib/ap-verify.ts +7 -17
- package/src/backend/lib/blocklist-purge.ts +676 -42
- package/src/backend/lib/blocklist.ts +278 -39
- package/src/backend/lib/community-visibility.ts +47 -33
- package/src/backend/lib/delivery/fanout-outbox.ts +336 -0
- package/src/backend/lib/delivery/planner.ts +16 -12
- package/src/backend/lib/delivery/queue-batching.ts +389 -145
- package/src/backend/lib/delivery/queue-delivery.ts +47 -25
- package/src/backend/lib/delivery/queue.ts +479 -73
- package/src/backend/lib/delivery/resolution-outbox.ts +456 -0
- package/src/backend/lib/delivery/types.ts +31 -7
- package/src/backend/lib/feed-exclude.ts +40 -28
- package/src/backend/lib/follow-edge-mutations.ts +217 -0
- package/src/backend/lib/notification-eligibility.ts +15 -9
- package/src/backend/lib/notification-push.ts +2 -2
- package/src/backend/lib/oidc-id-token.ts +66 -0
- package/src/backend/lib/personal-actor-moderation.ts +239 -0
- package/src/backend/lib/post-visibility.ts +79 -16
- package/src/backend/lib/remote-activity-id.ts +61 -0
- package/src/backend/lib/unread-counts.ts +3 -0
- package/src/backend/retention.ts +38 -1
- package/src/backend/routes/account-teardown.ts +422 -156
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
- package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
- package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
- package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
- package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
- package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
- package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
- package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
- package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
- package/src/backend/routes/activitypub/inbox-types.ts +14 -2
- package/src/backend/routes/activitypub/inbox.ts +81 -105
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub.ts +6 -5
- package/src/backend/routes/actors-helpers.ts +34 -8
- package/src/backend/routes/actors.ts +328 -164
- package/src/backend/routes/auth-helpers.ts +57 -9
- package/src/backend/routes/auth.ts +10 -2
- package/src/backend/routes/communities/membership-invites.ts +4 -1
- package/src/backend/routes/communities/membership-members.ts +201 -73
- package/src/backend/routes/communities/membership-requests.ts +141 -76
- package/src/backend/routes/communities/membership-shared.ts +228 -21
- package/src/backend/routes/communities/messages.ts +9 -2
- package/src/backend/routes/communities/routes.ts +48 -13
- package/src/backend/routes/dm/contacts.ts +29 -41
- package/src/backend/routes/dm/conversations-helpers.ts +9 -1
- package/src/backend/routes/dm/messages.ts +36 -42
- package/src/backend/routes/dm/read-archive.ts +4 -2
- package/src/backend/routes/dm/requests.ts +62 -54
- package/src/backend/routes/follow-helpers.ts +200 -60
- package/src/backend/routes/follow.ts +146 -140
- package/src/backend/routes/media.ts +20 -94
- package/src/backend/routes/moderation.ts +72 -4
- package/src/backend/routes/notes.ts +3 -4
- package/src/backend/routes/notifications.ts +209 -108
- package/src/backend/routes/posts/delete-cascade.ts +253 -89
- package/src/backend/routes/posts/federation.ts +373 -0
- package/src/backend/routes/posts/interactions.ts +160 -184
- package/src/backend/routes/posts/like-mutation.ts +240 -0
- package/src/backend/routes/posts/post-helpers.ts +112 -162
- package/src/backend/routes/posts/queries.ts +150 -54
- package/src/backend/routes/posts/routes.ts +169 -329
- package/src/backend/routes/posts/transformers.ts +61 -6
- package/src/backend/routes/recommendations.ts +37 -44
- package/src/backend/routes/rtc/index.ts +26 -6
- package/src/backend/routes/search.ts +39 -55
- package/src/backend/routes/stories/interactions.ts +22 -6
- package/src/backend/routes/stories/query-helpers.ts +28 -41
- package/src/backend/routes/stories/routes.ts +125 -108
- package/src/backend/routes/takos-tools/dm.ts +17 -17
- package/src/backend/routes/takos-tools/posts.ts +189 -106
- package/src/backend/routes/takos-tools/search.ts +13 -4
- package/src/backend/routes/takos-tools/timeline.ts +35 -27
- package/src/backend/routes/takos-tools-response.ts +6 -2
- package/src/backend/routes/timeline.ts +5 -5
- package/src/backend/runtime/call-signaling-do.ts +35 -4
- package/src/backend/runtime/one-time-ticket.ts +116 -0
- package/src/backend/runtime/realtime-stream-do.ts +5 -61
- package/src/backend/runtime/signaling-hub.ts +36 -3
- package/src/backend/server.ts +95 -80
- package/src/db/d1-write.ts +67 -43
- package/src/db/schema/federation.ts +42 -1
- package/src/db/schema/messaging.ts +110 -2
- package/src/db/schema.ts +1 -1
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { formatUsername, safeJsonParse } from "../../federation-helpers.ts";
|
|
2
|
+
import {
|
|
3
|
+
boundAttachmentsJson,
|
|
4
|
+
MAX_ATTACHMENTS,
|
|
5
|
+
MAX_ATTACHMENTS_JSON_LENGTH,
|
|
6
|
+
} from "../../lib/attachments.ts";
|
|
2
7
|
|
|
3
8
|
export const MAX_POST_CONTENT_LENGTH = 5000;
|
|
4
9
|
export const MAX_POST_SUMMARY_LENGTH = 500;
|
|
5
10
|
// Attachment bounds live in lib/attachments.ts (shared with the DM and
|
|
6
11
|
// community-chat validators); re-exported here for the existing post-route and
|
|
7
12
|
// inbound-handler importers.
|
|
8
|
-
export {
|
|
9
|
-
boundAttachmentsJson,
|
|
10
|
-
MAX_ATTACHMENTS,
|
|
11
|
-
MAX_ATTACHMENTS_JSON_LENGTH,
|
|
12
|
-
} from "../../lib/attachments.ts";
|
|
13
|
+
export { boundAttachmentsJson, MAX_ATTACHMENTS, MAX_ATTACHMENTS_JSON_LENGTH };
|
|
13
14
|
|
|
14
15
|
/** Truncate a string to `max` characters (no-op when already within bounds). */
|
|
15
16
|
export function truncate(s: string, max: number): string {
|
|
@@ -30,6 +31,60 @@ export function boundInboundSummary(summary: unknown): string | null {
|
|
|
30
31
|
? truncate(summary, MAX_POST_SUMMARY_LENGTH)
|
|
31
32
|
: null;
|
|
32
33
|
}
|
|
34
|
+
|
|
35
|
+
/** Maximum persisted ActivityStreams tag objects on one inbound Note. */
|
|
36
|
+
export const MAX_INBOUND_TAGS = 64;
|
|
37
|
+
export const MAX_INBOUND_TAGS_JSON_LENGTH = 16 * 1024;
|
|
38
|
+
|
|
39
|
+
function isJsonRecord(value: unknown): value is Record<string, unknown> {
|
|
40
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeBoundedRecordArray(
|
|
44
|
+
value: unknown,
|
|
45
|
+
maxItems: number,
|
|
46
|
+
maxJsonLength: number,
|
|
47
|
+
): string {
|
|
48
|
+
const candidates =
|
|
49
|
+
value === undefined || value === null
|
|
50
|
+
? []
|
|
51
|
+
: Array.isArray(value)
|
|
52
|
+
? value
|
|
53
|
+
: [value];
|
|
54
|
+
const records: Record<string, unknown>[] = [];
|
|
55
|
+
for (const candidate of candidates) {
|
|
56
|
+
if (!isJsonRecord(candidate)) continue;
|
|
57
|
+
records.push(candidate);
|
|
58
|
+
if (records.length >= maxItems) break;
|
|
59
|
+
}
|
|
60
|
+
const json = JSON.stringify(records);
|
|
61
|
+
return json.length <= maxJsonLength ? json : "[]";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Normalize the AS2 `attachment` cardinality before it reaches the SQL/API
|
|
66
|
+
* projection. ActivityStreams permits a single object or an array, while the
|
|
67
|
+
* Yurucommu post contract is always an array. Invalid entries are ignored and
|
|
68
|
+
* both count and serialized size are bounded at the local post limits.
|
|
69
|
+
*/
|
|
70
|
+
export function boundInboundNoteAttachmentsJson(attachment: unknown): string {
|
|
71
|
+
return boundAttachmentsJson(
|
|
72
|
+
normalizeBoundedRecordArray(
|
|
73
|
+
attachment,
|
|
74
|
+
MAX_ATTACHMENTS,
|
|
75
|
+
MAX_ATTACHMENTS_JSON_LENGTH,
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Persist only a bounded array of object-shaped AS2 tag entries. */
|
|
81
|
+
export function boundInboundTagsJson(tag: unknown): string {
|
|
82
|
+
return normalizeBoundedRecordArray(
|
|
83
|
+
tag,
|
|
84
|
+
MAX_INBOUND_TAGS,
|
|
85
|
+
MAX_INBOUND_TAGS_JSON_LENGTH,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
33
88
|
// Capped at 90 (not 100): a page's object ids are re-queried via
|
|
34
89
|
// `inArray(col, objectApIds)` for like/bookmark enrichment, and Cloudflare D1
|
|
35
90
|
// allows at most 100 bound parameters per query. 90 leaves headroom for the
|
|
@@ -116,7 +171,7 @@ export function formatPost(
|
|
|
116
171
|
type: p.type,
|
|
117
172
|
author: {
|
|
118
173
|
ap_id: p.attributed_to,
|
|
119
|
-
username: formatUsername(p.attributed_to),
|
|
174
|
+
username: formatUsername(p.attributed_to, p.author_username),
|
|
120
175
|
preferred_username: p.author_username,
|
|
121
176
|
name: p.author_name,
|
|
122
177
|
icon_url: p.author_icon_url,
|
|
@@ -2,8 +2,8 @@ import { Hono } from "hono";
|
|
|
2
2
|
import { sql } from "drizzle-orm";
|
|
3
3
|
import type { Env, Variables } from "../types.ts";
|
|
4
4
|
import { formatUsername } from "../federation-helpers.ts";
|
|
5
|
+
import { operatorActorNotBlockedSql } from "../lib/blocklist.ts";
|
|
5
6
|
import { logger } from "../lib/logger.ts";
|
|
6
|
-
import { CacheTags, CacheTTL, withCache } from "../middleware/cache.ts";
|
|
7
7
|
import { batchLoadActorInfo } from "./communities/membership-shared.ts";
|
|
8
8
|
|
|
9
9
|
const recommendations = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
@@ -16,23 +16,16 @@ const log = logger.child({ component: "recommendations" });
|
|
|
16
16
|
* Finds users followed by people the current user follows,
|
|
17
17
|
* ranked by number of mutual connections.
|
|
18
18
|
*/
|
|
19
|
-
recommendations.get(
|
|
20
|
-
"
|
|
21
|
-
|
|
22
|
-
ttl: CacheTTL.ACTOR_PROFILE,
|
|
23
|
-
varyByActor: true,
|
|
24
|
-
cacheTag: CacheTags.ACTOR,
|
|
25
|
-
}),
|
|
26
|
-
async (c) => {
|
|
27
|
-
const actor = c.get("actor");
|
|
28
|
-
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
19
|
+
recommendations.get("/users", async (c) => {
|
|
20
|
+
const actor = c.get("actor");
|
|
21
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
29
22
|
|
|
30
|
-
|
|
31
|
-
|
|
23
|
+
const db = c.get("db");
|
|
24
|
+
const myApId = actor.ap_id;
|
|
32
25
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
26
|
+
try {
|
|
27
|
+
const candidates = await db.all<{ ap_id: string; mutual_count: number }>(
|
|
28
|
+
sql`
|
|
36
29
|
SELECT f2.following_ap_id AS ap_id, COUNT(DISTINCT f2.follower_ap_id) AS mutual_count
|
|
37
30
|
FROM follows f1
|
|
38
31
|
JOIN follows f2 ON f1.following_ap_id = f2.follower_ap_id AND f2.status = 'accepted'
|
|
@@ -59,41 +52,41 @@ recommendations.get(
|
|
|
59
52
|
-- (actorCache) are unaffected, same scope as the other surfaces.
|
|
60
53
|
SELECT ap_id FROM actors WHERE deleted_at IS NOT NULL OR is_private = 1
|
|
61
54
|
)
|
|
55
|
+
AND ${operatorActorNotBlockedSql(sql.raw("f2.following_ap_id"))}
|
|
62
56
|
GROUP BY f2.following_ap_id
|
|
63
57
|
ORDER BY mutual_count DESC
|
|
64
58
|
LIMIT 5
|
|
65
59
|
`,
|
|
66
|
-
|
|
60
|
+
);
|
|
67
61
|
|
|
68
|
-
|
|
62
|
+
if (candidates.length === 0) return c.json({ users: [] });
|
|
69
63
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
64
|
+
const actorMap = await batchLoadActorInfo(
|
|
65
|
+
db,
|
|
66
|
+
candidates.map((r) => r.ap_id),
|
|
67
|
+
);
|
|
74
68
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
69
|
+
const users = candidates.map((row) => {
|
|
70
|
+
const info = actorMap.get(row.ap_id);
|
|
71
|
+
return {
|
|
72
|
+
ap_id: row.ap_id,
|
|
73
|
+
preferred_username: info?.preferredUsername ?? null,
|
|
74
|
+
name: info?.name ?? null,
|
|
75
|
+
icon_url: info?.iconUrl ?? null,
|
|
76
|
+
username: formatUsername(row.ap_id, info?.preferredUsername),
|
|
77
|
+
mutual_count: Number(row.mutual_count),
|
|
78
|
+
};
|
|
79
|
+
});
|
|
86
80
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
);
|
|
81
|
+
return c.json({ users });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
log.warn("Recommendations failed; returning empty suggestions", {
|
|
84
|
+
event: "recommendations.failed",
|
|
85
|
+
actorApId: myApId,
|
|
86
|
+
error,
|
|
87
|
+
});
|
|
88
|
+
return c.json({ users: [] });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
98
91
|
|
|
99
92
|
export default recommendations;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Call feature routes (WebRTC voice + video).
|
|
3
3
|
*
|
|
4
4
|
* POST /ap/rtc/signal server-to-server signaling ingest (HTTP-Signature)
|
|
5
|
+
* POST /api/rtc/ticket mint a one-time short-lived WS ticket
|
|
5
6
|
* GET /api/rtc/socket browser WebSocket upgrade -> per-user signaling hub
|
|
6
7
|
* GET /api/rtc/ice mint short-lived ICE (STUN/TURN) servers
|
|
7
8
|
* POST /api/rtc/calls start a call (block-list gate + callId + ICE)
|
|
@@ -23,7 +24,7 @@ import {
|
|
|
23
24
|
isActorMismatch,
|
|
24
25
|
signingActorFromKeyId,
|
|
25
26
|
} from "../activitypub/inbox.ts";
|
|
26
|
-
import {
|
|
27
|
+
import { isActorBlockedStrict } from "../../lib/blocklist.ts";
|
|
27
28
|
import {
|
|
28
29
|
getSignalingHub,
|
|
29
30
|
isSignalingAvailable,
|
|
@@ -75,20 +76,37 @@ rtc.post("/ap/rtc/signal", async (c) => {
|
|
|
75
76
|
if (!local) return c.json({ error: "unknown_recipient" }, 404);
|
|
76
77
|
|
|
77
78
|
// Never ring for a sender the local owner has blocked; drop silently.
|
|
78
|
-
if (await
|
|
79
|
+
if (await isActorBlockedStrict(db, envelope.from)) return c.body(null, 204);
|
|
79
80
|
|
|
80
81
|
await getSignalingHub(c.env).deliver(envelope.to, envelope);
|
|
81
82
|
return c.body(null, 204);
|
|
82
83
|
});
|
|
83
84
|
|
|
84
|
-
// --- Browser WebSocket upgrade
|
|
85
|
-
rtc.
|
|
85
|
+
// --- Browser WebSocket ticket + upgrade ------------------------------------
|
|
86
|
+
rtc.post("/api/rtc/ticket", async (c) => {
|
|
86
87
|
const actor = c.get("actor");
|
|
87
88
|
if (!actor) return c.json({ error: "unauthorized" }, 401);
|
|
88
89
|
if (!isSignalingAvailable(c.env)) {
|
|
89
90
|
return c.json({ error: "signaling_unavailable" }, 503);
|
|
90
91
|
}
|
|
91
|
-
|
|
92
|
+
const ticket = await getSignalingHub(c.env).mintTicket(actor.ap_id);
|
|
93
|
+
if (!ticket) return c.json({ error: "ticket_mint_failed" }, 500);
|
|
94
|
+
return c.json({ ticket, actor_ap_id: actor.ap_id });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
rtc.get("/api/rtc/socket", async (c) => {
|
|
98
|
+
if (!isSignalingAvailable(c.env)) {
|
|
99
|
+
return c.json({ error: "signaling_unavailable" }, 503);
|
|
100
|
+
}
|
|
101
|
+
const hub = getSignalingHub(c.env);
|
|
102
|
+
const actorParam = c.req.query("actor")?.trim();
|
|
103
|
+
const ticket = c.req.query("ticket")?.trim();
|
|
104
|
+
if (actorParam && ticket) {
|
|
105
|
+
// The actor only selects which per-user DO validates and consumes the
|
|
106
|
+
// ticket. A forged actor value reaches a DO that never minted it.
|
|
107
|
+
return hub.upgrade(c.req.raw, actorParam, ticket);
|
|
108
|
+
}
|
|
109
|
+
return c.json({ error: "unauthorized" }, 401);
|
|
92
110
|
});
|
|
93
111
|
|
|
94
112
|
// --- ICE servers ------------------------------------------------------------
|
|
@@ -118,7 +136,9 @@ rtc.post("/api/rtc/calls", async (c) => {
|
|
|
118
136
|
const media = normalizeMedia(payload.media);
|
|
119
137
|
|
|
120
138
|
// Do not let the local owner place a call to a contact they have blocked.
|
|
121
|
-
if (await
|
|
139
|
+
if (await isActorBlockedStrict(db, to)) {
|
|
140
|
+
return c.json({ error: "blocked" }, 403);
|
|
141
|
+
}
|
|
122
142
|
|
|
123
143
|
const provider = createRtcProvider(c.env);
|
|
124
144
|
const [iceServers, sfuFocus] = await Promise.all([
|
|
@@ -19,14 +19,17 @@ import {
|
|
|
19
19
|
notDeleted,
|
|
20
20
|
objects,
|
|
21
21
|
} from "../../db/index.ts";
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
tryParseRemoteActor,
|
|
25
|
-
} from "../lib/activitypub-validators.ts";
|
|
22
|
+
import { parseWebFinger } from "../lib/activitypub-validators.ts";
|
|
23
|
+
import { cacheRemoteActorDocument } from "../lib/activitypub-actor-cache.ts";
|
|
26
24
|
import { logger } from "../lib/logger.ts";
|
|
27
25
|
import { chunkForInClause } from "../lib/chunk.ts";
|
|
26
|
+
import {
|
|
27
|
+
filterBlockedActorApIds,
|
|
28
|
+
isActorBlocked,
|
|
29
|
+
isDomainBlocked,
|
|
30
|
+
} from "../lib/blocklist.ts";
|
|
28
31
|
import { type ActorInfo, loadActorInfoMap } from "./actors-helpers.ts";
|
|
29
|
-
import {
|
|
32
|
+
import { excludeModeratedActors } from "../lib/feed-exclude.ts";
|
|
30
33
|
import { withCache } from "../middleware/cache.ts";
|
|
31
34
|
|
|
32
35
|
const log = logger.child({ component: "search" });
|
|
@@ -259,7 +262,7 @@ function formatPost(
|
|
|
259
262
|
ap_id: post.apId,
|
|
260
263
|
author: {
|
|
261
264
|
ap_id: post.attributedTo,
|
|
262
|
-
username: formatUsername(post.attributedTo),
|
|
265
|
+
username: formatUsername(post.attributedTo, author?.preferredUsername),
|
|
263
266
|
preferred_username: author?.preferredUsername ?? null,
|
|
264
267
|
name: author?.name ?? null,
|
|
265
268
|
icon_url: author?.iconUrl ?? null,
|
|
@@ -371,6 +374,10 @@ search.get("/actors", async (c) => {
|
|
|
371
374
|
)
|
|
372
375
|
.limit(ACTOR_SEARCH_SCAN_CAP),
|
|
373
376
|
]);
|
|
377
|
+
const blockedCachedApIds = await filterBlockedActorApIds(
|
|
378
|
+
db,
|
|
379
|
+
cachedRows.map((actor) => actor.apId),
|
|
380
|
+
);
|
|
374
381
|
|
|
375
382
|
// UNION local + cached, with local taking priority on apId collision.
|
|
376
383
|
const seen = new Set(localRows.map((a) => a.apId));
|
|
@@ -385,7 +392,7 @@ search.get("/actors", async (c) => {
|
|
|
385
392
|
}[] = [
|
|
386
393
|
...localRows,
|
|
387
394
|
...cachedRows
|
|
388
|
-
.filter((a) => !seen.has(a.apId))
|
|
395
|
+
.filter((a) => !seen.has(a.apId) && !blockedCachedApIds.has(a.apId))
|
|
389
396
|
.map((a) => ({
|
|
390
397
|
apId: a.apId,
|
|
391
398
|
preferredUsername: a.preferredUsername,
|
|
@@ -430,7 +437,7 @@ search.get("/actors", async (c) => {
|
|
|
430
437
|
summary: a.summary,
|
|
431
438
|
follower_count: a.followerCount,
|
|
432
439
|
created_at: a.createdAt,
|
|
433
|
-
username: formatUsername(a.apId),
|
|
440
|
+
username: formatUsername(a.apId, a.preferredUsername),
|
|
434
441
|
}));
|
|
435
442
|
|
|
436
443
|
return c.json({ actors: result, limit, offset, has_more });
|
|
@@ -466,7 +473,7 @@ search.get("/posts", async (c) => {
|
|
|
466
473
|
// Suppress blocked/muted authors so keyword search honors the same
|
|
467
474
|
// moderation filter the home/timeline/notifications feeds apply
|
|
468
475
|
// (undefined for an anonymous viewer → and() drops it).
|
|
469
|
-
|
|
476
|
+
excludeModeratedActors(actor?.ap_id ?? ""),
|
|
470
477
|
),
|
|
471
478
|
)
|
|
472
479
|
.orderBy(...postOrderByDrizzle(sort))
|
|
@@ -498,6 +505,14 @@ search.get("/remote", async (c) => {
|
|
|
498
505
|
const [, username, domain] = match;
|
|
499
506
|
const safeDomain = normalizeRemoteDomain(domain);
|
|
500
507
|
if (!safeDomain) return c.json({ actors: [] });
|
|
508
|
+
const db = c.get("db");
|
|
509
|
+
|
|
510
|
+
// Remote search is an active federation fetch, not a passive local lookup.
|
|
511
|
+
// A domain the operator defederated must never be contacted through this
|
|
512
|
+
// convenience endpoint after ingress and delivery have already been cut.
|
|
513
|
+
if (await isDomainBlocked(db, safeDomain)) {
|
|
514
|
+
return c.json({ actors: [] });
|
|
515
|
+
}
|
|
501
516
|
|
|
502
517
|
try {
|
|
503
518
|
// WebFinger lookup
|
|
@@ -521,6 +536,12 @@ search.get("/remote", async (c) => {
|
|
|
521
536
|
if (!actorLink?.href || !isSafeRemoteUrl(actorLink.href)) {
|
|
522
537
|
return c.json({ actors: [] });
|
|
523
538
|
}
|
|
539
|
+
// WebFinger may redirect discovery authority to a different actor host.
|
|
540
|
+
// Re-check the resolved actor before the signed GET so an allowed alias
|
|
541
|
+
// cannot bypass an actor or domain block and repopulate the local cache.
|
|
542
|
+
if (await isActorBlocked(db, actorLink.href)) {
|
|
543
|
+
return c.json({ actors: [] });
|
|
544
|
+
}
|
|
524
545
|
|
|
525
546
|
// Fetch actor profile. Sign the GET as the instance actor so a remote in
|
|
526
547
|
// authorized-fetch / secure mode (which 401s the unsigned actor GET while
|
|
@@ -542,55 +563,18 @@ search.get("/remote", async (c) => {
|
|
|
542
563
|
if (!actorRes.ok) return c.json({ actors: [] });
|
|
543
564
|
|
|
544
565
|
const actorRaw: unknown = await actorRes.json();
|
|
545
|
-
const
|
|
546
|
-
if (!
|
|
547
|
-
|
|
548
|
-
if (actorData.id !== actorLink.href || !isSafeRemoteUrl(actorData.id)) {
|
|
549
|
-
return c.json({ actors: [] });
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
// Cache the actor (upsert: check if exists, then insert or update)
|
|
553
|
-
const db = c.get("db");
|
|
554
|
-
const cacheFields = {
|
|
555
|
-
type: actorData.type || "Person",
|
|
556
|
-
preferredUsername: actorData.preferredUsername || null,
|
|
557
|
-
name: actorData.name || null,
|
|
558
|
-
summary: actorData.summary || null,
|
|
559
|
-
iconUrl: actorData.icon?.url || null,
|
|
560
|
-
inbox: actorData.inbox || "",
|
|
561
|
-
outbox: actorData.outbox || null,
|
|
562
|
-
publicKeyId: actorData.publicKey?.id || null,
|
|
563
|
-
publicKeyPem: actorData.publicKey?.publicKeyPem || null,
|
|
564
|
-
rawJson: JSON.stringify(actorRaw),
|
|
565
|
-
};
|
|
566
|
-
|
|
567
|
-
const existing = await db
|
|
568
|
-
.select({ apId: actorCache.apId })
|
|
569
|
-
.from(actorCache)
|
|
570
|
-
.where(eq(actorCache.apId, actorData.id))
|
|
571
|
-
.get();
|
|
572
|
-
|
|
573
|
-
if (existing) {
|
|
574
|
-
await db
|
|
575
|
-
.update(actorCache)
|
|
576
|
-
.set(cacheFields)
|
|
577
|
-
.where(eq(actorCache.apId, actorData.id));
|
|
578
|
-
} else {
|
|
579
|
-
await db.insert(actorCache).values({
|
|
580
|
-
apId: actorData.id,
|
|
581
|
-
...cacheFields,
|
|
582
|
-
});
|
|
583
|
-
}
|
|
566
|
+
const cached = await cacheRemoteActorDocument(db, actorLink.href, actorRaw);
|
|
567
|
+
if (!cached.ok) return c.json({ actors: [] });
|
|
584
568
|
|
|
585
569
|
return c.json({
|
|
586
570
|
actors: [
|
|
587
571
|
{
|
|
588
|
-
ap_id:
|
|
589
|
-
username: `${
|
|
590
|
-
preferred_username:
|
|
591
|
-
name:
|
|
592
|
-
summary:
|
|
593
|
-
icon_url:
|
|
572
|
+
ap_id: cached.row.apId,
|
|
573
|
+
username: `${cached.row.preferredUsername ?? username}@${safeDomain}`,
|
|
574
|
+
preferred_username: cached.row.preferredUsername,
|
|
575
|
+
name: cached.row.name,
|
|
576
|
+
summary: cached.row.summary,
|
|
577
|
+
icon_url: cached.row.iconUrl,
|
|
594
578
|
},
|
|
595
579
|
],
|
|
596
580
|
});
|
|
@@ -625,7 +609,7 @@ search.get("/hashtag/:tag", async (c) => {
|
|
|
625
609
|
const postWhere = publicSearchableWhere(
|
|
626
610
|
sql`instr(lower(${objects.content}), lower(${hashtagPattern})) > 0`,
|
|
627
611
|
// Same block/mute moderation filter as the feeds (see /search/posts).
|
|
628
|
-
|
|
612
|
+
excludeModeratedActors(actor?.ap_id ?? ""),
|
|
629
613
|
);
|
|
630
614
|
|
|
631
615
|
// `LIKE '%#tag%'` is a SUPERSET prefilter: it also matches longer tags that
|
|
@@ -31,6 +31,8 @@ import { actorIsBlockedBy } from "../../lib/post-visibility.ts";
|
|
|
31
31
|
import { rateLimit, RateLimitConfigs } from "../../middleware/rate-limit.ts";
|
|
32
32
|
import { logger } from "../../lib/logger.ts";
|
|
33
33
|
import { isUniqueConstraintError } from "../../lib/parse-helpers.ts";
|
|
34
|
+
import { excludeModeratedActors } from "../../lib/feed-exclude.ts";
|
|
35
|
+
import { activityDeleteCascadeStatements } from "../../lib/activity-delete-cascade.ts";
|
|
34
36
|
|
|
35
37
|
const log = logger.child({ component: "stories.interactions" });
|
|
36
38
|
|
|
@@ -354,11 +356,14 @@ stories.delete("/:id/like", async (c) => {
|
|
|
354
356
|
// time and the re-like dedup guard only checks the `likes` edge (removed here),
|
|
355
357
|
// so without this a like→unlike→like cycle would leave the first inbox/activity
|
|
356
358
|
// rows behind and add new ones — duplicate "X liked your story" + a phantom
|
|
357
|
-
// unread +1.
|
|
359
|
+
// unread +1. Remove all activity-keyed push/delivery projections too; mirrors
|
|
360
|
+
// the post-path unlike reap.
|
|
358
361
|
const reapLikeNotification = like.activityApId
|
|
359
362
|
? [
|
|
360
|
-
|
|
361
|
-
|
|
363
|
+
...activityDeleteCascadeStatements(
|
|
364
|
+
db,
|
|
365
|
+
eq(activities.apId, like.activityApId),
|
|
366
|
+
),
|
|
362
367
|
]
|
|
363
368
|
: [];
|
|
364
369
|
await (db as unknown as Batchable).batch([
|
|
@@ -602,11 +607,17 @@ stories.get("/:id/views", async (c) => {
|
|
|
602
607
|
return c.json({ error: "Story has expired" }, 410);
|
|
603
608
|
}
|
|
604
609
|
|
|
605
|
-
// True total (uncapped) — stays accurate even when the list below is
|
|
610
|
+
// True eligible total (uncapped) — stays accurate even when the list below is
|
|
611
|
+
// capped, while retained operator/personal-blocked viewers stay suppressed.
|
|
606
612
|
const totalRow = await db
|
|
607
613
|
.select({ value: count() })
|
|
608
614
|
.from(storyViews)
|
|
609
|
-
.where(
|
|
615
|
+
.where(
|
|
616
|
+
and(
|
|
617
|
+
eq(storyViews.storyApId, apId),
|
|
618
|
+
excludeModeratedActors(actor.ap_id, storyViews.actorApId),
|
|
619
|
+
),
|
|
620
|
+
)
|
|
610
621
|
.get();
|
|
611
622
|
const view_count = totalRow?.value ?? 0;
|
|
612
623
|
|
|
@@ -617,7 +628,12 @@ stories.get("/:id/views", async (c) => {
|
|
|
617
628
|
viewedAt: storyViews.viewedAt,
|
|
618
629
|
})
|
|
619
630
|
.from(storyViews)
|
|
620
|
-
.where(
|
|
631
|
+
.where(
|
|
632
|
+
and(
|
|
633
|
+
eq(storyViews.storyApId, apId),
|
|
634
|
+
excludeModeratedActors(actor.ap_id, storyViews.actorApId),
|
|
635
|
+
),
|
|
636
|
+
)
|
|
621
637
|
.orderBy(desc(storyViews.viewedAt))
|
|
622
638
|
.limit(STORY_VIEWERS_LIMIT);
|
|
623
639
|
|
|
@@ -3,10 +3,8 @@ import type { Database } from "../../../db/index.ts";
|
|
|
3
3
|
import {
|
|
4
4
|
actorCache,
|
|
5
5
|
actors,
|
|
6
|
-
blocks,
|
|
7
6
|
communityMembers,
|
|
8
7
|
follows,
|
|
9
|
-
mutes,
|
|
10
8
|
objects,
|
|
11
9
|
storyVotes,
|
|
12
10
|
} from "../../../db/index.ts";
|
|
@@ -17,10 +15,14 @@ import {
|
|
|
17
15
|
safeJsonParse,
|
|
18
16
|
} from "../../federation-helpers.ts";
|
|
19
17
|
import {
|
|
20
|
-
|
|
18
|
+
deleteObjectsCascade,
|
|
21
19
|
purgeMediaBlobs,
|
|
22
20
|
} from "../posts/delete-cascade.ts";
|
|
23
21
|
import { chunkForInClause } from "../../lib/chunk.ts";
|
|
22
|
+
import {
|
|
23
|
+
isActorBlockedStrict,
|
|
24
|
+
operatorActorNotBlockedSql,
|
|
25
|
+
} from "../../lib/blocklist.ts";
|
|
24
26
|
|
|
25
27
|
// Bound the per-run expired-story reap. The cleanup is opportunistic (fired,
|
|
26
28
|
// unawaited, from the hot story-read path), so a hostile followed host that
|
|
@@ -108,6 +110,9 @@ export async function canViewerReadStory(
|
|
|
108
110
|
viewerApId: string | null | undefined,
|
|
109
111
|
): Promise<boolean> {
|
|
110
112
|
if (!viewerApId) return false;
|
|
113
|
+
// Retained rows from a failed/partial purge are unreadable and cannot be
|
|
114
|
+
// interacted with while their author is instance-operator blocked.
|
|
115
|
+
if (await isActorBlockedStrict(db, story.attributedTo)) return false;
|
|
111
116
|
if (story.attributedTo === viewerApId) return true;
|
|
112
117
|
if (story.communityApId) {
|
|
113
118
|
const member = await db
|
|
@@ -136,32 +141,6 @@ export async function canViewerReadStory(
|
|
|
136
141
|
return Boolean(follow);
|
|
137
142
|
}
|
|
138
143
|
|
|
139
|
-
// ---------------------------------------------------------------------------
|
|
140
|
-
// Blocked / muted helpers
|
|
141
|
-
// ---------------------------------------------------------------------------
|
|
142
|
-
|
|
143
|
-
/** Fetch blocked and muted ap_ids for the given actor. */
|
|
144
|
-
export async function fetchBlockedAndMutedIds(
|
|
145
|
-
db: Database,
|
|
146
|
-
actorApId: string,
|
|
147
|
-
): Promise<{ blockedIds: string[]; mutedIds: string[] }> {
|
|
148
|
-
const [blockRows, muteRows] = await Promise.all([
|
|
149
|
-
db
|
|
150
|
-
.select({ blockedApId: blocks.blockedApId })
|
|
151
|
-
.from(blocks)
|
|
152
|
-
.where(eq(blocks.blockerApId, actorApId)),
|
|
153
|
-
db
|
|
154
|
-
.select({ mutedApId: mutes.mutedApId })
|
|
155
|
-
.from(mutes)
|
|
156
|
-
.where(eq(mutes.muterApId, actorApId)),
|
|
157
|
-
]);
|
|
158
|
-
|
|
159
|
-
return {
|
|
160
|
-
blockedIds: blockRows.map((b) => b.blockedApId),
|
|
161
|
-
mutedIds: muteRows.map((m) => m.mutedApId),
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
|
|
165
144
|
// ---------------------------------------------------------------------------
|
|
166
145
|
// Vote helpers
|
|
167
146
|
// ---------------------------------------------------------------------------
|
|
@@ -177,7 +156,12 @@ export async function getVoteCounts(
|
|
|
177
156
|
count: count(),
|
|
178
157
|
})
|
|
179
158
|
.from(storyVotes)
|
|
180
|
-
.where(
|
|
159
|
+
.where(
|
|
160
|
+
and(
|
|
161
|
+
eq(storyVotes.storyApId, storyApId),
|
|
162
|
+
operatorActorNotBlockedSql(sql`${storyVotes.actorApId}`),
|
|
163
|
+
),
|
|
164
|
+
)
|
|
181
165
|
.groupBy(storyVotes.optionIndex);
|
|
182
166
|
|
|
183
167
|
return Object.fromEntries(votes.map((v) => [v.optionIndex, v.count]));
|
|
@@ -211,7 +195,12 @@ export async function fetchBatchVotes(
|
|
|
211
195
|
count: count(),
|
|
212
196
|
})
|
|
213
197
|
.from(storyVotes)
|
|
214
|
-
.where(
|
|
198
|
+
.where(
|
|
199
|
+
and(
|
|
200
|
+
inArray(storyVotes.storyApId, storyApIds),
|
|
201
|
+
operatorActorNotBlockedSql(sql`${storyVotes.actorApId}`),
|
|
202
|
+
),
|
|
203
|
+
)
|
|
215
204
|
.groupBy(storyVotes.storyApId, storyVotes.optionIndex);
|
|
216
205
|
|
|
217
206
|
const allVotes: Record<string, VoteResults> = {};
|
|
@@ -232,6 +221,7 @@ export async function fetchBatchVotes(
|
|
|
232
221
|
and(
|
|
233
222
|
inArray(storyVotes.storyApId, storyApIds),
|
|
234
223
|
eq(storyVotes.actorApId, actorApId),
|
|
224
|
+
operatorActorNotBlockedSql(sql`${storyVotes.actorApId}`),
|
|
235
225
|
),
|
|
236
226
|
);
|
|
237
227
|
userVotes = Object.fromEntries(
|
|
@@ -311,7 +301,7 @@ export function buildAuthor(
|
|
|
311
301
|
): StoryAuthor {
|
|
312
302
|
return {
|
|
313
303
|
ap_id: apId,
|
|
314
|
-
username: formatUsername(apId),
|
|
304
|
+
username: formatUsername(apId, data?.preferredUsername),
|
|
315
305
|
preferred_username: data?.preferredUsername || null,
|
|
316
306
|
name: data?.name || null,
|
|
317
307
|
icon_url: data?.iconUrl || null,
|
|
@@ -338,17 +328,14 @@ export async function cleanupExpiredStories(
|
|
|
338
328
|
|
|
339
329
|
const expiredApIds = expiredStories.map((s) => s.apId);
|
|
340
330
|
|
|
341
|
-
// Reap every child edge for
|
|
331
|
+
// Reap every child edge for the expired set through the shared batched object
|
|
342
332
|
// cascade (likes / announces / bookmarks / object_recipients / story_views /
|
|
343
333
|
// story_votes / story_shares + attached media_uploads), so expiry can't
|
|
344
|
-
// orphan rows the hand-rolled list previously missed
|
|
345
|
-
//
|
|
346
|
-
// When a `media` binding is threaded in, the backing R2
|
|
347
|
-
// purged too so expired-story storage does not leak.
|
|
348
|
-
const mediaKeys
|
|
349
|
-
for (const apId of expiredApIds) {
|
|
350
|
-
mediaKeys.push(...(await deleteObjectCascade(db, apId, media)));
|
|
351
|
-
}
|
|
334
|
+
// orphan rows the hand-rolled list previously missed or make one D1 round
|
|
335
|
+
// trip per child table per story. The helper reads the object rows, so run it
|
|
336
|
+
// before deleting them. When a `media` binding is threaded in, the backing R2
|
|
337
|
+
// blobs are best-effort purged too so expired-story storage does not leak.
|
|
338
|
+
const mediaKeys = await deleteObjectsCascade(db, expiredApIds, media);
|
|
352
339
|
|
|
353
340
|
// Delete EXACTLY the cascaded set (chunked for D1's 100-bound-param cap), not a
|
|
354
341
|
// broad `endTime < now` — with the per-run limit a broad delete could remove a
|