@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.
Files changed (99) hide show
  1. package/bin/commands/import-site.js +1 -1
  2. package/bin/commands/search-reindex.js +175 -0
  3. package/bin/lib/hugo-markdown.js +102 -0
  4. package/bin/lib/site-pull-media.js +1 -4
  5. package/dist/app-BI9bnCkO.js +5 -0
  6. package/dist/{app-Cu3lveYI.js → app-CtJDxZBb.js} +969 -373
  7. package/dist/client/.vite/manifest.json +2 -2
  8. package/dist/client/_assets/client-BQH7AQ24.css +2 -0
  9. package/dist/client/_assets/{client-auth-BRFl5zQA.js → client-auth-CXILhW1b.js} +144 -144
  10. package/dist/{env-wCpMcNXs.js → env-CgaH9Mut.js} +1 -1
  11. package/dist/{github-api-CficQztC.js → github-api-BkRWnqMx.js} +1 -1
  12. package/dist/{github-app-F4qZ05xk.js → github-app-WeadXMb8.js} +1 -1
  13. package/dist/{github-sync-zohnA9qv.js → github-sync-7y_nTXx1.js} +41 -14
  14. package/dist/index.js +5 -5
  15. package/dist/node.js +5 -5
  16. package/dist/{url-FvvgARU9.js → url-umUptr5z.js} +30 -1
  17. package/package.json +1 -1
  18. package/src/__tests__/helpers/app.ts +15 -4
  19. package/src/app.tsx +8 -0
  20. package/src/client/tiptap/__tests__/insert-paragraph-around.test.ts +228 -0
  21. package/src/client/tiptap/extensions.ts +3 -0
  22. package/src/client/tiptap/insert-paragraph-around.ts +79 -0
  23. package/src/db/migrations/0018_yummy_franklin_richards.sql +6 -0
  24. package/src/db/migrations/0019_bored_magus.sql +2 -0
  25. package/src/db/migrations/meta/0018_snapshot.json +2225 -0
  26. package/src/db/migrations/meta/0019_snapshot.json +2238 -0
  27. package/src/db/migrations/meta/_journal.json +15 -1
  28. package/src/db/migrations/pg/0016_familiar_lionheart.sql +6 -0
  29. package/src/db/migrations/pg/0017_bright_beyonder.sql +2 -0
  30. package/src/db/migrations/pg/meta/0016_snapshot.json +2840 -0
  31. package/src/db/migrations/pg/meta/0017_snapshot.json +2862 -0
  32. package/src/db/migrations/pg/meta/_journal.json +15 -1
  33. package/src/db/pg/schema.ts +22 -0
  34. package/src/db/schema.ts +27 -0
  35. package/src/index.ts +1 -2
  36. package/src/lib/__tests__/hosted-signin.test.ts +30 -0
  37. package/src/lib/__tests__/navigation.test.ts +4 -20
  38. package/src/lib/__tests__/rate-limit-d1.test.ts +82 -0
  39. package/src/lib/__tests__/rate-limit-memory.test.ts +69 -0
  40. package/src/lib/__tests__/summary.test.ts +140 -0
  41. package/src/lib/__tests__/view.test.ts +66 -0
  42. package/src/lib/feed.ts +70 -34
  43. package/src/lib/hosted-signin.ts +9 -3
  44. package/src/lib/icons.ts +37 -0
  45. package/src/lib/navigation.ts +11 -12
  46. package/src/lib/post-meta.ts +20 -2
  47. package/src/lib/rate-limit-d1.ts +99 -0
  48. package/src/lib/rate-limit-memory.ts +105 -0
  49. package/src/lib/rate-limit.ts +63 -0
  50. package/src/lib/render.tsx +9 -0
  51. package/src/lib/resolve-config.ts +9 -0
  52. package/src/lib/summary.ts +42 -7
  53. package/src/lib/url.ts +34 -0
  54. package/src/lib/view.ts +42 -8
  55. package/src/middleware/__tests__/auth.test.ts +44 -4
  56. package/src/middleware/__tests__/rate-limit.test.ts +113 -0
  57. package/src/middleware/__tests__/session.test.ts +85 -0
  58. package/src/middleware/auth.ts +62 -25
  59. package/src/middleware/rate-limit.ts +54 -0
  60. package/src/middleware/session.ts +36 -0
  61. package/src/routes/__tests__/compose.test.ts +1 -1
  62. package/src/routes/api/__tests__/search.test.ts +48 -0
  63. package/src/routes/api/__tests__/upload-multipart.test.ts +11 -4
  64. package/src/routes/api/internal/search-reindex.ts +40 -0
  65. package/src/routes/api/internal/sites.ts +1 -0
  66. package/src/routes/api/search.ts +13 -0
  67. package/src/routes/auth/dev.ts +1 -1
  68. package/src/routes/auth/signin.tsx +23 -5
  69. package/src/routes/dash/settings.tsx +3 -5
  70. package/src/routes/feed/__tests__/sitemap.test.ts +320 -4
  71. package/src/routes/feed/sitemap.ts +208 -33
  72. package/src/routes/pages/__tests__/page-canonical.test.ts +101 -0
  73. package/src/routes/pages/home.tsx +24 -15
  74. package/src/routes/pages/page.tsx +34 -0
  75. package/src/routes/pages/partials.tsx +4 -15
  76. package/src/runtime/cloudflare.ts +4 -0
  77. package/src/runtime/node.ts +16 -0
  78. package/src/services/__tests__/post.test.ts +205 -0
  79. package/src/services/__tests__/search.test.ts +44 -0
  80. package/src/services/__tests__/site-admin.test.ts +85 -0
  81. package/src/services/export.ts +9 -2
  82. package/src/services/post.ts +200 -2
  83. package/src/services/site-admin.ts +66 -1
  84. package/src/styles/ui.css +12 -0
  85. package/src/types/app-context.ts +20 -0
  86. package/src/types/config.ts +8 -0
  87. package/src/types/props.ts +0 -7
  88. package/src/ui/feed/LinkCard.tsx +3 -20
  89. package/src/ui/feed/LinkPreview.tsx +5 -19
  90. package/src/ui/feed/PostStatusBadges.tsx +4 -38
  91. package/src/ui/layouts/BaseLayout.tsx +23 -29
  92. package/src/ui/shared/DecorativeQuoteMark.tsx +2 -13
  93. package/src/ui/shared/Icon.tsx +60 -0
  94. package/src/ui/shared/IconSprite.tsx +57 -0
  95. package/src/ui/shared/PostFooter.tsx +6 -62
  96. package/src/ui/shared/custom-icons.ts +132 -0
  97. package/src/ui/shared/icon-collector.ts +37 -0
  98. package/dist/app-DzCB4yOp.js +0 -5
  99. 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
