@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,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare D1 allows at most 100 bound parameters per query. A Drizzle
|
|
3
|
+
* `inArray(col, ids)` / `notInArray(col, ids)` binds ONE parameter per id, so
|
|
4
|
+
* any id list re-queried via IN(...) must be split into chunks of <=90 (leaving
|
|
5
|
+
* headroom for the other bound params in the same statement) and the per-chunk
|
|
6
|
+
* results merged.
|
|
7
|
+
*
|
|
8
|
+
* This is invisible in CI: the tests run on libsql / better-sqlite3, whose
|
|
9
|
+
* ~32k-parameter ceiling never trips, so an over-large IN(...) passes locally
|
|
10
|
+
* but throws "too many SQL variables" on production D1. Prefer a `db.select`
|
|
11
|
+
* subquery (`col IN (SELECT ...)`, zero per-element params) when the id set is
|
|
12
|
+
* itself a query result; use this chunker when the ids only exist as an
|
|
13
|
+
* in-memory JS array.
|
|
14
|
+
*/
|
|
15
|
+
export const D1_IN_CHUNK = 90;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Split `items` into consecutive chunks of at most `size` (default
|
|
19
|
+
* {@link D1_IN_CHUNK}). Returns `[]` for an empty input and `[items]` when it
|
|
20
|
+
* already fits in a single chunk (no copy).
|
|
21
|
+
*/
|
|
22
|
+
export function chunkForInClause<T>(
|
|
23
|
+
items: T[],
|
|
24
|
+
size: number = D1_IN_CHUNK,
|
|
25
|
+
): T[][] {
|
|
26
|
+
if (items.length === 0) return [];
|
|
27
|
+
if (items.length <= size) return [items];
|
|
28
|
+
const out: T[][] = [];
|
|
29
|
+
for (let i = 0; i < items.length; i += size) {
|
|
30
|
+
out.push(items.slice(i, i + size));
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import type { Env, Variables } from "../types.ts";
|
|
3
|
+
|
|
4
|
+
const IPV4_PATTERN = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
5
|
+
const IPV6_PATTERN = /^[0-9a-fA-F:]+$/;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Validate IP address format (basic check).
|
|
9
|
+
*/
|
|
10
|
+
function isValidIP(ip: string): boolean {
|
|
11
|
+
if (IPV4_PATTERN.test(ip)) {
|
|
12
|
+
return ip
|
|
13
|
+
.split(".")
|
|
14
|
+
.map(Number)
|
|
15
|
+
.every((part) => part >= 0 && part <= 255);
|
|
16
|
+
}
|
|
17
|
+
return IPV6_PATTERN.test(ip) && ip.includes(":");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// How much an operator-declared reverse proxy is trusted for forwarding headers:
|
|
21
|
+
// - "none": no opt-in; only the genuine CF edge (request.cf) is trusted.
|
|
22
|
+
// - "cf": a Cloudflare front (edge OR cloudflared tunnel where request.cf
|
|
23
|
+
// is absent at the origin) — CF-Connecting-IP is authoritative.
|
|
24
|
+
// - "generic": a generic reverse proxy (nginx / Caddy / Traefik) that stamps
|
|
25
|
+
// X-Forwarded-For / X-Real-IP but neither sets nor strips the
|
|
26
|
+
// Cloudflare-specific header — trust XFF/X-Real-IP, NEVER a
|
|
27
|
+
// client-supplied CF-Connecting-IP.
|
|
28
|
+
// - "legacy": the historical `TAKOS_TRUST_PROXY=true` / `1` opt-in. Treated
|
|
29
|
+
// like "generic" for spoof-safe precedence (XFF/X-Real-IP win over
|
|
30
|
+
// a forged CF header), but keeps a CF-Connecting-IP FALLBACK when
|
|
31
|
+
// no XFF/X-Real-IP is present so an existing cloudflared/CF origin
|
|
32
|
+
// configured with `true` is not regressed.
|
|
33
|
+
type ProxyTrust = "none" | "cf" | "generic" | "legacy";
|
|
34
|
+
|
|
35
|
+
function proxyTrust(
|
|
36
|
+
c: Context<{ Bindings: Env; Variables: Variables }>,
|
|
37
|
+
): ProxyTrust {
|
|
38
|
+
const flag = c.env.TAKOS_TRUST_PROXY;
|
|
39
|
+
if (typeof flag !== "string") return "none";
|
|
40
|
+
const v = flag.trim().toLowerCase();
|
|
41
|
+
if (v === "cf" || v === "cloudflare") return "cf";
|
|
42
|
+
if (v === "generic" || v === "xff") return "generic";
|
|
43
|
+
if (v === "true" || v === "1") return "legacy";
|
|
44
|
+
return "none";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* True when the request provably transits the genuine Cloudflare edge. The
|
|
49
|
+
* Cloudflare Workers runtime injects `request.cf` (colo / country / etc.) on
|
|
50
|
+
* real edge requests; a client CANNOT forge it, and it is absent on the Bun /
|
|
51
|
+
* node-postgres / Caddy distributions. This lets the canonical Cloudflare
|
|
52
|
+
* deployment trust `CF-Connecting-IP` WITHOUT requiring `TAKOS_TRUST_PROXY` to
|
|
53
|
+
* be set, while non-Cloudflare deployments still reject the spoofable header
|
|
54
|
+
* unless the operator explicitly opts in.
|
|
55
|
+
*/
|
|
56
|
+
function isCloudflareEdge(
|
|
57
|
+
c: Context<{ Bindings: Env; Variables: Variables }>,
|
|
58
|
+
): boolean {
|
|
59
|
+
const raw = c.req.raw as Request & { cf?: unknown };
|
|
60
|
+
return raw.cf != null && typeof raw.cf === "object";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Extract client IP with proper validation.
|
|
65
|
+
*
|
|
66
|
+
* Trust model:
|
|
67
|
+
* - `CF-Connecting-IP` is set by the Cloudflare edge, which strips any
|
|
68
|
+
* client-supplied copy before invoking the worker. That guarantee only
|
|
69
|
+
* holds when the request actually transits a trusted edge/proxy, so the
|
|
70
|
+
* header is honoured ONLY when the operator has opted in via
|
|
71
|
+
* `TAKOS_TRUST_PROXY=true`. On the Bun (`bun src/backend/server.ts`) and
|
|
72
|
+
* node-postgres/Caddy distributions nothing strips this header, so an
|
|
73
|
+
* untrusted caller could otherwise rotate a forged `CF-Connecting-IP`
|
|
74
|
+
* per request to get a fresh rate-limit / login-lockout bucket.
|
|
75
|
+
* - `X-Forwarded-For` / `X-Real-IP` are likewise accepted only when the
|
|
76
|
+
* operator has opted in via `TAKOS_TRUST_PROXY=true`. A client speaking
|
|
77
|
+
* directly to the worker can set these headers, so trusting them by
|
|
78
|
+
* default would let arbitrary callers spoof their source IP and
|
|
79
|
+
* bypass per-IP rate limiting / abuse detection.
|
|
80
|
+
* - Falls back to the authentic TCP peer address that the self-host (Bun)
|
|
81
|
+
* entrypoint stamps onto the ExecutionContext props (server-side, not a
|
|
82
|
+
* client-settable header). This keeps a directly-exposed self-host from
|
|
83
|
+
* bucketing every caller as "unknown".
|
|
84
|
+
* - Only when none of the above resolve does it fall back to "unknown". Rate
|
|
85
|
+
* limiters bucket "unknown" together, so an attacker with no IP visibility
|
|
86
|
+
* shares a budget with every other anonymous caller (intentional: degraded
|
|
87
|
+
* mode, not bypass). Behind a reverse proxy whose socket address is constant,
|
|
88
|
+
* set `TAKOS_TRUST_PROXY=true` so the forwarded client IP is used instead.
|
|
89
|
+
*/
|
|
90
|
+
export function getClientIP(
|
|
91
|
+
c: Context<{ Bindings: Env; Variables: Variables }>,
|
|
92
|
+
): string {
|
|
93
|
+
// SECURITY (spoofable trust header / auth bypass): a generic reverse proxy
|
|
94
|
+
// (nginx / Caddy / Traefik) stamps X-Forwarded-For / X-Real-IP but does NOT
|
|
95
|
+
// set or strip the Cloudflare-specific `CF-Connecting-IP`, so a client-supplied
|
|
96
|
+
// copy passes through the proxy untouched. Honouring CF-Connecting-IP ahead of
|
|
97
|
+
// the proxy's XFF let an attacker rotate a forged header per request to defeat
|
|
98
|
+
// login-lockout / per-IP rate limits (or pin the owner's IP to lock them out).
|
|
99
|
+
// We therefore split the trust sources by an operator-declared proxy TYPE:
|
|
100
|
+
// CF-Connecting-IP is honoured only on the genuine CF edge or an explicit
|
|
101
|
+
// `cf`/cloudflared front; a generic/legacy proxy prefers the XFF it controls.
|
|
102
|
+
const trust = proxyTrust(c);
|
|
103
|
+
|
|
104
|
+
// CF-Connecting-IP is authoritative when we are provably on the Cloudflare
|
|
105
|
+
// edge (unspoofable `request.cf`) OR the operator declares a Cloudflare front
|
|
106
|
+
// (`TAKOS_TRUST_PROXY=cf`, e.g. a cloudflared tunnel where request.cf is
|
|
107
|
+
// absent at the origin). The canonical CF deployment works with no config.
|
|
108
|
+
if (isCloudflareEdge(c) || trust === "cf") {
|
|
109
|
+
const cfConnectingIp = c.req.header("CF-Connecting-IP");
|
|
110
|
+
if (cfConnectingIp && isValidIP(cfConnectingIp)) {
|
|
111
|
+
return cfConnectingIp;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// X-Forwarded-For (leftmost) / X-Real-IP: the value a trusted reverse proxy
|
|
116
|
+
// stamps. Honoured under any explicit proxy opt-in and PREFERRED over a
|
|
117
|
+
// client-supplied CF-Connecting-IP (which a generic proxy never controls).
|
|
118
|
+
if (trust === "generic" || trust === "legacy" || trust === "cf") {
|
|
119
|
+
const xff = c.req.header("X-Forwarded-For")?.split(",")[0]?.trim();
|
|
120
|
+
if (xff && isValidIP(xff)) return xff;
|
|
121
|
+
|
|
122
|
+
const xRealIp = c.req.header("X-Real-IP");
|
|
123
|
+
if (xRealIp && isValidIP(xRealIp)) return xRealIp;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Back-compat: the historical `TAKOS_TRUST_PROXY=true` opt-in honoured
|
|
127
|
+
// CF-Connecting-IP. A cloudflared tunnel sets it but ALSO stamps XFF, so the
|
|
128
|
+
// block above already covered the common case; keep CF-Connecting-IP as a
|
|
129
|
+
// last-resort fallback ONLY for the ambiguous legacy flag so an existing
|
|
130
|
+
// `true`-configured CF/cloudflared origin that sends only the CF header is not
|
|
131
|
+
// regressed. Explicit `generic` never honours a client-settable CF header.
|
|
132
|
+
if (trust === "legacy") {
|
|
133
|
+
const cfConnectingIp = c.req.header("CF-Connecting-IP");
|
|
134
|
+
if (cfConnectingIp && isValidIP(cfConnectingIp)) {
|
|
135
|
+
return cfConnectingIp;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Last resort: the authentic TCP peer address the self-host (Bun) entrypoint
|
|
140
|
+
// stamps onto the ExecutionContext props. Unlike a header it is NOT
|
|
141
|
+
// client-controllable, so it is safe to trust without an opt-in. This keeps a
|
|
142
|
+
// directly-exposed self-host from collapsing every caller into one "unknown"
|
|
143
|
+
// bucket (which would let any attacker trip the single owner's login-lockout).
|
|
144
|
+
// On Cloudflare the worker runs without our entrypoint, so this is simply
|
|
145
|
+
// absent there and the CF-Connecting-IP path above already returned.
|
|
146
|
+
const socketIp = stampedSocketIp(c);
|
|
147
|
+
if (socketIp) return socketIp;
|
|
148
|
+
|
|
149
|
+
return "unknown";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Read the server-stamped TCP peer address from the ExecutionContext props, if
|
|
154
|
+
* present and valid. `c.executionCtx` throws when the context was created
|
|
155
|
+
* without one (e.g. some unit tests), so access it defensively.
|
|
156
|
+
*/
|
|
157
|
+
function stampedSocketIp(
|
|
158
|
+
c: Context<{ Bindings: Env; Variables: Variables }>,
|
|
159
|
+
): string | null {
|
|
160
|
+
try {
|
|
161
|
+
const ctx = c.executionCtx as unknown as {
|
|
162
|
+
props?: { socketIp?: unknown };
|
|
163
|
+
};
|
|
164
|
+
const ip = ctx?.props?.socketIp;
|
|
165
|
+
return typeof ip === "string" && isValidIP(ip) ? ip : null;
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared community read-gate helper.
|
|
3
|
+
*
|
|
4
|
+
* Community-scoped Notes are stored with `visibility = "public"` (so the normal
|
|
5
|
+
* public/followers/direct visibility checks treat them as openly readable) but
|
|
6
|
+
* carry a non-empty `audienceJson = [communityApId]`. That non-empty audience is
|
|
7
|
+
* what keeps them out of the public / home / following feeds (which filter on
|
|
8
|
+
* `audienceJson = "[]"`).
|
|
9
|
+
*
|
|
10
|
+
* For a PRIVATE community this is not enough: a single-object fetch (GET a post,
|
|
11
|
+
* a reply, or an `/ap/objects/:id`) bypasses the audience filter entirely, so a
|
|
12
|
+
* "public"-visibility community post would leak to any caller. This module
|
|
13
|
+
* centralizes the membership gate those single-object paths must apply on top of
|
|
14
|
+
* the normal visibility check.
|
|
15
|
+
*
|
|
16
|
+
* Membership model: `community_members` has no status column — the presence of a
|
|
17
|
+
* row IS the acceptance (this mirrors `resolveCommunityRead` in routes/timeline.ts).
|
|
18
|
+
* A private community is readable only by an actor with a `community_members` row.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { and, eq, inArray } from "drizzle-orm";
|
|
22
|
+
import type { Database } from "../../db/index.ts";
|
|
23
|
+
import { communities, communityMembers } from "../../db/index.ts";
|
|
24
|
+
import { objects } from "../../db/index.ts";
|
|
25
|
+
import { safeJsonParse } from "../federation-helpers.ts";
|
|
26
|
+
import { chunkForInClause } from "./chunk.ts";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Drizzle predicate that matches only objects with NO extra audience (i.e. not
|
|
30
|
+
* community-scoped and not otherwise addressed). Anonymous LIST queries should
|
|
31
|
+
* AND this in so community / addressed posts never appear in a public feed.
|
|
32
|
+
*
|
|
33
|
+
* This is exactly `eq(objects.audienceJson, "[]")`; exported as a named constant
|
|
34
|
+
* so every list query references the same canonical condition.
|
|
35
|
+
*/
|
|
36
|
+
export const NO_AUDIENCE_PREDICATE = eq(objects.audienceJson, "[]");
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The community `visibility` values that REQUIRE membership to read. Single
|
|
40
|
+
* source of truth shared by JS predicates ({@link communityRequiresMembership})
|
|
41
|
+
* AND drizzle `inArray` gates, so the membership rule cannot drift per surface.
|
|
42
|
+
*
|
|
43
|
+
* The read-gate previously keyed on `=== "private"` (single-object / batched
|
|
44
|
+
* gates) while feed / roster / discovery / messages / inbox sites keyed on
|
|
45
|
+
* `!== "public"`. Those are equivalent ONLY because creation validation
|
|
46
|
+
* restricts the column to {public, private}; the moment a third members-only
|
|
47
|
+
* value is added it must be added HERE once and every gate updates atomically
|
|
48
|
+
* (otherwise the single-object gate would serve it while feeds hid it). As a
|
|
49
|
+
* `const` tuple it is usable directly in `inArray(communities.visibility, ...)`.
|
|
50
|
+
*/
|
|
51
|
+
export const MEMBERSHIP_REQUIRED_VISIBILITIES = ["private"] as const;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* True when reading a community with this `visibility` requires membership.
|
|
55
|
+
* (A non-member — including an anonymous viewer — cannot read it.)
|
|
56
|
+
*/
|
|
57
|
+
export function communityRequiresMembership(
|
|
58
|
+
visibility: string | null | undefined,
|
|
59
|
+
): boolean {
|
|
60
|
+
return (MEMBERSHIP_REQUIRED_VISIBILITIES as readonly string[]).includes(
|
|
61
|
+
visibility ?? "public",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Minimal object shape needed to evaluate the community read-gate. */
|
|
66
|
+
export type CommunityGateObject = {
|
|
67
|
+
audienceJson?: string | null;
|
|
68
|
+
communityApId?: string | null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* True when the object is addressed to a community (or otherwise carries a
|
|
73
|
+
* non-empty audience). Community-scoped Notes set `audienceJson = [communityApId]`.
|
|
74
|
+
*/
|
|
75
|
+
export function isAddressedToCommunity(obj: CommunityGateObject): boolean {
|
|
76
|
+
const audience = safeJsonParse<unknown[]>(obj.audienceJson ?? "[]", []);
|
|
77
|
+
return Array.isArray(audience) && audience.length > 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Extract the community AP IDs an object is addressed to. Prefers the explicit
|
|
82
|
+
* `communityApId` column when present, otherwise reads them out of `audienceJson`.
|
|
83
|
+
*/
|
|
84
|
+
function communityApIdsFor(obj: CommunityGateObject): string[] {
|
|
85
|
+
const ids = new Set<string>();
|
|
86
|
+
if (obj.communityApId) ids.add(obj.communityApId);
|
|
87
|
+
const audience = safeJsonParse<unknown[]>(obj.audienceJson ?? "[]", []);
|
|
88
|
+
if (Array.isArray(audience)) {
|
|
89
|
+
for (const entry of audience) {
|
|
90
|
+
if (typeof entry === "string" && entry.length > 0) ids.add(entry);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return [...ids];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Single-object community read-gate.
|
|
98
|
+
*
|
|
99
|
+
* If `obj` is addressed to a community whose `visibility` is "private", this
|
|
100
|
+
* returns `true` only when `viewerApId` is an accepted member (a row in
|
|
101
|
+
* `community_members`) of that community; otherwise it returns `true` and leaves
|
|
102
|
+
* the normal public/followers/direct visibility check to the caller.
|
|
103
|
+
*
|
|
104
|
+
* An anonymous viewer (`viewerApId` null/undefined) against a private community
|
|
105
|
+
* always returns `false`. This NEVER widens access: a non-community or
|
|
106
|
+
* public-community object short-circuits to `true` and is still subject to the
|
|
107
|
+
* caller's existing visibility gate.
|
|
108
|
+
*/
|
|
109
|
+
export async function canViewerReadObject(
|
|
110
|
+
db: Database,
|
|
111
|
+
obj: CommunityGateObject,
|
|
112
|
+
viewerApId: string | null | undefined,
|
|
113
|
+
): Promise<boolean> {
|
|
114
|
+
const communityIds = communityApIdsFor(obj);
|
|
115
|
+
if (communityIds.length === 0) return true;
|
|
116
|
+
|
|
117
|
+
// Only PRIVATE communities gate single-object reads. Resolve which (if any)
|
|
118
|
+
// of the addressed communities are private in one batched query.
|
|
119
|
+
const privateRows = await db
|
|
120
|
+
.select({ apId: communities.apId })
|
|
121
|
+
.from(communities)
|
|
122
|
+
.where(
|
|
123
|
+
and(
|
|
124
|
+
inArray(communities.apId, communityIds),
|
|
125
|
+
inArray(communities.visibility, MEMBERSHIP_REQUIRED_VISIBILITIES),
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
if (privateRows.length === 0) return true;
|
|
130
|
+
|
|
131
|
+
// Addressed to at least one private community: an anonymous viewer can never
|
|
132
|
+
// satisfy membership, so fail closed.
|
|
133
|
+
if (!viewerApId) return false;
|
|
134
|
+
|
|
135
|
+
const privateApIds = privateRows.map((r) => r.apId);
|
|
136
|
+
const membership = await db
|
|
137
|
+
.select({ communityApId: communityMembers.communityApId })
|
|
138
|
+
.from(communityMembers)
|
|
139
|
+
.where(
|
|
140
|
+
and(
|
|
141
|
+
inArray(communityMembers.communityApId, privateApIds),
|
|
142
|
+
eq(communityMembers.actorApId, viewerApId),
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
.limit(1)
|
|
146
|
+
.get();
|
|
147
|
+
|
|
148
|
+
return Boolean(membership);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Batched equivalent of {@link canViewerReadObject} for a whole page of objects.
|
|
153
|
+
*
|
|
154
|
+
* Returns the set of `apId`s that PASS the community read-gate, in TWO queries
|
|
155
|
+
* total (private-community lookup + viewer-membership lookup) instead of the
|
|
156
|
+
* 1-2 queries PER object the per-row form costs. The per-object semantics are
|
|
157
|
+
* identical: an object passes unless it is addressed to a private community the
|
|
158
|
+
* viewer is not a member of (an anonymous viewer never satisfies a private
|
|
159
|
+
* community). Objects with no community audience always pass.
|
|
160
|
+
*
|
|
161
|
+
* Both IN(...) lookups are chunked to stay under D1's 100-bound-parameter cap,
|
|
162
|
+
* since a page can address up to ~90 distinct communities.
|
|
163
|
+
*/
|
|
164
|
+
export async function communityReadableApIds<
|
|
165
|
+
T extends CommunityGateObject & { apId: string },
|
|
166
|
+
>(
|
|
167
|
+
db: Database,
|
|
168
|
+
objs: T[],
|
|
169
|
+
viewerApId: string | null | undefined,
|
|
170
|
+
): Promise<Set<string>> {
|
|
171
|
+
const readable = new Set<string>();
|
|
172
|
+
const perObjectCommunityIds = new Map<string, string[]>();
|
|
173
|
+
const unionIds = new Set<string>();
|
|
174
|
+
for (const o of objs) {
|
|
175
|
+
const ids = communityApIdsFor(o);
|
|
176
|
+
perObjectCommunityIds.set(o.apId, ids);
|
|
177
|
+
for (const id of ids) unionIds.add(id);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// No object is community-addressed → every object passes the gate.
|
|
181
|
+
if (unionIds.size === 0) {
|
|
182
|
+
for (const o of objs) readable.add(o.apId);
|
|
183
|
+
return readable;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Which addressed communities are private? (only private communities gate)
|
|
187
|
+
const privateSet = new Set<string>();
|
|
188
|
+
for (const chunk of chunkForInClause([...unionIds])) {
|
|
189
|
+
const rows = await db
|
|
190
|
+
.select({ apId: communities.apId })
|
|
191
|
+
.from(communities)
|
|
192
|
+
.where(
|
|
193
|
+
and(
|
|
194
|
+
inArray(communities.apId, chunk),
|
|
195
|
+
inArray(communities.visibility, MEMBERSHIP_REQUIRED_VISIBILITIES),
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
for (const r of rows) privateSet.add(r.apId);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Which of those private communities is the viewer a member of?
|
|
202
|
+
const viewerMemberSet = new Set<string>();
|
|
203
|
+
if (viewerApId && privateSet.size > 0) {
|
|
204
|
+
for (const chunk of chunkForInClause([...privateSet])) {
|
|
205
|
+
const rows = await db
|
|
206
|
+
.select({ communityApId: communityMembers.communityApId })
|
|
207
|
+
.from(communityMembers)
|
|
208
|
+
.where(
|
|
209
|
+
and(
|
|
210
|
+
inArray(communityMembers.communityApId, chunk),
|
|
211
|
+
eq(communityMembers.actorApId, viewerApId),
|
|
212
|
+
),
|
|
213
|
+
);
|
|
214
|
+
for (const r of rows) viewerMemberSet.add(r.communityApId);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const o of objs) {
|
|
219
|
+
const privateForObj = (perObjectCommunityIds.get(o.apId) ?? []).filter(
|
|
220
|
+
(id) => privateSet.has(id),
|
|
221
|
+
);
|
|
222
|
+
if (privateForObj.length === 0) {
|
|
223
|
+
readable.add(o.apId); // not addressed to any private community
|
|
224
|
+
} else if (privateForObj.some((id) => viewerMemberSet.has(id))) {
|
|
225
|
+
readable.add(o.apId); // member of at least one of its private communities
|
|
226
|
+
}
|
|
227
|
+
// else: addressed to a private community the viewer is not in → excluded.
|
|
228
|
+
}
|
|
229
|
+
return readable;
|
|
230
|
+
}
|