@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,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
|
+
}
|