@jant/core 0.3.42 → 0.3.43
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/bin/commands/import-site.js +1 -1
- package/bin/commands/search-reindex.js +175 -0
- package/bin/lib/hugo-markdown.js +102 -0
- package/bin/lib/site-pull-media.js +1 -4
- package/dist/app-Ctl0T0zO.js +5 -0
- package/dist/{app-Cu3lveYI.js → app-GbfwoeDJ.js} +630 -123
- package/dist/client/.vite/manifest.json +1 -1
- package/dist/client/_assets/{client-auth-BRFl5zQA.js → client-auth-CXILhW1b.js} +144 -144
- package/dist/{env-wCpMcNXs.js → env-CgaH9Mut.js} +1 -1
- package/dist/{github-api-CficQztC.js → github-api-BkRWnqMx.js} +1 -1
- package/dist/{github-app-F4qZ05xk.js → github-app-WeadXMb8.js} +1 -1
- package/dist/{github-sync-zohnA9qv.js → github-sync-7y_nTXx1.js} +41 -14
- package/dist/index.js +5 -5
- package/dist/node.js +5 -5
- package/dist/{url-FvvgARU9.js → url-umUptr5z.js} +30 -1
- package/package.json +1 -1
- package/src/__tests__/helpers/app.ts +15 -4
- package/src/app.tsx +8 -0
- package/src/client/tiptap/__tests__/insert-paragraph-around.test.ts +228 -0
- package/src/client/tiptap/extensions.ts +3 -0
- package/src/client/tiptap/insert-paragraph-around.ts +79 -0
- package/src/db/migrations/0018_yummy_franklin_richards.sql +6 -0
- package/src/db/migrations/meta/0018_snapshot.json +2225 -0
- package/src/db/migrations/meta/_journal.json +8 -1
- package/src/db/migrations/pg/0016_familiar_lionheart.sql +6 -0
- package/src/db/migrations/pg/meta/0016_snapshot.json +2840 -0
- package/src/db/migrations/pg/meta/_journal.json +8 -1
- package/src/db/pg/schema.ts +18 -0
- package/src/db/schema.ts +23 -0
- package/src/index.ts +1 -2
- package/src/lib/__tests__/hosted-signin.test.ts +30 -0
- package/src/lib/__tests__/navigation.test.ts +4 -20
- package/src/lib/__tests__/rate-limit-d1.test.ts +82 -0
- package/src/lib/__tests__/rate-limit-memory.test.ts +69 -0
- package/src/lib/__tests__/summary.test.ts +140 -0
- package/src/lib/__tests__/view.test.ts +66 -0
- package/src/lib/feed.ts +70 -34
- package/src/lib/hosted-signin.ts +9 -3
- package/src/lib/navigation.ts +11 -12
- package/src/lib/post-meta.ts +20 -2
- package/src/lib/rate-limit-d1.ts +99 -0
- package/src/lib/rate-limit-memory.ts +105 -0
- package/src/lib/rate-limit.ts +63 -0
- package/src/lib/render.tsx +9 -0
- package/src/lib/resolve-config.ts +9 -0
- package/src/lib/summary.ts +42 -7
- package/src/lib/url.ts +34 -0
- package/src/lib/view.ts +42 -8
- package/src/middleware/__tests__/auth.test.ts +44 -4
- package/src/middleware/__tests__/rate-limit.test.ts +113 -0
- package/src/middleware/__tests__/session.test.ts +85 -0
- package/src/middleware/auth.ts +62 -25
- package/src/middleware/rate-limit.ts +54 -0
- package/src/middleware/session.ts +36 -0
- package/src/routes/__tests__/compose.test.ts +1 -1
- package/src/routes/api/__tests__/search.test.ts +48 -0
- package/src/routes/api/__tests__/upload-multipart.test.ts +11 -4
- package/src/routes/api/internal/search-reindex.ts +40 -0
- package/src/routes/api/search.ts +13 -0
- package/src/routes/auth/dev.ts +1 -1
- package/src/routes/auth/signin.tsx +23 -5
- package/src/routes/dash/settings.tsx +3 -5
- package/src/routes/feed/__tests__/sitemap.test.ts +320 -4
- package/src/routes/feed/sitemap.ts +208 -33
- package/src/routes/pages/__tests__/page-canonical.test.ts +101 -0
- package/src/routes/pages/home.tsx +24 -15
- package/src/routes/pages/page.tsx +34 -0
- package/src/routes/pages/partials.tsx +4 -15
- package/src/runtime/cloudflare.ts +4 -0
- package/src/runtime/node.ts +16 -0
- package/src/services/__tests__/post.test.ts +205 -0
- package/src/services/__tests__/search.test.ts +44 -0
- package/src/services/export.ts +9 -2
- package/src/services/post.ts +200 -2
- package/src/types/app-context.ts +20 -0
- package/src/types/config.ts +8 -0
- package/src/types/props.ts +0 -7
- package/src/ui/layouts/BaseLayout.tsx +9 -0
- package/dist/app-DzCB4yOp.js +0 -5
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
isLocalHostname,
|
|
8
8
|
hasValidLocalDevToken,
|
|
9
9
|
} from "../auth.js";
|
|
10
|
+
import { attachSession } from "../session.js";
|
|
10
11
|
import { errorHandler } from "../error-handler.js";
|
|
11
12
|
import { DEFAULT_APP_PORT } from "../../lib/env.js";
|
|
12
13
|
import type { Bindings } from "../../types.js";
|
|
@@ -135,6 +136,7 @@ describe("requireAuth", () => {
|
|
|
135
136
|
} as AppVariables["services"]);
|
|
136
137
|
await next();
|
|
137
138
|
});
|
|
139
|
+
app.use("*", attachSession());
|
|
138
140
|
app.get("/settings", requireAuth(), (c) => c.text("Settings"));
|
|
139
141
|
|
|
140
142
|
const res = await app.request("/settings");
|
|
@@ -142,7 +144,7 @@ describe("requireAuth", () => {
|
|
|
142
144
|
expect(await res.text()).toBe("Settings");
|
|
143
145
|
});
|
|
144
146
|
|
|
145
|
-
it("redirects unauthenticated requests to /signin", async () => {
|
|
147
|
+
it("redirects unauthenticated requests to /signin with the original path", async () => {
|
|
146
148
|
const app = createTestHonoApp();
|
|
147
149
|
app.use("*", async (c, next) => {
|
|
148
150
|
c.set("auth", createMockAuth(false));
|
|
@@ -151,14 +153,38 @@ describe("requireAuth", () => {
|
|
|
151
153
|
} as AppVariables["services"]);
|
|
152
154
|
await next();
|
|
153
155
|
});
|
|
156
|
+
app.use("*", attachSession());
|
|
157
|
+
app.get("/settings/general", requireAuth(), (c) => c.text("Settings"));
|
|
158
|
+
|
|
159
|
+
const res = await app.request("/settings/general", { redirect: "manual" });
|
|
160
|
+
expect(res.status).toBe(302);
|
|
161
|
+
expect(res.headers.get("Location")).toBe(
|
|
162
|
+
"/signin?redirect=%2Fsettings%2Fgeneral",
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("preserves query string when redirecting to /signin", async () => {
|
|
167
|
+
const app = createTestHonoApp();
|
|
168
|
+
app.use("*", async (c, next) => {
|
|
169
|
+
c.set("auth", createMockAuth(false));
|
|
170
|
+
c.set("services", {
|
|
171
|
+
siteMembers: createMockSiteMembers(),
|
|
172
|
+
} as AppVariables["services"]);
|
|
173
|
+
await next();
|
|
174
|
+
});
|
|
175
|
+
app.use("*", attachSession());
|
|
154
176
|
app.get("/settings", requireAuth(), (c) => c.text("Settings"));
|
|
155
177
|
|
|
156
|
-
const res = await app.request("/settings", {
|
|
178
|
+
const res = await app.request("/settings?tab=profile", {
|
|
179
|
+
redirect: "manual",
|
|
180
|
+
});
|
|
157
181
|
expect(res.status).toBe(302);
|
|
158
|
-
expect(res.headers.get("Location")).toBe(
|
|
182
|
+
expect(res.headers.get("Location")).toBe(
|
|
183
|
+
"/signin?redirect=%2Fsettings%3Ftab%3Dprofile",
|
|
184
|
+
);
|
|
159
185
|
});
|
|
160
186
|
|
|
161
|
-
it("
|
|
187
|
+
it("does not add a redirect query when requireAuth targets a custom path", async () => {
|
|
162
188
|
const app = createTestHonoApp();
|
|
163
189
|
app.use("*", async (c, next) => {
|
|
164
190
|
c.set("auth", createMockAuth(false));
|
|
@@ -167,6 +193,7 @@ describe("requireAuth", () => {
|
|
|
167
193
|
} as AppVariables["services"]);
|
|
168
194
|
await next();
|
|
169
195
|
});
|
|
196
|
+
app.use("*", attachSession());
|
|
170
197
|
app.get("/settings", requireAuth("/login"), (c) => c.text("Settings"));
|
|
171
198
|
|
|
172
199
|
const res = await app.request("/settings", { redirect: "manual" });
|
|
@@ -187,6 +214,7 @@ describe("requireAuthApi", () => {
|
|
|
187
214
|
} as AppVariables["services"]);
|
|
188
215
|
await next();
|
|
189
216
|
});
|
|
217
|
+
app.use("*", attachSession());
|
|
190
218
|
app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
|
|
191
219
|
|
|
192
220
|
const res = await app.request("/api/data");
|
|
@@ -207,6 +235,7 @@ describe("requireAuthApi", () => {
|
|
|
207
235
|
} as AppVariables["services"]);
|
|
208
236
|
await next();
|
|
209
237
|
});
|
|
238
|
+
app.use("*", attachSession());
|
|
210
239
|
app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
|
|
211
240
|
|
|
212
241
|
const res = await app.request("/api/data");
|
|
@@ -234,6 +263,7 @@ describe("requireAuthApi", () => {
|
|
|
234
263
|
} as AppVariables["services"]);
|
|
235
264
|
await next();
|
|
236
265
|
});
|
|
266
|
+
app.use("*", attachSession());
|
|
237
267
|
app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
|
|
238
268
|
|
|
239
269
|
const res = await app.request("/api/data");
|
|
@@ -254,6 +284,7 @@ describe("requireAuthApi", () => {
|
|
|
254
284
|
} as AppVariables["services"]);
|
|
255
285
|
await next();
|
|
256
286
|
});
|
|
287
|
+
app.use("*", attachSession());
|
|
257
288
|
app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
|
|
258
289
|
|
|
259
290
|
const res = await app.request("/api/data", {
|
|
@@ -281,6 +312,7 @@ describe("requireAuthApi", () => {
|
|
|
281
312
|
} as AppVariables["services"]);
|
|
282
313
|
await next();
|
|
283
314
|
});
|
|
315
|
+
app.use("*", attachSession());
|
|
284
316
|
app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
|
|
285
317
|
|
|
286
318
|
const res = await app.request("/api/data", {
|
|
@@ -304,6 +336,7 @@ describe("requireAuthApi", () => {
|
|
|
304
336
|
} as AppVariables["services"]);
|
|
305
337
|
await next();
|
|
306
338
|
});
|
|
339
|
+
app.use("*", attachSession());
|
|
307
340
|
app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
|
|
308
341
|
|
|
309
342
|
const res = await app.request("/api/data", {
|
|
@@ -330,6 +363,7 @@ describe("requireAuthApi", () => {
|
|
|
330
363
|
} as AppVariables["services"]);
|
|
331
364
|
await next();
|
|
332
365
|
});
|
|
366
|
+
app.use("*", attachSession());
|
|
333
367
|
app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
|
|
334
368
|
|
|
335
369
|
const res = await app.request(LOCAL_API_URL, {
|
|
@@ -356,6 +390,7 @@ describe("requireAuthApi", () => {
|
|
|
356
390
|
} as AppVariables["services"]);
|
|
357
391
|
await next();
|
|
358
392
|
});
|
|
393
|
+
app.use("*", attachSession());
|
|
359
394
|
app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
|
|
360
395
|
|
|
361
396
|
const res = await app.request("https://myblog.com/api/data", {
|
|
@@ -382,6 +417,7 @@ describe("requireAuthApi", () => {
|
|
|
382
417
|
} as AppVariables["services"]);
|
|
383
418
|
await next();
|
|
384
419
|
});
|
|
420
|
+
app.use("*", attachSession());
|
|
385
421
|
app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
|
|
386
422
|
|
|
387
423
|
const res = await app.request("https://jant.localtest.me/api/data", {
|
|
@@ -410,6 +446,7 @@ describe("requireAuthApi", () => {
|
|
|
410
446
|
} as AppVariables["services"]);
|
|
411
447
|
await next();
|
|
412
448
|
});
|
|
449
|
+
app.use("*", attachSession());
|
|
413
450
|
app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
|
|
414
451
|
|
|
415
452
|
const res = await app.request("https://jant.me/api/data", {
|
|
@@ -436,6 +473,7 @@ describe("requireInternalAdminApi", () => {
|
|
|
436
473
|
} as AppVariables["services"]);
|
|
437
474
|
await next();
|
|
438
475
|
});
|
|
476
|
+
app.use("*", attachSession());
|
|
439
477
|
app.post("/api/internal/demo", requireInternalAdminApi(), (c) =>
|
|
440
478
|
c.json({ ok: true }),
|
|
441
479
|
);
|
|
@@ -459,6 +497,7 @@ describe("requireInternalAdminApi", () => {
|
|
|
459
497
|
} as AppVariables["services"]);
|
|
460
498
|
await next();
|
|
461
499
|
});
|
|
500
|
+
app.use("*", attachSession());
|
|
462
501
|
app.post("/api/internal/demo", requireInternalAdminApi(), (c) =>
|
|
463
502
|
c.json({ ok: true }),
|
|
464
503
|
);
|
|
@@ -485,6 +524,7 @@ describe("requireInternalAdminApi", () => {
|
|
|
485
524
|
} as AppVariables["services"]);
|
|
486
525
|
await next();
|
|
487
526
|
});
|
|
527
|
+
app.use("*", attachSession());
|
|
488
528
|
app.post("/api/internal/demo", requireInternalAdminApi(), (c) =>
|
|
489
529
|
c.json({ ok: true }),
|
|
490
530
|
);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { rateLimit } from "../rate-limit.js";
|
|
4
|
+
import type { RateLimiter } from "../../lib/rate-limit.js";
|
|
5
|
+
import type { Bindings } from "../../types.js";
|
|
6
|
+
import type { AppVariables } from "../../types/app-context.js";
|
|
7
|
+
|
|
8
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build a tiny Hono app that seeds just the slice of `c.var` the
|
|
12
|
+
* rate-limit middleware reads — keeps the blast radius of each test
|
|
13
|
+
* minimal and independent of the full `createTestApp` fixture.
|
|
14
|
+
*/
|
|
15
|
+
function buildApp(options: {
|
|
16
|
+
limiter: RateLimiter;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
}): Hono<Env> {
|
|
19
|
+
const app = new Hono<Env>();
|
|
20
|
+
app.use("*", async (c, next) => {
|
|
21
|
+
c.set("rateLimiter", options.limiter);
|
|
22
|
+
c.set("appConfig", {
|
|
23
|
+
rateLimit: {
|
|
24
|
+
disabled: options.disabled ?? false,
|
|
25
|
+
searchPerMinute: 30,
|
|
26
|
+
},
|
|
27
|
+
} as AppVariables["appConfig"]);
|
|
28
|
+
await next();
|
|
29
|
+
});
|
|
30
|
+
app.use("*", rateLimit({ name: "test", limit: 2, windowSec: 60 }));
|
|
31
|
+
app.get("/", (c) => c.text("ok"));
|
|
32
|
+
return app;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Fake limiter that lets us script results without real timing. */
|
|
36
|
+
function scriptedLimiter(
|
|
37
|
+
outcomes: Array<{ ok: boolean; retryAfterSec?: number }>,
|
|
38
|
+
) {
|
|
39
|
+
const keys: string[] = [];
|
|
40
|
+
let i = 0;
|
|
41
|
+
const limiter: RateLimiter = {
|
|
42
|
+
async check(key) {
|
|
43
|
+
keys.push(key);
|
|
44
|
+
const out = outcomes[i++] ?? { ok: true };
|
|
45
|
+
return out;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
return { limiter, keys: () => keys };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("rateLimit middleware", () => {
|
|
52
|
+
it("passes the request through when under limit", async () => {
|
|
53
|
+
const { limiter } = scriptedLimiter([{ ok: true }]);
|
|
54
|
+
const app = buildApp({ limiter });
|
|
55
|
+
|
|
56
|
+
const res = await app.request("/");
|
|
57
|
+
expect(res.status).toBe(200);
|
|
58
|
+
expect(await res.text()).toBe("ok");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("responds 429 with Retry-After when the limiter rejects", async () => {
|
|
62
|
+
const { limiter } = scriptedLimiter([{ ok: false, retryAfterSec: 42 }]);
|
|
63
|
+
const app = buildApp({ limiter });
|
|
64
|
+
|
|
65
|
+
const res = await app.request("/");
|
|
66
|
+
expect(res.status).toBe(429);
|
|
67
|
+
expect(res.headers.get("retry-after")).toBe("42");
|
|
68
|
+
expect(await res.json()).toEqual({
|
|
69
|
+
error: "Too many requests. Please slow down.",
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("falls back to the window size when retryAfterSec is missing", async () => {
|
|
74
|
+
const { limiter } = scriptedLimiter([{ ok: false }]);
|
|
75
|
+
const app = buildApp({ limiter });
|
|
76
|
+
|
|
77
|
+
const res = await app.request("/");
|
|
78
|
+
expect(res.headers.get("retry-after")).toBe("60");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("short-circuits when rateLimit.disabled is true", async () => {
|
|
82
|
+
const { limiter, keys } = scriptedLimiter([{ ok: false }]);
|
|
83
|
+
const app = buildApp({ limiter, disabled: true });
|
|
84
|
+
|
|
85
|
+
const res = await app.request("/");
|
|
86
|
+
expect(res.status).toBe(200);
|
|
87
|
+
// Limiter should not have been consulted at all when disabled.
|
|
88
|
+
expect(keys()).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("prefers cf-connecting-ip over x-forwarded-for for the bucket key", async () => {
|
|
92
|
+
const { limiter, keys } = scriptedLimiter([{ ok: true }]);
|
|
93
|
+
const app = buildApp({ limiter });
|
|
94
|
+
|
|
95
|
+
await app.request("/", {
|
|
96
|
+
headers: {
|
|
97
|
+
"cf-connecting-ip": "1.2.3.4",
|
|
98
|
+
"x-forwarded-for": "5.6.7.8",
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
expect(keys()).toEqual(["test:1.2.3.4"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("falls back to x-forwarded-for (first entry) when cf header is absent", async () => {
|
|
105
|
+
const { limiter, keys } = scriptedLimiter([{ ok: true }]);
|
|
106
|
+
const app = buildApp({ limiter });
|
|
107
|
+
|
|
108
|
+
await app.request("/", {
|
|
109
|
+
headers: { "x-forwarded-for": "10.0.0.1, 10.0.0.2" },
|
|
110
|
+
});
|
|
111
|
+
expect(keys()).toEqual(["test:10.0.0.1"]);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { attachSession } from "../session.js";
|
|
4
|
+
import type { Bindings } from "../../types.js";
|
|
5
|
+
import type { AppVariables } from "../../types/app-context.js";
|
|
6
|
+
|
|
7
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
8
|
+
|
|
9
|
+
function createAppWithAuth(mockAuth: AppVariables["auth"]): Hono<Env> & {
|
|
10
|
+
// ensures tests see session/isAuthenticated via c.var
|
|
11
|
+
_lastSession?: AppVariables["session"];
|
|
12
|
+
_lastIsAuthenticated?: boolean;
|
|
13
|
+
} {
|
|
14
|
+
const app = new Hono<Env>();
|
|
15
|
+
app.use("*", async (c, next) => {
|
|
16
|
+
c.set("auth", mockAuth);
|
|
17
|
+
await next();
|
|
18
|
+
});
|
|
19
|
+
app.use("*", attachSession());
|
|
20
|
+
return app;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildSessionMock(
|
|
24
|
+
impl: () => Promise<AppVariables["session"]>,
|
|
25
|
+
): AppVariables["auth"] {
|
|
26
|
+
return {
|
|
27
|
+
api: {
|
|
28
|
+
getSession: impl,
|
|
29
|
+
},
|
|
30
|
+
} as unknown as AppVariables["auth"];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("attachSession", () => {
|
|
34
|
+
it("populates c.var.session and isAuthenticated on a valid session", async () => {
|
|
35
|
+
const mockAuth = buildSessionMock(
|
|
36
|
+
async () =>
|
|
37
|
+
({
|
|
38
|
+
user: { id: "user-1", email: "x@y.z", name: "X" },
|
|
39
|
+
session: { id: "sess-1" },
|
|
40
|
+
}) as unknown as AppVariables["session"],
|
|
41
|
+
);
|
|
42
|
+
const app = createAppWithAuth(mockAuth);
|
|
43
|
+
app.get("/", (c) =>
|
|
44
|
+
c.json({
|
|
45
|
+
authed: c.var.isAuthenticated,
|
|
46
|
+
userId: (c.var.session?.user as { id?: string } | undefined)?.id,
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const res = await app.request("/");
|
|
51
|
+
expect(res.status).toBe(200);
|
|
52
|
+
expect(await res.json()).toEqual({ authed: true, userId: "user-1" });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("sets isAuthenticated=false and session=null when no session is present", async () => {
|
|
56
|
+
const mockAuth = buildSessionMock(async () => null);
|
|
57
|
+
const app = createAppWithAuth(mockAuth);
|
|
58
|
+
app.get("/", (c) =>
|
|
59
|
+
c.json({
|
|
60
|
+
authed: c.var.isAuthenticated,
|
|
61
|
+
session: c.var.session,
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const res = await app.request("/");
|
|
66
|
+
expect(await res.json()).toEqual({ authed: false, session: null });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("swallows errors from getSession and treats the request as unauthenticated", async () => {
|
|
70
|
+
const mockAuth = buildSessionMock(async () => {
|
|
71
|
+
throw new Error("session lookup failed");
|
|
72
|
+
});
|
|
73
|
+
const app = createAppWithAuth(mockAuth);
|
|
74
|
+
app.get("/", (c) =>
|
|
75
|
+
c.json({
|
|
76
|
+
authed: c.var.isAuthenticated,
|
|
77
|
+
session: c.var.session,
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const res = await app.request("/");
|
|
82
|
+
expect(res.status).toBe(200);
|
|
83
|
+
expect(await res.json()).toEqual({ authed: false, session: null });
|
|
84
|
+
});
|
|
85
|
+
});
|
package/src/middleware/auth.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { AppVariables } from "../types/app-context.js";
|
|
|
11
11
|
import { getDevApiToken, getInternalAdminToken } from "../lib/env.js";
|
|
12
12
|
import { NotFoundError, UnauthorizedError } from "../lib/errors.js";
|
|
13
13
|
import { getRuntimeSitePathPrefix } from "../lib/site-resolution.js";
|
|
14
|
-
import { toPublicHref } from "../lib/url.js";
|
|
14
|
+
import { isSafeInternalRedirect, toPublicHref } from "../lib/url.js";
|
|
15
15
|
|
|
16
16
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
17
17
|
|
|
@@ -78,6 +78,38 @@ export function hasValidLocalDevToken(
|
|
|
78
78
|
return hostname ? isLocalHostname(hostname) : false;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Paths that should never be used as post-signin redirect targets (would
|
|
83
|
+
* either loop back to signin or hit an unauthenticated endpoint).
|
|
84
|
+
*/
|
|
85
|
+
const POST_SIGNIN_REDIRECT_BLOCKLIST = new Set([
|
|
86
|
+
"/signin",
|
|
87
|
+
"/signout",
|
|
88
|
+
"/setup",
|
|
89
|
+
"/reset",
|
|
90
|
+
"/__sso",
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
function getPostSigninRedirect(requestUrl: string): string | null {
|
|
94
|
+
// `c.req.url` is already the app-internal URL — `prepareRequestForRouting`
|
|
95
|
+
// strips any configured site path prefix before Hono sees it, so we just
|
|
96
|
+
// need to preserve pathname + query and validate it as a safe same-origin
|
|
97
|
+
// redirect target.
|
|
98
|
+
let url: URL;
|
|
99
|
+
try {
|
|
100
|
+
url = new URL(requestUrl);
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const pathname = url.pathname || "/";
|
|
106
|
+
if (POST_SIGNIN_REDIRECT_BLOCKLIST.has(pathname)) return null;
|
|
107
|
+
if (pathname.startsWith("/api/")) return null;
|
|
108
|
+
|
|
109
|
+
const candidate = `${pathname}${url.search}`;
|
|
110
|
+
return isSafeInternalRedirect(candidate) ? candidate : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
81
113
|
/**
|
|
82
114
|
* Middleware that requires authentication.
|
|
83
115
|
* Redirects to signin page if not authenticated.
|
|
@@ -90,28 +122,38 @@ export function requireAuth(redirectTo = "/signin"): MiddlewareHandler<Env> {
|
|
|
90
122
|
appConfig: c.var.appConfig,
|
|
91
123
|
currentSiteDomain: c.var.currentSiteDomain,
|
|
92
124
|
});
|
|
93
|
-
const redirectTarget = toPublicHref(redirectTo, sitePathPrefix);
|
|
94
125
|
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
126
|
+
const buildRedirectTarget = () => {
|
|
127
|
+
const publicHref = toPublicHref(redirectTo, sitePathPrefix);
|
|
128
|
+
// Only append `?redirect=...` when redirecting to the default signin flow.
|
|
129
|
+
// Callers passing a custom path get it untouched.
|
|
130
|
+
if (redirectTo !== "/signin") return publicHref;
|
|
99
131
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
132
|
+
const postSignin = getPostSigninRedirect(c.req.url);
|
|
133
|
+
if (!postSignin) return publicHref;
|
|
134
|
+
|
|
135
|
+
const separator = publicHref.includes("?") ? "&" : "?";
|
|
136
|
+
return `${publicHref}${separator}redirect=${encodeURIComponent(postSignin)}`;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Session was already fetched by `attachSession` middleware.
|
|
140
|
+
const session = c.var.session;
|
|
141
|
+
if (!session?.user) {
|
|
142
|
+
return c.redirect(buildRedirectTarget());
|
|
143
|
+
}
|
|
103
144
|
|
|
145
|
+
try {
|
|
104
146
|
const membership = await c.var.services.siteMembers.get(
|
|
105
147
|
c.var.currentSite.id,
|
|
106
148
|
session.user.id,
|
|
107
149
|
);
|
|
108
150
|
if (!membership) {
|
|
109
|
-
return c.redirect(
|
|
151
|
+
return c.redirect(buildRedirectTarget());
|
|
110
152
|
}
|
|
111
153
|
|
|
112
154
|
await next();
|
|
113
155
|
} catch {
|
|
114
|
-
return c.redirect(
|
|
156
|
+
return c.redirect(buildRedirectTarget());
|
|
115
157
|
}
|
|
116
158
|
};
|
|
117
159
|
}
|
|
@@ -123,26 +165,21 @@ export function requireAuth(redirectTo = "/signin"): MiddlewareHandler<Env> {
|
|
|
123
165
|
*/
|
|
124
166
|
export function requireAuthApi(): MiddlewareHandler<Env> {
|
|
125
167
|
return async (c, next) => {
|
|
126
|
-
// 1. Try session auth (
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
if (session?.user) {
|
|
168
|
+
// 1. Try session auth (session is pre-fetched by `attachSession` middleware).
|
|
169
|
+
const session = c.var.session;
|
|
170
|
+
if (session?.user) {
|
|
171
|
+
try {
|
|
133
172
|
const membership = await c.var.services.siteMembers.get(
|
|
134
173
|
c.var.currentSite.id,
|
|
135
174
|
session.user.id,
|
|
136
175
|
);
|
|
137
|
-
if (
|
|
138
|
-
|
|
176
|
+
if (membership) {
|
|
177
|
+
await next();
|
|
178
|
+
return;
|
|
139
179
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return;
|
|
180
|
+
} catch {
|
|
181
|
+
// Membership check failed — fall through to Bearer token
|
|
143
182
|
}
|
|
144
|
-
} catch {
|
|
145
|
-
// Session check failed — fall through to Bearer token
|
|
146
183
|
}
|
|
147
184
|
|
|
148
185
|
// 2. Try Bearer token auth
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate-Limit Middleware
|
|
3
|
+
*
|
|
4
|
+
* Applies a per-IP rate limit to the routes it wraps. Which storage
|
|
5
|
+
* backs the limiter (D1 or in-memory) is decided upstream by the
|
|
6
|
+
* runtime; this middleware only reads `c.var.rateLimiter` and doesn't
|
|
7
|
+
* care.
|
|
8
|
+
*
|
|
9
|
+
* When the limit is exceeded, responds with HTTP 429 and a
|
|
10
|
+
* `Retry-After` header. When `appConfig.rateLimit.disabled` is true the
|
|
11
|
+
* middleware short-circuits to `next()` so test and dev environments
|
|
12
|
+
* don't have to reason about bucket state.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { MiddlewareHandler } from "hono";
|
|
16
|
+
import { getClientIp } from "../lib/rate-limit.js";
|
|
17
|
+
import type { Bindings } from "../types.js";
|
|
18
|
+
import type { AppVariables } from "../types/app-context.js";
|
|
19
|
+
|
|
20
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
21
|
+
|
|
22
|
+
export interface RateLimitMiddlewareOptions {
|
|
23
|
+
/**
|
|
24
|
+
* Storage-key prefix scoping this limit (e.g. "search"). Keeps
|
|
25
|
+
* counters for different endpoints independent when they share a
|
|
26
|
+
* storage backend.
|
|
27
|
+
*/
|
|
28
|
+
name: string;
|
|
29
|
+
/** Max requests per IP within `windowSec`. */
|
|
30
|
+
limit: number;
|
|
31
|
+
/** Sliding window size in seconds. */
|
|
32
|
+
windowSec: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function rateLimit(
|
|
36
|
+
opts: RateLimitMiddlewareOptions,
|
|
37
|
+
): MiddlewareHandler<Env> {
|
|
38
|
+
return async (c, next) => {
|
|
39
|
+
if (c.var.appConfig.rateLimit.disabled) return next();
|
|
40
|
+
|
|
41
|
+
const ip = getClientIp(c);
|
|
42
|
+
const result = await c.var.rateLimiter.check(`${opts.name}:${ip}`, {
|
|
43
|
+
limit: opts.limit,
|
|
44
|
+
windowSec: opts.windowSec,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!result.ok) {
|
|
48
|
+
c.header("Retry-After", String(result.retryAfterSec ?? opts.windowSec));
|
|
49
|
+
return c.json({ error: "Too many requests. Please slow down." }, 429);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return next();
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Middleware
|
|
3
|
+
*
|
|
4
|
+
* Runs once per request (after runtime init) to look up the better-auth
|
|
5
|
+
* session and stash it on `c.var.session` / `c.var.isAuthenticated`.
|
|
6
|
+
*
|
|
7
|
+
* This replaces ad-hoc `auth.api.getSession()` calls scattered across
|
|
8
|
+
* view helpers (e.g. `lib/navigation.ts`) so each request only parses
|
|
9
|
+
* the session cookie once. better-auth's own cookieCache (5 min) still
|
|
10
|
+
* keeps this cheap, but centralizing the call also unlocks `Promise.all`
|
|
11
|
+
* patterns in routes that previously serialized on a hidden session fetch.
|
|
12
|
+
*
|
|
13
|
+
* Never throws — any lookup error is treated as "not authenticated".
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { MiddlewareHandler } from "hono";
|
|
17
|
+
import type { Bindings } from "../types.js";
|
|
18
|
+
import type { AppVariables } from "../types/app-context.js";
|
|
19
|
+
|
|
20
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
21
|
+
|
|
22
|
+
export function attachSession(): MiddlewareHandler<Env> {
|
|
23
|
+
return async (c, next) => {
|
|
24
|
+
try {
|
|
25
|
+
const session = await c.var.auth.api.getSession({
|
|
26
|
+
headers: c.req.raw.headers,
|
|
27
|
+
});
|
|
28
|
+
c.set("session", session ?? null);
|
|
29
|
+
c.set("isAuthenticated", !!session?.user);
|
|
30
|
+
} catch {
|
|
31
|
+
c.set("session", null);
|
|
32
|
+
c.set("isAuthenticated", false);
|
|
33
|
+
}
|
|
34
|
+
await next();
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -45,7 +45,7 @@ describe("Compose Routes", () => {
|
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
expect(res.status).toBe(302);
|
|
48
|
-
expect(res.headers.get("Location")).toBe("/signin");
|
|
48
|
+
expect(res.headers.get("Location")).toBe("/signin?redirect=%2Fcompose");
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
it("creates a note post and returns timeline card via SSE", async () => {
|
|
@@ -110,4 +110,52 @@ describe("Search API Routes", () => {
|
|
|
110
110
|
// Should not return 401
|
|
111
111
|
expect(res.status).not.toBe(401);
|
|
112
112
|
});
|
|
113
|
+
|
|
114
|
+
it("rate-limits repeated requests from the same IP", async () => {
|
|
115
|
+
// Test app uses in-memory defaults (30/min). Send 31 requests from
|
|
116
|
+
// the same spoofed IP and confirm the tail gets a 429 with Retry-After.
|
|
117
|
+
const { app } = createTestApp({ fts: true });
|
|
118
|
+
app.route("/api/search", searchApiRoutes);
|
|
119
|
+
|
|
120
|
+
const headers = { "cf-connecting-ip": "203.0.113.7" };
|
|
121
|
+
let ok = 0;
|
|
122
|
+
let throttled = 0;
|
|
123
|
+
let lastRetryAfter: string | null = null;
|
|
124
|
+
|
|
125
|
+
for (let i = 0; i < 31; i++) {
|
|
126
|
+
const res = await app.request("/api/search?q=hi", { headers });
|
|
127
|
+
if (res.status === 429) {
|
|
128
|
+
throttled += 1;
|
|
129
|
+
lastRetryAfter = res.headers.get("retry-after");
|
|
130
|
+
} else if (res.status === 200) {
|
|
131
|
+
ok += 1;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
expect(ok).toBe(30);
|
|
136
|
+
expect(throttled).toBe(1);
|
|
137
|
+
expect(Number(lastRetryAfter)).toBeGreaterThan(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("does not rate-limit when appConfig.rateLimit.disabled is true", async () => {
|
|
141
|
+
const { app } = createTestApp({ fts: true });
|
|
142
|
+
|
|
143
|
+
// Flip the disabled flag after the test-app middleware seeds
|
|
144
|
+
// appConfig, but before the search route runs. Middleware order:
|
|
145
|
+
// createTestApp's global use → this override → search route.
|
|
146
|
+
app.use("/api/search/*", async (c, next) => {
|
|
147
|
+
c.set("appConfig", {
|
|
148
|
+
...c.var.appConfig,
|
|
149
|
+
rateLimit: { ...c.var.appConfig.rateLimit, disabled: true },
|
|
150
|
+
});
|
|
151
|
+
await next();
|
|
152
|
+
});
|
|
153
|
+
app.route("/api/search", searchApiRoutes);
|
|
154
|
+
|
|
155
|
+
const headers = { "cf-connecting-ip": "203.0.113.8" };
|
|
156
|
+
for (let i = 0; i < 40; i++) {
|
|
157
|
+
const res = await app.request("/api/search?q=hi", { headers });
|
|
158
|
+
expect(res.status).not.toBe(429);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
113
161
|
});
|