@jant/core 0.3.36 → 0.3.37

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 (271) hide show
  1. package/bin/commands/export.js +1 -1
  2. package/bin/commands/import-site.js +529 -0
  3. package/bin/commands/reset-password.js +3 -2
  4. package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
  5. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  6. package/dist/client/client.css +1 -1
  7. package/dist/client/client.js +4012 -3276
  8. package/dist/index.js +10285 -5809
  9. package/package.json +11 -3
  10. package/src/__tests__/helpers/app.ts +9 -9
  11. package/src/__tests__/helpers/db.ts +91 -93
  12. package/src/app.tsx +157 -27
  13. package/src/auth.ts +20 -2
  14. package/src/client/archive-nav.js +187 -0
  15. package/src/client/audio-player.ts +478 -0
  16. package/src/client/audio-processor.ts +84 -0
  17. package/src/client/avatar-upload.ts +3 -2
  18. package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
  19. package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
  20. package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
  21. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  22. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  23. package/src/client/components/collection-sidebar-types.ts +7 -9
  24. package/src/client/components/compose-types.ts +101 -4
  25. package/src/client/components/jant-collection-form.ts +43 -7
  26. package/src/client/components/jant-collection-sidebar.ts +88 -84
  27. package/src/client/components/jant-compose-dialog.ts +1655 -219
  28. package/src/client/components/jant-compose-editor.ts +732 -168
  29. package/src/client/components/jant-compose-fullscreen.ts +23 -78
  30. package/src/client/components/jant-media-lightbox.ts +2 -0
  31. package/src/client/components/jant-nav-manager.ts +24 -284
  32. package/src/client/components/jant-post-form.ts +89 -9
  33. package/src/client/components/jant-post-menu.ts +1019 -0
  34. package/src/client/components/jant-settings-avatar.ts +3 -3
  35. package/src/client/components/jant-settings-general.ts +38 -4
  36. package/src/client/components/jant-text-preview.ts +232 -0
  37. package/src/client/components/nav-manager-types.ts +4 -19
  38. package/src/client/components/post-form-template.ts +107 -12
  39. package/src/client/components/post-form-types.ts +11 -4
  40. package/src/client/compose-bridge.ts +410 -109
  41. package/src/client/image-processor.ts +26 -8
  42. package/src/client/media-metadata.ts +247 -0
  43. package/src/client/multipart-upload.ts +160 -0
  44. package/src/client/post-form-bridge.ts +52 -1
  45. package/src/client/settings-bridge.ts +0 -12
  46. package/src/client/thread-context.ts +140 -0
  47. package/src/client/tiptap/create-editor.ts +46 -0
  48. package/src/client/tiptap/extensions.ts +5 -0
  49. package/src/client/tiptap/image-node.ts +2 -8
  50. package/src/client/tiptap/paste-image.ts +2 -13
  51. package/src/client/tiptap/slash-commands.ts +173 -63
  52. package/src/client/toast.ts +101 -3
  53. package/src/client/types/sortablejs.d.ts +15 -0
  54. package/src/client/upload-with-metadata.ts +54 -0
  55. package/src/client/video-processor.ts +207 -0
  56. package/src/client.ts +5 -2
  57. package/src/db/__tests__/migrations.test.ts +118 -0
  58. package/src/db/index.ts +52 -0
  59. package/src/db/migrations/0000_baseline.sql +269 -0
  60. package/src/db/migrations/0001_fts_setup.sql +31 -0
  61. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  62. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  63. package/src/db/migrations/meta/_journal.json +4 -39
  64. package/src/db/schema.ts +409 -145
  65. package/src/i18n/__tests__/detect.test.ts +115 -0
  66. package/src/i18n/context.tsx +2 -2
  67. package/src/i18n/detect.ts +85 -1
  68. package/src/i18n/i18n.ts +1 -1
  69. package/src/i18n/index.ts +2 -1
  70. package/src/i18n/locales/en.po +487 -1217
  71. package/src/i18n/locales/en.ts +1 -1
  72. package/src/i18n/locales/zh-Hans.po +613 -996
  73. package/src/i18n/locales/zh-Hans.ts +1 -1
  74. package/src/i18n/locales/zh-Hant.po +624 -1007
  75. package/src/i18n/locales/zh-Hant.ts +1 -1
  76. package/src/i18n/middleware.ts +6 -0
  77. package/src/index.ts +5 -7
  78. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  79. package/src/lib/__tests__/constants.test.ts +0 -1
  80. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  81. package/src/lib/__tests__/nanoid.test.ts +26 -0
  82. package/src/lib/__tests__/schemas.test.ts +181 -63
  83. package/src/lib/__tests__/slug.test.ts +126 -0
  84. package/src/lib/__tests__/sse.test.ts +6 -6
  85. package/src/lib/__tests__/summary.test.ts +264 -0
  86. package/src/lib/__tests__/theme.test.ts +1 -1
  87. package/src/lib/__tests__/timeline.test.ts +33 -30
  88. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  89. package/src/lib/__tests__/view.test.ts +141 -66
  90. package/src/lib/blurhash-placeholder.ts +102 -0
  91. package/src/lib/constants.ts +3 -1
  92. package/src/lib/emoji-catalog.ts +885 -68
  93. package/src/lib/errors.ts +11 -8
  94. package/src/lib/feed.ts +78 -32
  95. package/src/lib/html.ts +2 -1
  96. package/src/lib/icon-catalog.ts +5033 -1
  97. package/src/lib/icons.ts +3 -2
  98. package/src/lib/index.ts +0 -1
  99. package/src/lib/markdown-to-tiptap.ts +286 -0
  100. package/src/lib/media-helpers.ts +12 -3
  101. package/src/lib/nanoid.ts +29 -0
  102. package/src/lib/navigation.ts +1 -1
  103. package/src/lib/render.tsx +20 -2
  104. package/src/lib/resolve-config.ts +6 -2
  105. package/src/lib/schemas.ts +224 -55
  106. package/src/lib/search-snippet.ts +34 -0
  107. package/src/lib/slug.ts +96 -0
  108. package/src/lib/sse.ts +6 -6
  109. package/src/lib/storage.ts +115 -7
  110. package/src/lib/summary.ts +66 -0
  111. package/src/lib/theme.ts +11 -8
  112. package/src/lib/timeline.ts +74 -34
  113. package/src/lib/tiptap-render.ts +5 -10
  114. package/src/lib/tiptap-to-markdown.ts +305 -0
  115. package/src/lib/upload.ts +190 -29
  116. package/src/lib/url.ts +31 -0
  117. package/src/lib/view.ts +204 -37
  118. package/src/middleware/__tests__/auth.test.ts +191 -11
  119. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  120. package/src/middleware/auth.ts +63 -9
  121. package/src/middleware/onboarding.ts +1 -1
  122. package/src/middleware/secure-headers.ts +40 -0
  123. package/src/preset.css +45 -2
  124. package/src/routes/__tests__/compose.test.ts +17 -24
  125. package/src/routes/api/__tests__/collections.test.ts +109 -61
  126. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  127. package/src/routes/api/__tests__/posts.test.ts +132 -68
  128. package/src/routes/api/__tests__/search.test.ts +15 -2
  129. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  130. package/src/routes/api/collections.ts +51 -42
  131. package/src/routes/api/custom-urls.ts +80 -0
  132. package/src/routes/api/export.ts +31 -0
  133. package/src/routes/api/nav-items.ts +23 -19
  134. package/src/routes/api/posts.ts +43 -39
  135. package/src/routes/api/search.ts +3 -4
  136. package/src/routes/api/upload-multipart.ts +245 -0
  137. package/src/routes/api/upload.ts +85 -19
  138. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  139. package/src/routes/auth/setup.tsx +26 -33
  140. package/src/routes/auth/signin.tsx +3 -7
  141. package/src/routes/compose.tsx +10 -55
  142. package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
  143. package/src/routes/dash/custom-urls.tsx +414 -0
  144. package/src/routes/dash/settings.tsx +304 -232
  145. package/src/routes/feed/__tests__/rss.test.ts +27 -28
  146. package/src/routes/feed/rss.ts +6 -4
  147. package/src/routes/feed/sitemap.ts +2 -12
  148. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  149. package/src/routes/pages/__tests__/featured.test.ts +41 -22
  150. package/src/routes/pages/archive.tsx +175 -39
  151. package/src/routes/pages/collection.tsx +22 -10
  152. package/src/routes/pages/collections.tsx +3 -3
  153. package/src/routes/pages/featured.tsx +28 -4
  154. package/src/routes/pages/home.tsx +16 -15
  155. package/src/routes/pages/latest.tsx +1 -11
  156. package/src/routes/pages/new.tsx +39 -0
  157. package/src/routes/pages/page.tsx +95 -49
  158. package/src/routes/pages/search.tsx +1 -1
  159. package/src/services/__tests__/api-token.test.ts +135 -0
  160. package/src/services/__tests__/collection.test.ts +275 -227
  161. package/src/services/__tests__/custom-url.test.ts +213 -0
  162. package/src/services/__tests__/media.test.ts +162 -22
  163. package/src/services/__tests__/navigation.test.ts +109 -68
  164. package/src/services/__tests__/post-timeline.test.ts +205 -32
  165. package/src/services/__tests__/post.test.ts +713 -234
  166. package/src/services/__tests__/search.test.ts +67 -10
  167. package/src/services/api-token.ts +166 -0
  168. package/src/services/auth.ts +17 -2
  169. package/src/services/collection.ts +397 -131
  170. package/src/services/custom-url.ts +188 -0
  171. package/src/services/export.ts +802 -0
  172. package/src/services/index.ts +26 -19
  173. package/src/services/media.ts +100 -22
  174. package/src/services/navigation.ts +158 -47
  175. package/src/services/path.ts +339 -0
  176. package/src/services/post.ts +687 -154
  177. package/src/services/search.ts +160 -75
  178. package/src/styles/components.css +58 -7
  179. package/src/styles/tokens.css +84 -6
  180. package/src/styles/ui.css +2964 -457
  181. package/src/types/bindings.ts +4 -1
  182. package/src/types/config.ts +12 -4
  183. package/src/types/constants.ts +15 -3
  184. package/src/types/entities.ts +74 -35
  185. package/src/types/operations.ts +11 -24
  186. package/src/types/props.ts +51 -16
  187. package/src/types/views.ts +45 -22
  188. package/src/ui/color-themes.ts +133 -23
  189. package/src/ui/compose/ComposeDialog.tsx +239 -17
  190. package/src/ui/compose/ComposePrompt.tsx +1 -1
  191. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  192. package/src/ui/dash/ListItemRow.tsx +1 -1
  193. package/src/ui/dash/StatusBadge.tsx +3 -1
  194. package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
  195. package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
  196. package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
  197. package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
  198. package/src/ui/dash/index.ts +0 -3
  199. package/src/ui/dash/settings/AccountContent.tsx +3 -57
  200. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  201. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  202. package/src/ui/dash/settings/AvatarContent.tsx +8 -0
  203. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  204. package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
  205. package/src/ui/feed/LinkCard.tsx +89 -40
  206. package/src/ui/feed/NoteCard.tsx +39 -25
  207. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  208. package/src/ui/feed/QuoteCard.tsx +38 -23
  209. package/src/ui/feed/ThreadPreview.tsx +90 -26
  210. package/src/ui/feed/TimelineFeed.tsx +3 -2
  211. package/src/ui/feed/TimelineItem.tsx +15 -6
  212. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  213. package/src/ui/feed/thread-preview-state.ts +61 -0
  214. package/src/ui/font-themes.ts +2 -2
  215. package/src/ui/layouts/BaseLayout.tsx +2 -2
  216. package/src/ui/layouts/SiteLayout.tsx +105 -92
  217. package/src/ui/pages/ArchivePage.tsx +923 -98
  218. package/src/ui/pages/ComposePage.tsx +54 -0
  219. package/src/ui/pages/PostPage.tsx +30 -45
  220. package/src/ui/pages/SearchPage.tsx +181 -37
  221. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  222. package/src/ui/shared/CollectionsSidebar.tsx +47 -37
  223. package/src/ui/shared/MediaGallery.tsx +445 -149
  224. package/src/ui/shared/PostFooter.tsx +204 -0
  225. package/src/ui/shared/StarRating.tsx +27 -0
  226. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  227. package/src/ui/shared/index.ts +0 -1
  228. package/dist/client/assets/url-8Dj-5CLW.js +0 -1
  229. package/src/client/media-upload.ts +0 -161
  230. package/src/client/page-slug-bridge.ts +0 -42
  231. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  232. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  233. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  234. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  235. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  236. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  237. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  238. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  239. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  240. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  241. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  242. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  243. package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
  244. package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
  245. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  246. package/src/lib/__tests__/sqid.test.ts +0 -65
  247. package/src/lib/sqid.ts +0 -79
  248. package/src/routes/api/__tests__/pages.test.ts +0 -218
  249. package/src/routes/api/pages.ts +0 -73
  250. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  251. package/src/routes/dash/index.tsx +0 -109
  252. package/src/routes/dash/media.tsx +0 -135
  253. package/src/routes/dash/pages.tsx +0 -245
  254. package/src/routes/dash/posts.tsx +0 -338
  255. package/src/routes/dash/redirects.tsx +0 -263
  256. package/src/routes/pages/post.tsx +0 -59
  257. package/src/services/__tests__/page.test.ts +0 -298
  258. package/src/services/__tests__/path-registry.test.ts +0 -165
  259. package/src/services/__tests__/redirect.test.ts +0 -159
  260. package/src/services/page.ts +0 -216
  261. package/src/services/path-registry.ts +0 -160
  262. package/src/services/redirect.ts +0 -97
  263. package/src/ui/dash/PageForm.tsx +0 -187
  264. package/src/ui/dash/PostList.tsx +0 -95
  265. package/src/ui/dash/media/MediaListContent.tsx +0 -206
  266. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  267. package/src/ui/dash/pages/PagesContent.tsx +0 -75
  268. package/src/ui/dash/posts/PostForm.tsx +0 -260
  269. package/src/ui/layouts/DashLayout.tsx +0 -247
  270. package/src/ui/pages/SinglePage.tsx +0 -23
  271. package/src/ui/shared/ThreadView.tsx +0 -136
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jant/core",
3
- "version": "0.3.36",
3
+ "version": "0.3.37",
4
4
  "description": "A modern, open-source microblogging platform built on Cloudflare Workers",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,15 +36,23 @@
