@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.
Files changed (79) 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-Ctl0T0zO.js +5 -0
  6. package/dist/{app-Cu3lveYI.js → app-GbfwoeDJ.js} +630 -123
  7. package/dist/client/.vite/manifest.json +1 -1
  8. package/dist/client/_assets/{client-auth-BRFl5zQA.js → client-auth-CXILhW1b.js} +144 -144
  9. package/dist/{env-wCpMcNXs.js → env-CgaH9Mut.js} +1 -1
  10. package/dist/{github-api-CficQztC.js → github-api-BkRWnqMx.js} +1 -1
  11. package/dist/{github-app-F4qZ05xk.js → github-app-WeadXMb8.js} +1 -1
  12. package/dist/{github-sync-zohnA9qv.js → github-sync-7y_nTXx1.js} +41 -14
  13. package/dist/index.js +5 -5
  14. package/dist/node.js +5 -5
  15. package/dist/{url-FvvgARU9.js → url-umUptr5z.js} +30 -1
  16. package/package.json +1 -1
  17. package/src/__tests__/helpers/app.ts +15 -4
  18. package/src/app.tsx +8 -0
  19. package/src/client/tiptap/__tests__/insert-paragraph-around.test.ts +228 -0
  20. package/src/client/tiptap/extensions.ts +3 -0
  21. package/src/client/tiptap/insert-paragraph-around.ts +79 -0
  22. package/src/db/migrations/0018_yummy_franklin_richards.sql +6 -0
  23. package/src/db/migrations/meta/0018_snapshot.json +2225 -0
  24. package/src/db/migrations/meta/_journal.json +8 -1
  25. package/src/db/migrations/pg/0016_familiar_lionheart.sql +6 -0
  26. package/src/db/migrations/pg/meta/0016_snapshot.json +2840 -0
  27. package/src/db/migrations/pg/meta/_journal.json +8 -1
  28. package/src/db/pg/schema.ts +18 -0
  29. package/src/db/schema.ts +23 -0
  30. package/src/index.ts +1 -2
  31. package/src/lib/__tests__/hosted-signin.test.ts +30 -0
  32. package/src/lib/__tests__/navigation.test.ts +4 -20
  33. package/src/lib/__tests__/rate-limit-d1.test.ts +82 -0
  34. package/src/lib/__tests__/rate-limit-memory.test.ts +69 -0
  35. package/src/lib/__tests__/summary.test.ts +140 -0
  36. package/src/lib/__tests__/view.test.ts +66 -0
  37. package/src/lib/feed.ts +70 -34
  38. package/src/lib/hosted-signin.ts +9 -3
  39. package/src/lib/navigation.ts +11 -12
  40. package/src/lib/post-meta.ts +20 -2
  41. package/src/lib/rate-limit-d1.ts +99 -0
  42. package/src/lib/rate-limit-memory.ts +105 -0
  43. package/src/lib/rate-limit.ts +63 -0
  44. package/src/lib/render.tsx +9 -0
  45. package/src/lib/resolve-config.ts +9 -0
  46. package/src/lib/summary.ts +42 -7
  47. package/src/lib/url.ts +34 -0
  48. package/src/lib/view.ts +42 -8
  49. package/src/middleware/__tests__/auth.test.ts +44 -4
  50. package/src/middleware/__tests__/rate-limit.test.ts +113 -0
  51. package/src/middleware/__tests__/session.test.ts +85 -0
  52. package/src/middleware/auth.ts +62 -25
  53. package/src/middleware/rate-limit.ts +54 -0
  54. package/src/middleware/session.ts +36 -0
  55. package/src/routes/__tests__/compose.test.ts +1 -1
  56. package/src/routes/api/__tests__/search.test.ts +48 -0
  57. package/src/routes/api/__tests__/upload-multipart.test.ts +11 -4
  58. package/src/routes/api/internal/search-reindex.ts +40 -0
  59. package/src/routes/api/search.ts +13 -0
  60. package/src/routes/auth/dev.ts +1 -1
  61. package/src/routes/auth/signin.tsx +23 -5
  62. package/src/routes/dash/settings.tsx +3 -5
  63. package/src/routes/feed/__tests__/sitemap.test.ts +320 -4
  64. package/src/routes/feed/sitemap.ts +208 -33
  65. package/src/routes/pages/__tests__/page-canonical.test.ts +101 -0
  66. package/src/routes/pages/home.tsx +24 -15
  67. package/src/routes/pages/page.tsx +34 -0
  68. package/src/routes/pages/partials.tsx +4 -15
  69. package/src/runtime/cloudflare.ts +4 -0
  70. package/src/runtime/node.ts +16 -0
  71. package/src/services/__tests__/post.test.ts +205 -0
  72. package/src/services/__tests__/search.test.ts +44 -0
  73. package/src/services/export.ts +9 -2
  74. package/src/services/post.ts +200 -2
  75. package/src/types/app-context.ts +20 -0
  76. package/src/types/config.ts +8 -0
  77. package/src/types/props.ts +0 -7
  78. package/src/ui/layouts/BaseLayout.tsx +9 -0
  79. package/dist/app-DzCB4yOp.js +0 -5
@@ -1,13 +1,13 @@
1
- import { _ as __exportAll, a as getSitePathPrefix, d as slugify, f as stripSitePathPrefix, h as toPublicPath, i as getSiteOrigin, l as normalizeSiteUrl, m as toPublicHref, n as extractDisplayDomain, o as isFullUrl, p as toAbsoluteSiteUrl, r as extractDomain, s as normalizePath, t as buildSiteUrl, u as sanitizeUrl } from "./url-FvvgARU9.js";
2
- import { A as JANT_POSITIVE_LOGO_PNG_FILENAME, B as getJantLogoHref, C as formatYearMonth, D as HOME_BRANDING_LINK_LABEL, E as toISOString, F as getJantBundledAsset, G as base64ToUint8Array, H as JANT_LOGO_PATH_DATA, I as getJantIconFilename, L as getJantIconHref, M as getDefaultJantAppleTouchIconBytes, N as getDefaultJantFaviconIcoBytes, O as HOME_BRANDING_PREFIX, P as getJantBrandPackHref, R as getJantLogoFilename, S as formatTime, U as JANT_LOGO_VIEW_BOX, V as getJantPositiveLogoPngHref, W as arrayBufferToBase64, _ as getMediaUrl, a as tiptapJsonToMarkdown, b as formatRelativeAge, c as render, d as extractSummary, f as extractSummaryHtml, g as getImageUrl, h as escapeHtml, i as createExportService, j as JANT_REPO_URL, k as JANT_BRAND_PACK_FILENAME, l as toPlainText, m as trimTiptapBody, n as createGitHubSyncService, o as markdownToTiptapJson, p as renderTiptapJson, u as extractBodyText, v as getPublicUrlForProvider, w as now, x as formatRelativeTime, y as formatDate, z as getJantLogoFills } from "./github-sync-zohnA9qv.js";
3
- import { C as coalesceDisplayText, S as shouldUseSecureCookies, _ as getInternalAdminToken, a as getConfiguredSingleSiteUrl, b as getSiteResolutionMode, c as getDevApiToken, d as getHostedControlPlaneBaseUrl, f as getHostedControlPlaneDomainCheckSecret, g as getHostedControlPlaneSsoSecret, h as getHostedControlPlaneProviderLabel$1, i as getConfiguredSingleSitePathPrefix, l as getEnvString, m as getHostedControlPlaneInternalToken, n as getAuthSecret, o as getConfiguredStorageDriver, p as getHostedControlPlaneInternalBaseUrl, r as getConfiguredSingleSiteOrigin, s as getCorsOrigins, u as getGitHubAppConfig, v as getLocalStoragePath } from "./env-wCpMcNXs.js";
4
- import { a as listInstallationReposPage, n as getInstallation, o as searchInstallationRepos, t as buildInstallUrl } from "./github-app-F4qZ05xk.js";
5
- import { r as parseRepoSlug, t as createGitHubClient } from "./github-api-CficQztC.js";
1
+ import { a as getSitePathPrefix, c as normalizePath, d as sanitizeUrl, f as slugify, g as toPublicPath, h as toPublicHref, i as getSiteOrigin, m as toAbsoluteSiteUrl, n as extractDisplayDomain, o as isFullUrl, p as stripSitePathPrefix, r as extractDomain, s as isSafeInternalRedirect, t as buildSiteUrl, u as normalizeSiteUrl, v as __exportAll } from "./url-umUptr5z.js";
2
+ import { A as JANT_BRAND_PACK_FILENAME, B as getJantLogoFills, C as formatTime, D as toISOString, F as getJantBrandPackHref, G as arrayBufferToBase64, H as getJantPositiveLogoPngHref, I as getJantBundledAsset, K as base64ToUint8Array, L as getJantIconFilename, M as JANT_REPO_URL, N as getDefaultJantAppleTouchIconBytes, O as HOME_BRANDING_LINK_LABEL, P as getDefaultJantFaviconIcoBytes, R as getJantIconHref, S as formatRelativeTime, T as now, U as JANT_LOGO_PATH_DATA, V as getJantLogoHref, W as JANT_LOGO_VIEW_BOX, _ as getImageUrl, a as tiptapJsonToMarkdown, b as formatDate, c as render, d as extractSummary, f as extractSummaryHtml, g as escapeHtml, h as trimTiptapBody, i as createExportService, j as JANT_POSITIVE_LOGO_PNG_FILENAME, k as HOME_BRANDING_PREFIX, l as toPlainText, m as renderTiptapJson, n as createGitHubSyncService, o as markdownToTiptapJson, p as renderTiptapDocument, u as extractBodyText, v as getMediaUrl, w as formatYearMonth, x as formatRelativeAge, y as getPublicUrlForProvider, z as getJantLogoFilename } from "./github-sync-7y_nTXx1.js";
3
+ import { C as coalesceDisplayText, S as shouldUseSecureCookies, _ as getInternalAdminToken, a as getConfiguredSingleSiteUrl, b as getSiteResolutionMode, c as getDevApiToken, d as getHostedControlPlaneBaseUrl, f as getHostedControlPlaneDomainCheckSecret, g as getHostedControlPlaneSsoSecret, h as getHostedControlPlaneProviderLabel$1, i as getConfiguredSingleSitePathPrefix, l as getEnvString, m as getHostedControlPlaneInternalToken, n as getAuthSecret, o as getConfiguredStorageDriver, p as getHostedControlPlaneInternalBaseUrl, r as getConfiguredSingleSiteOrigin, s as getCorsOrigins, u as getGitHubAppConfig, v as getLocalStoragePath } from "./env-CgaH9Mut.js";
4
+ import { a as listInstallationReposPage, n as getInstallation, o as searchInstallationRepos, t as buildInstallUrl } from "./github-app-WeadXMb8.js";
5
+ import { r as parseRepoSlug, t as createGitHubClient } from "./github-api-BkRWnqMx.js";
6
6
  import { I18n } from "@lingui/core";
7
7
  import { z } from "zod";
8
8
  import { fromString, typeidUnboxed } from "typeid-js";
9
9
  import { decode } from "blurhash";
10
- import { and, asc, desc, eq, inArray, isNotNull, isNull, lt, lte, ne, or, sql } from "drizzle-orm";
10
+ import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, lt, lte, ne, or, sql } from "drizzle-orm";
11
11
  import { generateKeyBetween } from "fractional-indexing";
12
12
  import { drizzle } from "drizzle-orm/better-sqlite3";
13
13
  import { drizzle as drizzle$1 } from "drizzle-orm/d1";