- * Default Sitemap renderer.
246
- *
247
- * @param data - Sitemap data with PostView[]
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 function defaultSitemapRenderer(data: SitemapData): string {
251
- const { siteUrl, sitemapUrl, posts } = data;
244
+ export const SITEMAP_SHARD_SIZE = 500;
252
245
 
253
- const postUrls = posts
254
- .map((post) => {
255
- const loc = escapeXml(new URL(post.permalink, siteUrl).toString());
256
- const lastmod = post.updatedAt.split("T")[0];
257
- const priority = post.featured ? "0.8" : "0.6";
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
- return `
260
- <url>
261
- <loc>${loc}</loc>
262
- <lastmod>${lastmod}</lastmod>
263
- <priority>${priority}</priority>
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
- const homepageUrl = `
269
- <url>
270
- <loc>${escapeXml(siteUrl)}</loc>
271
- <priority>1.0</priority>
272
- <changefreq>daily</changefreq>
273
- </url>`;
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
- <!-- Generated from ${escapeXml(sitemapUrl)} -->
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
+ }
@@ -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(publicRequestUrl: string): string {
8
+ function getHostedAdminContinuationPath(
9
+ publicRequestUrl: string,
10
+ redirect?: string,
11
+ ): string {
8
12
  const currentHost = new URL(publicRequestUrl).host;
9
- return `/auth/handoff/start?host=${encodeURIComponent(currentHost)}&redirect=${encodeURIComponent("/")}`;
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";
@@ -60,8 +60,15 @@ export function getHomeDefaultViewFromNavItems(
60
60
  * });
61
61
  * ```
62
62
  */
63
- export async function getNavigationData(c: Context): Promise<NavigationData> {
64
- const items = await c.var.services.navItems.list();
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
- // Check auth status (needed for compose button and system nav items)
90
- let isAuthenticated = false;
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[] = [];
@@ -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.bodyText);
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.bodyText);
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
+ }
@@ -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),