36
36
  "@tiptap/suggestion": "^3.20.0",
37
37
  "basecoat-css": "^0.3.10",
38
38
  "better-auth": "^1.4.18",
39
+ "blurhash": "^2.0.5",
39
40
  "drizzle-orm": "^0.45.1",
40
41
  "emoji-mart": "^5.6.0",
42
+ "fflate": "^0.8.2",
43
+ "fractional-indexing": "^3.2.0",
44
+ "heic-to": "^1.4.2",
41
45
  "limax": "^4.2.2",
42
46
  "lit": "^3.3.2",
43
47
  "lucide-static": "^0.574.0",
44
48
  "marked": "^17.0.1",
49
+ "mediabunny": "^1.35.1",
50
+ "nanoid": "^5.1.6",
51
+ "smol-toml": "^1.6.0",
45
52
  "sortablejs": "^1.15.6",
46
- "sqids": "^0.3.0",
53
+ "tiptap-markdown": "^0.9.0",
47
54
  "uuidv7": "^1.1.0",
55
+ "yaml": "^2.8.2",
48
56
  "zod": "^4.3.6"
49
57
  },
50
58
  "peerDependencies": {
@@ -103,7 +111,7 @@
103
111
  "build:lib": "vite build --config vite.config.worker.ts",
104
112
  "typecheck": "tsc -b --noEmit",
105
113
  "db:generate": "drizzle-kit generate",
106
- "i18n:extract": "lingui extract",
114
+ "i18n:extract": "lingui extract --clean",
107
115
  "i18n:compile": "lingui compile --typescript",
108
116
  "i18n:build": "pnpm i18n:extract && pnpm i18n:compile",
109
117
  "dev": "pnpm db:migrate:local && vite dev",
@@ -9,15 +9,15 @@ import type { Bindings } from "../../types.js";
9
9
  import type { AppVariables } from "../../types/app-context.js";
10
10
  import { createTestDatabase } from "./db.js";
11
11
  import { createPostService } from "../../services/post.js";
12
- import { createPageService } from "../../services/page.js";
12
+ import { createPathService } from "../../services/path.js";
13
13
  import { createSettingsService } from "../../services/settings.js";
14
- import { createRedirectService } from "../../services/redirect.js";
14
+ import { createCustomUrlService } from "../../services/custom-url.js";
15
15
  import { createMediaService } from "../../services/media.js";
16
16
  import { createCollectionService } from "../../services/collection.js";
17
17
  import { createSearchService } from "../../services/search.js";
18
18
  import { createNavItemService } from "../../services/navigation.js";
19
19
  import { createAuthService } from "../../services/auth.js";
20
- import { createPathRegistryService } from "../../services/path-registry.js";
20
+ import { createApiTokenService } from "../../services/api-token.js";
21
21
  import type { Database } from "../../db/index.js";
22
22
  import type BetterSqlite3 from "better-sqlite3";
23
23
  import { errorHandler } from "../../middleware/error-handler.js";
@@ -46,18 +46,18 @@ export function createTestApp(options: TestAppOptions = {}) {
46
46
  const mockD1 = createMockD1(sqlite);
47
47
 
48
48
  const settingsService = createSettingsService(db);
49
- const pathRegistryService = createPathRegistryService(db);
49
+ const pathService = createPathService(db);
50
50
  const services = {
51
- posts: createPostService(db, pathRegistryService),
52
- pages: createPageService(db, pathRegistryService),
51
+ paths: pathService,
52
+ posts: createPostService(db, { slugIdLength: 5 }, pathService),
53
53
  settings: settingsService,
54
- pathRegistry: pathRegistryService,
55
- redirects: createRedirectService(db, pathRegistryService),
54
+ customUrls: createCustomUrlService(db, pathService),
56
55
  media: createMediaService(db),
57
- collections: createCollectionService(db),
56
+ collections: createCollectionService(db, pathService),
58
57
  search: createSearchService(mockD1),
59
58
  navItems: createNavItemService(db),
60
59
  auth: createAuthService(db, settingsService),
60
+ apiTokens: createApiTokenService(db),
61
61
  };
62
62
 
63
63
  const app = new Hono<Env>();
@@ -1,146 +1,144 @@
1
1
  /**
2
2
  * Test Database Helper
3
3
  *
4
- * Creates an in-memory SQLite database with all migrations applied (up to v2).
4
+ * Creates an in-memory SQLite database with all migrations applied.
5
5
  * Used for service integration tests.
6
6
  */
7
7
 
8
8
  import Database from "better-sqlite3";
9
9
  import { drizzle } from "drizzle-orm/better-sqlite3";
10
10
  import * as schema from "../../db/schema.js";
11
- import { readFileSync } from "fs";
11
+ import { readFileSync, readdirSync } from "fs";
12
12
  import { resolve } from "path";
13
13
 
14
14
  const MIGRATIONS_DIR = resolve(import.meta.dirname, "../../db/migrations");
15
15
 
16
16
  /**
17
17
  * Applies a migration file, splitting on Drizzle statement breakpoints.
18
+ * When `skipFts` is true, silently skips statements that reference the
19
+ * FTS virtual table (triggers, rebuild) since it may not exist.
18
20
  */
19
- function applyMigration(sqlite: Database.Database, filename: string) {
21
+ function applyMigration(
22
+ sqlite: Database.Database,
23
+ filename: string,
24
+ options?: { skipFts?: boolean },
25
+ ) {
20
26
  const migration = readFileSync(resolve(MIGRATIONS_DIR, filename), "utf-8");
21
27
  for (const sql of migration.split("--> statement-breakpoint")) {
22
28
  const trimmed = sql.trim();
23
- if (trimmed) sqlite.exec(trimmed);
29
+ if (!trimmed) continue;
30
+ if (options?.skipFts && trimmed.includes("post_fts")) continue;
31
+ sqlite.exec(trimmed);
24
32
  }
25
33
  }
26
34
 
27
35
  /**
28
- * Creates a fresh in-memory SQLite database with all migrations applied.
29
- * Each call returns an isolated database instance for test isolation.
30
- *
31
- * @param options.fts - Whether to enable FTS5 for search tests (default: false).
32
- * The trigram tokenizer used in production may not be available in all
33
- * better-sqlite3 builds, so FTS is opt-in for tests that need it.
36
+ * Applies the FTS migration with fallback for environments lacking
37
+ * the trigram tokenizer.
34
38
  */
35
- export function createTestDatabase(options?: { fts?: boolean }) {
36
- const sqlite = new Database(":memory:");
37
-
38
- // Enable WAL mode for better performance
39
- sqlite.pragma("journal_mode = WAL");
40
- sqlite.pragma("foreign_keys = ON");
41
-
42
- // Apply v1 base migrations (0000-0004)
43
- applyMigration(sqlite, "0000_square_wallflower.sql");
44
- // Skip 0001 (FTS) — v2 migration will create updated FTS if needed
45
- applyMigration(sqlite, "0002_add_media_attachments.sql");
46
- applyMigration(sqlite, "0003_add_navigation_links.sql");
47
- applyMigration(sqlite, "0004_add_storage_provider.sql");
48
-
49
- // Apply v2 schema migration (0005)
50
- // Split FTS-related statements so we can handle them separately
51
- const v2Migration = readFileSync(
52
- resolve(MIGRATIONS_DIR, "0005_v2_schema_migration.sql"),
53
- "utf-8",
54
- );
55
-
56
- for (const stmt of v2Migration.split("--> statement-breakpoint")) {
39
+ function applyFtsMigration(sqlite: Database.Database, filename: string) {
40
+ const ftsSql = readFileSync(resolve(MIGRATIONS_DIR, filename), "utf-8");
41
+ for (const stmt of ftsSql.split("--> statement-breakpoint")) {
57
42
  const trimmed = stmt.trim();
58
43
  if (!trimmed) continue;
59
-
60
- // Skip FTS-related statements if FTS not requested
61
- const isFts = trimmed.includes("posts_fts");
62
- if (!options?.fts && isFts) continue;
63
-
64
44
  try {
65
45
  sqlite.exec(trimmed);
66
46
  } catch {
67
- // Handle trigram tokenizer failure for FTS virtual table
68
- if (options?.fts && trimmed.includes("CREATE VIRTUAL TABLE")) {
47
+ // Trigram tokenizer may not be available — fall back to default tokenizer
48
+ if (trimmed.includes("CREATE VIRTUAL TABLE")) {
69
49
  sqlite.exec(`
70
- CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
50
+ CREATE VIRTUAL TABLE IF NOT EXISTS post_fts USING fts5(
71
51
  title,
72
- body,
52
+ body_text,
73
53
  quote_text,
74
- content='posts',
75
- content_rowid='id'
54
+ url,
55
+ content='post',
56
+ content_rowid='rowid'
76
57
  );
77
58
  `);
78
59
  }
79
- // Ignore DROP TRIGGER/TABLE IF EXISTS failures silently
80
- else if (
81
- !trimmed.startsWith("DROP TRIGGER") &&
82
- !trimmed.startsWith("DROP TABLE")
83
- ) {
84
- throw new Error(`Migration statement failed: ${trimmed.slice(0, 100)}`);
85
- }
86
- }
87
- }
88
-
89
- // Apply 0006: rename slug to path on posts
90
- applyMigration(sqlite, "0006_rename_slug_to_path.sql");
91
-
92
- // Apply 0007: post_collections M:N junction table
93
- const m7 = readFileSync(
94
- resolve(MIGRATIONS_DIR, "0007_post_collections_m2m.sql"),
95
- "utf-8",
96
- );
97
- for (const stmt of m7.split("--> statement-breakpoint")) {
98
- const trimmed = stmt.trim();
99
- if (!trimmed) continue;
100
- // Skip FTS trigger statements if FTS not requested
101
- const isFts = trimmed.includes("posts_fts");
102
- if (!options?.fts && isFts) continue;
103
- try {
104
- sqlite.exec(trimmed);
105
- } catch {
106
- // Ignore DROP TRIGGER failures silently
107
- if (!trimmed.startsWith("DROP TRIGGER")) {
108
- throw new Error(`Migration 0007 failed: ${trimmed.slice(0, 100)}`);
60
+ // Ignore trigger failures if virtual table creation failed
61
+ else if (!trimmed.startsWith("CREATE TRIGGER")) {
62
+ throw new Error(
63
+ `FTS migration statement failed: ${trimmed.slice(0, 100)}`,
64
+ );
109
65
  }
110
66
  }
111
67
  }
112
68
 
113
- // Apply 0008: collection_dividers table
114
- applyMigration(sqlite, "0008_add_collection_dividers.sql");
115
-
116
- // Apply 0009: drop show_divider column from collections
117
- applyMigration(sqlite, "0009_drop_collection_show_divider.sql");
118
-
119
- // Apply 0010: performance indexes
120
- applyMigration(sqlite, "0010_add_performance_indexes.sql");
69
+ // If trigram fallback was used, triggers need to be created without trigram
70
+ // Re-create triggers unconditionally (IF NOT EXISTS handles idempotency)
71
+ sqlite.exec(`
72
+ CREATE TRIGGER IF NOT EXISTS post_ai AFTER INSERT ON post BEGIN
73
+ INSERT INTO post_fts(rowid, title, body_text, quote_text, url)
74
+ VALUES (new.rowid, new.title, new.body_text, new.quote_text, new.url);
75
+ END;
76
+ CREATE TRIGGER IF NOT EXISTS post_ad AFTER DELETE ON post BEGIN
77
+ INSERT INTO post_fts(post_fts, rowid, title, body_text, quote_text, url)
78
+ VALUES ('delete', old.rowid, old.title, old.body_text, old.quote_text, old.url);
79
+ END;
80
+ CREATE TRIGGER IF NOT EXISTS post_au AFTER UPDATE ON post BEGIN
81
+ INSERT INTO post_fts(post_fts, rowid, title, body_text, quote_text, url)
82
+ VALUES ('delete', old.rowid, old.title, old.body_text, old.quote_text, old.url);
83
+ INSERT INTO post_fts(rowid, title, body_text, quote_text, url)
84
+ VALUES (new.rowid, new.title, new.body_text, new.quote_text, new.url);
85
+ END;
86
+ `);
87
+ }
121
88
 
122
- // Apply 0011: path registry
123
- applyMigration(sqlite, "0011_add_path_registry.sql");
89
+ /**
90
+ * Creates a fresh in-memory SQLite database with all migrations applied.
91
+ * Each call returns an isolated database instance for test isolation.
92
+ *
93
+ * @param options.fts - Whether to enable FTS5 for search tests (default: false).
94
+ * The trigram tokenizer used in production may not be available in all
95
+ * better-sqlite3 builds, so FTS is opt-in for tests that need it.
96
+ */
97
+ export function createTestDatabase(options?: { fts?: boolean }) {
98
+ const sqlite = new Database(":memory:");
124
99
 
125
- // Apply 0012: Tiptap columns (summary)
126
- applyMigration(sqlite, "0012_add_tiptap_columns.sql");
100
+ // Enable WAL mode for better performance
101
+ sqlite.pragma("journal_mode = WAL");
102
+ sqlite.pragma("foreign_keys = ON");
127
103
 
128
- // Apply 0013: Replace featured with visibility
129
- applyMigration(sqlite, "0013_replace_featured_with_visibility.sql");
104
+ // Apply all migrations in order (sorted by filename prefix: 0000_, 0001_, …)
105
+ // FTS migration (0001_*) is only applied when requested because the trigram
106
+ // tokenizer may not be available in all better-sqlite3 builds.
107
+ const allFiles = readdirSync(MIGRATIONS_DIR)
108
+ .filter((f) => f.endsWith(".sql"))
109
+ .sort();
110
+
111
+ for (const file of allFiles) {
112
+ const isFts = file.startsWith("0001_");
113
+ if (isFts && !options?.fts) continue;
114
+
115
+ if (isFts) {
116
+ applyFtsMigration(sqlite, file);
117
+ } else {
118
+ applyMigration(sqlite, file, { skipFts: !options?.fts });
119
+ }
120
+ }
130
121
 
131
122
  const db = drizzle(sqlite, { schema });
132
123
 
133
124
  // Polyfill D1 batch() for test compatibility.
134
125
  // In production, D1 batch executes statements atomically in a single transaction.
135
- // In tests, better-sqlite3 is synchronous and single-threaded so sequential
136
- // execution is effectively atomic.
126
+ // In tests, wrap sequential execution in an explicit transaction so rollback
127
+ // behavior matches D1's all-or-nothing semantics.
137
128
  Object.defineProperty(db, "batch", {
138
129
  value: async (queries: PromiseLike<unknown>[]) => {
139
- const results = [];
140
- for (const q of queries) {
141
- results.push(await q);
130
+ sqlite.exec("BEGIN");
131
+ try {
132
+ const results = [];
133
+ for (const q of queries) {
134
+ results.push(await q);
135
+ }
136
+ sqlite.exec("COMMIT");
137
+ return results;
138
+ } catch (err) {
139
+ sqlite.exec("ROLLBACK");
140
+ throw err;
142
141
  }
143
- return results;
144
142
  },
145
143
  });
146
144
 
package/src/app.tsx CHANGED
@@ -16,7 +16,6 @@ import { resetRoutes } from "./routes/auth/reset.js";
16
16
 
17
17
  // Routes - Pages
18
18
  import { homeRoutes } from "./routes/pages/home.js";
19
- import { postRoutes } from "./routes/pages/post.js";
20
19
  import { pageRoutes } from "./routes/pages/page.js";
21
20
  import { collectionRoutes } from "./routes/pages/collection.js";
22
21
  import { archiveRoutes } from "./routes/pages/archive.js";
@@ -24,23 +23,22 @@ import { searchRoutes } from "./routes/pages/search.js";
24
23
  import { featuredRoutes } from "./routes/pages/featured.js";
25
24
  import { latestRoutes } from "./routes/pages/latest.js";
26
25
  import { collectionsPageRoutes } from "./routes/pages/collections.js";
26
+ import { newPostRoutes } from "./routes/pages/new.js";
27
27
 
28
- // Routes - Dashboard
29
- import { dashIndexRoutes } from "./routes/dash/index.js";
30
- import { postsRoutes as dashPostsRoutes } from "./routes/dash/posts.js";
31
- import { pagesRoutes as dashPagesRoutes } from "./routes/dash/pages.js";
32
- import { mediaRoutes as dashMediaRoutes } from "./routes/dash/media.js";
33
- import { settingsRoutes as dashSettingsRoutes } from "./routes/dash/settings.js";
34
- import { redirectsRoutes as dashRedirectsRoutes } from "./routes/dash/redirects.js";
28
+ // Routes - Settings (admin)
29
+ import { settingsRoutes } from "./routes/dash/settings.js";
30
+ import { customUrlsRoutes } from "./routes/dash/custom-urls.js";
35
31
 
36
32
  // Routes - API
37
33
  import { postsApiRoutes } from "./routes/api/posts.js";
38
- import { pagesApiRoutes } from "./routes/api/pages.js";
39
34
  import { navItemsApiRoutes } from "./routes/api/nav-items.js";
40
35
  import { collectionsApiRoutes } from "./routes/api/collections.js";
41
36
  import { settingsApiRoutes } from "./routes/api/settings.js";
42
37
  import { uploadApiRoutes } from "./routes/api/upload.js";
38
+ import { multipartUploadApiRoutes } from "./routes/api/upload-multipart.js";
43
39
  import { searchApiRoutes } from "./routes/api/search.js";
40
+ import { customUrlsApiRoutes } from "./routes/api/custom-urls.js";
41
+ import { exportApiRoutes } from "./routes/api/export.js";
44
42
  // Routes - Compose
45
43
  import { composeRoutes } from "./routes/compose.js";
46
44
 
@@ -49,10 +47,11 @@ import { rssRoutes } from "./routes/feed/rss.js";
49
47
  import { sitemapRoutes } from "./routes/feed/sitemap.js";
50
48
 
51
49
  // Middleware
52
- import { requireAuth } from "./middleware/auth.js";
50
+ import { requireAuth, isLocalHostname } from "./middleware/auth.js";
53
51
  import { requireOnboarding } from "./middleware/onboarding.js";
54
52
  import { errorHandler } from "./middleware/error-handler.js";
55
53
  import { withConfig } from "./middleware/config.js";
54
+ import { secureHeadersMiddleware } from "./middleware/secure-headers.js";
56
55
 
57
56
  import { createStorageDriver } from "./lib/storage.js";
58
57
  import { base64ToUint8Array } from "./lib/favicon.js";
@@ -80,6 +79,31 @@ export function createApp(): App {
80
79
 
81
80
  // Lightweight init — no DB queries
82
81
  app.use("*", async (c, next) => {
82
+ // Fail fast: DEV_API_TOKEN must never be reachable from a non-local hostname
83
+ if (c.env.DEV_API_TOKEN) {
84
+ const hostname = new URL(c.req.url).hostname;
85
+ if (!isLocalHostname(hostname)) {
86
+ return c.html(
87
+ `<!DOCTYPE html>
88
+ <html lang="en">
89
+ <head>
90
+ <meta charset="utf-8">
91
+ <meta name="viewport" content="width=device-width, initial-scale=1">
92
+ <title>Configuration Error</title>
93
+ <style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#fafafa;color:#111}div{max-width:480px;text-align:center}h1{font-size:1.25rem;font-weight:600}p{color:#666;line-height:1.6}code{background:#eee;padding:2px 6px;border-radius:4px;font-size:.9em}</style>
94
+ </head>
95
+ <body>
96
+ <div>
97
+ <h1>DEV_API_TOKEN must not be set in production</h1>
98
+ <p>Remove <code>DEV_API_TOKEN</code> from your environment variables. This token is only for local development.</p>
99
+ </div>
100
+ </body>
101
+ </html>`,
102
+ 500,
103
+ );
104
+ }
105
+ }
106
+
83
107
  if (!c.env.AUTH_SECRET) {
84
108
  return c.html(
85
109
  `<!DOCTYPE html>
@@ -107,7 +131,11 @@ export function createApp(): App {
107
131
  // Note: Drizzle ORM doesn't officially support D1DatabaseSession yet (issue #2226)
108
132
  // but it works at runtime. We use type assertion as a temporary workaround.
109
133
  const db = createDatabase(session as unknown as D1Database);
110
- c.set("services", createServices(db, session as unknown as D1Database));
134
+ const slugIdLength = parseInt(c.env.SLUG_ID_LENGTH ?? "5", 10) || 5;
135
+ c.set(
136
+ "services",
137
+ createServices(db, session as unknown as D1Database, { slugIdLength }),
138
+ );
111
139
  c.set("storage", createStorageDriver(c.env));
112
140
 
113
141
  const baseURL = c.env.SITE_URL || new URL(c.req.url).origin;
@@ -124,20 +152,118 @@ export function createApp(): App {
124
152
  await next();
125
153
  });
126
154
 
155
+ // Security headers (CSP, X-Frame-Options, etc.)
156
+ app.use("*", secureHeadersMiddleware());
157
+
127
158
  // --- Routes that don't need config/theme ---
128
159
 
129
160
  // Health check
130
161
  app.get("/health", (c) => c.json({ status: "ok" }));
131
162
 
163
+ // Fetch text media content by ID (same-origin proxy to avoid CORS with CDN URLs)
164
+ app.get("/api/media/:id/content", async (c) => {
165
+ const media = await c.var.services.media.getById(c.req.param("id"));
166
+ if (!media) return c.notFound();
167
+
168
+ const storage = c.var.storage;
169
+ if (!storage) return c.notFound();
170
+
171
+ const object = await storage.get(media.storageKey);
172
+ if (!object) return c.notFound();
173
+
174
+ const headers = new Headers();
175
+ headers.set(
176
+ "Content-Type",
177
+ object.contentType || "application/octet-stream",
178
+ );
179
+ // Use updatedAt as ETag so browsers can cache but revalidate on change
180
+ const etag = `"${media.updatedAt}"`;
181
+ headers.set("Cache-Control", "public, no-cache");
182
+ headers.set("ETag", etag);
183
+
184
+ if (c.req.header("If-None-Match") === etag) {
185
+ return new Response(null, { status: 304, headers });
186
+ }
187
+
188
+ return new Response(object.body, { headers });
189
+ });
190
+
132
191
  // Media files from storage (path matches storage key: media/YYYY/MM/uuid.ext)
192
+ // Supports HTTP Range requests for seekable audio/video playback.
133
193
  app.get("/media/*", async (c) => {
134
194
  const storage = c.var.storage;
135
195
  if (!storage) {
136
196
  return c.notFound();
137
197
  }
138
198
 
139
- // The storage key is the full path without the leading "/"
140
199
  const storageKey = c.req.path.slice(1);
200
+ if (storageKey.includes("..") || !storageKey.startsWith("media/")) {
201
+ return c.notFound();
202
+ }
203
+
204
+ const rangeHeader = c.req.header("Range");
205
+
206
+ // First fetch without range to get the total size
207
+ if (rangeHeader) {
208
+ // Get total size via a full request first
209
+ const full = await storage.get(storageKey);
210
+ if (!full) return c.notFound();
211
+
212
+ const totalSize = full.size;
213
+ if (!totalSize) {
214
+ // Driver doesn't report size — fall back to full response
215
+ const headers = new Headers();
216
+ headers.set(
217
+ "Content-Type",
218
+ full.contentType || "application/octet-stream",
219
+ );
220
+ headers.set("Cache-Control", "public, max-age=31536000, immutable");
221
+ return new Response(full.body, { headers });
222
+ }
223
+
224
+ // Cancel the full stream — we'll re-fetch with the range
225
+ await full.body.cancel();
226
+
227
+ // Parse "bytes=START-END" (END is optional)
228
+ const match = /^bytes=(\d+)-(\d*)$/.exec(rangeHeader);
229
+ if (!match) {
230
+ return new Response("Invalid Range", {
231
+ status: 416,
232
+ headers: { "Content-Range": `bytes */${totalSize}` },
233
+ });
234
+ }
235
+
236
+ const start = parseInt(match[1] ?? "0", 10);
237
+ const end = match[2]
238
+ ? Math.min(parseInt(match[2], 10), totalSize - 1)
239
+ : totalSize - 1;
240
+
241
+ if (start > end || start >= totalSize) {
242
+ return new Response("Range Not Satisfiable", {
243
+ status: 416,
244
+ headers: { "Content-Range": `bytes */${totalSize}` },
245
+ });
246
+ }
247
+
248
+ const rangeObj = await storage.get(storageKey, {
249
+ range: { offset: start, length: end - start + 1 },
250
+ });
251
+ if (!rangeObj) return c.notFound();
252
+
253
+ const headers = new Headers();
254
+ headers.set(
255
+ "Content-Type",
256
+ rangeObj.contentType || "application/octet-stream",
257
+ );
258
+ headers.set("Cache-Control", "public, max-age=31536000, immutable");
259
+ headers.set("Accept-Ranges", "bytes");
260
+ headers.set("Content-Range", `bytes ${start}-${end}/${totalSize}`);
261
+ headers.set("Content-Length", String(end - start + 1));
262
+
263
+ return new Response(rangeObj.body, { status: 206, headers });
264
+ }
265
+
266
+ // No Range header — serve full file
141
267
  const object = await storage.get(storageKey);
142
268
  if (!object) {
143
269
  return c.notFound();
@@ -149,6 +275,10 @@ export function createApp(): App {
149
275
  object.contentType || "application/octet-stream",
150
276
  );
151
277
  headers.set("Cache-Control", "public, max-age=31536000, immutable");
278
+ headers.set("Accept-Ranges", "bytes");
279
+ if (object.size) {
280
+ headers.set("Content-Length", String(object.size));
281
+ }
152
282
 
153
283
  return new Response(object.body, { headers });
154
284
  });
@@ -204,7 +334,7 @@ export function createApp(): App {
204
334
  await next();
205
335
  });
206
336
 
207
- // Redirect middleware
337
+ // Redirect middleware — only handles redirect-type custom URLs
208
338
  app.use("*", async (c, next) => {
209
339
  const path = new URL(c.req.url).pathname;
210
340
  // Skip redirect check for API routes and static assets
@@ -212,9 +342,9 @@ export function createApp(): App {
212
342
  return next();
213
343
  }
214
344
 
215
- const redirect = await c.var.services.redirects.getByPath(path);
216
- if (redirect) {
217
- return c.redirect(redirect.toPath, redirect.type);
345
+ const customUrl = await c.var.services.customUrls.getByPath(path.slice(1));
346
+ if (customUrl?.targetType === "redirect" && customUrl.toPath) {
347
+ return c.redirect(customUrl.toPath, customUrl.redirectType ?? 301);
218
348
  }
219
349
 
220
350
  await next();
@@ -228,25 +358,25 @@ export function createApp(): App {
228
358
 
229
359
  // API Routes
230
360
  app.route("/api/posts", postsApiRoutes);
231
- app.route("/api/pages", pagesApiRoutes);
232
361
  app.route("/api/nav-items", navItemsApiRoutes);
233
362
  app.route("/api/collections", collectionsApiRoutes);
234
363
  app.route("/api/settings", settingsApiRoutes);
364
+ app.route("/api/custom-urls", customUrlsApiRoutes);
365
+ app.route("/api/export", exportApiRoutes);
235
366
 
236
367
  // Auth routes
237
368
  app.route("/", setupRoutes);
238
369
  app.route("/", signinRoutes);
239
370
  app.route("/", resetRoutes);
240
371
 
241
- // Dashboard routes (protected)
242
- app.use("/dash/*", requireAuth());
243
- app.route("/dash", dashIndexRoutes);
244
- app.route("/dash/posts", dashPostsRoutes);
245
- app.route("/dash/pages", dashPagesRoutes);
246
- app.route("/dash/media", dashMediaRoutes);
247
- app.route("/dash/settings", dashSettingsRoutes);
248
- app.route("/dash/settings/redirects", dashRedirectsRoutes);
249
- // Protected API routes
372
+ // Settings routes (protected)
373
+ app.use("/settings/*", requireAuth());
374
+ app.use("/settings", requireAuth());
375
+ app.route("/settings/custom-urls", customUrlsRoutes);
376
+ app.route("/settings", settingsRoutes);
377
+
378
+ // Protected API routes (multipart must be registered before base upload)
379
+ app.route("/api/upload/multipart", multipartUploadApiRoutes);
250
380
  app.route("/api/upload", uploadApiRoutes);
251
381
  app.route("/api/search", searchApiRoutes);
252
382
 
@@ -259,12 +389,12 @@ export function createApp(): App {
259
389
 
260
390
  // Frontend routes
261
391
  app.route("/search", searchRoutes);
392
+ app.route("/", newPostRoutes);
262
393
  app.route("/archive", archiveRoutes);
263
394
  app.route("/featured", featuredRoutes);
264
395
  app.route("/latest", latestRoutes);
265
396
  app.route("/c", collectionsPageRoutes);
266
397
  app.route("/c", collectionRoutes);
267
- app.route("/p", postRoutes);
268
398
  app.route("/", homeRoutes);
269
399
 
270
400
  // Custom page catch-all (must be last)
package/src/auth.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Authentication with better-auth
3
3
  */
4
4
 
5
- import { betterAuth } from "better-auth";
5
+ import { betterAuth, APIError } from "better-auth";
6
6
  import { drizzleAdapter } from "better-auth/adapters/drizzle";
7
7
  import { drizzle } from "drizzle-orm/d1";
8
8
  import * as schema from "./db/schema.js";
@@ -34,12 +34,30 @@ export function createAuth(
34
34
  minPasswordLength: 8,
35
35
  },
36
36
  session: {
37
- expiresIn: 3600 * 24 * 366, // 366 days
37
+ expiresIn: 3600 * 24 * 30, // 30 days
38
38
  cookieCache: {
39
39
  enabled: true,
40
40
  maxAge: 60 * 5, // 5 minutes
41
41
  },
42
42
  },
43
+ databaseHooks: {
44
+ user: {
45
+ create: {
46
+ before: async (userData) => {
47
+ const existing = await db
48
+ .select({ id: schema.user.id })
49
+ .from(schema.user)
50
+ .limit(1);
51
+ if (existing.length > 0) {
52
+ throw new APIError("FORBIDDEN", {
53
+ message: "Registration is closed.",
54
+ });
55
+ }
56
+ return { data: { ...userData, role: "admin" } };
57
+ },
58
+ },
59
+ },
60
+ },
43
61
  });
44
62
  }
45
63