@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.
Files changed (185) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +82 -0
  3. package/migrations/0001_init.sql +495 -0
  4. package/migrations/0002_social_remote_actor_edges.sql +92 -0
  5. package/migrations/0003_activity_remote_object_edges.sql +68 -0
  6. package/migrations/0004_blocklist.sql +26 -0
  7. package/migrations/0005_story_community_scope.sql +13 -0
  8. package/migrations/0006_dm_community_read_status.sql +19 -0
  9. package/migrations/0007_moderation_reports.sql +22 -0
  10. package/migrations/0008_actor_fields_aka.sql +18 -0
  11. package/migrations/0009_object_tags.sql +13 -0
  12. package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
  13. package/migrations/0011_drop_remote_actor_fks.sql +205 -0
  14. package/migrations/0012_objects_content_fts.sql +39 -0
  15. package/migrations/0013_efficiency_indexes.sql +13 -0
  16. package/migrations/0014_inbox_actor_created_idx.sql +15 -0
  17. package/migrations/0015_community_bans.sql +16 -0
  18. package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
  19. package/migrations/0017_mobile_push_registrations.sql +22 -0
  20. package/migrations/README.md +122 -0
  21. package/package.json +75 -0
  22. package/packages/api/LICENSE +16 -0
  23. package/packages/api/package.json +30 -0
  24. package/packages/api/src/index.ts +4 -0
  25. package/packages/api/src/lib/api/account.ts +20 -0
  26. package/packages/api/src/lib/api/actors.ts +149 -0
  27. package/packages/api/src/lib/api/auth.ts +46 -0
  28. package/packages/api/src/lib/api/communities.ts +329 -0
  29. package/packages/api/src/lib/api/dm.test.ts +67 -0
  30. package/packages/api/src/lib/api/dm.ts +236 -0
  31. package/packages/api/src/lib/api/fetch.ts +111 -0
  32. package/packages/api/src/lib/api/follow.ts +30 -0
  33. package/packages/api/src/lib/api/media.ts +100 -0
  34. package/packages/api/src/lib/api/moderation.ts +98 -0
  35. package/packages/api/src/lib/api/normalize.ts +71 -0
  36. package/packages/api/src/lib/api/notifications.test.ts +63 -0
  37. package/packages/api/src/lib/api/notifications.ts +61 -0
  38. package/packages/api/src/lib/api/posts.test.ts +110 -0
  39. package/packages/api/src/lib/api/posts.ts +181 -0
  40. package/packages/api/src/lib/api/recommendations.ts +22 -0
  41. package/packages/api/src/lib/api/search.ts +88 -0
  42. package/packages/api/src/lib/api/stories.ts +80 -0
  43. package/packages/api/src/lib/api.ts +15 -0
  44. package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
  45. package/packages/api/src/lib/transport.ts +40 -0
  46. package/packages/api/src/social-server.ts +47 -0
  47. package/packages/api/src/types/index.ts +185 -0
  48. package/scripts/apply-takosumi-migrations.ts +621 -0
  49. package/src/backend/federation-helpers.ts +36 -0
  50. package/src/backend/index.ts +872 -0
  51. package/src/backend/lib/account-migration.ts +106 -0
  52. package/src/backend/lib/activitypub-actor-cache.ts +238 -0
  53. package/src/backend/lib/activitypub-helpers.ts +131 -0
  54. package/src/backend/lib/activitypub-validators.ts +323 -0
  55. package/src/backend/lib/ap-context.ts +16 -0
  56. package/src/backend/lib/ap-ids.ts +101 -0
  57. package/src/backend/lib/ap-response.ts +30 -0
  58. package/src/backend/lib/ap-signing.ts +87 -0
  59. package/src/backend/lib/ap-verify.ts +670 -0
  60. package/src/backend/lib/auth-lockout.ts +230 -0
  61. package/src/backend/lib/backend-paths.ts +34 -0
  62. package/src/backend/lib/base64.ts +30 -0
  63. package/src/backend/lib/blocklist-purge.ts +109 -0
  64. package/src/backend/lib/blocklist.ts +279 -0
  65. package/src/backend/lib/chunk.ts +33 -0
  66. package/src/backend/lib/client-ip.ts +169 -0
  67. package/src/backend/lib/community-visibility.ts +230 -0
  68. package/src/backend/lib/crypto.ts +424 -0
  69. package/src/backend/lib/delivery/circuit.ts +265 -0
  70. package/src/backend/lib/delivery/metrics.ts +30 -0
  71. package/src/backend/lib/delivery/planner.ts +190 -0
  72. package/src/backend/lib/delivery/queue-batching.ts +626 -0
  73. package/src/backend/lib/delivery/queue-delivery.ts +641 -0
  74. package/src/backend/lib/delivery/queue.ts +576 -0
  75. package/src/backend/lib/delivery/transformers.ts +56 -0
  76. package/src/backend/lib/delivery/types.ts +139 -0
  77. package/src/backend/lib/errors.ts +114 -0
  78. package/src/backend/lib/federation-fetch.ts +296 -0
  79. package/src/backend/lib/feed-cursor.ts +57 -0
  80. package/src/backend/lib/feed-exclude.ts +48 -0
  81. package/src/backend/lib/hex.ts +8 -0
  82. package/src/backend/lib/log-mask.ts +213 -0
  83. package/src/backend/lib/logger.ts +285 -0
  84. package/src/backend/lib/mobile-contract.ts +137 -0
  85. package/src/backend/lib/oauth-providers.ts +324 -0
  86. package/src/backend/lib/oauth-utils.ts +148 -0
  87. package/src/backend/lib/oidc-id-token.ts +151 -0
  88. package/src/backend/lib/parse-helpers.ts +31 -0
  89. package/src/backend/lib/post-visibility.ts +190 -0
  90. package/src/backend/lib/session-actor.ts +61 -0
  91. package/src/backend/lib/ssrf.ts +428 -0
  92. package/src/backend/lib/strip-image-metadata.ts +191 -0
  93. package/src/backend/middleware/bearer-auth.ts +70 -0
  94. package/src/backend/middleware/body-limit.ts +212 -0
  95. package/src/backend/middleware/cache.ts +429 -0
  96. package/src/backend/middleware/csrf.ts +130 -0
  97. package/src/backend/middleware/error-handler.ts +77 -0
  98. package/src/backend/middleware/rate-limit.ts +308 -0
  99. package/src/backend/public.ts +21 -0
  100. package/src/backend/routes/account-teardown.ts +430 -0
  101. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
  102. package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
  103. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
  104. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
  105. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
  106. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
  107. package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
  108. package/src/backend/routes/activitypub/inbox-types.ts +74 -0
  109. package/src/backend/routes/activitypub/inbox.ts +1191 -0
  110. package/src/backend/routes/activitypub/outbox.ts +0 -0
  111. package/src/backend/routes/activitypub/query-helpers.ts +227 -0
  112. package/src/backend/routes/activitypub.ts +616 -0
  113. package/src/backend/routes/actors-helpers.ts +487 -0
  114. package/src/backend/routes/actors.ts +1311 -0
  115. package/src/backend/routes/apps.ts +313 -0
  116. package/src/backend/routes/auth-helpers.ts +566 -0
  117. package/src/backend/routes/auth.ts +615 -0
  118. package/src/backend/routes/communities/membership-invites.ts +208 -0
  119. package/src/backend/routes/communities/membership-join.ts +335 -0
  120. package/src/backend/routes/communities/membership-members.ts +539 -0
  121. package/src/backend/routes/communities/membership-requests.ts +296 -0
  122. package/src/backend/routes/communities/membership-shared.ts +364 -0
  123. package/src/backend/routes/communities/messages.ts +479 -0
  124. package/src/backend/routes/communities/routes.ts +624 -0
  125. package/src/backend/routes/communities.ts +21 -0
  126. package/src/backend/routes/dm/contacts.ts +525 -0
  127. package/src/backend/routes/dm/conversations-helpers.ts +197 -0
  128. package/src/backend/routes/dm/conversations.ts +25 -0
  129. package/src/backend/routes/dm/messages.ts +658 -0
  130. package/src/backend/routes/dm/query-helpers.ts +85 -0
  131. package/src/backend/routes/dm/read-archive.ts +228 -0
  132. package/src/backend/routes/dm/requests.ts +222 -0
  133. package/src/backend/routes/dm/typing.ts +81 -0
  134. package/src/backend/routes/dm.ts +15 -0
  135. package/src/backend/routes/follow-helpers.ts +370 -0
  136. package/src/backend/routes/follow.ts +588 -0
  137. package/src/backend/routes/media.ts +692 -0
  138. package/src/backend/routes/mobile.ts +159 -0
  139. package/src/backend/routes/moderation.ts +373 -0
  140. package/src/backend/routes/notifications.ts +757 -0
  141. package/src/backend/routes/posts/delete-cascade.ts +330 -0
  142. package/src/backend/routes/posts/interactions.ts +795 -0
  143. package/src/backend/routes/posts/post-helpers.ts +847 -0
  144. package/src/backend/routes/posts/queries.ts +537 -0
  145. package/src/backend/routes/posts/routes.ts +865 -0
  146. package/src/backend/routes/posts/transformers.ts +161 -0
  147. package/src/backend/routes/posts.ts +17 -0
  148. package/src/backend/routes/recommendations.ts +88 -0
  149. package/src/backend/routes/search.ts +730 -0
  150. package/src/backend/routes/stories/interactions.ts +576 -0
  151. package/src/backend/routes/stories/query-helpers.ts +482 -0
  152. package/src/backend/routes/stories/routes.ts +906 -0
  153. package/src/backend/routes/stories.ts +13 -0
  154. package/src/backend/routes/takos-tools/dm.ts +249 -0
  155. package/src/backend/routes/takos-tools/follows.ts +225 -0
  156. package/src/backend/routes/takos-tools/posts.ts +292 -0
  157. package/src/backend/routes/takos-tools/search.ts +228 -0
  158. package/src/backend/routes/takos-tools/timeline.ts +132 -0
  159. package/src/backend/routes/takos-tools/types.ts +10 -0
  160. package/src/backend/routes/takos-tools-response.ts +178 -0
  161. package/src/backend/routes/takos-tools.ts +153 -0
  162. package/src/backend/routes/timeline.ts +755 -0
  163. package/src/backend/runtime/bun.ts +620 -0
  164. package/src/backend/runtime/cloudflare.ts +202 -0
  165. package/src/backend/runtime/compat-bun/types.ts +44 -0
  166. package/src/backend/runtime/memory-kv.ts +104 -0
  167. package/src/backend/runtime/shared.ts +142 -0
  168. package/src/backend/runtime/types.ts +205 -0
  169. package/src/backend/server.ts +636 -0
  170. package/src/backend/types.ts +143 -0
  171. package/src/db/index.ts +97 -0
  172. package/src/db/schema/actors.ts +129 -0
  173. package/src/db/schema/communities.ts +133 -0
  174. package/src/db/schema/date-utils.ts +17 -0
  175. package/src/db/schema/index.ts +17 -0
  176. package/src/db/schema/messaging.ts +241 -0
  177. package/src/db/schema/mobile.ts +37 -0
  178. package/src/db/schema/posts.ts +150 -0
  179. package/src/db/schema/relations.ts +266 -0
  180. package/src/db/schema/reports.ts +33 -0
  181. package/src/db/schema/social.ts +106 -0
  182. package/src/db/schema/stories.ts +70 -0
  183. package/src/db/schema.ts +15 -0
  184. package/src/plugin/public.ts +7 -0
  185. 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
+ }