@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,191 @@
1
+ /**
2
+ * Strip privacy-sensitive metadata from an uploaded raster image at ingest.
3
+ *
4
+ * Phone cameras embed EXIF GPS coordinates, capture timestamps and camera serial
5
+ * numbers in uploaded JPEGs (and, less commonly, PNG/WebP). yurucommu serves
6
+ * uploaded media verbatim under a 1-year public cache, so a public post or
7
+ * profile image would otherwise leak the poster's location — a recognised
8
+ * fediverse-class privacy defect (Mastodon strips image metadata server-side for
9
+ * exactly this reason). This module removes the metadata segments WITHOUT
10
+ * re-encoding the pixels, so the visible image is unchanged.
11
+ *
12
+ * Pure byte-surgery (no native image library): it walks the container structure
13
+ * and drops only metadata segments/chunks, copying everything else verbatim. It
14
+ * NEVER corrupts: on any structural surprise it returns the original bytes
15
+ * unchanged (the magic-byte validation has already confirmed the declared type).
16
+ *
17
+ * Covered: JPEG (APP1 EXIF/XMP, APP13 IPTC, COM), PNG (tEXt/zTXt/iTXt/eXIf/tIME),
18
+ * WebP (EXIF / XMP chunks). GIF and video are passed through unchanged (GIF
19
+ * rarely carries geotags; video metadata stripping needs a transcode pipeline).
20
+ */
21
+
22
+ /** Strip metadata for a supported raster image; pass other types through. */
23
+ export function stripImageMetadata(
24
+ bytes: Uint8Array,
25
+ mimeType: string,
26
+ ): Uint8Array {
27
+ try {
28
+ switch (mimeType) {
29
+ case "image/jpeg":
30
+ return stripJpeg(bytes);
31
+ case "image/png":
32
+ return stripPng(bytes);
33
+ case "image/webp":
34
+ return stripWebp(bytes);
35
+ default:
36
+ return bytes; // gif / video / unknown: unchanged
37
+ }
38
+ } catch {
39
+ // Never let a parsing surprise corrupt or drop the upload.
40
+ return bytes;
41
+ }
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // JPEG: a stream of marker segments. Drop APP1 (EXIF + XMP), APP13 (IPTC /
46
+ // Photoshop) and COM (comment); keep APP0 (JFIF), APP2 (ICC), APP14 (Adobe
47
+ // color transform), the quantization/Huffman tables, and the scan data verbatim.
48
+ // ---------------------------------------------------------------------------
49
+ function stripJpeg(bytes: Uint8Array): Uint8Array {
50
+ if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return bytes;
51
+
52
+ const out: number[] = [0xff, 0xd8]; // SOI
53
+ let i = 2;
54
+
55
+ while (i + 1 < bytes.length) {
56
+ if (bytes[i] !== 0xff) return bytes; // not at a marker — bail unchanged
57
+ const marker = bytes[i + 1];
58
+
59
+ // Start of Scan: entropy-coded data runs to EOI — copy the rest verbatim.
60
+ if (marker === 0xda) {
61
+ for (let k = i; k < bytes.length; k++) out.push(bytes[k]);
62
+ return Uint8Array.from(out);
63
+ }
64
+ // Standalone markers carry no length payload.
65
+ if (
66
+ marker === 0xd8 ||
67
+ marker === 0xd9 ||
68
+ marker === 0x01 ||
69
+ (marker >= 0xd0 && marker <= 0xd7)
70
+ ) {
71
+ out.push(0xff, marker);
72
+ i += 2;
73
+ continue;
74
+ }
75
+ if (i + 3 >= bytes.length) return bytes; // truncated length — bail unchanged
76
+ const len = (bytes[i + 2] << 8) | bytes[i + 3]; // includes the 2 length bytes
77
+ if (len < 2 || i + 2 + len > bytes.length) return bytes; // malformed — bail
78
+
79
+ const drop =
80
+ marker === 0xe1 || // APP1: EXIF + XMP (the GPS carriers)
81
+ marker === 0xed || // APP13: IPTC / Photoshop
82
+ marker === 0xfe; // COM: comment
83
+ if (!drop) {
84
+ for (let k = i; k < i + 2 + len; k++) out.push(bytes[k]);
85
+ }
86
+ i += 2 + len;
87
+ }
88
+ return Uint8Array.from(out);
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // PNG: an 8-byte signature followed by length-prefixed chunks. Drop the textual
93
+ // / metadata ancillary chunks; keep critical + rendering-relevant chunks.
94
+ // ---------------------------------------------------------------------------
95
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
96
+ const PNG_DROP_CHUNKS = new Set(["tEXt", "zTXt", "iTXt", "eXIf", "tIME"]);
97
+
98
+ function stripPng(bytes: Uint8Array): Uint8Array {
99
+ if (bytes.length < 8) return bytes;
100
+ for (let i = 0; i < 8; i++) if (bytes[i] !== PNG_SIGNATURE[i]) return bytes;
101
+
102
+ const out: number[] = [...PNG_SIGNATURE];
103
+ let i = 8;
104
+ while (i + 8 <= bytes.length) {
105
+ const len =
106
+ (bytes[i] << 24) |
107
+ (bytes[i + 1] << 16) |
108
+ (bytes[i + 2] << 8) |
109
+ bytes[i + 3];
110
+ if (len < 0) return bytes; // overflow / malformed — bail unchanged
111
+ const type = String.fromCharCode(
112
+ bytes[i + 4],
113
+ bytes[i + 5],
114
+ bytes[i + 6],
115
+ bytes[i + 7],
116
+ );
117
+ const chunkEnd = i + 12 + len; // length(4) + type(4) + data(len) + crc(4)
118
+ if (chunkEnd > bytes.length) return bytes; // truncated — bail unchanged
119
+
120
+ if (!PNG_DROP_CHUNKS.has(type)) {
121
+ for (let k = i; k < chunkEnd; k++) out.push(bytes[k]);
122
+ }
123
+ i = chunkEnd;
124
+ if (type === "IEND") break;
125
+ }
126
+ return Uint8Array.from(out);
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // WebP: a RIFF container ("RIFF" <size> "WEBP" <chunks>). Drop the "EXIF" and
131
+ // "XMP " chunks, clear the matching VP8X feature-flag bits, and rewrite the RIFF
132
+ // size. Chunks are FourCC(4) + size(4, little-endian) + payload + 1 pad byte to
133
+ // an even length.
134
+ // ---------------------------------------------------------------------------
135
+ function fourCC(bytes: Uint8Array, off: number): string {
136
+ return String.fromCharCode(
137
+ bytes[off],
138
+ bytes[off + 1],
139
+ bytes[off + 2],
140
+ bytes[off + 3],
141
+ );
142
+ }
143
+
144
+ function stripWebp(bytes: Uint8Array): Uint8Array {
145
+ if (bytes.length < 16) return bytes;
146
+ if (fourCC(bytes, 0) !== "RIFF" || fourCC(bytes, 8) !== "WEBP") return bytes;
147
+
148
+ const head: number[] = [];
149
+ for (let k = 0; k < 12; k++) head.push(bytes[k]); // RIFF + size + WEBP
150
+ const body: number[] = [];
151
+ let removed = false;
152
+
153
+ let i = 12;
154
+ while (i + 8 <= bytes.length) {
155
+ const cc = fourCC(bytes, i);
156
+ const size =
157
+ bytes[i + 4] |
158
+ (bytes[i + 5] << 8) |
159
+ (bytes[i + 6] << 16) |
160
+ (bytes[i + 7] << 24);
161
+ if (size < 0) return bytes; // overflow — bail unchanged
162
+ const padded = size + (size & 1); // chunks pad to an even length
163
+ const chunkEnd = i + 8 + padded;
164
+ if (chunkEnd > bytes.length) return bytes; // truncated — bail unchanged
165
+
166
+ if (cc === "EXIF" || cc === "XMP ") {
167
+ removed = true; // skip this chunk entirely
168
+ } else {
169
+ for (let k = i; k < chunkEnd; k++) body.push(bytes[k]);
170
+ }
171
+ i = chunkEnd;
172
+ }
173
+ if (!removed) return bytes; // nothing to strip — keep original bytes
174
+
175
+ // Clear the EXIF (bit 3) / XMP (bit 2) feature flags in a VP8X header so the
176
+ // declared features match the chunks that remain. VP8X payload byte 0 holds
177
+ // the flags; it sits at body offset 8 when VP8X is the first chunk.
178
+ if (body.length >= 9 && fourCC(Uint8Array.from(body), 0) === "VP8X") {
179
+ body[8] &= ~0b00001100;
180
+ }
181
+
182
+ // Rewrite the RIFF chunk size = bytes after the 8-byte RIFF header = "WEBP"
183
+ // (4) + body.
184
+ const riffSize = 4 + body.length;
185
+ head[4] = riffSize & 0xff;
186
+ head[5] = (riffSize >> 8) & 0xff;
187
+ head[6] = (riffSize >> 16) & 0xff;
188
+ head[7] = (riffSize >> 24) & 0xff;
189
+
190
+ return Uint8Array.from([...head, ...body]);
191
+ }
@@ -0,0 +1,70 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+ import type { Env, Variables } from "../types.ts";
3
+ import {
4
+ getOidcClientCredentials,
5
+ getOidcIssuerUrl,
6
+ issuerEndpoint,
7
+ } from "../lib/oauth-providers.ts";
8
+
9
+ export function requireBearerAuth(
10
+ requiredScope: string,
11
+ ): MiddlewareHandler<{ Bindings: Env; Variables: Variables }> {
12
+ return async (c, next) => {
13
+ const auth = c.req.header("Authorization");
14
+ if (!auth?.startsWith("Bearer ")) {
15
+ return c.json({ error: "unauthorized" }, 401);
16
+ }
17
+ const token = auth.slice(7);
18
+ const issuer = getOidcIssuerUrl(c.env);
19
+ const { clientId, clientSecret } = getOidcClientCredentials(c.env);
20
+ if (!issuer || !clientId || !clientSecret) {
21
+ return c.json(
22
+ {
23
+ error: "server_error",
24
+ error_description: "Takosumi Accounts OIDC client not configured",
25
+ },
26
+ 500,
27
+ );
28
+ }
29
+
30
+ const res = await fetch(issuerEndpoint(issuer, "/oauth/introspect"), {
31
+ method: "POST",
32
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
33
+ body: new URLSearchParams({
34
+ token,
35
+ client_id: clientId,
36
+ client_secret: clientSecret,
37
+ }).toString(),
38
+ });
39
+ if (!res.ok) {
40
+ return c.json(
41
+ {
42
+ error: "server_error",
43
+ error_description: "Introspect failed",
44
+ },
45
+ 500,
46
+ );
47
+ }
48
+
49
+ const info = (await res.json()) as {
50
+ active: boolean;
51
+ scope?: string;
52
+ sub?: string;
53
+ client_id?: string;
54
+ };
55
+ if (!info.active) {
56
+ return c.json({ error: "invalid_token" }, 401);
57
+ }
58
+ const scopes = (info.scope ?? "").split(" ");
59
+ if (!scopes.includes(requiredScope)) {
60
+ return c.json({ error: "insufficient_scope" }, 403);
61
+ }
62
+
63
+ c.set("oauthToken", {
64
+ sub: info.sub ?? "",
65
+ scope: info.scope ?? "",
66
+ client_id: info.client_id ?? "",
67
+ });
68
+ await next();
69
+ };
70
+ }
@@ -0,0 +1,212 @@
1
+ import type { Context, MiddlewareHandler, Next } from "hono";
2
+
3
+ import type { Env, Variables } from "../types.ts";
4
+ import { logger } from "../lib/logger.ts";
5
+
6
+ const log = logger.child({ component: "middleware.body_limit" });
7
+
8
+ /**
9
+ * DoS body-size guard.
10
+ *
11
+ * Reads `Content-Length` before any handler runs and rejects the request with
12
+ * a `413 body_too_large` envelope when the declared body exceeds the
13
+ * configured cap. The check is conservative on purpose:
14
+ *
15
+ * - HEAD / GET / OPTIONS / DELETE without a body are skipped.
16
+ * - The cap is bytes. `1 MiB = 1024 * 1024`.
17
+ *
18
+ * Missing `Content-Length` is allowed by default because the Fetch `Request`
19
+ * constructor does not auto-populate the header for string bodies in tests,
20
+ * and many real callers omit it for chunked encoded uploads. A declared
21
+ * `Content-Length` is NOT trusted as the only line of defense: when the
22
+ * header is absent (chunked transfer-encoding), the request body is wrapped
23
+ * in a streaming counter that aborts once the byte count exceeds the cap, so
24
+ * an attacker cannot bypass the limit by simply omitting `Content-Length`.
25
+ * Set `requireContentLength: true` on routes that must refuse chunked-only
26
+ * requests outright with 411 instead (= the strict mode rejects the request
27
+ * before the body is even read).
28
+ *
29
+ * Per-route caps stack on top of the global default cap — the middleware is
30
+ * normally registered globally first, with stricter caps mounted on specific
31
+ * route paths afterward. The first registered middleware wins because Hono
32
+ * runs middleware in registration order; a per-route override should
33
+ * therefore be mounted BEFORE the global gate when it needs to LIFT the
34
+ * cap (e.g. media uploads). When it merely TIGHTENS the cap, mounting it
35
+ * after the global gate also works because the global gate already let the
36
+ * smaller body through.
37
+ */
38
+
39
+ const BODY_BEARING_METHODS = new Set(["POST", "PUT", "PATCH"]);
40
+
41
+ export interface BodyLimitOptions {
42
+ /** Maximum body size in bytes. */
43
+ maxBytes: number;
44
+ /**
45
+ * When true, requests without a `Content-Length` header are rejected with
46
+ * 411 on body-bearing methods. Defaults to false — the middleware then
47
+ * only validates the header value when present and lets chunked-only
48
+ * traffic through to the next layer (which can still enforce a stream
49
+ * cap).
50
+ */
51
+ requireContentLength?: boolean;
52
+ }
53
+
54
+ export const DEFAULT_BODY_LIMIT_BYTES = 1 * 1024 * 1024; // 1 MiB
55
+
56
+ function parseContentLength(header: string | null): number | null {
57
+ if (!header) return null;
58
+ const trimmed = header.trim();
59
+ if (!trimmed) return null;
60
+ // RFC 7230 forbids non-digit characters; treat anything else as missing
61
+ // rather than zero (zero would silently pass the cap).
62
+ if (!/^[0-9]+$/.test(trimmed)) return null;
63
+ const parsed = Number.parseInt(trimmed, 10);
64
+ if (!Number.isFinite(parsed) || parsed < 0) return null;
65
+ return parsed;
66
+ }
67
+
68
+ export type BodyLimitDecision =
69
+ | { ok: true }
70
+ | {
71
+ ok: false;
72
+ reason: "body_too_large" | "body_length_required";
73
+ limit: number;
74
+ declared: number | null;
75
+ };
76
+
77
+ /**
78
+ * Pure decision function. Exposed for unit tests; the middleware below is a
79
+ * thin Hono wrapper.
80
+ */
81
+ export function evaluateBodyLimit(
82
+ request: Request,
83
+ options: BodyLimitOptions,
84
+ ): BodyLimitDecision {
85
+ const method = request.method.toUpperCase();
86
+ if (!BODY_BEARING_METHODS.has(method)) return { ok: true };
87
+
88
+ const declared = parseContentLength(request.headers.get("content-length"));
89
+ if (declared === null) {
90
+ if (!options.requireContentLength) return { ok: true };
91
+ return {
92
+ ok: false,
93
+ reason: "body_length_required",
94
+ limit: options.maxBytes,
95
+ declared: null,
96
+ };
97
+ }
98
+ if (declared > options.maxBytes) {
99
+ return {
100
+ ok: false,
101
+ reason: "body_too_large",
102
+ limit: options.maxBytes,
103
+ declared,
104
+ };
105
+ }
106
+ return { ok: true };
107
+ }
108
+
109
+ /** Sentinel error thrown by the stream counter when the cap is exceeded. */
110
+ export class BodyTooLargeError extends Error {
111
+ readonly limit: number;
112
+ constructor(limit: number) {
113
+ super(`Request body exceeds the ${limit} byte cap`);
114
+ this.name = "BodyTooLargeError";
115
+ this.limit = limit;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Wrap a request body stream so that it errors (cancelling the source) once
121
+ * more than `maxBytes` have flowed through. This closes the chunked-transfer
122
+ * bypass: a request without a trusted `Content-Length` is still capped while
123
+ * its body is consumed downstream, instead of relying on the (omittable)
124
+ * header alone.
125
+ */
126
+ export function capRequestBodyStream(
127
+ request: Request,
128
+ maxBytes: number,
129
+ ): Request {
130
+ const body = request.body;
131
+ if (!body) return request;
132
+
133
+ let seen = 0;
134
+ const capped = body.pipeThrough(
135
+ new TransformStream<Uint8Array, Uint8Array>({
136
+ transform(chunk, controller) {
137
+ seen += chunk.byteLength;
138
+ if (seen > maxBytes) {
139
+ controller.error(new BodyTooLargeError(maxBytes));
140
+ return;
141
+ }
142
+ controller.enqueue(chunk);
143
+ },
144
+ }),
145
+ );
146
+
147
+ // `duplex: "half"` is required when constructing a Request with a stream
148
+ // body. The cloned Request keeps method / headers / url so downstream
149
+ // handlers and signature verification see an unchanged request shape.
150
+ return new Request(request.url, {
151
+ method: request.method,
152
+ headers: request.headers,
153
+ body: capped,
154
+ redirect: request.redirect,
155
+ signal: request.signal,
156
+ // @ts-expect-error duplex is a valid RequestInit field at runtime but is
157
+ // not yet in the lib.dom typings shipped with this TypeScript target.
158
+ duplex: "half",
159
+ });
160
+ }
161
+
162
+ export function bodyLimit(
163
+ options: BodyLimitOptions,
164
+ ): MiddlewareHandler<{ Bindings: Env; Variables: Variables }> {
165
+ return async (
166
+ c: Context<{ Bindings: Env; Variables: Variables }>,
167
+ next: Next,
168
+ ) => {
169
+ const decision = evaluateBodyLimit(c.req.raw, options);
170
+ if (decision.ok) {
171
+ // The header check above only guards declared `Content-Length`. For
172
+ // body-bearing requests that omit it (chunked transfer-encoding), wrap
173
+ // the stream so the cap is still enforced as the body is consumed.
174
+ const method = c.req.method.toUpperCase();
175
+ if (
176
+ BODY_BEARING_METHODS.has(method) &&
177
+ c.req.raw.headers.get("content-length") === null &&
178
+ c.req.raw.body !== null
179
+ ) {
180
+ const capped = capRequestBodyStream(c.req.raw, options.maxBytes);
181
+ if (capped !== c.req.raw) {
182
+ // Replace the underlying Request so downstream `c.req.*` readers
183
+ // consume the length-capped stream.
184
+ c.req.raw = capped;
185
+ }
186
+ }
187
+ return await next();
188
+ }
189
+
190
+ log.warn("body limit exceeded", {
191
+ event: "body_limit.rejected",
192
+ reason: decision.reason,
193
+ limit: decision.limit,
194
+ declared: decision.declared,
195
+ path: new URL(c.req.url).pathname,
196
+ method: c.req.method,
197
+ });
198
+
199
+ const status = decision.reason === "body_length_required" ? 411 : 413;
200
+ return c.json(
201
+ {
202
+ error: decision.reason,
203
+ message:
204
+ decision.reason === "body_too_large"
205
+ ? `Request body exceeds the ${decision.limit} byte cap`
206
+ : "Content-Length header is required",
207
+ limit: decision.limit,
208
+ },
209
+ status,
210
+ );
211
+ };
212
+ }