@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/lib/feed.ts
CHANGED
|
@@ -10,12 +10,7 @@
|
|
|
10
10
|
* ```
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import type {
|
|
14
|
-
FeedData,
|
|
15
|
-
FeedPostView,
|
|
16
|
-
PostView,
|
|
17
|
-
SitemapData,
|
|
18
|
-
} from "../types.js";
|
|
13
|
+
import type { FeedData, FeedPostView, PostView } from "../types.js";
|
|
19
14
|
import { extractDisplayDomain } from "./url.js";
|
|
20
15
|
|
|
21
16
|
/**
|
|
@@ -242,40 +237,81 @@ export function defaultFeedRenderer(data: FeedData): string {
|
|
|
242
237
|
}
|
|
243
238
|
|
|
244
239
|
/**
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
* @returns Sitemap XML string
|
|
240
|
+
* Maximum URLs per sitemap shard. The sitemap.xml spec allows up to 50,000
|
|
241
|
+
* per file; 500 keeps individual shards cheap to generate on D1 and makes old
|
|
242
|
+
* (already-filled) shards small enough to cache aggressively at the edge.
|
|
249
243
|
*/
|
|
250
|
-
export
|
|
251
|
-
const { siteUrl, sitemapUrl, posts } = data;
|
|
244
|
+
export const SITEMAP_SHARD_SIZE = 500;
|
|
252
245
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
246
|
+
/** One `<url>` entry inside a sitemap `<urlset>`. */
|
|
247
|
+
export interface SitemapUrlEntry {
|
|
248
|
+
loc: string;
|
|
249
|
+
/** ISO date (YYYY-MM-DD) or full ISO datetime */
|
|
250
|
+
lastmod?: string;
|
|
251
|
+
changefreq?:
|
|
252
|
+
| "always"
|
|
253
|
+
| "hourly"
|
|
254
|
+
| "daily"
|
|
255
|
+
| "weekly"
|
|
256
|
+
| "monthly"
|
|
257
|
+
| "yearly"
|
|
258
|
+
| "never";
|
|
259
|
+
/** "0.0" – "1.0" */
|
|
260
|
+
priority?: string;
|
|
261
|
+
}
|
|
258
262
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
</url>`;
|
|
265
|
-
})
|
|
266
|
-
.join("");
|
|
263
|
+
/** One `<sitemap>` entry inside a `<sitemapindex>`. */
|
|
264
|
+
export interface SitemapIndexEntry {
|
|
265
|
+
loc: string;
|
|
266
|
+
lastmod?: string;
|
|
267
|
+
}
|
|
267
268
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
269
|
+
/**
|
|
270
|
+
* Render a sitemap `<urlset>` XML document from a list of URL entries.
|
|
271
|
+
*
|
|
272
|
+
* Used by the sharded sitemap endpoints in `routes/feed/sitemap.ts`.
|
|
273
|
+
*/
|
|
274
|
+
export function renderSitemapUrlSet(entries: SitemapUrlEntry[]): string {
|
|
275
|
+
const urls = entries
|
|
276
|
+
.map((entry) => {
|
|
277
|
+
const parts = [` <loc>${escapeXml(entry.loc)}</loc>`];
|
|
278
|
+
if (entry.lastmod) {
|
|
279
|
+
parts.push(` <lastmod>${escapeXml(entry.lastmod)}</lastmod>`);
|
|
280
|
+
}
|
|
281
|
+
if (entry.changefreq) {
|
|
282
|
+
parts.push(
|
|
283
|
+
` <changefreq>${escapeXml(entry.changefreq)}</changefreq>`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (entry.priority) {
|
|
287
|
+
parts.push(` <priority>${escapeXml(entry.priority)}</priority>`);
|
|
288
|
+
}
|
|
289
|
+
return ` <url>\n${parts.join("\n")}\n </url>`;
|
|
290
|
+
})
|
|
291
|
+
.join("\n");
|
|
274
292
|
|
|
275
293
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
276
294
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
277
|
-
|
|
278
|
-
${homepageUrl}
|
|
279
|
-
${postUrls}
|
|
295
|
+
${urls}
|
|
280
296
|
</urlset>`;
|
|
281
297
|
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Render a `<sitemapindex>` XML document listing shard sitemap URLs.
|
|
301
|
+
*/
|
|
302
|
+
export function renderSitemapIndex(entries: SitemapIndexEntry[]): string {
|
|
303
|
+
const items = entries
|
|
304
|
+
.map((entry) => {
|
|
305
|
+
const parts = [` <loc>${escapeXml(entry.loc)}</loc>`];
|
|
306
|
+
if (entry.lastmod) {
|
|
307
|
+
parts.push(` <lastmod>${escapeXml(entry.lastmod)}</lastmod>`);
|
|
308
|
+
}
|
|
309
|
+
return ` <sitemap>\n${parts.join("\n")}\n </sitemap>`;
|
|
310
|
+
})
|
|
311
|
+
.join("\n");
|
|
312
|
+
|
|
313
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
314
|
+
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
315
|
+
${items}
|
|
316
|
+
</sitemapindex>`;
|
|
317
|
+
}
|
package/src/lib/hosted-signin.ts
CHANGED
|
@@ -3,10 +3,15 @@ import {
|
|
|
3
3
|
getHostedControlPlaneProviderLabel as getConfiguredHostedControlPlaneProviderLabel,
|
|
4
4
|
getSiteResolutionMode,
|
|
5
5
|
} from "./env.js";
|
|
6
|
+
import { isSafeInternalRedirect } from "./url.js";
|
|
6
7
|
|
|
7
|
-
function getHostedAdminContinuationPath(
|
|
8
|
+
function getHostedAdminContinuationPath(
|
|
9
|
+
publicRequestUrl: string,
|
|
10
|
+
redirect?: string,
|
|
11
|
+
): string {
|
|
8
12
|
const currentHost = new URL(publicRequestUrl).host;
|
|
9
|
-
|
|
13
|
+
const safeRedirect = isSafeInternalRedirect(redirect) ? redirect : "/";
|
|
14
|
+
return `/auth/handoff/start?host=${encodeURIComponent(currentHost)}&redirect=${encodeURIComponent(safeRedirect)}`;
|
|
10
15
|
}
|
|
11
16
|
|
|
12
17
|
function buildHostedControlPlaneUrl(
|
|
@@ -31,11 +36,12 @@ function buildHostedControlPlaneUrl(
|
|
|
31
36
|
export function getHostedControlPlaneSigninUrl(
|
|
32
37
|
env: object | undefined | null,
|
|
33
38
|
publicRequestUrl: string,
|
|
39
|
+
redirect?: string,
|
|
34
40
|
): string | null {
|
|
35
41
|
return buildHostedControlPlaneUrl(
|
|
36
42
|
env,
|
|
37
43
|
"/auth/handoff/start",
|
|
38
|
-
getHostedAdminContinuationPath(publicRequestUrl).replace(
|
|
44
|
+
getHostedAdminContinuationPath(publicRequestUrl, redirect).replace(
|
|
39
45
|
/^\/auth\/handoff\/start/,
|
|
40
46
|
"",
|
|
41
47
|
),
|
package/src/lib/icons.ts
CHANGED
|
@@ -36,3 +36,40 @@ export function getIconSvg(name: string): string | null {
|
|
|
36
36
|
const svg = (lucideIcons as Record<string, string>)[pascalName];
|
|
37
37
|
return typeof svg === "string" ? svg : null;
|
|
38
38
|
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get the inner SVG contents for a Lucide icon (the path children only,
|
|
42
|
+
* without the outer <svg> wrapper). Used by the icon sprite to build
|
|
43
|
+
* <symbol> definitions.
|
|
44
|
+
*
|
|
45
|
+
* @param name - Kebab-case icon name
|
|
46
|
+
* @returns Inner SVG markup (e.g. "<path ... />"), or null when unknown
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```ts
|
|
50
|
+
* getIconInnerSvg("book-open");
|
|
51
|
+
* // -> "<path d=\"...\"/><path d=\"...\"/>"
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export function getIconInnerSvg(name: string): string | null {
|
|
55
|
+
const svg = getIconSvg(name);
|
|
56
|
+
if (!svg) return null;
|
|
57
|
+
// lucide-static output looks like:
|
|
58
|
+
// <svg ... viewBox="0 0 24 24" ...><path .../>...<line .../></svg>
|
|
59
|
+
// Strip the outer <svg ...> and </svg>.
|
|
60
|
+
const openTagEnd = svg.indexOf(">");
|
|
61
|
+
const closeTagStart = svg.lastIndexOf("</svg>");
|
|
62
|
+
if (openTagEnd < 0 || closeTagStart < 0) return null;
|
|
63
|
+
return svg.slice(openTagEnd + 1, closeTagStart);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Default stroke/fill attributes inherited by <symbol> children when a
|
|
68
|
+
* lucide icon is referenced via <use>. These mirror the attributes lucide
|
|
69
|
+
* normally sets on the outer <svg> so the currentColor-based theming keeps
|
|
70
|
+
* working through <use>.
|
|
71
|
+
*/
|
|
72
|
+
export const LUCIDE_SYMBOL_ATTRS =
|
|
73
|
+
'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
|
|
74
|
+
|
|
75
|
+
export const LUCIDE_VIEWBOX = "0 0 24 24";
|
package/src/lib/navigation.ts
CHANGED
|
@@ -60,8 +60,15 @@ export function getHomeDefaultViewFromNavItems(
|
|
|
60
60
|
* });
|
|
61
61
|
* ```
|
|
62
62
|
*/
|
|
63
|
-
export async function getNavigationData(
|
|
64
|
-
|
|
63
|
+
export async function getNavigationData(
|
|
64
|
+
c: Context,
|
|
65
|
+
options?: { preloadedItems?: NavItem[] },
|
|
66
|
+
): Promise<NavigationData> {
|
|
67
|
+
// Callers that already fetched nav items (e.g. home route, which needs
|
|
68
|
+
// `homeDefaultView` before deciding which timeline to assemble) can pass
|
|
69
|
+
// them in to avoid a redundant DB round-trip.
|
|
70
|
+
const items =
|
|
71
|
+
options?.preloadedItems ?? (await c.var.services.navItems.list());
|
|
65
72
|
const currentPath = c.var.publicPath;
|
|
66
73
|
const appConfig = c.var.appConfig;
|
|
67
74
|
|
|
@@ -86,17 +93,9 @@ export async function getNavigationData(c: Context): Promise<NavigationData> {
|
|
|
86
93
|
// Render footer markdown
|
|
87
94
|
const siteFooterHtml = siteFooter ? renderMarkdown(siteFooter) : undefined;
|
|
88
95
|
|
|
89
|
-
//
|
|
90
|
-
|
|
96
|
+
// Auth state is populated once per request by `attachSession` middleware.
|
|
97
|
+
const isAuthenticated = c.var.isAuthenticated;
|
|
91
98
|
let collections: Collection[] = [];
|
|
92
|
-
try {
|
|
93
|
-
const session = await c.var.auth.api.getSession({
|
|
94
|
-
headers: c.req.raw.headers,
|
|
95
|
-
});
|
|
96
|
-
isAuthenticated = !!session?.user;
|
|
97
|
-
} catch {
|
|
98
|
-
// Not authenticated
|
|
99
|
-
}
|
|
100
99
|
|
|
101
100
|
// Compute freshness for collection nav items
|
|
102
101
|
const collectionNavIds: string[] = [];
|
package/src/lib/post-meta.ts
CHANGED
|
@@ -1,9 +1,27 @@
|
|
|
1
1
|
import type { Post } from "../types.js";
|
|
2
2
|
import { extractDisplayDomain } from "./url.js";
|
|
3
|
+
import { extractBodyText } from "./summary.js";
|
|
3
4
|
|
|
4
5
|
const TITLE_MAX_CHARS = 72;
|
|
5
6
|
const DESCRIPTION_MAX_CHARS = 160;
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Derive a clean plain-text projection of the body for human-facing meta.
|
|
10
|
+
*
|
|
11
|
+
* We cannot reuse `post.bodyText` here: that column is written with
|
|
12
|
+
* `includeLinkHrefs: true` so inline link URLs land in the FTS index, which
|
|
13
|
+
* pollutes the stored text with trailing URLs. Re-derive from the source
|
|
14
|
+
* TipTap JSON (`post.body`) without that option.
|
|
15
|
+
*/
|
|
16
|
+
function getCleanBodyText(post: Post): string {
|
|
17
|
+
// Prefer re-derivation from `post.body` (TipTap JSON). Fall back to
|
|
18
|
+
// `post.bodyText` only when `body` is absent (legacy rows / fixtures);
|
|
19
|
+
// without link marks in the source, there is no URL pollution to worry
|
|
20
|
+
// about.
|
|
21
|
+
if (post.body) return extractBodyText(post.body) ?? "";
|
|
22
|
+
return post.bodyText ?? "";
|
|
23
|
+
}
|
|
24
|
+
|
|
7
25
|
function normalizeText(text: string | null | undefined): string {
|
|
8
26
|
return (text ?? "").replace(/\s+/g, " ").trim();
|
|
9
27
|
}
|
|
@@ -49,7 +67,7 @@ function getTitleCandidate(post: Post): string {
|
|
|
49
67
|
const summarySnippet = getFirstParagraph(post.summary);
|
|
50
68
|
if (summarySnippet) return clipText(summarySnippet, TITLE_MAX_CHARS);
|
|
51
69
|
|
|
52
|
-
const bodySnippet = getFirstParagraph(post
|
|
70
|
+
const bodySnippet = getFirstParagraph(getCleanBodyText(post));
|
|
53
71
|
if (bodySnippet) return clipText(bodySnippet, TITLE_MAX_CHARS);
|
|
54
72
|
|
|
55
73
|
if (post.format === "link" && post.url) {
|
|
@@ -68,7 +86,7 @@ function getDescriptionCandidate(post: Post): string {
|
|
|
68
86
|
const summaryText = normalizeText(post.summary);
|
|
69
87
|
if (summaryText) return clipText(summaryText, DESCRIPTION_MAX_CHARS);
|
|
70
88
|
|
|
71
|
-
const bodyText = normalizeText(post
|
|
89
|
+
const bodyText = normalizeText(getCleanBodyText(post));
|
|
72
90
|
if (bodyText) return clipText(bodyText, DESCRIPTION_MAX_CHARS);
|
|
73
91
|
|
|
74
92
|
const quoteText = normalizeText(post.quoteText);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D1 Sliding-Window Rate Limiter
|
|
3
|
+
*
|
|
4
|
+
* Used by the Cloudflare Workers runtime where isolates are ephemeral and
|
|
5
|
+
* memory-based limiters would silently drop state between requests. Each
|
|
6
|
+
* check performs one SELECT (over a two-row range) plus one UPSERT — both
|
|
7
|
+
* hit a composite primary key so the round-trips are cheap.
|
|
8
|
+
*
|
|
9
|
+
* Algorithm: two-window weighted counter. The previous window's tally
|
|
10
|
+
* decays linearly as the current window fills, giving a smoother limit
|
|
11
|
+
* than a naive fixed window (which would allow a 2x burst at the
|
|
12
|
+
* boundary) while remaining a single-key storage primitive.
|
|
13
|
+
*
|
|
14
|
+
* For Node deployments use `createMemoryRateLimiter` instead.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { and, eq, inArray, lt, sql } from "drizzle-orm";
|
|
18
|
+
import type { Database } from "../db/index.js";
|
|
19
|
+
import type { DatabaseSchema } from "../db/schema-bundle.js";
|
|
20
|
+
import type {
|
|
21
|
+
RateLimitCheckOptions,
|
|
22
|
+
RateLimitResult,
|
|
23
|
+
RateLimiter,
|
|
24
|
+
} from "./rate-limit.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Probability of running opportunistic cleanup on any given write.
|
|
28
|
+
* 1% strikes a balance between bounded table growth and per-request cost.
|
|
29
|
+
*/
|
|
30
|
+
const CLEANUP_PROBABILITY = 0.01;
|
|
31
|
+
|
|
32
|
+
export function createD1RateLimiter(
|
|
33
|
+
db: Database,
|
|
34
|
+
schema: DatabaseSchema,
|
|
35
|
+
now: () => number = () => Math.floor(Date.now() / 1000),
|
|
36
|
+
): RateLimiter {
|
|
37
|
+
const { rateLimit } = schema;
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
async check(
|
|
41
|
+
key: string,
|
|
42
|
+
opts: RateLimitCheckOptions,
|
|
43
|
+
): Promise<RateLimitResult> {
|
|
44
|
+
const { limit, windowSec } = opts;
|
|
45
|
+
const nowSec = now();
|
|
46
|
+
const currentWindow = Math.floor(nowSec / windowSec) * windowSec;
|
|
47
|
+
const previousWindow = currentWindow - windowSec;
|
|
48
|
+
|
|
49
|
+
const rows = await db
|
|
50
|
+
.select({
|
|
51
|
+
windowStart: rateLimit.windowStart,
|
|
52
|
+
count: rateLimit.count,
|
|
53
|
+
})
|
|
54
|
+
.from(rateLimit)
|
|
55
|
+
.where(
|
|
56
|
+
and(
|
|
57
|
+
eq(rateLimit.key, key),
|
|
58
|
+
inArray(rateLimit.windowStart, [currentWindow, previousWindow]),
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
let currentCount = 0;
|
|
63
|
+
let previousCount = 0;
|
|
64
|
+
for (const row of rows) {
|
|
65
|
+
if (row.windowStart === currentWindow) currentCount = row.count;
|
|
66
|
+
else if (row.windowStart === previousWindow) previousCount = row.count;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const elapsed = nowSec - currentWindow;
|
|
70
|
+
const prevWeight = 1 - elapsed / windowSec;
|
|
71
|
+
const estimate = previousCount * prevWeight + currentCount;
|
|
72
|
+
|
|
73
|
+
if (estimate >= limit) {
|
|
74
|
+
// Don't record the rejected hit — otherwise a sustained flood
|
|
75
|
+
// would keep increasing `count` past the limit for no benefit.
|
|
76
|
+
const retryAfterSec = Math.max(1, windowSec - elapsed);
|
|
77
|
+
return { ok: false, retryAfterSec };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await db
|
|
81
|
+
.insert(rateLimit)
|
|
82
|
+
.values({ key, windowStart: currentWindow, count: 1 })
|
|
83
|
+
.onConflictDoUpdate({
|
|
84
|
+
target: [rateLimit.key, rateLimit.windowStart],
|
|
85
|
+
set: { count: sql`${rateLimit.count} + 1` },
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Opportunistic cleanup: keep the table bounded without writing
|
|
89
|
+
// a DELETE on every request.
|
|
90
|
+
if (Math.random() < CLEANUP_PROBABILITY) {
|
|
91
|
+
await db
|
|
92
|
+
.delete(rateLimit)
|
|
93
|
+
.where(lt(rateLimit.windowStart, currentWindow - windowSec * 2));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { ok: true };
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-Memory Rate Limiter
|
|
3
|
+
*
|
|
4
|
+
* Used by the Node runtime. The server process is long-lived and
|
|
5
|
+
* single-instance, so a local `Map` is reliable and avoids unnecessary DB
|
|
6
|
+
* round-trips. Uses the classic sliding-window-counter algorithm (two
|
|
7
|
+
* aligned buckets with a weighted estimate) for smooth limiting without
|
|
8
|
+
* the 2x boundary burst of a fixed window.
|
|
9
|
+
*
|
|
10
|
+
* On Cloudflare Workers use `createD1RateLimiter` instead — isolates are
|
|
11
|
+
* ephemeral and cannot share memory across requests.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
RateLimitCheckOptions,
|
|
16
|
+
RateLimitResult,
|
|
17
|
+
RateLimiter,
|
|
18
|
+
} from "./rate-limit.js";
|
|
19
|
+
|
|
20
|
+
interface Bucket {
|
|
21
|
+
/** Unix seconds, aligned to the start of the current window. */
|
|
22
|
+
windowStart: number;
|
|
23
|
+
/** Hits recorded in the current window. */
|
|
24
|
+
count: number;
|
|
25
|
+
/** Hits recorded in the previous window (used for weighted estimate). */
|
|
26
|
+
prevCount: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Number of live keys after which we prune old buckets on the next write.
|
|
31
|
+
* Keeps the Map bounded under abuse without paying for eager sweeps.
|
|
32
|
+
*/
|
|
33
|
+
const SWEEP_THRESHOLD = 10_000;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Creates an isolated in-memory limiter. Tests construct one per test app;
|
|
37
|
+
* the Node runtime holds a single module-level instance across requests.
|
|
38
|
+
*
|
|
39
|
+
* `now` is injectable so tests can assert window-rollover behavior without
|
|
40
|
+
* relying on real time.
|
|
41
|
+
*/
|
|
42
|
+
export function createMemoryRateLimiter(
|
|
43
|
+
now: () => number = () => Math.floor(Date.now() / 1000),
|
|
44
|
+
): RateLimiter {
|
|
45
|
+
const buckets = new Map<string, Bucket>();
|
|
46
|
+
|
|
47
|
+
function sweep(nowSec: number) {
|
|
48
|
+
if (buckets.size < SWEEP_THRESHOLD) return;
|
|
49
|
+
// Drop entries whose window is older than 2 windows in the past. We
|
|
50
|
+
// use the bucket's stored windowSize via the difference between prev
|
|
51
|
+
// hits existing and the current time; since the sweep runs rarely we
|
|
52
|
+
// simply drop anything with windowStart < nowSec - 2 * largestWindow.
|
|
53
|
+
// In practice callers use a single window size; we approximate by
|
|
54
|
+
// dropping anything more than 10 minutes stale, which is generous.
|
|
55
|
+
const cutoff = nowSec - 600;
|
|
56
|
+
for (const [key, bucket] of buckets) {
|
|
57
|
+
if (bucket.windowStart < cutoff) buckets.delete(key);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
async check(
|
|
63
|
+
key: string,
|
|
64
|
+
opts: RateLimitCheckOptions,
|
|
65
|
+
): Promise<RateLimitResult> {
|
|
66
|
+
const { limit, windowSec } = opts;
|
|
67
|
+
const nowSec = now();
|
|
68
|
+
const currentWindow = Math.floor(nowSec / windowSec) * windowSec;
|
|
69
|
+
|
|
70
|
+
let bucket = buckets.get(key);
|
|
71
|
+
if (!bucket) {
|
|
72
|
+
bucket = { windowStart: currentWindow, count: 0, prevCount: 0 };
|
|
73
|
+
buckets.set(key, bucket);
|
|
74
|
+
} else if (bucket.windowStart !== currentWindow) {
|
|
75
|
+
// Roll forward: if exactly one window ago, preserve prev count for
|
|
76
|
+
// the weighted estimate; otherwise treat the gap as cold-start.
|
|
77
|
+
if (bucket.windowStart === currentWindow - windowSec) {
|
|
78
|
+
bucket.prevCount = bucket.count;
|
|
79
|
+
} else {
|
|
80
|
+
bucket.prevCount = 0;
|
|
81
|
+
}
|
|
82
|
+
bucket.count = 0;
|
|
83
|
+
bucket.windowStart = currentWindow;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const elapsed = nowSec - currentWindow;
|
|
87
|
+
const prevWeight = 1 - elapsed / windowSec;
|
|
88
|
+
const estimate = bucket.prevCount * prevWeight + bucket.count;
|
|
89
|
+
|
|
90
|
+
if (estimate >= limit) {
|
|
91
|
+
// Suggest waiting until the current window ends. This is
|
|
92
|
+
// deliberately coarse; a more precise retry-after would require
|
|
93
|
+
// computing when the weighted estimate drops back under the
|
|
94
|
+
// limit, which is more complexity than this DoS-mitigation
|
|
95
|
+
// feature warrants.
|
|
96
|
+
const retryAfterSec = Math.max(1, windowSec - elapsed);
|
|
97
|
+
return { ok: false, retryAfterSec };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
bucket.count += 1;
|
|
101
|
+
sweep(nowSec);
|
|
102
|
+
return { ok: true };
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate Limiting Abstraction
|
|
3
|
+
*
|
|
4
|
+
* Shared interface for per-key rate limiting. Runtimes provide their own
|
|
5
|
+
* implementation: Cloudflare Workers uses a D1-backed sliding-window table
|
|
6
|
+
* (ephemeral isolates can't hold memory state), while Node uses an
|
|
7
|
+
* in-process Map (the process is persistent and avoids DB round-trips).
|
|
8
|
+
*
|
|
9
|
+
* Consumers depend only on this interface; they are runtime-agnostic.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Context } from "hono";
|
|
13
|
+
|
|
14
|
+
export interface RateLimitCheckOptions {
|
|
15
|
+
/** Max requests allowed within `windowSec`. */
|
|
16
|
+
limit: number;
|
|
17
|
+
/** Sliding window size in seconds. */
|
|
18
|
+
windowSec: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RateLimitResult {
|
|
22
|
+
/** True when the request is under the limit (already counted). */
|
|
23
|
+
ok: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* When `ok` is false, suggested seconds the client should wait before
|
|
26
|
+
* retrying. Implementations may return the full window as a safe default.
|
|
27
|
+
*/
|
|
28
|
+
retryAfterSec?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RateLimiter {
|
|
32
|
+
/**
|
|
33
|
+
* Records a hit against `key` and reports whether the request is under
|
|
34
|
+
* the configured limit. Implementations must be race-safe enough that
|
|
35
|
+
* concurrent callers cannot durably exceed the limit.
|
|
36
|
+
*/
|
|
37
|
+
check(key: string, opts: RateLimitCheckOptions): Promise<RateLimitResult>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Extracts the client IP from a Hono request context.
|
|
42
|
+
*
|
|
43
|
+
* On Cloudflare Workers, `cf-connecting-ip` is set by the edge and is
|
|
44
|
+
* authoritative. On Node deployments we fall back to the leftmost
|
|
45
|
+
* `x-forwarded-for` entry, which is the conventional client IP when the
|
|
46
|
+
* app sits behind a single trusted proxy. When neither header is
|
|
47
|
+
* available we return `"unknown"` so all such requests share a bucket —
|
|
48
|
+
* preferable to skipping the rate limit entirely.
|
|
49
|
+
*
|
|
50
|
+
* Note: this helper does not verify proxy trust. It is used for DoS
|
|
51
|
+
* protection, not authentication. If header-forgery resistance becomes
|
|
52
|
+
* important, gate the `x-forwarded-for` branch on `shouldTrustProxy`.
|
|
53
|
+
*/
|
|
54
|
+
export function getClientIp(c: Context): string {
|
|
55
|
+
const cf = c.req.header("cf-connecting-ip");
|
|
56
|
+
if (cf) return cf;
|
|
57
|
+
const fwd = c.req.header("x-forwarded-for");
|
|
58
|
+
if (fwd) {
|
|
59
|
+
const first = fwd.split(",")[0]?.trim();
|
|
60
|
+
if (first) return first;
|
|
61
|
+
}
|
|
62
|
+
return "unknown";
|
|
63
|
+
}
|
package/src/lib/render.tsx
CHANGED
|
@@ -24,6 +24,13 @@ export interface RenderPublicPageOptions {
|
|
|
24
24
|
appleTouchHref?: string;
|
|
25
25
|
/** Optional explicit social image href */
|
|
26
26
|
socialImageUrl?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Absolute canonical URL for this page. Forwarded to `BaseLayout` and
|
|
29
|
+
* rendered as `<link rel="canonical">`. Only set when the page has a
|
|
30
|
+
* different canonical location (e.g. thread reply pages point back to the
|
|
31
|
+
* thread root).
|
|
32
|
+
*/
|
|
33
|
+
canonicalHref?: string;
|
|
27
34
|
/** Navigation data (from getNavigationData) */
|
|
28
35
|
navData: NavigationData;
|
|
29
36
|
/** Page content JSX to render inside SiteLayout */
|
|
@@ -66,6 +73,7 @@ export function renderPublicPage(c: Context, options: RenderPublicPageOptions) {
|
|
|
66
73
|
faviconHref,
|
|
67
74
|
appleTouchHref,
|
|
68
75
|
socialImageUrl,
|
|
76
|
+
canonicalHref,
|
|
69
77
|
navData,
|
|
70
78
|
content,
|
|
71
79
|
sidebar,
|
|
@@ -116,6 +124,7 @@ export function renderPublicPage(c: Context, options: RenderPublicPageOptions) {
|
|
|
116
124
|
faviconHref={faviconHref}
|
|
117
125
|
appleTouchHref={appleTouchHref}
|
|
118
126
|
socialImageUrl={socialImageUrl}
|
|
127
|
+
canonicalHref={canonicalHref}
|
|
119
128
|
faviconUrl={faviconUrl}
|
|
120
129
|
faviconVersion={faviconVersion}
|
|
121
130
|
noindex={noindex}
|
|
@@ -228,6 +228,15 @@ export function resolveConfig(
|
|
|
228
228
|
siteAvatarUrl,
|
|
229
229
|
faviconVersion: allSettings["SITE_FAVICON_VERSION"] ?? "",
|
|
230
230
|
|
|
231
|
+
// Rate limiting (ENV only). Defaults are conservative enough for a
|
|
232
|
+
// human typing in the search UI but reject bot floods.
|
|
233
|
+
rateLimit: {
|
|
234
|
+
disabled: getEnvString(env, "RATE_LIMIT_DISABLED") === "true",
|
|
235
|
+
searchPerMinute:
|
|
236
|
+
parseInt(getEnvString(env, "RATE_LIMIT_SEARCH_PER_MIN") ?? "30", 10) ||
|
|
237
|
+
30,
|
|
238
|
+
},
|
|
239
|
+
|
|
231
240
|
// Settings form placeholders (ENV > Default, without DB)
|
|
232
241
|
fallbacks: {
|
|
233
242
|
siteName: resolveFallback("SITE_NAME", env),
|