@jant/core 0.3.42 → 0.3.44
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-BI9bnCkO.js +5 -0
- package/dist/{app-Cu3lveYI.js → app-CtJDxZBb.js} +969 -373
- package/dist/client/.vite/manifest.json +2 -2
- package/dist/client/_assets/client-BQH7AQ24.css +2 -0
- 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/0019_bored_magus.sql +2 -0
- package/src/db/migrations/meta/0018_snapshot.json +2225 -0
- package/src/db/migrations/meta/0019_snapshot.json +2238 -0
- package/src/db/migrations/meta/_journal.json +15 -1
- package/src/db/migrations/pg/0016_familiar_lionheart.sql +6 -0
- package/src/db/migrations/pg/0017_bright_beyonder.sql +2 -0
- package/src/db/migrations/pg/meta/0016_snapshot.json +2840 -0
- package/src/db/migrations/pg/meta/0017_snapshot.json +2862 -0
- package/src/db/migrations/pg/meta/_journal.json +15 -1
- package/src/db/pg/schema.ts +22 -0
- package/src/db/schema.ts +27 -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/icons.ts +37 -0
- 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/internal/sites.ts +1 -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/__tests__/site-admin.test.ts +85 -0
- package/src/services/export.ts +9 -2
- package/src/services/post.ts +200 -2
- package/src/services/site-admin.ts +66 -1
- package/src/styles/ui.css +12 -0
- 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/feed/LinkCard.tsx +3 -20
- package/src/ui/feed/LinkPreview.tsx +5 -19
- package/src/ui/feed/PostStatusBadges.tsx +4 -38
- package/src/ui/layouts/BaseLayout.tsx +23 -29
- package/src/ui/shared/DecorativeQuoteMark.tsx +2 -13
- package/src/ui/shared/Icon.tsx +60 -0
- package/src/ui/shared/IconSprite.tsx +57 -0
- package/src/ui/shared/PostFooter.tsx +6 -62
- package/src/ui/shared/custom-icons.ts +132 -0
- package/src/ui/shared/icon-collector.ts +37 -0
- package/dist/app-DzCB4yOp.js +0 -5
- package/dist/client/_assets/client-C_kImWZj.css +0 -2
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
|
});
|
|
@@ -19,6 +19,7 @@ import { createSiteMemberService } from "../../../services/site-member.js";
|
|
|
19
19
|
import { errorHandler } from "../../../middleware/error-handler.js";
|
|
20
20
|
import { createI18n } from "../../../i18n/i18n.js";
|
|
21
21
|
import { DEFAULT_APP_PORT } from "../../../lib/env.js";
|
|
22
|
+
import { createMemoryRateLimiter } from "../../../lib/rate-limit-memory.js";
|
|
22
23
|
import { resolveConfig } from "../../../lib/resolve-config.js";
|
|
23
24
|
import type { Database } from "../../../db/index.js";
|
|
24
25
|
import type { StorageDriver, UploadedPart } from "../../../lib/storage.js";
|
|
@@ -265,6 +266,7 @@ function createTestAppWithStorage(options: {
|
|
|
265
266
|
c.set("allSettings", allSettings);
|
|
266
267
|
c.set("appConfig", resolveConfig(c.env, allSettings));
|
|
267
268
|
c.set("storage", options.storage);
|
|
269
|
+
c.set("rateLimiter", createMemoryRateLimiter());
|
|
268
270
|
|
|
269
271
|
const i18n = createI18n("en");
|
|
270
272
|
c.set("lang", "en");
|
|
@@ -276,20 +278,25 @@ function createTestAppWithStorage(options: {
|
|
|
276
278
|
"test-user",
|
|
277
279
|
"owner",
|
|
278
280
|
);
|
|
281
|
+
const session = {
|
|
282
|
+
user: { id: "test-user", email: "test@test.com", name: "Test" },
|
|
283
|
+
session: { id: "test-session" },
|
|
284
|
+
} as unknown as AppVariables["session"];
|
|
279
285
|
c.set("auth", {
|
|
280
286
|
api: {
|
|
281
|
-
getSession: async () =>
|
|
282
|
-
user: { id: "test-user", email: "test@test.com", name: "Test" },
|
|
283
|
-
session: { id: "test-session" },
|
|
284
|
-
}),
|
|
287
|
+
getSession: async () => session,
|
|
285
288
|
},
|
|
286
289
|
} as AppVariables["auth"]);
|
|
290
|
+
c.set("session", session);
|
|
291
|
+
c.set("isAuthenticated", true);
|
|
287
292
|
} else {
|
|
288
293
|
c.set("auth", {
|
|
289
294
|
api: {
|
|
290
295
|
getSession: async () => null,
|
|
291
296
|
},
|
|
292
297
|
} as AppVariables["auth"]);
|
|
298
|
+
c.set("session", null);
|
|
299
|
+
c.set("isAuthenticated", false);
|
|
293
300
|
}
|
|
294
301
|
|
|
295
302
|
await next();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { requireInternalAdminApi } from "../../../middleware/auth.js";
|
|
4
|
+
import { parseValidated } from "../../../lib/schemas.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
|
+
const ReindexSchema = z.object({
|
|
11
|
+
limit: z.number().int().positive().max(500).optional(),
|
|
12
|
+
cursor: z.string().min(1).optional(),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const internalSearchReindexRoutes = new Hono<Env>();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Rebuild `post.body_text` for a batch of non-deleted posts. FTS indexes
|
|
19
|
+
* (SQLite trigger / Postgres generated column) refresh automatically when
|
|
20
|
+
* `body_text` changes.
|
|
21
|
+
*
|
|
22
|
+
* Idempotent. Callers loop with the returned `nextCursor` until `done: true`.
|
|
23
|
+
* Used by the `jant search-reindex` CLI to backfill search indexes for
|
|
24
|
+
* existing posts after changes to the text extraction logic (e.g. including
|
|
25
|
+
* link mark hrefs so inline URLs become searchable).
|
|
26
|
+
*/
|
|
27
|
+
internalSearchReindexRoutes.post("/", requireInternalAdminApi(), async (c) => {
|
|
28
|
+
const contentType = c.req.header("Content-Type") || "";
|
|
29
|
+
const rawBody = contentType.includes("application/json")
|
|
30
|
+
? await c.req.json().catch(() => ({}))
|
|
31
|
+
: {};
|
|
32
|
+
const body = parseValidated(ReindexSchema, rawBody);
|
|
33
|
+
|
|
34
|
+
const result = await c.var.services.posts.reindexBodyText({
|
|
35
|
+
limit: body.limit,
|
|
36
|
+
cursor: body.cursor,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return c.json(result);
|
|
40
|
+
});
|
|
@@ -33,6 +33,7 @@ const CreateManagedSiteSchema = z.object({
|
|
|
33
33
|
siteName: z.string().trim().min(1).max(120),
|
|
34
34
|
siteLanguage: z.string().trim().max(35).optional(),
|
|
35
35
|
timeZone: z.string().trim().max(100).optional(),
|
|
36
|
+
idempotencyKey: z.string().trim().min(1).max(128).optional(),
|
|
36
37
|
});
|
|
37
38
|
|
|
38
39
|
const ManagedSiteDomainSchema = z.object({
|
package/src/routes/api/search.ts
CHANGED
|
@@ -7,11 +7,24 @@ import type { Bindings } from "../../types.js";
|
|
|
7
7
|
import type { AppVariables } from "../../types/app-context.js";
|
|
8
8
|
import { ValidationError, ExternalServiceError } from "../../lib/errors.js";
|
|
9
9
|
import { toSearchApiResult } from "../../lib/api-search.js";
|
|
10
|
+
import { rateLimit } from "../../middleware/rate-limit.js";
|
|
10
11
|
|
|
11
12
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
12
13
|
|
|
13
14
|
export const searchApiRoutes = new Hono<Env>();
|
|
14
15
|
|
|
16
|
+
// Per-IP rate limit. The request-time wrapper is needed because the
|
|
17
|
+
// per-minute cap is pulled from `appConfig` which is only available on
|
|
18
|
+
// `c.var`; constructing the middleware once at module load would capture
|
|
19
|
+
// an undefined value.
|
|
20
|
+
searchApiRoutes.use("*", async (c, next) =>
|
|
21
|
+
rateLimit({
|
|
22
|
+
name: "search",
|
|
23
|
+
limit: c.var.appConfig.rateLimit.searchPerMinute,
|
|
24
|
+
windowSec: 60,
|
|
25
|
+
})(c, next),
|
|
26
|
+
);
|
|
27
|
+
|
|
15
28
|
// Search posts
|
|
16
29
|
searchApiRoutes.get("/", async (c) => {
|
|
17
30
|
const query = c.req.query("q");
|
package/src/routes/auth/dev.ts
CHANGED
|
@@ -65,7 +65,7 @@ devAuthRoutes.get("/__dev/login", async (c) => {
|
|
|
65
65
|
});
|
|
66
66
|
} catch {
|
|
67
67
|
return c.text(
|
|
68
|
-
"Dev login failed. Finish /setup once or run `mise run db-
|
|
68
|
+
"Dev login failed. Finish /setup once or run `mise run db-wrangler-rebuild-demo`, then retry.",
|
|
69
69
|
500,
|
|
70
70
|
);
|
|
71
71
|
}
|
|
@@ -14,7 +14,7 @@ import { SigninSchema } from "../../lib/schemas.js";
|
|
|
14
14
|
import { buildPageTitle } from "../../lib/page-title.js";
|
|
15
15
|
import { getI18n } from "../../i18n/index.js";
|
|
16
16
|
import { getHostedControlPlaneSigninUrl } from "../../lib/hosted-signin.js";
|
|
17
|
-
import { toPublicPath } from "../../lib/url.js";
|
|
17
|
+
import { isSafeInternalRedirect, toPublicPath } from "../../lib/url.js";
|
|
18
18
|
|
|
19
19
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
20
20
|
|
|
@@ -22,11 +22,13 @@ const SigninContent: FC<{
|
|
|
22
22
|
demoEmail?: string;
|
|
23
23
|
demoPassword?: string;
|
|
24
24
|
sitePathPrefix?: string;
|
|
25
|
-
|
|
25
|
+
redirect?: string;
|
|
26
|
+
}> = ({ demoEmail, demoPassword, sitePathPrefix = "", redirect }) => {
|
|
26
27
|
const { i18n } = useLingui();
|
|
27
28
|
const signals = JSON.stringify({
|
|
28
29
|
email: demoEmail || "",
|
|
29
30
|
password: demoPassword || "",
|
|
31
|
+
...(redirect ? { redirect } : {}),
|
|
30
32
|
}).replace(/</g, "\\u003c");
|
|
31
33
|
|
|
32
34
|
return (
|
|
@@ -121,9 +123,15 @@ const SigninContent: FC<{
|
|
|
121
123
|
export const signinRoutes = new Hono<Env>();
|
|
122
124
|
|
|
123
125
|
signinRoutes.get("/signin", async (c) => {
|
|
126
|
+
const rawRedirect = c.req.query("redirect");
|
|
127
|
+
const redirect = isSafeInternalRedirect(rawRedirect)
|
|
128
|
+
? rawRedirect
|
|
129
|
+
: undefined;
|
|
130
|
+
|
|
124
131
|
const hostedSigninUrl = getHostedControlPlaneSigninUrl(
|
|
125
132
|
c.env,
|
|
126
133
|
c.var.publicRequestUrl,
|
|
134
|
+
redirect,
|
|
127
135
|
);
|
|
128
136
|
if (hostedSigninUrl) {
|
|
129
137
|
return c.redirect(hostedSigninUrl);
|
|
@@ -157,6 +165,7 @@ signinRoutes.get("/signin", async (c) => {
|
|
|
157
165
|
demoEmail={c.var.appConfig.demoEmail}
|
|
158
166
|
demoPassword={c.var.appConfig.demoPassword}
|
|
159
167
|
sitePathPrefix={c.var.appConfig.sitePathPrefix}
|
|
168
|
+
redirect={redirect}
|
|
160
169
|
/>
|
|
161
170
|
</BaseLayout>,
|
|
162
171
|
);
|
|
@@ -195,6 +204,14 @@ signinRoutes.post("/signin", async (c) => {
|
|
|
195
204
|
}
|
|
196
205
|
|
|
197
206
|
const { email, password } = parsed.data;
|
|
207
|
+
const rawRedirect =
|
|
208
|
+
body && typeof body === "object" && "redirect" in body
|
|
209
|
+
? (body as { redirect?: unknown }).redirect
|
|
210
|
+
: undefined;
|
|
211
|
+
const redirectTarget =
|
|
212
|
+
typeof rawRedirect === "string" && isSafeInternalRedirect(rawRedirect)
|
|
213
|
+
? rawRedirect
|
|
214
|
+
: "/";
|
|
198
215
|
|
|
199
216
|
try {
|
|
200
217
|
const { headers } = await c.var.auth.api.signInEmail({
|
|
@@ -203,9 +220,10 @@ signinRoutes.post("/signin", async (c) => {
|
|
|
203
220
|
headers: c.req.raw.headers,
|
|
204
221
|
});
|
|
205
222
|
|
|
206
|
-
return dsRedirect(
|
|
207
|
-
|
|
208
|
-
|
|
223
|
+
return dsRedirect(
|
|
224
|
+
toPublicPath(redirectTarget, c.var.appConfig.sitePathPrefix),
|
|
225
|
+
{ headers },
|
|
226
|
+
);
|
|
209
227
|
} catch {
|
|
210
228
|
return dsToast(
|
|
211
229
|
i18n._(
|
|
@@ -1016,11 +1016,9 @@ settingsRoutes.get("/account/sessions", async (c) => {
|
|
|
1016
1016
|
|
|
1017
1017
|
const navData = await getNavigationData(c);
|
|
1018
1018
|
|
|
1019
|
-
//
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
});
|
|
1023
|
-
const currentToken = currentSession?.session?.token ?? "";
|
|
1019
|
+
// Session was pre-fetched by `attachSession`; this route is behind
|
|
1020
|
+
// `requireAuth`, so it's guaranteed to be present here.
|
|
1021
|
+
const currentToken = c.var.session?.session?.token ?? "";
|
|
1024
1022
|
|
|
1025
1023
|
// List all active sessions
|
|
1026
1024
|
const rawSessions = await c.var.auth.api.listSessions({
|