@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,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caching Middleware for Yurucommu Backend
|
|
3
|
+
*
|
|
4
|
+
* Supports multiple runtimes:
|
|
5
|
+
* - Cloudflare Workers: Uses Cache API
|
|
6
|
+
* - Node.js/Bun: Uses in-memory LRU cache
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - TTL-based caching
|
|
10
|
+
* - ETag and Last-Modified headers
|
|
11
|
+
* - Conditional requests
|
|
12
|
+
* - Cache invalidation
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Context, MiddlewareHandler, Next } from "hono";
|
|
16
|
+
import type { Env, Variables } from "../types.ts";
|
|
17
|
+
import { logger } from "../lib/logger.ts";
|
|
18
|
+
import { bytesToHex } from "../lib/hex.ts";
|
|
19
|
+
|
|
20
|
+
const log = logger.child({ component: "middleware.cache" });
|
|
21
|
+
|
|
22
|
+
declare global {
|
|
23
|
+
interface CacheStorage {
|
|
24
|
+
default: Cache;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Types
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
type HonoContext = Context<{ Bindings: Env; Variables: Variables }>;
|
|
33
|
+
type HonoMiddleware = MiddlewareHandler<{
|
|
34
|
+
Bindings: Env;
|
|
35
|
+
Variables: Variables;
|
|
36
|
+
}>;
|
|
37
|
+
|
|
38
|
+
interface CacheConfig {
|
|
39
|
+
/** Time-to-live in seconds */
|
|
40
|
+
ttl: number;
|
|
41
|
+
/** Whether to include query params in cache key (default: true) */
|
|
42
|
+
includeQueryParams?: boolean;
|
|
43
|
+
/** Specific query params to include (if not specified, all are included) */
|
|
44
|
+
queryParamsToInclude?: string[];
|
|
45
|
+
/** Cache tag for invalidation grouping */
|
|
46
|
+
cacheTag?: string;
|
|
47
|
+
/** Whether to add stale-while-revalidate (default: false) */
|
|
48
|
+
staleWhileRevalidate?: number;
|
|
49
|
+
/** Whether to vary cache by authenticated actor */
|
|
50
|
+
varyByActor?: boolean;
|
|
51
|
+
/** Custom cache key generator */
|
|
52
|
+
cacheKeyGenerator?: (c: HonoContext) => string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const CacheTTL = {
|
|
56
|
+
/** Public timeline (2 minutes) - frequently updated */
|
|
57
|
+
PUBLIC_TIMELINE: 120,
|
|
58
|
+
/** Actor profile data (5 minutes) */
|
|
59
|
+
ACTOR_PROFILE: 300,
|
|
60
|
+
/** ActivityPub actor JSON (10 minutes) */
|
|
61
|
+
ACTIVITYPUB_ACTOR: 600,
|
|
62
|
+
/** WebFinger response (1 hour) */
|
|
63
|
+
WEBFINGER: 3600,
|
|
64
|
+
/** Community info (5 minutes) */
|
|
65
|
+
COMMUNITY: 300,
|
|
66
|
+
/** Search results (1 minute) */
|
|
67
|
+
SEARCH: 60,
|
|
68
|
+
} as const;
|
|
69
|
+
|
|
70
|
+
export const CacheTags = {
|
|
71
|
+
TIMELINE: "timeline",
|
|
72
|
+
ACTOR: "actor",
|
|
73
|
+
COMMUNITY: "community",
|
|
74
|
+
WEBFINGER: "webfinger",
|
|
75
|
+
} as const;
|
|
76
|
+
|
|
77
|
+
// ============================================================================
|
|
78
|
+
// In-Memory LRU Cache (for non-Cloudflare runtimes)
|
|
79
|
+
// ============================================================================
|
|
80
|
+
|
|
81
|
+
interface CacheEntry {
|
|
82
|
+
body: string;
|
|
83
|
+
headers: Record<string, string>;
|
|
84
|
+
status: number;
|
|
85
|
+
expiresAt: number;
|
|
86
|
+
etag: string;
|
|
87
|
+
lastModified: string;
|
|
88
|
+
tag?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
class LRUCache {
|
|
92
|
+
private cache: Map<string, CacheEntry> = new Map();
|
|
93
|
+
private maxSize: number;
|
|
94
|
+
|
|
95
|
+
constructor(maxSize: number = 1000) {
|
|
96
|
+
this.maxSize = maxSize;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
get(key: string): CacheEntry | undefined {
|
|
100
|
+
const entry = this.cache.get(key);
|
|
101
|
+
if (!entry) return undefined;
|
|
102
|
+
|
|
103
|
+
if (Date.now() > entry.expiresAt) {
|
|
104
|
+
this.cache.delete(key);
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Move to end (most recently used)
|
|
109
|
+
this.cache.delete(key);
|
|
110
|
+
this.cache.set(key, entry);
|
|
111
|
+
|
|
112
|
+
return entry;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
set(key: string, entry: CacheEntry): void {
|
|
116
|
+
while (this.cache.size >= this.maxSize) {
|
|
117
|
+
const firstKey = this.cache.keys().next().value;
|
|
118
|
+
if (firstKey) {
|
|
119
|
+
this.cache.delete(firstKey);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this.cache.set(key, entry);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
cleanup(): void {
|
|
127
|
+
const now = Date.now();
|
|
128
|
+
for (const [key, entry] of this.cache) {
|
|
129
|
+
if (now > entry.expiresAt) {
|
|
130
|
+
this.cache.delete(key);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const memoryCache = new LRUCache(1000);
|
|
137
|
+
|
|
138
|
+
const CLEANUP_INTERVAL = 5 * 60 * 1000;
|
|
139
|
+
let lastCleanup = Date.now();
|
|
140
|
+
|
|
141
|
+
function maybeCleanup(): void {
|
|
142
|
+
const now = Date.now();
|
|
143
|
+
if (now - lastCleanup >= CLEANUP_INTERVAL) {
|
|
144
|
+
lastCleanup = now;
|
|
145
|
+
memoryCache.cleanup();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ============================================================================
|
|
150
|
+
// Shared Helpers
|
|
151
|
+
// ============================================================================
|
|
152
|
+
|
|
153
|
+
// Exported for tests: the per-viewer (varyByActor) dimension MUST land in the
|
|
154
|
+
// query string, never a URL fragment — Cloudflare's Cache API strips fragments,
|
|
155
|
+
// which would collapse all viewers onto one key (see the comment below).
|
|
156
|
+
export function generateCacheKey(c: HonoContext, config: CacheConfig): string {
|
|
157
|
+
if (config.cacheKeyGenerator) {
|
|
158
|
+
return config.cacheKeyGenerator(c);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const url = new URL(c.req.url);
|
|
162
|
+
let cacheKey = url.pathname;
|
|
163
|
+
|
|
164
|
+
if (config.includeQueryParams !== false) {
|
|
165
|
+
const params = new URLSearchParams();
|
|
166
|
+
|
|
167
|
+
if (config.queryParamsToInclude) {
|
|
168
|
+
for (const key of config.queryParamsToInclude) {
|
|
169
|
+
const value = url.searchParams.get(key);
|
|
170
|
+
if (value !== null) {
|
|
171
|
+
params.set(key, value);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
const sortedKeys = Array.from(url.searchParams.keys()).sort();
|
|
176
|
+
for (const key of sortedKeys) {
|
|
177
|
+
params.set(key, url.searchParams.get(key)!);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const queryString = params.toString();
|
|
182
|
+
if (queryString) {
|
|
183
|
+
cacheKey += `?${queryString}`;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (config.varyByActor) {
|
|
188
|
+
const actor = c.get("actor");
|
|
189
|
+
const actorVal = actor ? actor.ap_id : "anonymous";
|
|
190
|
+
// Fold the per-viewer dimension into the QUERY STRING, NOT a URL fragment.
|
|
191
|
+
// On Cloudflare the Cache API strips the fragment during match()/put(), so a
|
|
192
|
+
// `#actor:` suffix collapsed ALL viewers onto one key and served viewer A's
|
|
193
|
+
// private (authenticated, per-viewer) response to viewer B for the TTL. A
|
|
194
|
+
// query param is part of the real cache key on both the CF and memory paths.
|
|
195
|
+
cacheKey += `${cacheKey.includes("?") ? "&" : "?"}__actor=${encodeURIComponent(
|
|
196
|
+
actorVal,
|
|
197
|
+
)}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return cacheKey;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function generateETag(body: string): Promise<string> {
|
|
204
|
+
const data = new TextEncoder().encode(body);
|
|
205
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
206
|
+
const hashHex = bytesToHex(new Uint8Array(hashBuffer));
|
|
207
|
+
return `"${hashHex.substring(0, 16)}"`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function buildCacheControl(config: CacheConfig): string {
|
|
211
|
+
let value = `public, max-age=${config.ttl}`;
|
|
212
|
+
if (config.staleWhileRevalidate) {
|
|
213
|
+
value += `, stale-while-revalidate=${config.staleWhileRevalidate}`;
|
|
214
|
+
}
|
|
215
|
+
return value;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Check If-None-Match and If-Modified-Since conditional request headers.
|
|
220
|
+
* Returns true if the client's cached copy is still fresh (caller should respond 304).
|
|
221
|
+
*/
|
|
222
|
+
function isConditionalHit(
|
|
223
|
+
c: HonoContext,
|
|
224
|
+
etag: string | null,
|
|
225
|
+
lastModified: string | null,
|
|
226
|
+
): boolean {
|
|
227
|
+
const ifNoneMatch = c.req.header("If-None-Match");
|
|
228
|
+
if (ifNoneMatch && etag && ifNoneMatch === etag) {
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const ifModifiedSince = c.req.header("If-Modified-Since");
|
|
233
|
+
if (ifModifiedSince && lastModified) {
|
|
234
|
+
if (new Date(ifModifiedSince) >= new Date(lastModified)) {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Apply standard cache headers to a Headers object.
|
|
244
|
+
* Mutates `headers` in place and returns it for chaining convenience.
|
|
245
|
+
*/
|
|
246
|
+
function applyCacheHeaders(
|
|
247
|
+
headers: Headers,
|
|
248
|
+
config: CacheConfig,
|
|
249
|
+
etag: string,
|
|
250
|
+
lastModified: string,
|
|
251
|
+
cacheStatus: "HIT" | "MISS",
|
|
252
|
+
): Headers {
|
|
253
|
+
headers.set("Cache-Control", buildCacheControl(config));
|
|
254
|
+
headers.set("ETag", etag);
|
|
255
|
+
headers.set("Last-Modified", lastModified);
|
|
256
|
+
headers.set("X-Cache", cacheStatus);
|
|
257
|
+
if (config.cacheTag) {
|
|
258
|
+
// Emits a Cache-Tag header for OPTIONAL operator-side purging (Cloudflare
|
|
259
|
+
// Enterprise Cache-Tag purge API). NOTE: there is NO in-app, mutation-
|
|
260
|
+
// triggered invalidation — cached reads expire by TTL ONLY, so a deleted/
|
|
261
|
+
// edited/now-private object can be served stale until its TTL lapses (worst
|
|
262
|
+
// case ~1h for webfinger, 2-5min for feeds/actor). TTLs are deliberately
|
|
263
|
+
// short and varyByActor/authed-bypass prevent cross-audience leaks, so the
|
|
264
|
+
// staleness window is the accepted tradeoff rather than a correctness bug.
|
|
265
|
+
headers.set("Cache-Tag", config.cacheTag);
|
|
266
|
+
}
|
|
267
|
+
return headers;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function isCloudflareWorkers(): boolean {
|
|
271
|
+
return typeof caches !== "undefined" && "default" in caches;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ============================================================================
|
|
275
|
+
// Caching Middleware
|
|
276
|
+
// ============================================================================
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Create a caching middleware
|
|
280
|
+
*
|
|
281
|
+
* @example
|
|
282
|
+
* // Cache public timeline for 2 minutes
|
|
283
|
+
* timeline.get('/', withCache({ ttl: CacheTTL.PUBLIC_TIMELINE }), handler);
|
|
284
|
+
*
|
|
285
|
+
* // Cache actor profile with tag for invalidation
|
|
286
|
+
* actors.get('/:username', withCache({
|
|
287
|
+
* ttl: CacheTTL.ACTOR_PROFILE,
|
|
288
|
+
* cacheTag: CacheTags.ACTOR,
|
|
289
|
+
* }), handler);
|
|
290
|
+
*/
|
|
291
|
+
export function withCache(config: CacheConfig): HonoMiddleware {
|
|
292
|
+
return async (c, next) => {
|
|
293
|
+
if (c.req.method !== "GET") {
|
|
294
|
+
await next();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!config.varyByActor && c.get("actor")) {
|
|
299
|
+
await next();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const cacheKey = generateCacheKey(c, config);
|
|
304
|
+
|
|
305
|
+
if (isCloudflareWorkers()) {
|
|
306
|
+
return handleCloudflareCache(c, next, cacheKey, config);
|
|
307
|
+
}
|
|
308
|
+
return handleMemoryCache(c, next, cacheKey, config);
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function handleCloudflareCache(
|
|
313
|
+
c: HonoContext,
|
|
314
|
+
next: Next,
|
|
315
|
+
cacheKey: string,
|
|
316
|
+
config: CacheConfig,
|
|
317
|
+
): Promise<Response | void> {
|
|
318
|
+
const cache = caches.default;
|
|
319
|
+
const url = new URL(c.req.url);
|
|
320
|
+
const fullCacheKey = new Request(`${url.origin}/_cache${cacheKey}`);
|
|
321
|
+
|
|
322
|
+
const cachedResponse = await cache.match(fullCacheKey);
|
|
323
|
+
|
|
324
|
+
if (cachedResponse) {
|
|
325
|
+
const etag = cachedResponse.headers.get("ETag");
|
|
326
|
+
const lastModified = cachedResponse.headers.get("Last-Modified");
|
|
327
|
+
|
|
328
|
+
if (isConditionalHit(c, etag, lastModified)) {
|
|
329
|
+
return c.body(null, 304);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const headers = new Headers(cachedResponse.headers);
|
|
333
|
+
headers.set("X-Cache", "HIT");
|
|
334
|
+
return new Response(cachedResponse.body, {
|
|
335
|
+
status: cachedResponse.status,
|
|
336
|
+
headers,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
await next();
|
|
341
|
+
|
|
342
|
+
if (c.res.status !== 200) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const responseBody = await c.res.text();
|
|
347
|
+
const etag = await generateETag(responseBody);
|
|
348
|
+
const lastModified = new Date().toUTCString();
|
|
349
|
+
|
|
350
|
+
const headers = new Headers(c.res.headers);
|
|
351
|
+
applyCacheHeaders(headers, config, etag, lastModified, "MISS");
|
|
352
|
+
|
|
353
|
+
const responseToCache = new Response(responseBody, {
|
|
354
|
+
status: 200,
|
|
355
|
+
headers,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const ctx = c.executionCtx;
|
|
359
|
+
if (ctx && typeof ctx.waitUntil === "function") {
|
|
360
|
+
ctx.waitUntil(
|
|
361
|
+
cache.put(fullCacheKey, responseToCache.clone()).catch((err) => {
|
|
362
|
+
log.error("Failed to store response in cache", {
|
|
363
|
+
event: "cache.put.failed",
|
|
364
|
+
cacheKey: fullCacheKey,
|
|
365
|
+
error: err,
|
|
366
|
+
});
|
|
367
|
+
}),
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
c.res = responseToCache;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function handleMemoryCache(
|
|
375
|
+
c: HonoContext,
|
|
376
|
+
next: Next,
|
|
377
|
+
cacheKey: string,
|
|
378
|
+
config: CacheConfig,
|
|
379
|
+
): Promise<Response | void> {
|
|
380
|
+
maybeCleanup();
|
|
381
|
+
|
|
382
|
+
const cached = memoryCache.get(cacheKey);
|
|
383
|
+
|
|
384
|
+
if (cached) {
|
|
385
|
+
if (isConditionalHit(c, cached.etag, cached.lastModified)) {
|
|
386
|
+
return c.body(null, 304);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const headers = new Headers(cached.headers);
|
|
390
|
+
headers.set("X-Cache", "HIT");
|
|
391
|
+
return new Response(cached.body, {
|
|
392
|
+
status: cached.status,
|
|
393
|
+
headers,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
await next();
|
|
398
|
+
|
|
399
|
+
if (c.res.status !== 200) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const responseBody = await c.res.text();
|
|
404
|
+
const etag = await generateETag(responseBody);
|
|
405
|
+
const lastModified = new Date().toUTCString();
|
|
406
|
+
|
|
407
|
+
const headers = new Headers(c.res.headers);
|
|
408
|
+
applyCacheHeaders(headers, config, etag, lastModified, "MISS");
|
|
409
|
+
|
|
410
|
+
const headersObj: Record<string, string> = {};
|
|
411
|
+
headers.forEach((value, key) => {
|
|
412
|
+
headersObj[key] = value;
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
memoryCache.set(cacheKey, {
|
|
416
|
+
body: responseBody,
|
|
417
|
+
headers: headersObj,
|
|
418
|
+
status: 200,
|
|
419
|
+
expiresAt: Date.now() + config.ttl * 1000,
|
|
420
|
+
etag,
|
|
421
|
+
lastModified,
|
|
422
|
+
tag: config.cacheTag,
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
c.res = new Response(responseBody, {
|
|
426
|
+
status: 200,
|
|
427
|
+
headers,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { Context, 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.csrf" });
|
|
7
|
+
|
|
8
|
+
const STATE_CHANGING_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]);
|
|
9
|
+
|
|
10
|
+
function getOrigin(url: string | null): string | null {
|
|
11
|
+
if (!url) return null;
|
|
12
|
+
try {
|
|
13
|
+
const parsed = new URL(url);
|
|
14
|
+
return `${parsed.protocol}//${parsed.host}`;
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isActivityPubInbox(path: string): boolean {
|
|
21
|
+
return path.includes("/inbox") && path.includes("/ap/");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isDevLocalhost(appUrl: string | undefined): boolean {
|
|
25
|
+
return (
|
|
26
|
+
!!appUrl && (appUrl.includes("localhost") || appUrl.includes("127.0.0.1"))
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build the set of allowed origins for CSRF Origin / Referer check.
|
|
32
|
+
*
|
|
33
|
+
* Sources (= union):
|
|
34
|
+
* - `APP_URL` env (= 既存 production-equivalent origin)
|
|
35
|
+
* - `CSRF_ALLOWED_ORIGINS` env (= comma-separated 追加 origin、 dev hostname
|
|
36
|
+
* を register するための env、 default 未設定で backward compat)
|
|
37
|
+
*
|
|
38
|
+
* 未正規化 / 空文字 / 構文不正な URL は skip。 trailing slash は正規化される
|
|
39
|
+
* (= `getOrigin` が `protocol//host` 形式に戻すため)。
|
|
40
|
+
*/
|
|
41
|
+
function buildAllowedOrigins(env: {
|
|
42
|
+
APP_URL?: string;
|
|
43
|
+
CSRF_ALLOWED_ORIGINS?: string;
|
|
44
|
+
}): Set<string> {
|
|
45
|
+
const origins = new Set<string>();
|
|
46
|
+
const appOrigin = getOrigin(env.APP_URL ?? null);
|
|
47
|
+
if (appOrigin) origins.add(appOrigin);
|
|
48
|
+
|
|
49
|
+
const extra =
|
|
50
|
+
env.CSRF_ALLOWED_ORIGINS?.split(",")
|
|
51
|
+
.map((s) => s.trim())
|
|
52
|
+
.filter(Boolean) ?? [];
|
|
53
|
+
for (const raw of extra) {
|
|
54
|
+
const normalized = getOrigin(raw);
|
|
55
|
+
if (normalized) origins.add(normalized);
|
|
56
|
+
}
|
|
57
|
+
return origins;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isBearerApiRequest(
|
|
61
|
+
c: Context<{ Bindings: Env; Variables: Variables }>,
|
|
62
|
+
) {
|
|
63
|
+
const auth = c.req.header("Authorization");
|
|
64
|
+
if (!auth?.startsWith("Bearer ")) return false;
|
|
65
|
+
|
|
66
|
+
// Browser session requests must still satisfy CSRF checks even if a script
|
|
67
|
+
// adds an Authorization header. Server-to-server bearer calls should not
|
|
68
|
+
// carry cookies.
|
|
69
|
+
return !c.req.header("Cookie");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* CSRF protection middleware.
|
|
74
|
+
* Validates Origin/Referer for state-changing requests as defense-in-depth
|
|
75
|
+
* alongside SameSite=Lax cookies.
|
|
76
|
+
*/
|
|
77
|
+
export function csrfProtection() {
|
|
78
|
+
return async (
|
|
79
|
+
c: Context<{ Bindings: Env; Variables: Variables }>,
|
|
80
|
+
next: Next,
|
|
81
|
+
) => {
|
|
82
|
+
if (!STATE_CHANGING_METHODS.has(c.req.method.toUpperCase())) return next();
|
|
83
|
+
if (isActivityPubInbox(c.req.path)) return next();
|
|
84
|
+
if (isBearerApiRequest(c)) return next();
|
|
85
|
+
|
|
86
|
+
const appUrl = c.env.APP_URL;
|
|
87
|
+
const allowedOrigins = buildAllowedOrigins(c.env);
|
|
88
|
+
|
|
89
|
+
const requestOrigin =
|
|
90
|
+
c.req.header("Origin") || getOrigin(c.req.header("Referer") ?? null);
|
|
91
|
+
if (!requestOrigin) {
|
|
92
|
+
log.warn("CSRF check failed: missing origin", {
|
|
93
|
+
event: "csrf.check.missing_origin",
|
|
94
|
+
method: c.req.method,
|
|
95
|
+
path: c.req.path,
|
|
96
|
+
});
|
|
97
|
+
return c.json(
|
|
98
|
+
{ error: "CSRF validation failed: missing Origin header" },
|
|
99
|
+
403,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!allowedOrigins.has(requestOrigin)) {
|
|
104
|
+
const ro = (() => {
|
|
105
|
+
try {
|
|
106
|
+
return new URL(requestOrigin).hostname;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
})();
|
|
111
|
+
if (
|
|
112
|
+
isDevLocalhost(appUrl) &&
|
|
113
|
+
ro &&
|
|
114
|
+
(ro === "localhost" || ro === "127.0.0.1" || ro === "[::1]")
|
|
115
|
+
) {
|
|
116
|
+
return next();
|
|
117
|
+
}
|
|
118
|
+
log.warn("CSRF check failed: origin mismatch", {
|
|
119
|
+
event: "csrf.check.origin_mismatch",
|
|
120
|
+
method: c.req.method,
|
|
121
|
+
path: c.req.path,
|
|
122
|
+
allowedOrigins: [...allowedOrigins],
|
|
123
|
+
requestOrigin,
|
|
124
|
+
});
|
|
125
|
+
return c.json({ error: "CSRF validation failed" }, 403);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return next();
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
AppError,
|
|
6
|
+
BadRequestError,
|
|
7
|
+
InternalError,
|
|
8
|
+
isAppError,
|
|
9
|
+
logError,
|
|
10
|
+
} from "../lib/errors.ts";
|
|
11
|
+
|
|
12
|
+
interface ErrorMiddlewareOptions {
|
|
13
|
+
/** Custom error logger */
|
|
14
|
+
logger?: (error: unknown, context?: Record<string, unknown>) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getCorrelationId(c: Context): string {
|
|
18
|
+
return (
|
|
19
|
+
c.req.header("x-request-id") ??
|
|
20
|
+
c.req.header("CF-Ray") ??
|
|
21
|
+
crypto.randomUUID()
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the incoming error to an AppError, logging unknown errors.
|
|
27
|
+
*/
|
|
28
|
+
function resolveAppError(
|
|
29
|
+
err: Error,
|
|
30
|
+
c: Context,
|
|
31
|
+
correlationId: string,
|
|
32
|
+
logger: ErrorMiddlewareOptions["logger"],
|
|
33
|
+
): AppError {
|
|
34
|
+
if (isAppError(err)) return err;
|
|
35
|
+
|
|
36
|
+
// Client-input parse failures that reach the top-level handler are 400s, not
|
|
37
|
+
// 500s, and are not logged as faults (they're expected client behavior):
|
|
38
|
+
// - SyntaxError: a malformed/empty JSON request body from `c.req.json()`
|
|
39
|
+
// (internal JSON parsing uses safeJsonParse, which never throws).
|
|
40
|
+
// - URIError: malformed percent-encoding in a route/query param decoded with
|
|
41
|
+
// decodeURIComponent (e.g. a bad `:encodedApId`).
|
|
42
|
+
// TypeError/RangeError are deliberately NOT mapped here: they usually signal
|
|
43
|
+
// an internal bug and must be fixed at the call site, not masked as 400.
|
|
44
|
+
if (err instanceof SyntaxError) {
|
|
45
|
+
return new BadRequestError("Invalid or malformed JSON request body");
|
|
46
|
+
}
|
|
47
|
+
if (err instanceof URIError) {
|
|
48
|
+
return new BadRequestError("Malformed URL encoding in request");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
logger?.(err, {
|
|
52
|
+
correlationId,
|
|
53
|
+
path: c.req.path,
|
|
54
|
+
method: c.req.method,
|
|
55
|
+
requestId: c.req.header("x-request-id"),
|
|
56
|
+
});
|
|
57
|
+
return new InternalError("An unexpected error occurred");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Create Hono error middleware
|
|
62
|
+
*/
|
|
63
|
+
export function createErrorMiddleware(
|
|
64
|
+
options: ErrorMiddlewareOptions = {},
|
|
65
|
+
): (err: Error, c: Context) => Response {
|
|
66
|
+
const { logger = logError } = options;
|
|
67
|
+
|
|
68
|
+
return (err: Error, c: Context): Response => {
|
|
69
|
+
const correlationId = getCorrelationId(c);
|
|
70
|
+
const appError = resolveAppError(err, c, correlationId, logger);
|
|
71
|
+
|
|
72
|
+
const response = appError.toResponse();
|
|
73
|
+
response.correlation_id = correlationId;
|
|
74
|
+
|
|
75
|
+
return c.json(response, appError.statusCode as ContentfulStatusCode);
|
|
76
|
+
};
|
|
77
|
+
}
|