@@ -3352,9 +3352,9 @@ function normalizeThemeColorForMeta(color) {
3352
3352
  * internal paths (e.g. `/_assets/client-HASH.js`) embedded by the Worker build
3353
3353
  * from the Vite client manifest. Used only in production (IS_VITE_DEV=false).
3354
3354
  */ var IS_VITE_DEV = typeof __JANT_DEV__ !== "undefined" && __JANT_DEV__ === true;
3355
- var CORE_VERSION = "0.3.42-fc459b837c6eaa85";
3355
+ var CORE_VERSION = "0.3.43-8e1ad0b8fb20998c";
3356
3356
  var CLIENT_JS_FILE = "/_assets/client-D95FNDg5.js";
3357
- var CLIENT_AUTH_JS_FILE = "/_assets/client-auth-BRFl5zQA.js";
3357
+ var CLIENT_AUTH_JS_FILE = "/_assets/client-auth-CXILhW1b.js";
3358
3358
  var CLIENT_CSS_FILE = "/_assets/client-C_kImWZj.css";
3359
3359
  var CLIENT_CJK_CSS_FILE = "/_assets/client-cjk-B7Z0snDu.css";
3360
3360
  var CLIENT_CJK_TC_CSS_FILE = "/_assets/client-cjk-tc-BesJYrb2.css";
@@ -3370,7 +3370,7 @@ var CLIENT_CJK_KR_CSS_FILE = "/_assets/client-cjk-kr-_3ZNI2ZP.css";
3370
3370
  *
3371
3371
  * In dev mode (Vite), serves assets via Vite's dev server.
3372
3372
  * In production, serves pre-built assets with content-hashed filenames.
3373
- */ var BaseLayout = ({ title, description, lang, c, toast, faviconHref, appleTouchHref, faviconUrl, faviconVersion, socialImageUrl, noindex, isAuthenticated = false, clientBundle, children }) => {
3373
+ */ var BaseLayout = ({ title, description, lang, c, toast, faviconHref, appleTouchHref, faviconUrl, faviconVersion, socialImageUrl, canonicalHref, noindex, isAuthenticated = false, clientBundle, children }) => {
3374
3374
  const resolvedLang = lang ?? (c ? c.get("lang") : "en");
3375
3375
  const appConfig = c ? c.get("appConfig") : void 0;
3376
3376
  const resolvedSocialImagePath = socialImageUrl ?? faviconUrl ?? appConfig?.siteAvatarUrl ?? getJantIconHref("socialImage", appConfig?.sitePathPrefix || "");
@@ -3398,7 +3398,7 @@ var CLIENT_CJK_KR_CSS_FILE = "/_assets/client-cjk-kr-_3ZNI2ZP.css";
3398
3398
  const cjkSerifFont = appConfig?.cjkSerifFont ?? "off";
3399
3399
  const cjkStylesheetPath = cjkSerifFont === "zh-Hans" ? IS_VITE_DEV ? assetPath("/src/style-cjk.css") : toPublicAssetPath(CLIENT_CJK_CSS_FILE, assetBasePath) : cjkSerifFont === "zh-Hant" ? IS_VITE_DEV ? assetPath("/src/style-cjk-tc.css") : toPublicAssetPath(CLIENT_CJK_TC_CSS_FILE, assetBasePath) : cjkSerifFont === "ja" ? IS_VITE_DEV ? assetPath("/src/style-cjk-jp.css") : toPublicAssetPath(CLIENT_CJK_JP_CSS_FILE, assetBasePath) : cjkSerifFont === "ko" ? IS_VITE_DEV ? assetPath("/src/style-cjk-kr.css") : toPublicAssetPath(CLIENT_CJK_KR_CSS_FILE, assetBasePath) : null;
3400
3400
  const clientScriptPath = IS_VITE_DEV ? resolvedClientBundle === "full" ? assetPath("/src/client-auth.ts") : assetPath("/src/client.ts") : toPublicAssetPath(resolvedClientBundle === "full" ? CLIENT_AUTH_JS_FILE : CLIENT_JS_FILE, assetBasePath);
3401
- const faviconAssetVersion = resolvedFaviconVersion || "0.3.42-fc459b837c6eaa85";
3401
+ const faviconAssetVersion = resolvedFaviconVersion || "0.3.43-8e1ad0b8fb20998c";
3402
3402
  const resolvedFaviconHref = faviconHref ?? (faviconAssetVersion ? toPublicPath(`/favicon.ico?v=${faviconAssetVersion}`, sitePathPrefix) : toPublicPath("/favicon.ico", sitePathPrefix));
3403
3403
  const resolvedAppleTouchHref = appleTouchHref ?? (faviconAssetVersion ? toPublicPath(`/apple-touch-icon.png?v=${faviconAssetVersion}`, sitePathPrefix) : toPublicPath("/apple-touch-icon.png", sitePathPrefix));
3404
3404
  const socialImageHref = resolvedSocialImagePath && (isFullUrl(resolvedSocialImagePath) || resolvedSocialImagePath.startsWith("//") ? resolvedSocialImagePath : toAbsoluteSiteUrl(resolvedSocialImagePath, appConfig?.siteUrl || "", sitePathPrefix));
@@ -3497,6 +3497,10 @@ var CLIENT_CJK_KR_CSS_FILE = "/_assets/client-cjk-kr-_3ZNI2ZP.css";
3497
3497
  name: "robots",
3498
3498
  content: "noindex, nofollow"
3499
3499
  }),
3500
+ canonicalHref && /* @__PURE__ */ jsxDEV$1("link", {
3501
+ rel: "canonical",
3502
+ href: canonicalHref
3503
+ }),
3500
3504
  /* @__PURE__ */ jsxDEV$1("link", {
3501
3505
  rel: "icon",
3502
3506
  href: resolvedFaviconHref,
@@ -5381,9 +5385,10 @@ setupRoutes.post("/setup", async (c) => {
5381
5385
  });
5382
5386
  //#endregion
5383
5387
  //#region src/lib/hosted-signin.ts
5384
- function getHostedAdminContinuationPath(publicRequestUrl) {
5388
+ function getHostedAdminContinuationPath(publicRequestUrl, redirect) {
5385
5389
  const currentHost = new URL(publicRequestUrl).host;
5386
- return `/auth/handoff/start?host=${encodeURIComponent(currentHost)}&redirect=${encodeURIComponent("/")}`;
5390
+ const safeRedirect = isSafeInternalRedirect(redirect) ? redirect : "/";
5391
+ return `/auth/handoff/start?host=${encodeURIComponent(currentHost)}&redirect=${encodeURIComponent(safeRedirect)}`;
5387
5392
  }
5388
5393
  function buildHostedControlPlaneUrl(env, pathname, search) {
5389
5394
  const hostedControlPlaneBaseUrl = getHostedControlPlaneBaseUrl(env);
@@ -5393,8 +5398,8 @@ function buildHostedControlPlaneUrl(env, pathname, search) {
5393
5398
  location.search = search ?? "";
5394
5399
  return location.toString();
5395
5400
  }
5396
- function getHostedControlPlaneSigninUrl(env, publicRequestUrl) {
5397
- return buildHostedControlPlaneUrl(env, "/auth/handoff/start", getHostedAdminContinuationPath(publicRequestUrl).replace(/^\/auth\/handoff\/start/, ""));
5401
+ function getHostedControlPlaneSigninUrl(env, publicRequestUrl, redirect) {
5402
+ return buildHostedControlPlaneUrl(env, "/auth/handoff/start", getHostedAdminContinuationPath(publicRequestUrl, redirect).replace(/^\/auth\/handoff\/start/, ""));
5398
5403
  }
5399
5404
  function getHostedControlPlaneResetUrl(env, publicRequestUrl) {
5400
5405
  const search = new URLSearchParams();
@@ -5419,11 +5424,12 @@ function getHostedControlPlaneProviderLabel(env) {
5419
5424
  //#region src/routes/auth/signin.tsx
5420
5425
  /**
5421
5426
  * Sign-in / Sign-out Routes
5422
- */ var SigninContent = ({ demoEmail, demoPassword, sitePathPrefix = "" }) => {
5427
+ */ var SigninContent = ({ demoEmail, demoPassword, sitePathPrefix = "", redirect }) => {
5423
5428
  const { i18n } = useLingui();
5424
5429
  const signals = JSON.stringify({
5425
5430
  email: demoEmail || "",
5426
- password: demoPassword || ""
5431
+ password: demoPassword || "",
5432
+ ...redirect ? { redirect } : {}
5427
5433
  }).replace(/</g, "\\u003c");
5428
5434
  return /* @__PURE__ */ jsxDEV$1("div", {
5429
5435
  class: "min-h-screen flex items-center justify-center",
@@ -5488,7 +5494,9 @@ function getHostedControlPlaneProviderLabel(env) {
5488
5494
  };
5489
5495
  var signinRoutes = new Hono();
5490
5496
  signinRoutes.get("/signin", async (c) => {
5491
- const hostedSigninUrl = getHostedControlPlaneSigninUrl(c.env, c.var.publicRequestUrl);
5497
+ const rawRedirect = c.req.query("redirect");
5498
+ const redirect = isSafeInternalRedirect(rawRedirect) ? rawRedirect : void 0;
5499
+ const hostedSigninUrl = getHostedControlPlaneSigninUrl(c.env, c.var.publicRequestUrl, redirect);
5492
5500
  if (hostedSigninUrl) return c.redirect(hostedSigninUrl);
5493
5501
  const i18n = getI18n(c);
5494
5502
  const isSetup = c.req.query("setup") !== void 0;
@@ -5503,7 +5511,8 @@ signinRoutes.get("/signin", async (c) => {
5503
5511
  children: /* @__PURE__ */ jsxDEV$1(SigninContent, {
5504
5512
  demoEmail: c.var.appConfig.demoEmail,
5505
5513
  demoPassword: c.var.appConfig.demoPassword,
5506
- sitePathPrefix: c.var.appConfig.sitePathPrefix
5514
+ sitePathPrefix: c.var.appConfig.sitePathPrefix,
5515
+ redirect
5507
5516
  })
5508
5517
  }));
5509
5518
  });
@@ -5514,6 +5523,8 @@ signinRoutes.post("/signin", async (c) => {
5514
5523
  const parsed = SigninSchema.safeParse(body);
5515
5524
  if (!parsed.success) return dsToast(parsed.error.issues[0]?.message ?? i18n._({ id: "vXCC6J" }), "error");
5516
5525
  const { email, password } = parsed.data;
5526
+ const rawRedirect = body && typeof body === "object" && "redirect" in body ? body.redirect : void 0;
5527
+ const redirectTarget = typeof rawRedirect === "string" && isSafeInternalRedirect(rawRedirect) ? rawRedirect : "/";
5517
5528
  try {
5518
5529
  const { headers } = await c.var.auth.api.signInEmail({
5519
5530
  returnHeaders: true,
@@ -5523,7 +5534,7 @@ signinRoutes.post("/signin", async (c) => {
5523
5534
  },
5524
5535
  headers: c.req.raw.headers
5525
5536
  });
5526
- return dsRedirect(toPublicPath("/", c.var.appConfig.sitePathPrefix), { headers });
5537
+ return dsRedirect(toPublicPath(redirectTarget, c.var.appConfig.sitePathPrefix), { headers });
5527
5538
  } catch {
5528
5539
  return dsToast(i18n._({ id: "nFukaP" }), "error");
5529
5540
  }
@@ -5721,23 +5732,53 @@ function getRequestHostname(requestUrl, requestHost) {
5721
5732
  return hostname ? isLocalHostname(hostname) : false;
5722
5733
  }
5723
5734
  /**
5735
+ * Paths that should never be used as post-signin redirect targets (would
5736
+ * either loop back to signin or hit an unauthenticated endpoint).
5737
+ */ var POST_SIGNIN_REDIRECT_BLOCKLIST = new Set([
5738
+ "/signin",
5739
+ "/signout",
5740
+ "/setup",
5741
+ "/reset",
5742
+ "/__sso"
5743
+ ]);
5744
+ function getPostSigninRedirect(requestUrl) {
5745
+ let url;
5746
+ try {
5747
+ url = new URL(requestUrl);
5748
+ } catch {
5749
+ return null;
5750
+ }
5751
+ const pathname = url.pathname || "/";
5752
+ if (POST_SIGNIN_REDIRECT_BLOCKLIST.has(pathname)) return null;
5753
+ if (pathname.startsWith("/api/")) return null;
5754
+ const candidate = `${pathname}${url.search}`;
5755
+ return isSafeInternalRedirect(candidate) ? candidate : null;
5756
+ }
5757
+ /**
5724
5758
  * Middleware that requires authentication.
5725
5759
  * Redirects to signin page if not authenticated.
5726
5760
  * Session-only — Bearer tokens are not accepted for dashboard pages.
5727
5761
  */ function requireAuth(redirectTo = "/signin") {
5728
5762
  return async (c, next) => {
5729
- const redirectTarget = toPublicHref(redirectTo, getRuntimeSitePathPrefix({
5763
+ const sitePathPrefix = getRuntimeSitePathPrefix({
5730
5764
  env: c.env,
5731
5765
  appConfig: c.var.appConfig,
5732
5766
  currentSiteDomain: c.var.currentSiteDomain
5733
- }));
5767
+ });
5768
+ const buildRedirectTarget = () => {
5769
+ const publicHref = toPublicHref(redirectTo, sitePathPrefix);
5770
+ if (redirectTo !== "/signin") return publicHref;
5771
+ const postSignin = getPostSigninRedirect(c.req.url);
5772
+ if (!postSignin) return publicHref;
5773
+ return `${publicHref}${publicHref.includes("?") ? "&" : "?"}redirect=${encodeURIComponent(postSignin)}`;
5774
+ };
5775
+ const session = c.var.session;
5776
+ if (!session?.user) return c.redirect(buildRedirectTarget());
5734
5777
  try {
5735
- const session = await c.var.auth.api.getSession({ headers: c.req.raw.headers });
5736
- if (!session?.user) return c.redirect(redirectTarget);
5737
- if (!await c.var.services.siteMembers.get(c.var.currentSite.id, session.user.id)) return c.redirect(redirectTarget);
5778
+ if (!await c.var.services.siteMembers.get(c.var.currentSite.id, session.user.id)) return c.redirect(buildRedirectTarget());
5738
5779
  await next();
5739
5780
  } catch {
5740
- return c.redirect(redirectTarget);
5781
+ return c.redirect(buildRedirectTarget());
5741
5782
  }
5742
5783
  };
5743
5784
  }
@@ -5747,10 +5788,9 @@ function getRequestHostname(requestUrl, requestHost) {
5747
5788
  * Returns 401 if neither method succeeds.
5748
5789
  */ function requireAuthApi() {
5749
5790
  return async (c, next) => {
5750
- try {
5751
- const session = await c.var.auth.api.getSession({ headers: c.req.raw.headers });
5752
- if (session?.user) {
5753
- if (!await c.var.services.siteMembers.get(c.var.currentSite.id, session.user.id)) throw new UnauthorizedError();
5791
+ const session = c.var.session;
5792
+ if (session?.user) try {
5793
+ if (await c.var.services.siteMembers.get(c.var.currentSite.id, session.user.id)) {
5754
5794
  await next();
5755
5795
  return;
5756
5796
  }
@@ -5827,7 +5867,7 @@ devAuthRoutes.get("/__dev/login", async (c) => {
5827
5867
  headers: responseHeaders
5828
5868
  });
5829
5869
  } catch {
5830
- return c.text("Dev login failed. Finish /setup once or run `mise run db-local-rebuild-demo`, then retry.", 500);
5870
+ return c.text("Dev login failed. Finish /setup once or run `mise run db-wrangler-rebuild-demo`, then retry.", 500);
5831
5871
  }
5832
5872
  });
5833
5873
  //#endregion
@@ -6314,7 +6354,8 @@ function getLegacyBodyPreview(post) {
6314
6354
  }
6315
6355
  function getPlainSummary(post) {
6316
6356
  if (post.format === "quote") return normalizePreviewText(post.quoteText);
6317
- return normalizePreviewText(post.summary) || normalizePreviewText(post.bodyText) || getLegacyBodyPreview(post) || normalizePreviewText(post.url);
6357
+ const cleanBody = post.body ? extractBodyText(post.body) : null;
6358
+ return normalizePreviewText(post.summary) || normalizePreviewText(cleanBody) || normalizePreviewText(post.bodyText) || getLegacyBodyPreview(post) || normalizePreviewText(post.url);
6318
6359
  }
6319
6360
  function clipPreviewText(text, maxChars) {
6320
6361
  if (!text) return void 0;
@@ -6344,10 +6385,16 @@ function clipPreviewText(text, maxChars) {
6344
6385
  if (result) {
6345
6386
  summaryHtml = result.html;
6346
6387
  summaryHasMore = result.hasMore;
6347
- if (result.hasMore && post.bodyHtml) {
6348
- const pos = result.html.length;
6349
- bodyHtmlWithAnchor = post.bodyHtml.slice(0, pos) + "<span id=\"continue\"></span>" + post.bodyHtml.slice(pos);
6350
- }
6388
+ if (result.hasMore && post.bodyHtml) try {
6389
+ const doc = JSON.parse(post.body);
6390
+ if (doc.type === "doc" && Array.isArray(doc.content) && result.breakAtIndex > 0 && result.breakAtIndex <= doc.content.length) {
6391
+ const beforeHtml = renderTiptapDocument({
6392
+ type: "doc",
6393
+ content: doc.content.slice(0, result.breakAtIndex)
6394
+ });
6395
+ if (post.bodyHtml.startsWith(beforeHtml)) bodyHtmlWithAnchor = beforeHtml + "<span id=\"continue\"></span>" + post.bodyHtml.slice(beforeHtml.length);
6396
+ }
6397
+ } catch {}
6351
6398
  }
6352
6399
  }
6353
6400
  const collections = (postCollections ?? []).map((c) => ({
@@ -6589,8 +6636,8 @@ function clipPreviewText(text, maxChars) {
6589
6636
  * content: <MyContent />,
6590
6637
  * });
6591
6638
  * ```
6592
- */ async function getNavigationData(c) {
6593
- const items = await c.var.services.navItems.list();
6639
+ */ async function getNavigationData(c, options) {
6640
+ const items = options?.preloadedItems ?? await c.var.services.navItems.list();
6594
6641
  const currentPath = c.var.publicPath;
6595
6642
  const appConfig = c.var.appConfig;
6596
6643
  const siteName = appConfig.siteName;
@@ -6602,11 +6649,8 @@ function clipPreviewText(text, maxChars) {
6602
6649
  const siteAvatarUrl = appConfig.siteAvatarUrl || void 0;
6603
6650
  const showHeaderAvatar = appConfig.showHeaderAvatar;
6604
6651
  const siteFooterHtml = siteFooter ? render(siteFooter) : void 0;
6605
- let isAuthenticated = false;
6652
+ const isAuthenticated = c.var.isAuthenticated;
6606
6653
  let collections = [];
6607
- try {
6608
- isAuthenticated = !!(await c.var.auth.api.getSession({ headers: c.req.raw.headers }))?.user;
6609
- } catch {}
6610
6654
  const collectionNavIds = [];
6611
6655
  for (const item of items) if (item.type === "collection" && item.collectionId) collectionNavIds.push(item.collectionId);
6612
6656
  const collectionFreshness = collectionNavIds.length > 0 ? await c.var.services.navItems.getCollectionFreshness(collectionNavIds) : void 0;
@@ -7510,7 +7554,7 @@ var SiteLayout = ({ siteName, links, currentPath, sitePathPrefix = "", isAuthent
7510
7554
  * });
7511
7555
  * ```
7512
7556
  */ function renderPublicPage(c, options) {
7513
- const { title, description, faviconHref, appleTouchHref, socialImageUrl, navData, content, sidebar, toast, showComposeDialog, showHeader, showHomeBranding, composeCollectionId } = options;
7557
+ const { title, description, faviconHref, appleTouchHref, socialImageUrl, canonicalHref, navData, content, sidebar, toast, showComposeDialog, showHeader, showHomeBranding, composeCollectionId } = options;
7514
7558
  const metaDescription = description || navData.siteDescription || void 0;
7515
7559
  const appConfig = c.get("appConfig");
7516
7560
  const allSettings = c.get("allSettings");
@@ -7543,6 +7587,7 @@ var SiteLayout = ({ siteName, links, currentPath, sitePathPrefix = "", isAuthent
7543
7587
  faviconHref,
7544
7588
  appleTouchHref,
7545
7589
  socialImageUrl,
7590
+ canonicalHref,
7546
7591
  faviconUrl,
7547
7592
  faviconVersion,
7548
7593
  noindex,
@@ -10086,16 +10131,23 @@ var PaginatedPageHeader = ({ title, currentPage = 1, totalPages, description, ic
10086
10131
  * Latest and Featured in navigation. The explicit feed routes still work.
10087
10132
  */ var homeRoutes = new Hono();
10088
10133
  homeRoutes.get("/", async (c) => {
10089
- const navData = await getNavigationData(c);
10090
10134
  const i18n = getI18n(c);
10091
10135
  const page = parsePageNumber(c.req.query("page"));
10092
10136
  const paginatedPageTitle = formatPageLabel(page);
10093
- if (navData.homeDefaultView === "featured") {
10137
+ const isAuthenticated = c.var.isAuthenticated;
10138
+ const navItems = await c.var.services.navItems.list();
10139
+ const homeDefaultView = getHomeDefaultViewFromNavItems(navItems);
10140
+ const timelinePromise = homeDefaultView === "featured" ? assembleFeaturedTimeline(c, {
10141
+ page,
10142
+ isAuthenticated
10143
+ }) : assembleTimeline(c, {
10144
+ page,
10145
+ isAuthenticated
10146
+ });
10147
+ const [navData, timeline] = await Promise.all([getNavigationData(c, { preloadedItems: navItems }), timelinePromise]);
10148
+ const { items, currentPage, totalPages } = timeline;
10149
+ if (homeDefaultView === "featured") {
10094
10150
  const featuredTitle = i18n._({ id: "FkMol5" });
10095
- const { items, currentPage, totalPages } = await assembleFeaturedTimeline(c, {
10096
- page,
10097
- isAuthenticated: navData.isAuthenticated
10098
- });
10099
10151
  return renderPublicPage(c, {
10100
10152
  title: page > 1 ? buildPageTitle(featuredTitle, paginatedPageTitle, navData.siteName) : navData.siteName,
10101
10153
  navData,
@@ -10109,10 +10161,6 @@ homeRoutes.get("/", async (c) => {
10109
10161
  });
10110
10162
  }
10111
10163
  const latestTitle = i18n._({ id: "wL3cK8" });
10112
- const { items, currentPage, totalPages } = await assembleTimeline(c, {
10113
- page,
10114
- isAuthenticated: navData.isAuthenticated
10115
- });
10116
10164
  return renderPublicPage(c, {
10117
10165
  title: page > 1 ? buildPageTitle(latestTitle, paginatedPageTitle, navData.siteName) : navData.siteName,
10118
10166
  navData,
@@ -10170,6 +10218,17 @@ var PostPage = ({ post, threadPosts }) => {
10170
10218
  //#region src/lib/post-meta.ts
10171
10219
  var TITLE_MAX_CHARS = 72;
10172
10220
  var DESCRIPTION_MAX_CHARS = 160;
10221
+ /**
10222
+ * Derive a clean plain-text projection of the body for human-facing meta.
10223
+ *
10224
+ * We cannot reuse `post.bodyText` here: that column is written with
10225
+ * `includeLinkHrefs: true` so inline link URLs land in the FTS index, which
10226
+ * pollutes the stored text with trailing URLs. Re-derive from the source
10227
+ * TipTap JSON (`post.body`) without that option.
10228
+ */ function getCleanBodyText(post) {
10229
+ if (post.body) return extractBodyText(post.body) ?? "";
10230
+ return post.bodyText ?? "";
10231
+ }
10173
10232
  function normalizeText(text) {
10174
10233
  return (text ?? "").replace(/\s+/g, " ").trim();
10175
10234
  }
@@ -10193,7 +10252,7 @@ function getTitleCandidate(post) {
10193
10252
  if (normalizeText(post.title)) return normalizeText(post.title);
10194
10253
  const summarySnippet = getFirstParagraph(post.summary);
10195
10254
  if (summarySnippet) return clipText(summarySnippet, TITLE_MAX_CHARS);
10196
- const bodySnippet = getFirstParagraph(post.bodyText);
10255
+ const bodySnippet = getFirstParagraph(getCleanBodyText(post));
10197
10256
  if (bodySnippet) return clipText(bodySnippet, TITLE_MAX_CHARS);
10198
10257
  if (post.format === "link" && post.url) return extractDisplayDomain(post.url) || post.url;
10199
10258
  return "";
@@ -10205,7 +10264,7 @@ function getDescriptionCandidate(post) {
10205
10264
  }
10206
10265
  const summaryText = normalizeText(post.summary);
10207
10266
  if (summaryText) return clipText(summaryText, DESCRIPTION_MAX_CHARS);
10208
- const bodyText = normalizeText(post.bodyText);
10267
+ const bodyText = normalizeText(getCleanBodyText(post));
10209
10268
  if (bodyText) return clipText(bodyText, DESCRIPTION_MAX_CHARS);
10210
10269
  const quoteText = normalizeText(post.quoteText);
10211
10270
  if (quoteText) return clipText(quoteText, DESCRIPTION_MAX_CHARS);
@@ -10305,6 +10364,7 @@ function buildPostMeta(post, siteName) {
10305
10364
  pathRegistry: () => pathRegistry$1,
10306
10365
  postCollections: () => postCollections$1,
10307
10366
  posts: () => posts$1,
10367
+ rateLimit: () => rateLimit$2,
10308
10368
  session: () => session$1,
10309
10369
  settings: () => settings$1,
10310
10370
  siteDomains: () => siteDomains$1,
@@ -10752,6 +10812,19 @@ var githubAppInstallation$1 = sqliteTable("github_app_installation", {
10752
10812
  index("github_app_installation_by_site").on(table.siteId),
10753
10813
  check("chk_github_app_installation_account_type", sql`${table.accountType} IN (${sqlTextEnum$1(GITHUB_APP_ACCOUNT_TYPES$1)})`)
10754
10814
  ]);
10815
+ /**
10816
+ * Per-key sliding-window rate-limit counters. Used by the Cloudflare Workers
10817
+ * runtime; Node deployments keep the state in memory instead.
10818
+ *
10819
+ * `key` is typically a scope prefix plus client IP (e.g. "search:1.2.3.4").
10820
+ * `window_start` is the aligned start of the window in Unix seconds — we
10821
+ * store two adjacent windows per key to support the sliding-window-counter
10822
+ * algorithm.
10823
+ */ var rateLimit$2 = sqliteTable("rate_limit", {
10824
+ key: text("key").notNull(),
10825
+ windowStart: integer("window_start").notNull(),
10826
+ count: integer("count").notNull().default(0)
10827
+ }, (table) => [primaryKey({ columns: [table.key, table.windowStart] })]);
10755
10828
  //#endregion
10756
10829
  //#region src/db/index.ts
10757
10830
  /**
@@ -10856,6 +10929,7 @@ function isNodeSqliteDatabase(db) {
10856
10929
  pathRegistry: () => pathRegistry,
10857
10930
  postCollections: () => postCollections,
10858
10931
  posts: () => posts,
10932
+ rateLimit: () => rateLimit$1,
10859
10933
  session: () => session,
10860
10934
  settings: () => settings,
10861
10935
  siteDomains: () => siteDomains,
@@ -11344,6 +11418,14 @@ var githubAppInstallation = pgTable("github_app_installation", {
11344
11418
  index$1("github_app_installation_by_site").on(table.siteId),
11345
11419
  check$1("chk_github_app_installation_account_type", sql`${table.accountType} IN (${sqlTextEnum(GITHUB_APP_ACCOUNT_TYPES)})`)
11346
11420
  ]);
11421
+ /**
11422
+ * Per-key sliding-window rate-limit counters. Mirrors the SQLite `rate_limit`
11423
+ * table — kept in lockstep because both dialects are production targets.
11424
+ */ var rateLimit$1 = pgTable("rate_limit", {
11425
+ key: text$1("key").notNull(),
11426
+ windowStart: integer$1("window_start").notNull(),
11427
+ count: integer$1("count").notNull().default(0)
11428
+ }, (table) => [primaryKey$1({ columns: [table.key, table.windowStart] })]);
11347
11429
  //#endregion
11348
11430
  //#region src/db/schema-bundle.ts
11349
11431
  var sqliteSchemaBundle = schema_exports$1;
@@ -12706,33 +12788,33 @@ function getAtomTitle(post) {
12706
12788
  </feed>`;
12707
12789
  }
12708
12790
  /**
12709
- * Default Sitemap renderer.
12791
+ * Render a sitemap `<urlset>` XML document from a list of URL entries.
12710
12792
  *
12711
- * @param data - Sitemap data with PostView[]
12712
- * @returns Sitemap XML string
12713
- */ function defaultSitemapRenderer(data) {
12714
- const { siteUrl, sitemapUrl, posts } = data;
12715
- const postUrls = posts.map((post) => {
12716
- return `
12717
- <url>
12718
- <loc>${escapeXml(new URL(post.permalink, siteUrl).toString())}</loc>
12719
- <lastmod>${post.updatedAt.split("T")[0]}</lastmod>
12720
- <priority>${post.featured ? "0.8" : "0.6"}</priority>
12721
- </url>`;
12722
- }).join("");
12723
- const homepageUrl = `
12724
- <url>
12725
- <loc>${escapeXml(siteUrl)}</loc>
12726
- <priority>1.0</priority>
12727
- <changefreq>daily</changefreq>
12728
- </url>`;
12793
+ * Used by the sharded sitemap endpoints in `routes/feed/sitemap.ts`.
12794
+ */ function renderSitemapUrlSet(entries) {
12729
12795
  return `<?xml version="1.0" encoding="UTF-8"?>
12730
12796
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
12731
- <!-- Generated from ${escapeXml(sitemapUrl)} -->
12732
- ${homepageUrl}
12733
- ${postUrls}
12797
+ ${entries.map((entry) => {
12798
+ const parts = [` <loc>${escapeXml(entry.loc)}</loc>`];
12799
+ if (entry.lastmod) parts.push(` <lastmod>${escapeXml(entry.lastmod)}</lastmod>`);
12800
+ if (entry.changefreq) parts.push(` <changefreq>${escapeXml(entry.changefreq)}</changefreq>`);
12801
+ if (entry.priority) parts.push(` <priority>${escapeXml(entry.priority)}</priority>`);
12802
+ return ` <url>\n${parts.join("\n")}\n </url>`;
12803
+ }).join("\n")}
12734
12804
  </urlset>`;
12735
12805
  }
12806
+ /**
12807
+ * Render a `<sitemapindex>` XML document listing shard sitemap URLs.
12808
+ */ function renderSitemapIndex(entries) {
12809
+ return `<?xml version="1.0" encoding="UTF-8"?>
12810
+ <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
12811
+ ${entries.map((entry) => {
12812
+ const parts = [` <loc>${escapeXml(entry.loc)}</loc>`];
12813
+ if (entry.lastmod) parts.push(` <lastmod>${escapeXml(entry.lastmod)}</lastmod>`);
12814
+ return ` <sitemap>\n${parts.join("\n")}\n </sitemap>`;
12815
+ }).join("\n")}
12816
+ </sitemapindex>`;
12817
+ }
12736
12818
  //#endregion
12737
12819
  //#region src/routes/pages/archive.tsx
12738
12820
  /**
@@ -13760,12 +13842,29 @@ collectionRoutes.get("/:slug/feed", async (c) => {
13760
13842
  * Resolves post slugs, aliases, redirects, and collection aliases.
13761
13843
  * Must be registered last.
13762
13844
  */ var pageRoutes = new Hono();
13845
+ /**
13846
+ * Build the canonical absolute URL for a post page.
13847
+ *
13848
+ * Reply URLs render the full thread, so search engines see overlapping
13849
+ * content at every reply URL. Point the canonical to the thread root so
13850
+ * crawlers consolidate ranking on one URL.
13851
+ *
13852
+ * The root post is always at index 0 of `threadPostViews` (getThread orders
13853
+ * by createdAt ASC, and the DB check constraint guarantees root has the
13854
+ * smallest createdAt in its thread). When `threadPostViews` is undefined the
13855
+ * post is not part of a multi-post thread, so the post itself is the root.
13856
+ */ function buildPostCanonicalHref(postView, threadPostViews, siteUrl) {
13857
+ const rootPermalink = threadPostViews?.[0]?.permalink ?? postView.permalink;
13858
+ if (!siteUrl) return rootPermalink;
13859
+ return new URL(rootPermalink, siteUrl).toString();
13860
+ }
13763
13861
  async function renderPostWithTextPreview(c, post, autoOpen) {
13764
13862
  const navDataPromise = getNavigationData(c);
13765
13863
  const display = await assemblePostPageDisplay(c, post, { isAuthenticated: true });
13766
13864
  if (!display) return c.notFound();
13767
13865
  const navData = await navDataPromise;
13768
13866
  const meta = buildPostMeta(post, navData.siteName);
13867
+ const canonicalHref = buildPostCanonicalHref(display.postView, display.threadPostViews, c.var.appConfig.siteUrl);
13769
13868
  const pageTitle = autoOpen.attachmentTitle || meta.title;
13770
13869
  const autoOpenMeta = JSON.stringify({
13771
13870
  shareHref: autoOpen.shareHref,
@@ -13776,6 +13875,7 @@ async function renderPostWithTextPreview(c, post, autoOpen) {
13776
13875
  return renderPublicPage(c, {
13777
13876
  title: pageTitle,
13778
13877
  description: meta.description,
13878
+ canonicalHref,
13779
13879
  navData,
13780
13880
  content: /* @__PURE__ */ jsxDEV$1(Fragment$1, { children: [
13781
13881
  /* @__PURE__ */ jsxDEV$1(PostPage, {
@@ -13824,9 +13924,11 @@ async function renderPost(c, post) {
13824
13924
  if (!display) return c.notFound();
13825
13925
  const navData = await navDataPromise;
13826
13926
  const meta = buildPostMeta(post, navData.siteName);
13927
+ const canonicalHref = buildPostCanonicalHref(display.postView, display.threadPostViews, c.var.appConfig.siteUrl);
13827
13928
  return renderPublicPage(c, {
13828
13929
  title: meta.title,
13829
13930
  description: meta.description,
13931
+ canonicalHref,
13830
13932
  navData,
13831
13933
  content: /* @__PURE__ */ jsxDEV$1(PostPage, {
13832
13934
  post: display.postView,
@@ -14845,18 +14947,11 @@ newPostRoutes.get("/new", async (c) => {
14845
14947
  //#endregion
14846
14948
  //#region src/routes/pages/partials.tsx
14847
14949
  var partialPageRoutes = new Hono();
14848
- async function getIsAuthenticated(c) {
14849
- try {
14850
- return !!(await c.var.auth.api.getSession({ headers: c.req.raw.headers }))?.user;
14851
- } catch {
14852
- return false;
14853
- }
14854
- }
14855
14950
  partialPageRoutes.get("/_/version", (c) => {
14856
14951
  return c.json({ version: CORE_VERSION });
14857
14952
  });
14858
14953
  partialPageRoutes.get("/_/timeline-item/:threadRootId", async (c) => {
14859
- const item = await assembleTimelineItem(c, parseIdParam(c.req.param("threadRootId"), ID_PREFIX.post), { isAuthenticated: await getIsAuthenticated(c) });
14954
+ const item = await assembleTimelineItem(c, parseIdParam(c.req.param("threadRootId"), ID_PREFIX.post), { isAuthenticated: c.var.isAuthenticated });
14860
14955
  if (!item) return c.notFound();
14861
14956
  return c.html(/* @__PURE__ */ jsxDEV$1(I18nProvider, {
14862
14957
  c,
@@ -14864,7 +14959,7 @@ partialPageRoutes.get("/_/timeline-item/:threadRootId", async (c) => {
14864
14959
  }));
14865
14960
  });
14866
14961
  partialPageRoutes.get("/_/post-card/:postId", async (c) => {
14867
- const postView = await assemblePostCardView(c, parseIdParam(c.req.param("postId"), ID_PREFIX.post), { isAuthenticated: await getIsAuthenticated(c) });
14962
+ const postView = await assemblePostCardView(c, parseIdParam(c.req.param("postId"), ID_PREFIX.post), { isAuthenticated: c.var.isAuthenticated });
14868
14963
  if (!postView) return c.notFound();
14869
14964
  return c.html(/* @__PURE__ */ jsxDEV$1(I18nProvider, {
14870
14965
  c,
@@ -14872,7 +14967,7 @@ partialPageRoutes.get("/_/post-card/:postId", async (c) => {
14872
14967
  }));
14873
14968
  });
14874
14969
  partialPageRoutes.get("/_/post-view/:postId", async (c) => {
14875
- const display = await assemblePostPageDisplay(c, parseIdParam(c.req.param("postId"), ID_PREFIX.post), { isAuthenticated: await getIsAuthenticated(c) });
14970
+ const display = await assemblePostPageDisplay(c, parseIdParam(c.req.param("postId"), ID_PREFIX.post), { isAuthenticated: c.var.isAuthenticated });
14876
14971
  if (!display) return c.notFound();
14877
14972
  return c.html(/* @__PURE__ */ jsxDEV$1(I18nProvider, {
14878
14973
  c,
@@ -19829,6 +19924,10 @@ function parseConfigInt(value, fallback) {
19829
19924
  showHeaderAvatar: allSettings["SHOW_HEADER_AVATAR"] === "true",
19830
19925
  siteAvatarUrl,
19831
19926
  faviconVersion: allSettings["SITE_FAVICON_VERSION"] ?? "",
19927
+ rateLimit: {
19928
+ disabled: getEnvString(env, "RATE_LIMIT_DISABLED") === "true",
19929
+ searchPerMinute: parseInt(getEnvString(env, "RATE_LIMIT_SEARCH_PER_MIN") ?? "30", 10) || 30
19930
+ },
19832
19931
  fallbacks: {
19833
19932
  siteName: resolveFallback("SITE_NAME", env),
19834
19933
  siteDescription: resolveFallback("SITE_DESCRIPTION", env),
@@ -20019,8 +20118,8 @@ async function syncHostedControlPlaneSiteAvatar(input) {
20019
20118
  return;
20020
20119
  }
20021
20120
  await markSyncPending(settings);
20022
- const { createGitHubSyncService } = await import("./github-sync-zohnA9qv.js").then((n) => n.r);
20023
- const { getGitHubAppConfig } = await import("./env-wCpMcNXs.js").then((n) => n.t);
20121
+ const { createGitHubSyncService } = await import("./github-sync-7y_nTXx1.js").then((n) => n.r);
20122
+ const { getGitHubAppConfig } = await import("./env-CgaH9Mut.js").then((n) => n.t);
20024
20123
  const run = runBackgroundSync(settings, createGitHubSyncService(c.var.services, c.var.currentSite.id, await buildSyncSiteConfig(c), {
20025
20124
  storage: c.var.storage,
20026
20125
  githubApp: getGitHubAppConfig(c.env)
@@ -20634,7 +20733,7 @@ settingsRoutes.get("/account", async (c) => {
20634
20733
  settingsRoutes.get("/account/sessions", async (c) => {
20635
20734
  if (c.var.appConfig.demoMode) return c.redirect(publicPath(c, "/settings/account"));
20636
20735
  const navData = await getNavigationData(c);
20637
- const currentToken = (await c.var.auth.api.getSession({ headers: c.req.raw.headers }))?.session?.token ?? "";
20736
+ const currentToken = c.var.session?.session?.token ?? "";
20638
20737
  const sessions = (await c.var.auth.api.listSessions({ headers: c.req.raw.headers }) ?? []).map((s) => ({
20639
20738
  token: s.token,
20640
20739
  ipAddress: s.ipAddress ?? null,
@@ -20787,7 +20886,7 @@ settingsRoutes.post("/github-sync/connect", async (c) => {
20787
20886
  if (getGitHubAppConfig(c.env)) return dsToast("This deployment uses GitHub App authentication. Use Install GitHub App instead.", "error");
20788
20887
  const body = await c.req.json();
20789
20888
  if (!body.token?.trim() || !body.repo?.trim()) return dsToast("Token and repository are required.", "error");
20790
- const { parseRepoSlug, createGitHubClient } = await import("./github-api-CficQztC.js").then((n) => n.n);
20889
+ const { parseRepoSlug, createGitHubClient } = await import("./github-api-BkRWnqMx.js").then((n) => n.n);
20791
20890
  const parsed = parseRepoSlug(body.repo);
20792
20891
  if (!parsed) return dsToast("Invalid repository format. Use owner/repo.", "error");
20793
20892
  const client = createGitHubClient(body.token);
@@ -20806,7 +20905,7 @@ settingsRoutes.post("/github-sync/connect", async (c) => {
20806
20905
  await c.var.services.settings.set("GITHUB_SYNC_AUTH_MODE", "pat");
20807
20906
  await c.var.services.settings.set("GITHUB_SYNC_APP_INSTALLATION_ID", "");
20808
20907
  await c.var.services.settings.set("GITHUB_SYNC_ENABLED", "true");
20809
- const { createGitHubSyncService } = await import("./github-sync-zohnA9qv.js").then((n) => n.r);
20908
+ const { createGitHubSyncService } = await import("./github-sync-7y_nTXx1.js").then((n) => n.r);
20810
20909
  const syncService = createGitHubSyncService(c.var.services, c.var.currentSite.id, await buildSyncSiteConfig(c), {
20811
20910
  storage: c.var.storage,
20812
20911
  githubApp: getGitHubAppConfig(c.env)
@@ -20825,7 +20924,7 @@ settingsRoutes.post("/github-sync/connect", async (c) => {
20825
20924
  return dsRedirect(publicPath(c, "/settings/github-sync"));
20826
20925
  });
20827
20926
  settingsRoutes.post("/github-sync/push", async (c) => {
20828
- const { createGitHubSyncService } = await import("./github-sync-zohnA9qv.js").then((n) => n.r);
20927
+ const { createGitHubSyncService } = await import("./github-sync-7y_nTXx1.js").then((n) => n.r);
20829
20928
  const syncService = createGitHubSyncService(c.var.services, c.var.currentSite.id, await buildSyncSiteConfig(c), {
20830
20929
  storage: c.var.storage,
20831
20930
  githubApp: getGitHubAppConfig(c.env)
@@ -20845,7 +20944,7 @@ settingsRoutes.post("/github-sync/push", async (c) => {
20845
20944
  });
20846
20945
  });
20847
20946
  settingsRoutes.post("/github-sync/disconnect", async (c) => {
20848
- const { createGitHubSyncService } = await import("./github-sync-zohnA9qv.js").then((n) => n.r);
20947
+ const { createGitHubSyncService } = await import("./github-sync-7y_nTXx1.js").then((n) => n.r);
20849
20948
  await createGitHubSyncService(c.var.services, c.var.currentSite.id, await buildSyncSiteConfig(c), { githubApp: getGitHubAppConfig(c.env) }).teardownWebhook();
20850
20949
  return dsRedirect(publicPath(c, "/settings/github-sync"));
20851
20950
  });
@@ -21033,10 +21132,10 @@ function buildRepoPickerLabels(c) {
21033
21132
  const repo = String(body.repo ?? "").trim();
21034
21133
  const confirmForeign = body.confirmForeign === true || body.confirmForeign === "true";
21035
21134
  if (!installationId || !repo) return wantsJson ? c.json({ error: "Missing installationId or repo." }, 400) : c.text("Missing installationId or repo.", 400);
21036
- const { parseRepoSlug, createGitHubClient } = await import("./github-api-CficQztC.js").then((n) => n.n);
21135
+ const { parseRepoSlug, createGitHubClient } = await import("./github-api-BkRWnqMx.js").then((n) => n.n);
21037
21136
  const parsed = parseRepoSlug(repo);
21038
21137
  if (!parsed) return wantsJson ? c.json({ error: "Invalid repository format." }, 400) : c.text("Invalid repository format.", 400);
21039
- const { classifyRepoForSync } = await import("./github-sync-zohnA9qv.js").then((n) => n.r);
21138
+ const { classifyRepoForSync } = await import("./github-sync-7y_nTXx1.js").then((n) => n.r);
21040
21139
  const ghClient = createGitHubClient(() => getInstallationTokenFromApp(app, installationId));
21041
21140
  let classification;
21042
21141
  try {
@@ -21066,7 +21165,7 @@ function buildRepoPickerLabels(c) {
21066
21165
  await c.var.services.settings.set("GITHUB_SYNC_REPO", repo);
21067
21166
  await c.var.services.settings.set("GITHUB_SYNC_TOKEN", "");
21068
21167
  await c.var.services.settings.set("GITHUB_SYNC_ENABLED", "true");
21069
- const { createGitHubSyncService } = await import("./github-sync-zohnA9qv.js").then((n) => n.r);
21168
+ const { createGitHubSyncService } = await import("./github-sync-7y_nTXx1.js").then((n) => n.r);
21070
21169
  const syncService = createGitHubSyncService(c.var.services, c.var.currentSite.id, await buildSyncSiteConfig(c), {
21071
21170
  storage: c.var.storage,
21072
21171
  githubApp: app
@@ -21105,7 +21204,7 @@ function requireGitHubApp(c) {
21105
21204
  * build a client for classification without adding the helper to the
21106
21205
  * top-level imports (the module already lazy-imports github-api).
21107
21206
  */ async function getInstallationTokenFromApp(app, installationId) {
21108
- const { getInstallationToken } = await import("./github-app-F4qZ05xk.js").then((n) => n.i);
21207
+ const { getInstallationToken } = await import("./github-app-WeadXMb8.js").then((n) => n.i);
21109
21208
  return getInstallationToken(app, installationId);
21110
21209
  }
21111
21210
  /** List GitHub App installations authorized for this site. */ settingsRoutes.get("/github-sync/app/installations", async (c) => {
@@ -21179,10 +21278,10 @@ function requireGitHubApp(c) {
21179
21278
  const installationId = String(body.installationId ?? "").trim();
21180
21279
  const repo = String(body.repo ?? "").trim();
21181
21280
  if (!installationId || !repo) return c.json({ error: "Missing installationId or repo." }, 400);
21182
- const { parseRepoSlug, createGitHubClient } = await import("./github-api-CficQztC.js").then((n) => n.n);
21281
+ const { parseRepoSlug, createGitHubClient } = await import("./github-api-BkRWnqMx.js").then((n) => n.n);
21183
21282
  const parsed = parseRepoSlug(repo);
21184
21283
  if (!parsed) return c.json({ error: "Invalid repository format." }, 400);
21185
- const { classifyRepoForSync } = await import("./github-sync-zohnA9qv.js").then((n) => n.r);
21284
+ const { classifyRepoForSync } = await import("./github-sync-7y_nTXx1.js").then((n) => n.r);
21186
21285
  const client = createGitHubClient(() => getInstallationTokenFromApp(app, installationId));
21187
21286
  try {
21188
21287
  const classification = await classifyRepoForSync(client, parsed.owner, parsed.repo, c.var.currentSite.id);
@@ -23389,10 +23488,78 @@ function toSearchApiResult(post, snippet, sitePathPrefix) {
23389
23488
  };
23390
23489
  }
23391
23490
  //#endregion
23491
+ //#region src/lib/rate-limit.ts
23492
+ /**
23493
+ * Rate Limiting Abstraction
23494
+ *
23495
+ * Shared interface for per-key rate limiting. Runtimes provide their own
23496
+ * implementation: Cloudflare Workers uses a D1-backed sliding-window table
23497
+ * (ephemeral isolates can't hold memory state), while Node uses an
23498
+ * in-process Map (the process is persistent and avoids DB round-trips).
23499
+ *
23500
+ * Consumers depend only on this interface; they are runtime-agnostic.
23501
+ */ /**
23502
+ * Extracts the client IP from a Hono request context.
23503
+ *
23504
+ * On Cloudflare Workers, `cf-connecting-ip` is set by the edge and is
23505
+ * authoritative. On Node deployments we fall back to the leftmost
23506
+ * `x-forwarded-for` entry, which is the conventional client IP when the
23507
+ * app sits behind a single trusted proxy. When neither header is
23508
+ * available we return `"unknown"` so all such requests share a bucket —
23509
+ * preferable to skipping the rate limit entirely.
23510
+ *
23511
+ * Note: this helper does not verify proxy trust. It is used for DoS
23512
+ * protection, not authentication. If header-forgery resistance becomes
23513
+ * important, gate the `x-forwarded-for` branch on `shouldTrustProxy`.
23514
+ */ function getClientIp(c) {
23515
+ const cf = c.req.header("cf-connecting-ip");
23516
+ if (cf) return cf;
23517
+ const fwd = c.req.header("x-forwarded-for");
23518
+ if (fwd) {
23519
+ const first = fwd.split(",")[0]?.trim();
23520
+ if (first) return first;
23521
+ }
23522
+ return "unknown";
23523
+ }
23524
+ //#endregion
23525
+ //#region src/middleware/rate-limit.ts
23526
+ /**
23527
+ * Rate-Limit Middleware
23528
+ *
23529
+ * Applies a per-IP rate limit to the routes it wraps. Which storage
23530
+ * backs the limiter (D1 or in-memory) is decided upstream by the
23531
+ * runtime; this middleware only reads `c.var.rateLimiter` and doesn't
23532
+ * care.
23533
+ *
23534
+ * When the limit is exceeded, responds with HTTP 429 and a
23535
+ * `Retry-After` header. When `appConfig.rateLimit.disabled` is true the
23536
+ * middleware short-circuits to `next()` so test and dev environments
23537
+ * don't have to reason about bucket state.
23538
+ */ function rateLimit(opts) {
23539
+ return async (c, next) => {
23540
+ if (c.var.appConfig.rateLimit.disabled) return next();
23541
+ const ip = getClientIp(c);
23542
+ const result = await c.var.rateLimiter.check(`${opts.name}:${ip}`, {
23543
+ limit: opts.limit,
23544
+ windowSec: opts.windowSec
23545
+ });
23546
+ if (!result.ok) {
23547
+ c.header("Retry-After", String(result.retryAfterSec ?? opts.windowSec));
23548
+ return c.json({ error: "Too many requests. Please slow down." }, 429);
23549
+ }
23550
+ return next();
23551
+ };
23552
+ }
23553
+ //#endregion
23392
23554
  //#region src/routes/api/search.ts
23393
23555
  /**
23394
23556
  * Search API Routes
23395
23557
  */ var searchApiRoutes = new Hono();
23558
+ searchApiRoutes.use("*", async (c, next) => rateLimit({
23559
+ name: "search",
23560
+ limit: c.var.appConfig.rateLimit.searchPerMinute,
23561
+ windowSec: 60
23562
+ })(c, next));
23396
23563
  searchApiRoutes.get("/", async (c) => {
23397
23564
  const query = c.req.query("q");
23398
23565
  if (!query || query.trim().length === 0) throw new ValidationError("Query parameter 'q' is required");
@@ -24857,6 +25024,30 @@ var internalTextAttachmentsRoutes = new Hono();
24857
25024
  return c.json(result);
24858
25025
  });
24859
25026
  //#endregion
25027
+ //#region src/routes/api/internal/search-reindex.ts
25028
+ var ReindexSchema = z.object({
25029
+ limit: z.number().int().positive().max(500).optional(),
25030
+ cursor: z.string().min(1).optional()
25031
+ });
25032
+ var internalSearchReindexRoutes = new Hono();
25033
+ /**
25034
+ * Rebuild `post.body_text` for a batch of non-deleted posts. FTS indexes
25035
+ * (SQLite trigger / Postgres generated column) refresh automatically when
25036
+ * `body_text` changes.
25037
+ *
25038
+ * Idempotent. Callers loop with the returned `nextCursor` until `done: true`.
25039
+ * Used by the `jant search-reindex` CLI to backfill search indexes for
25040
+ * existing posts after changes to the text extraction logic (e.g. including
25041
+ * link mark hrefs so inline URLs become searchable).
25042
+ */ internalSearchReindexRoutes.post("/", requireInternalAdminApi(), async (c) => {
25043
+ const body = parseValidated(ReindexSchema, (c.req.header("Content-Type") || "").includes("application/json") ? await c.req.json().catch(() => ({})) : {});
25044
+ const result = await c.var.services.posts.reindexBodyText({
25045
+ limit: body.limit,
25046
+ cursor: body.cursor
25047
+ });
25048
+ return c.json(result);
25049
+ });
25050
+ //#endregion
24860
25051
  //#region src/routes/api/internal/uploads.ts
24861
25052
  var CleanupUploadsSchema = z.object({ limit: z.number().int().positive().max(500).optional() });
24862
25053
  var internalUploadsRoutes = new Hono();
@@ -25354,30 +25545,111 @@ feedRoutes.get("/all/atom.xml", (c) => {
25354
25545
  //#region src/routes/feed/sitemap.ts
25355
25546
  /**
25356
25547
  * Sitemap Routes
25548
+ *
25549
+ * Sitemap is sharded to keep each shard small, cache-friendly, and stable:
25550
+ *
25551
+ * /sitemap.xml → sitemap index listing all shards
25552
+ * /sitemap-posts-N.xml → one shard of published non-reply posts
25553
+ * /sitemap-collections.xml → public collection pages
25554
+ * /sitemap-pages.xml → homepage + static aggregate pages
25555
+ *
25556
+ * Post shards are keyset-paginated by post `id` (TypeIDs embed a
25557
+ * creation-ordered UUIDv7 timestamp), so once a shard fills up its membership
25558
+ * never changes: new posts always land in the last shard, never rewriting an
25559
+ * older one. This lets old shards be cached at the edge for a long time.
25357
25560
  */ var sitemapRoutes = new Hono();
25358
- sitemapRoutes.get("/sitemap.xml", async (c) => {
25359
- const { appConfig } = c.var;
25360
- const siteUrl = appConfig.siteUrl;
25361
- const posts = await c.var.services.posts.list({
25362
- status: "published",
25363
- excludeReplies: true,
25364
- excludePrivate: true,
25365
- limit: 1e3
25366
- });
25367
- const mediaCtx = createMediaContext(appConfig);
25368
- const aliasesMap = await c.var.services.paths.getPostAliases(posts.map((p) => p.id));
25369
- const aliasMap = /* @__PURE__ */ new Map();
25370
- for (const [id, aliases] of aliasesMap) if (aliases[0]) aliasMap.set(id, aliases[0]);
25371
- const postViews = toPostViewsFromPosts(posts, mediaCtx, void 0, aliasMap);
25372
- const xml = defaultSitemapRenderer({
25373
- siteUrl,
25374
- sitemapUrl: toAbsoluteSiteUrl("/sitemap.xml", siteUrl, appConfig.sitePathPrefix),
25375
- posts: postViews
25376
- });
25561
+ var CACHE_SHORT = "public, max-age=180";
25562
+ var CACHE_FULL_SHARD = "public, max-age=86400, s-maxage=86400";
25563
+ function xmlResponse(xml, cacheControl) {
25377
25564
  return new Response(xml, { headers: {
25378
25565
  "Content-Type": "application/xml; charset=utf-8",
25379
- "Cache-Control": "public, max-age=180"
25566
+ "Cache-Control": cacheControl
25380
25567
  } });
25568
+ }
25569
+ /**
25570
+ * Build a public URL entry (absolute URL) from an internal path.
25571
+ */ function absoluteUrl(internalPath, siteUrl, sitePathPrefix) {
25572
+ return toAbsoluteSiteUrl(internalPath, siteUrl, sitePathPrefix);
25573
+ }
25574
+ /** Convert a unix-seconds timestamp into a `YYYY-MM-DD` string. */ function toIsoDate(unixSeconds) {
25575
+ return (/* @__PURE__ */ new Date(unixSeconds * 1e3)).toISOString().slice(0, 10);
25576
+ }
25577
+ sitemapRoutes.get("/sitemap.xml", async (c) => {
25578
+ const { appConfig } = c.var;
25579
+ const { siteUrl, sitePathPrefix } = appConfig;
25580
+ const postCount = await c.var.services.posts.countForSitemap();
25581
+ const postShardCount = Math.max(1, Math.ceil(postCount / 500));
25582
+ const entries = [{ loc: absoluteUrl("/sitemap-pages.xml", siteUrl, sitePathPrefix) }];
25583
+ for (let page = 1; page <= postShardCount; page++) entries.push({ loc: absoluteUrl(`/sitemap-posts-${page}.xml`, siteUrl, sitePathPrefix) });
25584
+ if ((await c.var.services.collections.list()).length > 0) entries.push({ loc: absoluteUrl("/sitemap-collections.xml", siteUrl, sitePathPrefix) });
25585
+ return xmlResponse(renderSitemapIndex(entries), CACHE_SHORT);
25586
+ });
25587
+ sitemapRoutes.get("/:file{sitemap-posts-[0-9]+\\.xml}", async (c) => {
25588
+ const { appConfig } = c.var;
25589
+ const { siteUrl, sitePathPrefix } = appConfig;
25590
+ const file = c.req.param("file");
25591
+ const match = /^sitemap-posts-([0-9]+)\.xml$/.exec(file);
25592
+ if (!match) return c.notFound();
25593
+ const page = Number(match[1]);
25594
+ if (!Number.isFinite(page) || page < 1) return c.notFound();
25595
+ let afterId;
25596
+ if (page > 1) {
25597
+ const cursorOffset = (page - 1) * 500 - 1;
25598
+ const cursor = await c.var.services.posts.getSitemapIdAt(cursorOffset);
25599
+ if (cursor === null) return c.notFound();
25600
+ afterId = cursor;
25601
+ }
25602
+ const shardEntries = await c.var.services.posts.listForSitemap({
25603
+ afterId,
25604
+ limit: 500
25605
+ });
25606
+ const urls = shardEntries.map((entry) => {
25607
+ return {
25608
+ loc: absoluteUrl(entry.alias ?? `/${entry.slug}`, siteUrl, sitePathPrefix),
25609
+ lastmod: toIsoDate(entry.updatedAt),
25610
+ priority: entry.featuredAt ? "0.8" : "0.6"
25611
+ };
25612
+ });
25613
+ const cacheControl = shardEntries.length === 500 ? CACHE_FULL_SHARD : CACHE_SHORT;
25614
+ return xmlResponse(renderSitemapUrlSet(urls), cacheControl);
25615
+ });
25616
+ sitemapRoutes.get("/sitemap-collections.xml", async (c) => {
25617
+ const { appConfig } = c.var;
25618
+ const { siteUrl, sitePathPrefix } = appConfig;
25619
+ const collections = await c.var.services.collections.list();
25620
+ return xmlResponse(renderSitemapUrlSet(await Promise.all(collections.map(async (collection) => {
25621
+ const alias = await c.var.services.customUrls.getByTarget("collection", collection.id);
25622
+ return {
25623
+ loc: absoluteUrl(alias ? `/${alias.path}` : `/${collection.slug}`, siteUrl, sitePathPrefix),
25624
+ lastmod: toIsoDate(collection.updatedAt),
25625
+ priority: "0.7"
25626
+ };
25627
+ }))), CACHE_SHORT);
25628
+ });
25629
+ sitemapRoutes.get("/sitemap-pages.xml", async (c) => {
25630
+ const { appConfig } = c.var;
25631
+ const { siteUrl, sitePathPrefix, homeDefaultView } = appConfig;
25632
+ const urls = [{
25633
+ loc: absoluteUrl("/", siteUrl, sitePathPrefix),
25634
+ priority: "1.0",
25635
+ changefreq: "daily"
25636
+ }, {
25637
+ loc: absoluteUrl("/archive", siteUrl, sitePathPrefix),
25638
+ priority: "0.5",
25639
+ changefreq: "weekly"
25640
+ }];
25641
+ const secondaryAggregate = homeDefaultView === "featured" ? "/latest" : "/featured";
25642
+ urls.push({
25643
+ loc: absoluteUrl(secondaryAggregate, siteUrl, sitePathPrefix),
25644
+ priority: "0.6",
25645
+ changefreq: "daily"
25646
+ });
25647
+ if ((await c.var.services.collections.list()).length > 0) urls.push({
25648
+ loc: absoluteUrl("/collections", siteUrl, sitePathPrefix),
25649
+ priority: "0.5",
25650
+ changefreq: "weekly"
25651
+ });
25652
+ return xmlResponse(renderSitemapUrlSet(urls), CACHE_SHORT);
25381
25653
  });
25382
25654
  sitemapRoutes.get("/robots.txt", async (c) => {
25383
25655
  const { appConfig } = c.var;
@@ -25395,7 +25667,7 @@ sitemapRoutes.get("/robots.txt", async (c) => {
25395
25667
  ].join("\n");
25396
25668
  return new Response(robots, { headers: {
25397
25669
  "Content-Type": "text/plain; charset=utf-8",
25398
- "Cache-Control": "public, max-age=180"
25670
+ "Cache-Control": CACHE_SHORT
25399
25671
  } });
25400
25672
  });
25401
25673
  //#endregion
@@ -25438,6 +25710,34 @@ manifestRoutes.get("/manifest.webmanifest", (c) => {
25438
25710
  } });
25439
25711
  });
25440
25712
  //#endregion
25713
+ //#region src/middleware/session.ts
25714
+ /**
25715
+ * Session Middleware
25716
+ *
25717
+ * Runs once per request (after runtime init) to look up the better-auth
25718
+ * session and stash it on `c.var.session` / `c.var.isAuthenticated`.
25719
+ *
25720
+ * This replaces ad-hoc `auth.api.getSession()` calls scattered across
25721
+ * view helpers (e.g. `lib/navigation.ts`) so each request only parses
25722
+ * the session cookie once. better-auth's own cookieCache (5 min) still
25723
+ * keeps this cheap, but centralizing the call also unlocks `Promise.all`
25724
+ * patterns in routes that previously serialized on a hidden session fetch.
25725
+ *
25726
+ * Never throws — any lookup error is treated as "not authenticated".
25727
+ */ function attachSession() {
25728
+ return async (c, next) => {
25729
+ try {
25730
+ const session = await c.var.auth.api.getSession({ headers: c.req.raw.headers });
25731
+ c.set("session", session ?? null);
25732
+ c.set("isAuthenticated", !!session?.user);
25733
+ } catch {
25734
+ c.set("session", null);
25735
+ c.set("isAuthenticated", false);
25736
+ }
25737
+ await next();
25738
+ };
25739
+ }
25740
+ //#endregion
25441
25741
  //#region src/middleware/onboarding.ts
25442
25742
  /**
25443
25743
  * Onboarding Middleware
@@ -26361,6 +26661,60 @@ async function deriveScryptKey(password, saltHex, opts) {
26361
26661
  });
26362
26662
  }
26363
26663
  //#endregion
26664
+ //#region src/lib/rate-limit-d1.ts
26665
+ /**
26666
+ * D1 Sliding-Window Rate Limiter
26667
+ *
26668
+ * Used by the Cloudflare Workers runtime where isolates are ephemeral and
26669
+ * memory-based limiters would silently drop state between requests. Each
26670
+ * check performs one SELECT (over a two-row range) plus one UPSERT — both
26671
+ * hit a composite primary key so the round-trips are cheap.
26672
+ *
26673
+ * Algorithm: two-window weighted counter. The previous window's tally
26674
+ * decays linearly as the current window fills, giving a smoother limit
26675
+ * than a naive fixed window (which would allow a 2x burst at the
26676
+ * boundary) while remaining a single-key storage primitive.
26677
+ *
26678
+ * For Node deployments use `createMemoryRateLimiter` instead.
26679
+ */
26680
+ /**
26681
+ * Probability of running opportunistic cleanup on any given write.
26682
+ * 1% strikes a balance between bounded table growth and per-request cost.
26683
+ */ var CLEANUP_PROBABILITY = .01;
26684
+ function createD1RateLimiter(db, schema, now = () => Math.floor(Date.now() / 1e3)) {
26685
+ const { rateLimit } = schema;
26686
+ return { async check(key, opts) {
26687
+ const { limit, windowSec } = opts;
26688
+ const nowSec = now();
26689
+ const currentWindow = Math.floor(nowSec / windowSec) * windowSec;
26690
+ const previousWindow = currentWindow - windowSec;
26691
+ const rows = await db.select({
26692
+ windowStart: rateLimit.windowStart,
26693
+ count: rateLimit.count
26694
+ }).from(rateLimit).where(and(eq(rateLimit.key, key), inArray(rateLimit.windowStart, [currentWindow, previousWindow])));
26695
+ let currentCount = 0;
26696
+ let previousCount = 0;
26697
+ for (const row of rows) if (row.windowStart === currentWindow) currentCount = row.count;
26698
+ else if (row.windowStart === previousWindow) previousCount = row.count;
26699
+ const elapsed = nowSec - currentWindow;
26700
+ const prevWeight = 1 - elapsed / windowSec;
26701
+ if (previousCount * prevWeight + currentCount >= limit) return {
26702
+ ok: false,
26703
+ retryAfterSec: Math.max(1, windowSec - elapsed)
26704
+ };
26705
+ await db.insert(rateLimit).values({
26706
+ key,
26707
+ windowStart: currentWindow,
26708
+ count: 1
26709
+ }).onConflictDoUpdate({
26710
+ target: [rateLimit.key, rateLimit.windowStart],
26711
+ set: { count: sql`${rateLimit.count} + 1` }
26712
+ });
26713
+ if (Math.random() < CLEANUP_PROBABILITY) await db.delete(rateLimit).where(lt(rateLimit.windowStart, currentWindow - windowSec * 2));
26714
+ return { ok: true };
26715
+ } };
26716
+ }
26717
+ //#endregion
26364
26718
  //#region src/lib/hosted-sso.ts
26365
26719
  var textEncoder = new TextEncoder();
26366
26720
  var textDecoder = new TextDecoder();
@@ -27649,6 +28003,51 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
27649
28003
  if (filters.offset !== void 0) query = query.offset(filters.offset);
27650
28004
  return hydratePosts(await query);
27651
28005
  },
28006
+ async listForSitemap({ afterId, limit }) {
28007
+ const conditions = buildFilterConditions({
28008
+ status: "published",
28009
+ excludePrivate: true,
28010
+ excludeReplies: true
28011
+ });
28012
+ if (afterId !== void 0) conditions.push(sql`${posts.id} > ${afterId}`);
28013
+ const rows = await db.select({
28014
+ id: posts.id,
28015
+ updatedAt: posts.updatedAt,
28016
+ featuredAt: posts.featuredAt
28017
+ }).from(posts).where(conditions.length > 0 ? and(...conditions) : void 0).orderBy(asc(posts.id)).limit(limit);
28018
+ if (rows.length === 0) return [];
28019
+ const ids = rows.map((row) => row.id);
28020
+ const [slugMap, aliasesMap] = await Promise.all([resolvedPaths.getPostSlugMap(ids), resolvedPaths.getPostAliases(ids)]);
28021
+ return rows.map((row) => {
28022
+ const slug = slugMap.get(row.id);
28023
+ if (!slug) return null;
28024
+ const alias = aliasesMap.get(row.id)?.[0] ?? null;
28025
+ return {
28026
+ id: row.id,
28027
+ slug,
28028
+ alias,
28029
+ updatedAt: row.updatedAt,
28030
+ featuredAt: row.featuredAt
28031
+ };
28032
+ }).filter((entry) => entry !== null);
28033
+ },
28034
+ async countForSitemap() {
28035
+ const conditions = buildFilterConditions({
28036
+ status: "published",
28037
+ excludePrivate: true,
28038
+ excludeReplies: true
28039
+ });
28040
+ return (await db.select({ count: sql`CAST(count(*) AS INTEGER)`.as("count") }).from(posts).where(conditions.length > 0 ? and(...conditions) : void 0))[0]?.count ?? 0;
28041
+ },
28042
+ async getSitemapIdAt(offset) {
28043
+ if (offset < 0) return null;
28044
+ const conditions = buildFilterConditions({
28045
+ status: "published",
28046
+ excludePrivate: true,
28047
+ excludeReplies: true
28048
+ });
28049
+ return (await db.select({ id: posts.id }).from(posts).where(conditions.length > 0 ? and(...conditions) : void 0).orderBy(asc(posts.id)).limit(1).offset(offset))[0]?.id ?? null;
28050
+ },
27652
28051
  async count(filters = {}) {
27653
28052
  const conditions = buildFilterConditions(filters);
27654
28053
  return (await db.select({ count: sql`CAST(count(*) AS INTEGER)`.as("count") }).from(posts).where(conditions.length > 0 ? and(...conditions) : void 0))[0]?.count ?? 0;
@@ -27688,7 +28087,7 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
27688
28087
  hasAttachments: data.attachments ? data.attachments.length > 0 : void 0
27689
28088
  });
27690
28089
  const bodyHtml = body ? renderTiptapJson(body) : null;
27691
- const bodyText = body ? extractBodyText(body) : null;
28090
+ const bodyText = body ? extractBodyText(body, { includeLinkHrefs: true }) : null;
27692
28091
  let summary = null;
27693
28092
  if (format === "note" && title && body && summaryConfig) summary = extractSummary(body, summaryConfig.maxParagraphs, summaryConfig.maxChars);
27694
28093
  let threadId = id;
@@ -27968,7 +28367,7 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
27968
28367
  const normalizedBody = rawBody ? trimTiptapBody(rawBody) : null;
27969
28368
  updates.body = normalizedBody;
27970
28369
  updates.bodyHtml = normalizedBody ? renderTiptapJson(normalizedBody) : null;
27971
- updates.bodyText = normalizedBody ? extractBodyText(normalizedBody) : null;
28370
+ updates.bodyText = normalizedBody ? extractBodyText(normalizedBody, { includeLinkHrefs: true }) : null;
27972
28371
  }
27973
28372
  if (summaryConfig) {
27974
28373
  const format = nextFormat;
@@ -28406,6 +28805,40 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
28406
28805
  const conditions = [...buildFilterConditions(filters), isNotNull(posts.publishedAt)];
28407
28806
  const publishedYearExpr = buildPublishedYearExpr();
28408
28807
  return (await db.select({ year: publishedYearExpr.as("year") }).from(posts).where(conditions.length > 0 ? and(...conditions) : void 0).groupBy(publishedYearExpr).orderBy(desc(publishedYearExpr))).map((r) => parseInt(r.year, 10));
28808
+ },
28809
+ async reindexBodyText(options = {}) {
28810
+ const requested = options.limit ?? 50;
28811
+ const limit = Math.min(Math.max(Math.trunc(requested), 1), 500);
28812
+ const cursor = options.cursor;
28813
+ const whereConditions = [eq(posts.siteId, siteId), isNull(posts.deletedAt)];
28814
+ if (cursor) whereConditions.push(gt(posts.id, cursor));
28815
+ const rows = await db.select({
28816
+ id: posts.id,
28817
+ body: posts.body,
28818
+ bodyText: posts.bodyText
28819
+ }).from(posts).where(and(...whereConditions)).orderBy(asc(posts.id)).limit(limit + 1);
28820
+ const hasMore = rows.length > limit;
28821
+ const batch = hasMore ? rows.slice(0, limit) : rows;
28822
+ let updated = 0;
28823
+ let skipped = 0;
28824
+ for (const row of batch) {
28825
+ const nextBodyText = row.body ? extractBodyText(row.body, { includeLinkHrefs: true }) : null;
28826
+ if (nextBodyText === row.bodyText) {
28827
+ skipped++;
28828
+ continue;
28829
+ }
28830
+ await db.update(posts).set({ bodyText: nextBodyText }).where(and(eq(posts.siteId, siteId), eq(posts.id, row.id)));
28831
+ updated++;
28832
+ }
28833
+ const lastRow = batch.at(-1);
28834
+ const lastId = lastRow ? lastRow.id : null;
28835
+ return {
28836
+ processed: batch.length,
28837
+ updated,
28838
+ skipped,
28839
+ nextCursor: hasMore ? lastId : null,
28840
+ done: !hasMore
28841
+ };
28409
28842
  }
28410
28843
  };
28411
28844
  }
@@ -30863,6 +31296,7 @@ function getResolvedSiteBaseUrl(env, publicRequestUrl, pathPrefix) {
30863
31296
  schema: sqliteSchemaBundle,
30864
31297
  secret: hostedControlPlaneSsoSecret
30865
31298
  }),
31299
+ rateLimiter: createD1RateLimiter(db, sqliteSchemaBundle),
30866
31300
  services: createServices(db, session, siteLookup.site.id, {
30867
31301
  databaseDialect: "sqlite",
30868
31302
  bootstrapSite: getSingleSiteBootstrapOptions(env),
@@ -30876,7 +31310,76 @@ function getResolvedSiteBaseUrl(env, publicRequestUrl, pathPrefix) {
30876
31310
  };
30877
31311
  }
30878
31312
  //#endregion
31313
+ //#region src/lib/rate-limit-memory.ts
31314
+ /**
31315
+ * In-Memory Rate Limiter
31316
+ *
31317
+ * Used by the Node runtime. The server process is long-lived and
31318
+ * single-instance, so a local `Map` is reliable and avoids unnecessary DB
31319
+ * round-trips. Uses the classic sliding-window-counter algorithm (two
31320
+ * aligned buckets with a weighted estimate) for smooth limiting without
31321
+ * the 2x boundary burst of a fixed window.
31322
+ *
31323
+ * On Cloudflare Workers use `createD1RateLimiter` instead — isolates are
31324
+ * ephemeral and cannot share memory across requests.
31325
+ */ /**
31326
+ * Number of live keys after which we prune old buckets on the next write.
31327
+ * Keeps the Map bounded under abuse without paying for eager sweeps.
31328
+ */ var SWEEP_THRESHOLD = 1e4;
31329
+ /**
31330
+ * Creates an isolated in-memory limiter. Tests construct one per test app;
31331
+ * the Node runtime holds a single module-level instance across requests.
31332
+ *
31333
+ * `now` is injectable so tests can assert window-rollover behavior without
31334
+ * relying on real time.
31335
+ */ function createMemoryRateLimiter(now = () => Math.floor(Date.now() / 1e3)) {
31336
+ const buckets = /* @__PURE__ */ new Map();
31337
+ function sweep(nowSec) {
31338
+ if (buckets.size < SWEEP_THRESHOLD) return;
31339
+ const cutoff = nowSec - 600;
31340
+ for (const [key, bucket] of buckets) if (bucket.windowStart < cutoff) buckets.delete(key);
31341
+ }
31342
+ return { async check(key, opts) {
31343
+ const { limit, windowSec } = opts;
31344
+ const nowSec = now();
31345
+ const currentWindow = Math.floor(nowSec / windowSec) * windowSec;
31346
+ let bucket = buckets.get(key);
31347
+ if (!bucket) {
31348
+ bucket = {
31349
+ windowStart: currentWindow,
31350
+ count: 0,
31351
+ prevCount: 0
31352
+ };
31353
+ buckets.set(key, bucket);
31354
+ } else if (bucket.windowStart !== currentWindow) {
31355
+ if (bucket.windowStart === currentWindow - windowSec) bucket.prevCount = bucket.count;
31356
+ else bucket.prevCount = 0;
31357
+ bucket.count = 0;
31358
+ bucket.windowStart = currentWindow;
31359
+ }
31360
+ const elapsed = nowSec - currentWindow;
31361
+ const prevWeight = 1 - elapsed / windowSec;
31362
+ if (bucket.prevCount * prevWeight + bucket.count >= limit) return {
31363
+ ok: false,
31364
+ retryAfterSec: Math.max(1, windowSec - elapsed)
31365
+ };
31366
+ bucket.count += 1;
31367
+ sweep(nowSec);
31368
+ return { ok: true };
31369
+ } };
31370
+ }
31371
+ //#endregion
30879
31372
  //#region src/runtime/node.ts
31373
+ /**
31374
+ * Single process-wide rate limiter for the Node runtime. Node serves all
31375
+ * requests out of one persistent process, so in-memory counters are
31376
+ * reliable and avoid per-request D1 round-trips. Constructed lazily on
31377
+ * first use so tests that never build a request runtime don't pay for it.
31378
+ */ var sharedNodeRateLimiter = null;
31379
+ function getNodeRateLimiter() {
31380
+ sharedNodeRateLimiter ??= createMemoryRateLimiter();
31381
+ return sharedNodeRateLimiter;
31382
+ }
30880
31383
  function createBetterSqliteRawQuery(sqlite) {
30881
31384
  return { prepare(query) {
30882
31385
  let params = [];
@@ -30928,6 +31431,7 @@ function createBetterSqliteRawQuery(sqlite) {
30928
31431
  schema: databaseSchema,
30929
31432
  secret: hostedControlPlaneSsoSecret
30930
31433
  }),
31434
+ rateLimiter: getNodeRateLimiter(),
30931
31435
  services: createServices(db, rawQuery, siteLookup.site.id, {
30932
31436
  databaseDialect,
30933
31437
  bootstrapSite: getSingleSiteBootstrapOptions(env),
@@ -31204,8 +31708,10 @@ async function servePublicStorage(c) {
31204
31708
  c.set("auth", runtime.auth);
31205
31709
  c.set("currentSite", runtime.currentSite);
31206
31710
  c.set("currentSiteDomain", runtime.currentSiteDomain);
31711
+ c.set("rateLimiter", runtime.rateLimiter);
31207
31712
  await next();
31208
31713
  });
31714
+ app.use("*", attachSession());
31209
31715
  app.use("*", async (c, next) => {
31210
31716
  const redirectUrl = await getHostedCanonicalRedirect({
31211
31717
  currentSite: c.var.currentSite,
@@ -31223,6 +31729,7 @@ async function servePublicStorage(c) {
31223
31729
  app.route("/api/internal/api-tokens", internalApiTokensRoutes);
31224
31730
  app.route("/api/internal/sites", internalSitesRoutes);
31225
31731
  app.route("/api/internal/text-attachments", internalTextAttachmentsRoutes);
31732
+ app.route("/api/internal/search/reindex", internalSearchReindexRoutes);
31226
31733
  app.route("/api/internal/uploads", internalUploadsRoutes);
31227
31734
  app.route("/api/github-sync", githubSyncWebhookRoutes);
31228
31735
  app.get("/api/media/:id/content", async (c) => {
@@ -31352,4 +31859,4 @@ async function servePublicStorage(c) {
31352
31859
  return app;
31353
31860
  }
31354
31861
  //#endregion
31355
- export { MEDIA_KINDS as A, toNavItemViews as C, FORMATS$2 as D, toSearchResultView as E, buildThemeStyle as F, BUILTIN_COLOR_THEMES as I, getPublicAssetBasePath as L, SORT_ORDERS as M, STATUSES$2 as N, MAX_MEDIA_ATTACHMENTS as O, TEXT_ATTACHMENT_CONTENT_FORMATS as P, isAssetPath as R, toNavItemView as S, toPostViews as T, schema_exports$1 as _, createSiteService as a, toArchiveGroupsWithMedia as b, resolveConfig as c, getFontThemeCssVariables as d, defaultFeedRenderer as f, createNodeDatabase as g, sqliteSchemaBundle as h, createNodeRequestRuntime as i, NAV_ITEM_TYPES$2 as j, MAX_PINNED_POSTS as k, BUILTIN_FONT_THEMES as l, pgSchemaBundle as m, createRequestRuntime as n, resolveDatabaseDialect as o, defaultSitemapRenderer as p, createNodeCliRuntime as r, getHostBasedStartupConfigurationIssues as s, createApp as t, getCjkSerifCssVariables as u, createMediaContext as v, toPostView as w, toMediaView as x, toArchiveGroups as y };
31862
+ export { NAV_ITEM_TYPES$2 as A, toPostView as C, MAX_MEDIA_ATTACHMENTS as D, FORMATS$2 as E, BUILTIN_COLOR_THEMES as F, getPublicAssetBasePath as I, isAssetPath as L, STATUSES$2 as M, TEXT_ATTACHMENT_CONTENT_FORMATS as N, MAX_PINNED_POSTS as O, buildThemeStyle as P, toNavItemViews as S, toSearchResultView as T, createMediaContext as _, createSiteService as a, toMediaView as b, resolveConfig as c, getFontThemeCssVariables as d, defaultFeedRenderer as f, schema_exports$1 as g, createNodeDatabase as h, createNodeRequestRuntime as i, SORT_ORDERS as j, MEDIA_KINDS as k, BUILTIN_FONT_THEMES as l, sqliteSchemaBundle as m, createRequestRuntime as n, resolveDatabaseDialect as o, pgSchemaBundle as p, createNodeCliRuntime as r, getHostBasedStartupConfigurationIssues as s, createApp as t, getCjkSerifCssVariables as u, toArchiveGroups as v, toPostViews as w, toNavItemView as x, toArchiveGroupsWithMedia as y };