cursedbelt-server 1.0.0

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 (606) hide show
  1. package/LICENSE +15 -0
  2. package/dist/server/activity/index.d.ts +36 -0
  3. package/dist/server/activity/index.js +36 -0
  4. package/dist/server/activity/migrations.d.ts +21 -0
  5. package/dist/server/activity/migrations.js +55 -0
  6. package/dist/server/activity/shareSource.d.ts +11 -0
  7. package/dist/server/activity/shareSource.js +45 -0
  8. package/dist/server/activity/store.d.ts +123 -0
  9. package/dist/server/activity/store.js +178 -0
  10. package/dist/server/app.d.ts +56 -0
  11. package/dist/server/app.js +45 -0
  12. package/dist/server/audit/index.d.ts +2 -0
  13. package/dist/server/audit/index.js +2 -0
  14. package/dist/server/audit/migrations.d.ts +2 -0
  15. package/dist/server/audit/migrations.js +79 -0
  16. package/dist/server/audit/sqliteSink.d.ts +36 -0
  17. package/dist/server/audit/sqliteSink.js +95 -0
  18. package/dist/server/audit/sqliteSink.test.d.ts +1 -0
  19. package/dist/server/audit/sqliteSink.test.js +180 -0
  20. package/dist/server/auth/cookie.d.ts +22 -0
  21. package/dist/server/auth/cookie.js +35 -0
  22. package/dist/server/auth/csrf.d.ts +42 -0
  23. package/dist/server/auth/csrf.js +36 -0
  24. package/dist/server/auth/fileTokenSigner.d.ts +10 -0
  25. package/dist/server/auth/fileTokenSigner.js +22 -0
  26. package/dist/server/auth/getOnly.d.ts +20 -0
  27. package/dist/server/auth/getOnly.js +27 -0
  28. package/dist/server/auth/index.d.ts +12 -0
  29. package/dist/server/auth/index.js +15 -0
  30. package/dist/server/auth/jwt.d.ts +18 -0
  31. package/dist/server/auth/jwt.js +56 -0
  32. package/dist/server/auth/loginThrottle.d.ts +118 -0
  33. package/dist/server/auth/loginThrottle.js +204 -0
  34. package/dist/server/auth/middleware.d.ts +14 -0
  35. package/dist/server/auth/middleware.js +47 -0
  36. package/dist/server/auth/password.d.ts +14 -0
  37. package/dist/server/auth/password.js +65 -0
  38. package/dist/server/auth/sessionStore.d.ts +19 -0
  39. package/dist/server/auth/sessionStore.js +221 -0
  40. package/dist/server/auth/totp.d.ts +34 -0
  41. package/dist/server/auth/totp.js +81 -0
  42. package/dist/server/auth/types.d.ts +133 -0
  43. package/dist/server/auth/types.js +1 -0
  44. package/dist/server/auth/vault.d.ts +27 -0
  45. package/dist/server/auth/vault.js +57 -0
  46. package/dist/server/context.d.ts +35 -0
  47. package/dist/server/context.js +1 -0
  48. package/dist/server/ctgr.d.ts +32 -0
  49. package/dist/server/ctgr.js +69 -0
  50. package/dist/server/db/kysely.d.ts +14 -0
  51. package/dist/server/db/kysely.js +16 -0
  52. package/dist/server/db/plumbingTypes.d.ts +116 -0
  53. package/dist/server/db/plumbingTypes.js +12 -0
  54. package/dist/server/dropzone/duplicates.d.ts +82 -0
  55. package/dist/server/dropzone/duplicates.js +90 -0
  56. package/dist/server/dropzone/index.d.ts +13 -0
  57. package/dist/server/dropzone/index.js +13 -0
  58. package/dist/server/dropzone/scan.d.ts +166 -0
  59. package/dist/server/dropzone/scan.js +272 -0
  60. package/dist/server/errors.d.ts +7 -0
  61. package/dist/server/errors.js +7 -0
  62. package/dist/server/guard/argon2Binding.d.ts +97 -0
  63. package/dist/server/guard/argon2Binding.js +138 -0
  64. package/dist/server/guard/index.d.ts +4 -0
  65. package/dist/server/guard/index.js +6 -0
  66. package/dist/server/guard/kdfWorker.d.ts +101 -0
  67. package/dist/server/guard/kdfWorker.js +301 -0
  68. package/dist/server/guard/kdfWorkerEntry.d.ts +1 -0
  69. package/dist/server/guard/kdfWorkerEntry.js +15 -0
  70. package/dist/server/guard/passwordVerifier.d.ts +65 -0
  71. package/dist/server/guard/passwordVerifier.js +122 -0
  72. package/dist/server/guard/revocationStore.d.ts +132 -0
  73. package/dist/server/guard/revocationStore.js +211 -0
  74. package/dist/server/guard/sessionSigner.d.ts +132 -0
  75. package/dist/server/guard/sessionSigner.js +303 -0
  76. package/dist/server/guard/verifier.d.ts +21 -0
  77. package/dist/server/guard/verifier.js +1 -0
  78. package/dist/server/incidents/channels.d.ts +161 -0
  79. package/dist/server/incidents/channels.js +357 -0
  80. package/dist/server/incidents/checks.d.ts +61 -0
  81. package/dist/server/incidents/checks.js +55 -0
  82. package/dist/server/incidents/index.d.ts +51 -0
  83. package/dist/server/incidents/index.js +51 -0
  84. package/dist/server/incidents/migrations.d.ts +20 -0
  85. package/dist/server/incidents/migrations.js +58 -0
  86. package/dist/server/incidents/notifier.d.ts +79 -0
  87. package/dist/server/incidents/notifier.js +141 -0
  88. package/dist/server/incidents/reporter.d.ts +60 -0
  89. package/dist/server/incidents/reporter.js +49 -0
  90. package/dist/server/incidents/routes.d.ts +91 -0
  91. package/dist/server/incidents/routes.js +202 -0
  92. package/dist/server/incidents/store.d.ts +75 -0
  93. package/dist/server/incidents/store.js +176 -0
  94. package/dist/server/incidents/types.d.ts +107 -0
  95. package/dist/server/incidents/types.js +32 -0
  96. package/dist/server/index.d.ts +25 -0
  97. package/dist/server/index.js +53 -0
  98. package/dist/server/jobs/fireQueue.d.ts +12 -0
  99. package/dist/server/jobs/fireQueue.js +37 -0
  100. package/dist/server/jobs/index.d.ts +6 -0
  101. package/dist/server/jobs/index.js +10 -0
  102. package/dist/server/jobs/logger.d.ts +10 -0
  103. package/dist/server/jobs/logger.js +14 -0
  104. package/dist/server/jobs/plainjobAdapter.d.ts +19 -0
  105. package/dist/server/jobs/plainjobAdapter.js +48 -0
  106. package/dist/server/jobs/polling.d.ts +17 -0
  107. package/dist/server/jobs/polling.js +79 -0
  108. package/dist/server/jobs/runner.d.ts +24 -0
  109. package/dist/server/jobs/runner.js +186 -0
  110. package/dist/server/jobs/timeout.d.ts +28 -0
  111. package/dist/server/jobs/timeout.js +47 -0
  112. package/dist/server/jobs/types.d.ts +167 -0
  113. package/dist/server/jobs/types.js +12 -0
  114. package/dist/server/master-lock/guard.d.ts +53 -0
  115. package/dist/server/master-lock/guard.js +205 -0
  116. package/dist/server/master-lock/index.d.ts +21 -0
  117. package/dist/server/master-lock/index.js +21 -0
  118. package/dist/server/master-lock/lockPage.d.ts +34 -0
  119. package/dist/server/master-lock/lockPage.js +281 -0
  120. package/dist/server/master-lock/masterLock.d.ts +294 -0
  121. package/dist/server/master-lock/masterLock.js +523 -0
  122. package/dist/server/master-lock/principals.d.ts +76 -0
  123. package/dist/server/master-lock/principals.js +157 -0
  124. package/dist/server/master-lock/seed.d.ts +47 -0
  125. package/dist/server/master-lock/seed.js +93 -0
  126. package/dist/server/master-lock/store.d.ts +19 -0
  127. package/dist/server/master-lock/store.js +20 -0
  128. package/dist/server/media-bun/image.d.ts +7 -0
  129. package/dist/server/media-bun/image.js +96 -0
  130. package/dist/server/media-bun/index.d.ts +4 -0
  131. package/dist/server/media-bun/index.js +10 -0
  132. package/dist/server/media-bun/streaming.d.ts +86 -0
  133. package/dist/server/media-bun/streaming.js +237 -0
  134. package/dist/server/media-bun/video.d.ts +35 -0
  135. package/dist/server/media-bun/video.js +181 -0
  136. package/dist/server/metrics/metricsBuffer.d.ts +39 -0
  137. package/dist/server/metrics/metricsBuffer.js +36 -0
  138. package/dist/server/metrics/normalizeRoute.d.ts +5 -0
  139. package/dist/server/metrics/normalizeRoute.js +58 -0
  140. package/dist/server/middleware/correlationId.d.ts +9 -0
  141. package/dist/server/middleware/correlationId.js +20 -0
  142. package/dist/server/middleware/corsAllowlist.d.ts +38 -0
  143. package/dist/server/middleware/corsAllowlist.js +37 -0
  144. package/dist/server/middleware/errorEnvelope.d.ts +17 -0
  145. package/dist/server/middleware/errorEnvelope.js +32 -0
  146. package/dist/server/middleware/index.d.ts +9 -0
  147. package/dist/server/middleware/index.js +10 -0
  148. package/dist/server/middleware/localOrigin.d.ts +71 -0
  149. package/dist/server/middleware/localOrigin.js +119 -0
  150. package/dist/server/middleware/rateLimit.d.ts +56 -0
  151. package/dist/server/middleware/rateLimit.js +86 -0
  152. package/dist/server/middleware/requestLogger.d.ts +29 -0
  153. package/dist/server/middleware/requestLogger.js +47 -0
  154. package/dist/server/middleware/securityHeaders.d.ts +29 -0
  155. package/dist/server/middleware/securityHeaders.js +44 -0
  156. package/dist/server/middleware/zValidator.d.ts +29 -0
  157. package/dist/server/middleware/zValidator.js +57 -0
  158. package/dist/server/migration/runner.d.ts +42 -0
  159. package/dist/server/migration/runner.js +70 -0
  160. package/dist/server/migration/types.d.ts +16 -0
  161. package/dist/server/migration/types.js +1 -0
  162. package/dist/server/net/outboundGuard.d.ts +30 -0
  163. package/dist/server/net/outboundGuard.js +199 -0
  164. package/dist/server/notifications/hub.d.ts +67 -0
  165. package/dist/server/notifications/hub.js +187 -0
  166. package/dist/server/notifications/index.d.ts +47 -0
  167. package/dist/server/notifications/index.js +46 -0
  168. package/dist/server/notifications/migrations.d.ts +13 -0
  169. package/dist/server/notifications/migrations.js +20 -0
  170. package/dist/server/notifications/routes.d.ts +58 -0
  171. package/dist/server/notifications/routes.js +113 -0
  172. package/dist/server/notifications/service.d.ts +58 -0
  173. package/dist/server/notifications/service.js +41 -0
  174. package/dist/server/paths/appDataHome.d.ts +19 -0
  175. package/dist/server/paths/appDataHome.js +68 -0
  176. package/dist/server/private-media/crypto.d.ts +37 -0
  177. package/dist/server/private-media/crypto.js +111 -0
  178. package/dist/server/private-media/index-store.d.ts +31 -0
  179. package/dist/server/private-media/index-store.js +36 -0
  180. package/dist/server/private-media/mountPrivateRoutes.d.ts +26 -0
  181. package/dist/server/private-media/mountPrivateRoutes.js +219 -0
  182. package/dist/server/private-media/pipeline.d.ts +37 -0
  183. package/dist/server/private-media/pipeline.js +97 -0
  184. package/dist/server/private-media/unlock.d.ts +52 -0
  185. package/dist/server/private-media/unlock.js +55 -0
  186. package/dist/server/resume/index.d.ts +2 -0
  187. package/dist/server/resume/index.js +2 -0
  188. package/dist/server/resume/routes.d.ts +41 -0
  189. package/dist/server/resume/routes.js +50 -0
  190. package/dist/server/resume/store.d.ts +37 -0
  191. package/dist/server/resume/store.js +38 -0
  192. package/dist/server/retention/archive.d.ts +78 -0
  193. package/dist/server/retention/archive.js +284 -0
  194. package/dist/server/retention/fleet.d.ts +50 -0
  195. package/dist/server/retention/fleet.js +66 -0
  196. package/dist/server/retention/index.d.ts +38 -0
  197. package/dist/server/retention/index.js +38 -0
  198. package/dist/server/retention/policy.d.ts +263 -0
  199. package/dist/server/retention/policy.js +264 -0
  200. package/dist/server/retention/store.d.ts +101 -0
  201. package/dist/server/retention/store.js +251 -0
  202. package/dist/server/retention/sweeper.d.ts +69 -0
  203. package/dist/server/retention/sweeper.js +289 -0
  204. package/dist/server/retention/target.d.ts +143 -0
  205. package/dist/server/retention/target.js +438 -0
  206. package/dist/server/search/index.d.ts +2 -0
  207. package/dist/server/search/index.js +6 -0
  208. package/dist/server/search/pgSearchProvider.d.ts +91 -0
  209. package/dist/server/search/pgSearchProvider.js +330 -0
  210. package/dist/server/search/pgSearchProvider.pg.test.d.ts +1 -0
  211. package/dist/server/search/pgSearchProvider.pg.test.js +338 -0
  212. package/dist/server/search/pgSearchSchema.d.ts +81 -0
  213. package/dist/server/search/pgSearchSchema.js +89 -0
  214. package/dist/server/sharing/index.d.ts +18 -0
  215. package/dist/server/sharing/index.js +16 -0
  216. package/dist/server/sharing/migrations.d.ts +6 -0
  217. package/dist/server/sharing/migrations.js +79 -0
  218. package/dist/server/sharing/router.d.ts +111 -0
  219. package/dist/server/sharing/router.js +93 -0
  220. package/dist/server/sharing/sql.d.ts +67 -0
  221. package/dist/server/sharing/sql.js +88 -0
  222. package/dist/server/sharing/store.d.ts +173 -0
  223. package/dist/server/sharing/store.js +484 -0
  224. package/dist/server/sqlite/backup.d.ts +48 -0
  225. package/dist/server/sqlite/backup.js +97 -0
  226. package/dist/server/sqlite/browse.d.ts +42 -0
  227. package/dist/server/sqlite/browse.js +84 -0
  228. package/dist/server/sqlite/export.d.ts +32 -0
  229. package/dist/server/sqlite/export.js +42 -0
  230. package/dist/server/sqlite/fts5.d.ts +106 -0
  231. package/dist/server/sqlite/fts5.js +154 -0
  232. package/dist/server/sqlite/health.d.ts +44 -0
  233. package/dist/server/sqlite/health.js +52 -0
  234. package/dist/server/sqlite/import.d.ts +36 -0
  235. package/dist/server/sqlite/import.js +108 -0
  236. package/dist/server/sqlite/index.d.ts +9 -0
  237. package/dist/server/sqlite/index.js +15 -0
  238. package/dist/server/sqlite/readonlySql.d.ts +15 -0
  239. package/dist/server/sqlite/readonlySql.js +65 -0
  240. package/dist/server/sqlite/stats.d.ts +22 -0
  241. package/dist/server/sqlite/stats.js +32 -0
  242. package/dist/server/sqlite/viewer.d.ts +17 -0
  243. package/dist/server/sqlite/viewer.js +56 -0
  244. package/dist/server/storage/binaryServerProcessing.d.ts +23 -0
  245. package/dist/server/storage/binaryServerProcessing.js +81 -0
  246. package/dist/server/storage/binaryServerStore.d.ts +3 -0
  247. package/dist/server/storage/binaryServerStore.js +95 -0
  248. package/dist/server/storage/diskStore.d.ts +2 -0
  249. package/dist/server/storage/diskStore.js +88 -0
  250. package/dist/server/storage/files/adapter.d.ts +33 -0
  251. package/dist/server/storage/files/adapter.js +203 -0
  252. package/dist/server/storage/files/catalogue.d.ts +72 -0
  253. package/dist/server/storage/files/catalogue.js +1033 -0
  254. package/dist/server/storage/files/chunkedUpload.d.ts +56 -0
  255. package/dist/server/storage/files/chunkedUpload.js +70 -0
  256. package/dist/server/storage/files/fileRoutes.d.ts +51 -0
  257. package/dist/server/storage/files/fileRoutes.js +405 -0
  258. package/dist/server/storage/files/index.d.ts +9 -0
  259. package/dist/server/storage/files/index.js +11 -0
  260. package/dist/server/storage/files/migrations.d.ts +2 -0
  261. package/dist/server/storage/files/migrations.js +163 -0
  262. package/dist/server/storage/files/mimeByExt.d.ts +1 -0
  263. package/dist/server/storage/files/mimeByExt.js +13 -0
  264. package/dist/server/storage/files/stack.d.ts +136 -0
  265. package/dist/server/storage/files/stack.js +132 -0
  266. package/dist/server/storage/files/storageOps.d.ts +54 -0
  267. package/dist/server/storage/files/storageOps.js +110 -0
  268. package/dist/server/storage/files/types.d.ts +261 -0
  269. package/dist/server/storage/files/types.js +22 -0
  270. package/dist/server/storage/imageThumbnail.d.ts +3 -0
  271. package/dist/server/storage/imageThumbnail.js +50 -0
  272. package/dist/server/storage/index.d.ts +15 -0
  273. package/dist/server/storage/index.js +27 -0
  274. package/dist/server/storage/mountMediaRoutes.d.ts +28 -0
  275. package/dist/server/storage/mountMediaRoutes.js +141 -0
  276. package/dist/server/storage/objectStoreMediaStore.d.ts +4 -0
  277. package/dist/server/storage/objectStoreMediaStore.js +57 -0
  278. package/dist/server/storage/s3ObjectStore.d.ts +63 -0
  279. package/dist/server/storage/s3ObjectStore.js +187 -0
  280. package/dist/server/storage/sources.d.ts +47 -0
  281. package/dist/server/storage/sources.js +148 -0
  282. package/dist/server/storage/streamRange.d.ts +19 -0
  283. package/dist/server/storage/streamRange.js +69 -0
  284. package/dist/server/storage/streamToken.d.ts +16 -0
  285. package/dist/server/storage/streamToken.js +54 -0
  286. package/dist/server/storage/transcodeJobs.d.ts +65 -0
  287. package/dist/server/storage/transcodeJobs.js +104 -0
  288. package/dist/server/storage/types.d.ts +193 -0
  289. package/dist/server/storage/types.js +41 -0
  290. package/dist/server/storage/videoDerivativeKeys.d.ts +127 -0
  291. package/dist/server/storage/videoDerivativeKeys.js +149 -0
  292. package/dist/server/storage/videoProcessor.d.ts +30 -0
  293. package/dist/server/storage/videoProcessor.js +148 -0
  294. package/dist/server/sync/alarm.d.ts +34 -0
  295. package/dist/server/sync/alarm.js +91 -0
  296. package/dist/server/sync/commands.d.ts +87 -0
  297. package/dist/server/sync/commands.js +241 -0
  298. package/dist/server/sync/engine.d.ts +62 -0
  299. package/dist/server/sync/engine.js +184 -0
  300. package/dist/server/sync/http.d.ts +106 -0
  301. package/dist/server/sync/http.js +243 -0
  302. package/dist/server/sync/index.d.ts +41 -0
  303. package/dist/server/sync/index.js +41 -0
  304. package/dist/server/sync/opLog.d.ts +61 -0
  305. package/dist/server/sync/opLog.js +96 -0
  306. package/dist/server/sync/planner.d.ts +31 -0
  307. package/dist/server/sync/planner.js +30 -0
  308. package/dist/server/sync/status.d.ts +63 -0
  309. package/dist/server/sync/status.js +49 -0
  310. package/dist/server/sync/timer.d.ts +52 -0
  311. package/dist/server/sync/timer.js +170 -0
  312. package/dist/server/sync/tokens.d.ts +25 -0
  313. package/dist/server/sync/tokens.js +51 -0
  314. package/dist/server/sync/types.d.ts +101 -0
  315. package/dist/server/sync/types.js +29 -0
  316. package/dist/server/ws/eventBus.d.ts +14 -0
  317. package/dist/server/ws/eventBus.js +42 -0
  318. package/dist/server/ws/hub.d.ts +20 -0
  319. package/dist/server/ws/hub.js +154 -0
  320. package/dist/server/ws/index.d.ts +5 -0
  321. package/dist/server/ws/index.js +7 -0
  322. package/dist/server/ws/presence.d.ts +11 -0
  323. package/dist/server/ws/presence.js +20 -0
  324. package/dist/server/ws/room.d.ts +17 -0
  325. package/dist/server/ws/room.js +23 -0
  326. package/dist/server/ws/types.d.ts +122 -0
  327. package/dist/server/ws/types.js +1 -0
  328. package/package.json +234 -0
  329. package/src/declaredDepsAreImported.spec.ts +87 -0
  330. package/src/leafSubpathsImportNothing.spec.ts +358 -0
  331. package/src/lockfileIsTracked.spec.ts +50 -0
  332. package/src/namedSubpathsResolve.spec.ts +122 -0
  333. package/src/noPathDeps.spec.ts +24 -0
  334. package/src/publishShape.spec.ts +89 -0
  335. package/src/server/activity/index.ts +59 -0
  336. package/src/server/activity/migrations.ts +67 -0
  337. package/src/server/activity/shareSource.ts +57 -0
  338. package/src/server/activity/store.spec.ts +255 -0
  339. package/src/server/activity/store.ts +368 -0
  340. package/src/server/app.spec.ts +243 -0
  341. package/src/server/app.ts +89 -0
  342. package/src/server/audit/index.ts +2 -0
  343. package/src/server/audit/migrations.ts +88 -0
  344. package/src/server/audit/sqliteSink.test.ts +200 -0
  345. package/src/server/audit/sqliteSink.ts +162 -0
  346. package/src/server/auth/cookie.spec.ts +46 -0
  347. package/src/server/auth/cookie.ts +42 -0
  348. package/src/server/auth/csrf.spec.ts +81 -0
  349. package/src/server/auth/csrf.ts +83 -0
  350. package/src/server/auth/fileTokenSigner.ts +40 -0
  351. package/src/server/auth/getOnly.spec.ts +53 -0
  352. package/src/server/auth/getOnly.ts +32 -0
  353. package/src/server/auth/index.ts +51 -0
  354. package/src/server/auth/jwt.spec.ts +106 -0
  355. package/src/server/auth/jwt.ts +75 -0
  356. package/src/server/auth/loginThrottle.spec.ts +213 -0
  357. package/src/server/auth/loginThrottle.ts +261 -0
  358. package/src/server/auth/middleware.spec.ts +148 -0
  359. package/src/server/auth/middleware.ts +56 -0
  360. package/src/server/auth/password.spec.ts +48 -0
  361. package/src/server/auth/password.ts +71 -0
  362. package/src/server/auth/sessionStore.spec.ts +189 -0
  363. package/src/server/auth/sessionStore.ts +261 -0
  364. package/src/server/auth/totp.spec.ts +73 -0
  365. package/src/server/auth/totp.ts +92 -0
  366. package/src/server/auth/types.ts +146 -0
  367. package/src/server/auth/vault.spec.ts +51 -0
  368. package/src/server/auth/vault.ts +71 -0
  369. package/src/server/context.ts +37 -0
  370. package/src/server/ctgr.spec.ts +138 -0
  371. package/src/server/ctgr.ts +108 -0
  372. package/src/server/db/kysely.spec.ts +62 -0
  373. package/src/server/db/kysely.ts +21 -0
  374. package/src/server/db/plumbingTypes.ts +126 -0
  375. package/src/server/dropzone/duplicates.ts +121 -0
  376. package/src/server/dropzone/index.ts +30 -0
  377. package/src/server/dropzone/scan.spec.ts +255 -0
  378. package/src/server/dropzone/scan.ts +342 -0
  379. package/src/server/errors.spec.ts +76 -0
  380. package/src/server/errors.ts +22 -0
  381. package/src/server/guard/argon2Binding.spec.ts +117 -0
  382. package/src/server/guard/argon2Binding.ts +195 -0
  383. package/src/server/guard/argon2Kat.spec.ts +250 -0
  384. package/src/server/guard/guard.spec.ts +390 -0
  385. package/src/server/guard/index.ts +6 -0
  386. package/src/server/guard/kdfWorker.spec.ts +283 -0
  387. package/src/server/guard/kdfWorker.ts +367 -0
  388. package/src/server/guard/kdfWorkerEntry.ts +16 -0
  389. package/src/server/guard/passwordVerifier.spec.ts +71 -0
  390. package/src/server/guard/passwordVerifier.ts +175 -0
  391. package/src/server/guard/revocationStore.spec.ts +135 -0
  392. package/src/server/guard/revocationStore.ts +291 -0
  393. package/src/server/guard/sessionSigner.ts +425 -0
  394. package/src/server/guard/verifier.ts +27 -0
  395. package/src/server/incidents/channels.ts +487 -0
  396. package/src/server/incidents/checks.ts +120 -0
  397. package/src/server/incidents/incidents.spec.ts +463 -0
  398. package/src/server/incidents/index.ts +96 -0
  399. package/src/server/incidents/migrations.ts +66 -0
  400. package/src/server/incidents/notifier.ts +215 -0
  401. package/src/server/incidents/reporter.ts +110 -0
  402. package/src/server/incidents/routes.ts +314 -0
  403. package/src/server/incidents/store.ts +329 -0
  404. package/src/server/incidents/types.ts +116 -0
  405. package/src/server/index.ts +248 -0
  406. package/src/server/jobs/fireQueue.spec.ts +85 -0
  407. package/src/server/jobs/fireQueue.ts +37 -0
  408. package/src/server/jobs/index.ts +26 -0
  409. package/src/server/jobs/logger.ts +16 -0
  410. package/src/server/jobs/plainjobAdapter.spec.ts +34 -0
  411. package/src/server/jobs/plainjobAdapter.ts +54 -0
  412. package/src/server/jobs/polling.spec.ts +219 -0
  413. package/src/server/jobs/polling.ts +81 -0
  414. package/src/server/jobs/runner.spec.ts +213 -0
  415. package/src/server/jobs/runner.ts +213 -0
  416. package/src/server/jobs/timeout.spec.ts +35 -0
  417. package/src/server/jobs/timeout.ts +53 -0
  418. package/src/server/jobs/types.ts +188 -0
  419. package/src/server/master-lock/guard.spec.ts +347 -0
  420. package/src/server/master-lock/guard.ts +290 -0
  421. package/src/server/master-lock/index.ts +44 -0
  422. package/src/server/master-lock/lockPage.spec.ts +88 -0
  423. package/src/server/master-lock/lockPage.ts +313 -0
  424. package/src/server/master-lock/masterLock.spec.ts +338 -0
  425. package/src/server/master-lock/masterLock.ts +627 -0
  426. package/src/server/master-lock/principals.spec.ts +378 -0
  427. package/src/server/master-lock/principals.ts +175 -0
  428. package/src/server/master-lock/seed.spec.ts +69 -0
  429. package/src/server/master-lock/seed.ts +112 -0
  430. package/src/server/master-lock/store.ts +35 -0
  431. package/src/server/media-bun/image.spec.ts +115 -0
  432. package/src/server/media-bun/image.ts +134 -0
  433. package/src/server/media-bun/index.ts +52 -0
  434. package/src/server/media-bun/streaming.spec.ts +134 -0
  435. package/src/server/media-bun/streaming.ts +347 -0
  436. package/src/server/media-bun/video.spec.ts +115 -0
  437. package/src/server/media-bun/video.ts +232 -0
  438. package/src/server/metrics/metricsBuffer.ts +92 -0
  439. package/src/server/metrics/normalizeRoute.spec.ts +51 -0
  440. package/src/server/metrics/normalizeRoute.ts +63 -0
  441. package/src/server/middleware/correlationId.ts +24 -0
  442. package/src/server/middleware/corsAllowlist.spec.ts +118 -0
  443. package/src/server/middleware/corsAllowlist.ts +69 -0
  444. package/src/server/middleware/errorEnvelope.ts +38 -0
  445. package/src/server/middleware/index.ts +19 -0
  446. package/src/server/middleware/localOrigin.spec.ts +168 -0
  447. package/src/server/middleware/localOrigin.ts +139 -0
  448. package/src/server/middleware/rateLimit.spec.ts +72 -0
  449. package/src/server/middleware/rateLimit.ts +125 -0
  450. package/src/server/middleware/requestLogger.ts +95 -0
  451. package/src/server/middleware/securityHeaders.ts +78 -0
  452. package/src/server/middleware/zValidator.spec.ts +251 -0
  453. package/src/server/middleware/zValidator.ts +114 -0
  454. package/src/server/migration/runner.spec.ts +88 -0
  455. package/src/server/migration/runner.ts +87 -0
  456. package/src/server/migration/types.ts +17 -0
  457. package/src/server/net/outboundGuard.spec.ts +90 -0
  458. package/src/server/net/outboundGuard.ts +205 -0
  459. package/src/server/notifications/hub.ts +245 -0
  460. package/src/server/notifications/index.ts +77 -0
  461. package/src/server/notifications/migrations.ts +39 -0
  462. package/src/server/notifications/routes.ts +177 -0
  463. package/src/server/notifications/service.spec.ts +305 -0
  464. package/src/server/notifications/service.ts +112 -0
  465. package/src/server/paths/appDataHome.spec.ts +87 -0
  466. package/src/server/paths/appDataHome.ts +84 -0
  467. package/src/server/private-media/blobRoute.spec.ts +165 -0
  468. package/src/server/private-media/crypto.spec.ts +81 -0
  469. package/src/server/private-media/crypto.ts +155 -0
  470. package/src/server/private-media/gatePolicy.spec.ts +98 -0
  471. package/src/server/private-media/index-store.ts +83 -0
  472. package/src/server/private-media/mountPrivateRoutes.ts +265 -0
  473. package/src/server/private-media/pipeline.spec.ts +111 -0
  474. package/src/server/private-media/pipeline.ts +170 -0
  475. package/src/server/private-media/routes.spec.ts +265 -0
  476. package/src/server/private-media/unlock.ts +112 -0
  477. package/src/server/resume/index.ts +8 -0
  478. package/src/server/resume/resume.spec.ts +118 -0
  479. package/src/server/resume/routes.ts +92 -0
  480. package/src/server/resume/store.ts +82 -0
  481. package/src/server/retention/archive.spec.ts +181 -0
  482. package/src/server/retention/archive.ts +353 -0
  483. package/src/server/retention/fleet.ts +104 -0
  484. package/src/server/retention/index.ts +101 -0
  485. package/src/server/retention/policy.spec.ts +132 -0
  486. package/src/server/retention/policy.ts +392 -0
  487. package/src/server/retention/store.spec.ts +236 -0
  488. package/src/server/retention/store.ts +338 -0
  489. package/src/server/retention/sweeper.spec.ts +321 -0
  490. package/src/server/retention/sweeper.ts +367 -0
  491. package/src/server/retention/target.spec.ts +592 -0
  492. package/src/server/retention/target.ts +613 -0
  493. package/src/server/search/index.ts +12 -0
  494. package/src/server/search/pgSearchProvider.pg.test.ts +392 -0
  495. package/src/server/search/pgSearchProvider.ts +421 -0
  496. package/src/server/search/pgSearchSchema.ts +147 -0
  497. package/src/server/sharing/index.ts +65 -0
  498. package/src/server/sharing/migrations.ts +128 -0
  499. package/src/server/sharing/router.spec.ts +167 -0
  500. package/src/server/sharing/router.ts +194 -0
  501. package/src/server/sharing/sql.ts +109 -0
  502. package/src/server/sharing/store.spec.ts +408 -0
  503. package/src/server/sharing/store.ts +737 -0
  504. package/src/server/sqlite/backup.spec.ts +114 -0
  505. package/src/server/sqlite/backup.ts +154 -0
  506. package/src/server/sqlite/browse.spec.ts +70 -0
  507. package/src/server/sqlite/browse.ts +120 -0
  508. package/src/server/sqlite/export.spec.ts +43 -0
  509. package/src/server/sqlite/export.ts +74 -0
  510. package/src/server/sqlite/fts5.spec.ts +175 -0
  511. package/src/server/sqlite/fts5.ts +255 -0
  512. package/src/server/sqlite/health.spec.ts +79 -0
  513. package/src/server/sqlite/health.ts +105 -0
  514. package/src/server/sqlite/import.spec.ts +75 -0
  515. package/src/server/sqlite/import.ts +141 -0
  516. package/src/server/sqlite/index.ts +64 -0
  517. package/src/server/sqlite/readonlySql.spec.ts +58 -0
  518. package/src/server/sqlite/readonlySql.ts +67 -0
  519. package/src/server/sqlite/stats.spec.ts +21 -0
  520. package/src/server/sqlite/stats.ts +61 -0
  521. package/src/server/sqlite/viewer.spec.ts +127 -0
  522. package/src/server/sqlite/viewer.ts +72 -0
  523. package/src/server/storage/binaryServerProcessing.integration.spec.ts +222 -0
  524. package/src/server/storage/binaryServerProcessing.spec.ts +150 -0
  525. package/src/server/storage/binaryServerProcessing.ts +127 -0
  526. package/src/server/storage/binaryServerStore.ts +102 -0
  527. package/src/server/storage/chunked.integration.spec.ts +185 -0
  528. package/src/server/storage/diskStore.ts +83 -0
  529. package/src/server/storage/files/adapter.photo.display.spec.ts +169 -0
  530. package/src/server/storage/files/adapter.photo.spec.ts +128 -0
  531. package/src/server/storage/files/adapter.spec.ts +121 -0
  532. package/src/server/storage/files/adapter.ts +255 -0
  533. package/src/server/storage/files/catalogue.media-host.spec.ts +184 -0
  534. package/src/server/storage/files/catalogue.orderings.spec.ts +157 -0
  535. package/src/server/storage/files/catalogue.private.spec.ts +106 -0
  536. package/src/server/storage/files/catalogue.recover.spec.ts +131 -0
  537. package/src/server/storage/files/catalogue.sort.spec.ts +154 -0
  538. package/src/server/storage/files/catalogue.spec.ts +133 -0
  539. package/src/server/storage/files/catalogue.trash-import.spec.ts +125 -0
  540. package/src/server/storage/files/catalogue.ts +1326 -0
  541. package/src/server/storage/files/chunkedUpload.spec.ts +111 -0
  542. package/src/server/storage/files/chunkedUpload.ts +146 -0
  543. package/src/server/storage/files/collections.spec.ts +294 -0
  544. package/src/server/storage/files/fileRoutes.replace.spec.ts +88 -0
  545. package/src/server/storage/files/fileRoutes.spec.ts +327 -0
  546. package/src/server/storage/files/fileRoutes.ts +462 -0
  547. package/src/server/storage/files/index.ts +66 -0
  548. package/src/server/storage/files/migrations.ts +187 -0
  549. package/src/server/storage/files/mimeByExt.ts +14 -0
  550. package/src/server/storage/files/stack.spec.ts +258 -0
  551. package/src/server/storage/files/stack.ts +284 -0
  552. package/src/server/storage/files/storageOps.spec.ts +126 -0
  553. package/src/server/storage/files/storageOps.ts +213 -0
  554. package/src/server/storage/files/types.ts +285 -0
  555. package/src/server/storage/imageThumbnail.spec.ts +81 -0
  556. package/src/server/storage/imageThumbnail.ts +60 -0
  557. package/src/server/storage/index.ts +122 -0
  558. package/src/server/storage/mountMediaRoutes.ts +231 -0
  559. package/src/server/storage/objectStore.spec.ts +272 -0
  560. package/src/server/storage/objectStoreMediaStore.ts +63 -0
  561. package/src/server/storage/s3ObjectStore.ts +239 -0
  562. package/src/server/storage/sources.ts +245 -0
  563. package/src/server/storage/storage.spec.ts +219 -0
  564. package/src/server/storage/streamRange.ts +92 -0
  565. package/src/server/storage/streamToken.ts +76 -0
  566. package/src/server/storage/transcode.integration.spec.ts +134 -0
  567. package/src/server/storage/transcodeJobs.spec.ts +152 -0
  568. package/src/server/storage/transcodeJobs.ts +171 -0
  569. package/src/server/storage/types.ts +230 -0
  570. package/src/server/storage/videoDerivativeKeys.spec.ts +112 -0
  571. package/src/server/storage/videoDerivativeKeys.ts +172 -0
  572. package/src/server/storage/videoProcessor.posterOnly.spec.ts +106 -0
  573. package/src/server/storage/videoProcessor.ts +229 -0
  574. package/src/server/sync/alarm.spec.ts +149 -0
  575. package/src/server/sync/alarm.ts +137 -0
  576. package/src/server/sync/commands.spec.ts +145 -0
  577. package/src/server/sync/commands.ts +361 -0
  578. package/src/server/sync/engine.spec.ts +496 -0
  579. package/src/server/sync/engine.ts +255 -0
  580. package/src/server/sync/http.ts +316 -0
  581. package/src/server/sync/httpRemote.spec.ts +158 -0
  582. package/src/server/sync/index.ts +90 -0
  583. package/src/server/sync/opLog.spec.ts +110 -0
  584. package/src/server/sync/opLog.ts +189 -0
  585. package/src/server/sync/planner.spec.ts +53 -0
  586. package/src/server/sync/planner.ts +62 -0
  587. package/src/server/sync/status.spec.ts +92 -0
  588. package/src/server/sync/status.ts +108 -0
  589. package/src/server/sync/timer.spec.ts +150 -0
  590. package/src/server/sync/timer.ts +207 -0
  591. package/src/server/sync/tokens.spec.ts +38 -0
  592. package/src/server/sync/tokens.ts +94 -0
  593. package/src/server/sync/types.ts +108 -0
  594. package/src/server/ws/eventBus.spec.ts +67 -0
  595. package/src/server/ws/eventBus.ts +46 -0
  596. package/src/server/ws/hub.integration.spec.ts +93 -0
  597. package/src/server/ws/hub.spec.ts +282 -0
  598. package/src/server/ws/hub.ts +168 -0
  599. package/src/server/ws/index.ts +20 -0
  600. package/src/server/ws/presence.spec.ts +27 -0
  601. package/src/server/ws/presence.ts +26 -0
  602. package/src/server/ws/room.spec.ts +36 -0
  603. package/src/server/ws/room.ts +29 -0
  604. package/src/server/ws/types.ts +134 -0
  605. package/src/skippedTestsAreAnswered.spec.ts +215 -0
  606. package/src/testTmpRoot.spec.ts +102 -0
@@ -0,0 +1,1033 @@
1
+ import { mkdir, readdir, rm, stat as fsStat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { renderImage } from '../../media-bun/image';
4
+ import { sealMeta } from '../../private-media/crypto';
5
+ import { processPrivateBlob, processPrivateVideo } from '../../private-media/pipeline';
6
+ import { ffmpegImageThumbnail, isBrowserRenderableImage } from '../imageThumbnail';
7
+ import { fsDisplayKey, fsHlsPrefix, fsMp4Key, fsPosterKey, fsSourceKey, fsSpriteKey, fsVttKey, privateFsKey, } from '../types';
8
+ import { processVideo } from '../videoProcessor';
9
+ import { mimeByExt } from './mimeByExt';
10
+ import { classifyFileType, } from './types';
11
+ // Stream an upload body to a local plaintext file (staged for the private pipeline; wiped after).
12
+ // Bun.write(dest, new Response(stream)) hangs in Bun 1.3.x — pump via a FileSink instead.
13
+ async function streamToFile(body, dest) {
14
+ await mkdir(path.dirname(dest), { recursive: true });
15
+ const sink = Bun.file(dest).writer();
16
+ const reader = body.getReader();
17
+ let size = 0;
18
+ try {
19
+ while (true) {
20
+ const { done, value } = await reader.read();
21
+ if (done)
22
+ break;
23
+ size += value.byteLength;
24
+ sink.write(value);
25
+ }
26
+ }
27
+ finally {
28
+ await sink.end();
29
+ }
30
+ return size;
31
+ }
32
+ // The general primary binary for a NON-video file (images/docs/…) lives here; video files store a
33
+ // raw source (fsSourceKey) + derived mp4/HLS/poster/sprite via the shared video keyspace.
34
+ export const fileBinaryKey = (owner, id) => `fs/${owner}/${id}`;
35
+ /** Where a file's uploaded bytes must land for `registerUploaded` to find them: a video's raw
36
+ * source (fed to the transcode), else the primary binary. The chunked-upload path targets this key
37
+ * so an out-of-band upload lands exactly where the catalogue + serve routes expect it. */
38
+ export const catalogueUploadKey = (owner, id, mime) => mime.startsWith('video/') ? fsSourceKey(owner, id) : fileBinaryKey(owner, id);
39
+ const b64 = (s) => Buffer.from(s).toString('base64url');
40
+ const unb64 = (s) => Buffer.from(s, 'base64url').toString();
41
+ // Sort-aware keyset pagination. The cursor encodes (optional shuffle seed, sort key, id tie-break);
42
+ // the key is inner-b64'd so a file NAME containing '|' can't break parsing. The `random` sort mints
43
+ // a seed on the first page and threads it here so every subsequent page reshuffles identically. A
44
+ // cursor is only meaningful under the sort that minted it (the caller passes the same `sort` on
45
+ // every page). Segment order is `seed|b64(key)|id` — b64(key) and a UUID id never contain '|', and
46
+ // seed is a bare integer, so a 3-way split is unambiguous.
47
+ const encodeCursor = (key, id, seed) => b64(`${seed ?? ''}|${b64(String(key))}|${id}`);
48
+ function decodeCursor(c) {
49
+ try {
50
+ const [seed, k, id] = unb64(c).split('|');
51
+ if (!id)
52
+ return null;
53
+ return { seed: seed === '' ? undefined : Number(seed), key: unb64(k), id };
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ // The seeded `random` remap uses modular arithmetic under a Mersenne prime (2^31 - 1). rand_key and
60
+ // the derived coefficients are all < 2^31, so `rand_key * mul + add` stays < 2^62 — exact in both
61
+ // SQLite's signed-64-bit integer math and JS BigInt (the two must agree for keyset boundaries).
62
+ const RANDOM_MOD = 2147483647;
63
+ /** Derive the `random` sort spec for a given session seed: order by the seed-remapped rand_key
64
+ * (ties broken by id). The remap `(rand_key * mul + add) mod P` reshuffles per seed while staying a
65
+ * stable total order, so keyset pagination holds as long as the cursor carries the same seed. */
66
+ function randomSortSpec(seed) {
67
+ const mul = (seed % (RANDOM_MOD - 1)) + 1; // [1, P-1] — nonzero mod P
68
+ const add = seed % RANDOM_MOD; // [0, P-1]
69
+ const expr = '((f.rand_key * $rmul + $radd) % $rmod)';
70
+ return {
71
+ orderBy: `${expr} ASC, f.id ASC`,
72
+ keysetWhere: `(${expr} > $curKey OR (${expr} = $curKey AND f.id > $curId))`,
73
+ key: (r) => Number((BigInt(r.rand_key) * BigInt(mul) + BigInt(add)) % BigInt(RANDOM_MOD)),
74
+ numericKey: true,
75
+ params: { $rmul: mul, $radd: add, $rmod: RANDOM_MOD },
76
+ };
77
+ }
78
+ /** The `lastViewed` sort: most-recently-opened first, never-viewed files (no ledger row) last via
79
+ * COALESCE(...,0), ties broken by id. LEFT JOIN keeps files with no view. */
80
+ const LAST_VIEWED_SPEC = {
81
+ join: 'LEFT JOIN file_views v ON v.file_id = f.id',
82
+ selectExtra: 'COALESCE(v.viewed_at, 0) AS view_key',
83
+ orderBy: 'COALESCE(v.viewed_at, 0) DESC, f.id DESC',
84
+ keysetWhere: '(COALESCE(v.viewed_at, 0) < $curKey OR (COALESCE(v.viewed_at, 0) = $curKey AND f.id < $curId))',
85
+ key: (r) => r.view_key ?? 0,
86
+ numericKey: true,
87
+ };
88
+ /** Per-sort SQL for the STATIC (parameter-free) orders. `recent` = newest first; `oldest` = oldest
89
+ * first; `name` = A→Z; `size` = largest first. `random` / `lastViewed` are built per-request above. */
90
+ const SORT_SPECS = {
91
+ recent: {
92
+ orderBy: 'f.created_at DESC, f.id DESC',
93
+ keysetWhere: '(f.created_at < $curKey OR (f.created_at = $curKey AND f.id < $curId))',
94
+ key: (r) => r.created_at,
95
+ numericKey: true,
96
+ },
97
+ oldest: {
98
+ orderBy: 'f.created_at ASC, f.id ASC',
99
+ keysetWhere: '(f.created_at > $curKey OR (f.created_at = $curKey AND f.id > $curId))',
100
+ key: (r) => r.created_at,
101
+ numericKey: true,
102
+ },
103
+ name: {
104
+ orderBy: 'f.name ASC, f.id ASC',
105
+ keysetWhere: '(f.name > $curKey OR (f.name = $curKey AND f.id > $curId))',
106
+ key: (r) => r.name,
107
+ numericKey: false,
108
+ },
109
+ size: {
110
+ orderBy: 'f.size DESC, f.id DESC',
111
+ keysetWhere: '(f.size < $curKey OR (f.size = $curKey AND f.id < $curId))',
112
+ key: (r) => r.size,
113
+ numericKey: true,
114
+ },
115
+ };
116
+ export function createSqliteFileCatalogue(opts) {
117
+ const { db, store, workDir } = opts;
118
+ const ownerOf = opts.owner ?? ((c) => c.tenantId);
119
+ const now = opts.now ?? (() => Date.now());
120
+ const rand31 = () => Math.floor(Math.random() * RANDOM_MOD);
121
+ const randKey = opts.randKey ?? rand31;
122
+ const randomSeed = opts.randomSeed ?? rand31;
123
+ const insertFile = db.query(`INSERT INTO files (id, owner, name, mime, size, file_type, is_private, processing_status, processing_progress, storage_backend, rand_key, created_at, updated_at)
124
+ VALUES ($id, $owner, $name, $mime, $size, $type, 0, $status, $progress, 'disk', $rand, $now, $now)`);
125
+ const insertTag = db.query(`INSERT OR IGNORE INTO file_tags (file_id, tag, owner, is_private) VALUES ($file, $tag, $owner, 0)`);
126
+ const deleteTags = db.query(`DELETE FROM file_tags WHERE file_id = $file`);
127
+ const getRow = db.query(`SELECT * FROM files WHERE id = $id AND owner = $owner`);
128
+ const deleteFile = db.query(`DELETE FROM files WHERE id = $id AND owner = $owner`);
129
+ const trashFile = db.query(`UPDATE files SET trashed_at = $now, updated_at = $now WHERE id = $id AND owner = $owner AND trashed_at IS NULL`);
130
+ const restoreFile = db.query(`UPDATE files SET trashed_at = NULL, updated_at = $now WHERE id = $id AND owner = $owner`);
131
+ const setProcessing = db.query(`UPDATE files SET processing_status = $status, processing_progress = $progress, updated_at = $now WHERE id = $id`);
132
+ const bumpProcessingAttempts = db.query(`UPDATE files SET processing_attempts = processing_attempts + 1 WHERE id = $id`);
133
+ const maxProcessingAttempts = opts.maxProcessingAttempts ?? 3;
134
+ const setProcessed = db.query(`UPDATE files SET processing_status = $status, processing_progress = 100, has_hls = $hls,
135
+ has_thumbnail = $thumb, has_sprites = $sprites, duration = $duration, updated_at = $now WHERE id = $id`);
136
+ const setMeta = db.query(`UPDATE files SET name = $name, updated_at = $now WHERE id = $id AND owner = $owner`);
137
+ const setThumbnail = db.query(`UPDATE files SET has_thumbnail = $thumb, updated_at = $now WHERE id = $id AND owner = $owner`);
138
+ // replaceContent: new bytes under the SAME id — identity (tags/collections/views) survives,
139
+ // byte-derived columns reset and re-derive.
140
+ const replaceMeta = db.query(`UPDATE files SET name = $name, mime = $mime, size = $size, file_type = $type,
141
+ processing_status = $status, processing_progress = $progress,
142
+ has_hls = 0, has_thumbnail = 0, has_sprites = 0, duration = NULL, updated_at = $now
143
+ WHERE id = $id AND owner = $owner AND is_private = 0`);
144
+ // Private (encrypted) rows: name/tags are sealed in enc_name (sealedMeta); the wrapped AES-128
145
+ // content key is in enc_dek; enc_key_type records the wrap mode. is_private = 1 keeps them out of
146
+ // the public feed. These rows are surfaced to `mountPrivateRoutes` via `asPrivateIndex()`.
147
+ const insertPrivate = db.query(`INSERT INTO files (id, owner, name, mime, size, file_type, is_private, has_hls, duration,
148
+ processing_status, processing_progress, storage_backend, enc_name, enc_dek, enc_key_type, rand_key, created_at, updated_at)
149
+ VALUES ($id, $owner, '', '', $size, $type, 1, 0, NULL, $status, $progress, 'disk', $sealed, $wrapped, $mode, $rand, $now, $now)`);
150
+ const finishPrivate = db.query(`UPDATE files SET processing_status = $status, processing_progress = $progress, has_hls = $hls,
151
+ duration = $duration, size = $size, enc_name = $sealed, enc_dek = $wrapped, updated_at = $now WHERE id = $id`);
152
+ const getPrivateRow = db.query(`SELECT * FROM files WHERE id = $id AND owner = $owner AND is_private = 1`);
153
+ const listPrivateFullRows = db.query(`SELECT * FROM files WHERE owner = $owner AND is_private = 1 ORDER BY created_at DESC, id DESC`);
154
+ const updatePrivateRow = db.query(`UPDATE files SET size = $size, processing_status = $status, enc_name = $sealed, enc_dek = $wrapped,
155
+ enc_key_type = $mode, updated_at = $now WHERE id = $id AND owner = $owner AND is_private = 1`);
156
+ const deletePrivateRow = db.query(`DELETE FROM files WHERE id = $id AND owner = $owner AND is_private = 1`);
157
+ // ── Collection prepared statements ───────────────────────────────────────────────
158
+ const insertCollection = db.query(`INSERT INTO file_collections (id, owner, name, kind, visibility, created_at, updated_at)
159
+ VALUES ($id, $owner, $name, $kind, $visibility, $now, $now)
160
+ RETURNING *`);
161
+ const getCollection = db.query(`SELECT * FROM file_collections WHERE id = $id AND owner = $owner`);
162
+ const getFavoritesCollection = db.query(`SELECT * FROM file_collections WHERE owner = $owner AND kind = 'favorites'`);
163
+ const updateCollectionRow = db.query(`UPDATE file_collections SET name = $name, visibility = $visibility, updated_at = $now
164
+ WHERE id = $id AND owner = $owner`);
165
+ const deleteCollection = db.query(`DELETE FROM file_collections WHERE id = $id AND owner = $owner AND kind != 'favorites'`);
166
+ const deleteCollectionMembers = db.query(`DELETE FROM file_collection_files WHERE collection_id = $id`);
167
+ // Membership manipulation — owner is enforced by joining against files.owner so a tenant can
168
+ // only add their own files.
169
+ const insertMember = db.query(`INSERT OR IGNORE INTO file_collection_files (collection_id, file_id, owner, added_at)
170
+ SELECT $cid, f.id, f.owner, $now FROM files f
171
+ WHERE f.id = $fid AND f.owner = $owner`);
172
+ const deleteMember = db.query(`DELETE FROM file_collection_files
173
+ WHERE collection_id = $cid AND file_id = $fid AND owner = $owner`);
174
+ // Remove all membership rows for a given file (used when the file itself is deleted).
175
+ const deleteFileMemberships = db.query(`DELETE FROM file_collection_files WHERE file_id = $file AND owner = $owner`);
176
+ // View-event ledger: upsert the last-viewed timestamp + bump the count. The SELECT-from-files
177
+ // gate means a view can only be recorded for one of the caller's OWN files (unknown/other-owner
178
+ // ids insert nothing). Powers the `lastViewed` sort.
179
+ const recordViewStmt = db.query(`INSERT INTO file_views (file_id, owner, viewed_at, view_count)
180
+ SELECT f.id, f.owner, $now, 1 FROM files f WHERE f.id = $id AND f.owner = $owner
181
+ ON CONFLICT(file_id) DO UPDATE SET viewed_at = excluded.viewed_at, view_count = view_count + 1`);
182
+ const deleteFileViews = db.query(`DELETE FROM file_views WHERE file_id = $file`);
183
+ const rowToPrivateItem = (r, owner) => ({
184
+ id: r.id,
185
+ owner,
186
+ sizeBytes: r.size,
187
+ createdAt: r.created_at,
188
+ status: r.processing_status,
189
+ keyMode: (r.enc_key_type || 'deniable'),
190
+ wrappedContentKey: r.enc_dek,
191
+ sealedMeta: r.enc_name,
192
+ });
193
+ const tagsFor = (ids) => {
194
+ const map = new Map();
195
+ if (ids.length === 0)
196
+ return map;
197
+ const placeholders = ids.map(() => '?').join(',');
198
+ const rows = db
199
+ .query(`SELECT file_id, tag FROM file_tags WHERE file_id IN (${placeholders}) ORDER BY tag`)
200
+ .all(...ids);
201
+ for (const r of rows) {
202
+ const list = map.get(r.file_id) ?? [];
203
+ list.push(r.tag);
204
+ map.set(r.file_id, list);
205
+ }
206
+ return map;
207
+ };
208
+ /** Batch-fetch collection memberships for a page of file ids. Returns a map of file_id →
209
+ * collection_ids[], and a separate Set of favorited file_ids (by their Favorites collection). */
210
+ const collectionsFor = (owner, fileIds) => {
211
+ const collMap = new Map();
212
+ const favSet = new Set();
213
+ if (fileIds.length === 0)
214
+ return { collMap, favSet };
215
+ const placeholders = fileIds.map(() => '?').join(',');
216
+ const rows = db
217
+ .query(`SELECT fcf.file_id, fcf.collection_id, fc.kind
218
+ FROM file_collection_files fcf
219
+ JOIN file_collections fc ON fc.id = fcf.collection_id
220
+ WHERE fcf.file_id IN (${placeholders}) AND fcf.owner = ?`)
221
+ .all(...fileIds, owner);
222
+ for (const r of rows) {
223
+ const list = collMap.get(r.file_id) ?? [];
224
+ list.push(r.collection_id);
225
+ collMap.set(r.file_id, list);
226
+ if (r.kind === 'favorites')
227
+ favSet.add(r.file_id);
228
+ }
229
+ return { collMap, favSet };
230
+ };
231
+ const rowToRecord = (row, tags, collectionIds, favorite) => ({
232
+ id: row.id,
233
+ name: row.name,
234
+ mime: row.mime,
235
+ size: row.size,
236
+ fileType: row.file_type,
237
+ tags,
238
+ isPrivate: row.is_private === 1,
239
+ hasThumbnail: row.has_thumbnail === 1,
240
+ hasHls: row.has_hls === 1,
241
+ hasSprites: row.has_sprites === 1,
242
+ durationSeconds: row.duration ?? undefined,
243
+ processingStatus: row.processing_status,
244
+ processingProgress: row.processing_progress,
245
+ favorite,
246
+ collectionIds,
247
+ createdAt: row.created_at,
248
+ updatedAt: row.updated_at,
249
+ trashedAt: row.trashed_at ?? undefined,
250
+ });
251
+ const collectionRowToRecord = (row, fileCount) => ({
252
+ id: row.id,
253
+ name: row.name,
254
+ kind: row.kind,
255
+ visibility: row.visibility,
256
+ fileCount,
257
+ createdAt: row.created_at,
258
+ updatedAt: row.updated_at,
259
+ });
260
+ // A background job can outlive this catalogue's Database — a host shutting down mid-transcode,
261
+ // or a test teardown that closed the db after its settle timeout. The job's late progress/result
262
+ // writes must not crash the process (the rows they'd update are gone with the closed db, and
263
+ // recoverProcessing re-kicks anything left 'processing' on the next boot) — drop exactly the
264
+ // finalized/closed-statement failure, surface everything else.
265
+ const lateWrite = (write) => {
266
+ try {
267
+ write();
268
+ }
269
+ catch (err) {
270
+ const message = err instanceof Error ? err.message : String(err);
271
+ if (/finalized|closed/i.test(message))
272
+ return;
273
+ throw err;
274
+ }
275
+ };
276
+ // Fire-and-forget the transcode for a public video whose source sits at fsSourceKey; the row's
277
+ // processing columns flip to ready/failed when it finishes. Reused by ingest, registerUploaded,
278
+ // and recoverProcessing (re-kick after a restart lost an in-flight job).
279
+ // Returns a promise that resolves when the derivatives finish, so callers that must NOT run
280
+ // ffmpeg jobs concurrently (backfill on a memory-constrained host) can await + serialize. Normal
281
+ // ingest ignores the return value (fire-and-forget).
282
+ const kickVideoProcessing = (owner, id) => {
283
+ if (opts.transcodeVideos === false) {
284
+ // No derivatives at all: the raw source plays progressively, matching the ffmpeg-unavailable
285
+ // path. The strictest memory guard — not even a poster frame is decoded.
286
+ setProcessed.run({
287
+ $id: id,
288
+ $status: 'ready',
289
+ $hls: 0,
290
+ $thumb: 0,
291
+ $sprites: 0,
292
+ $duration: null,
293
+ $now: now(),
294
+ });
295
+ return Promise.resolve();
296
+ }
297
+ // transcodeVideos:'poster' = the memory-constrained-host sweet spot (2026-07-04 OOM guard). We
298
+ // skip the heavy HLS/re-encode ladder that OOM'd the box, but STILL extract a cheap single-frame
299
+ // poster so the grid keeps thumbnails and the player shows no "optimization failed" badge.
300
+ const posterOnly = opts.transcodeVideos === 'poster';
301
+ bumpProcessingAttempts.run({ $id: id });
302
+ return (opts.videoProcessor ?? processVideo)({
303
+ store,
304
+ workDir,
305
+ owner,
306
+ id,
307
+ sourceKey: fsSourceKey(owner, id),
308
+ ladderHeights: opts.ladderHeights,
309
+ posterOnly,
310
+ onProgress: (status, pct) => lateWrite(() => setProcessing.run({ $id: id, $status: status, $progress: pct, $now: now() })),
311
+ }).then((res) => {
312
+ lateWrite(() => setProcessed.run({
313
+ $id: id,
314
+ $status: res.status,
315
+ $hls: res.hasHls ? 1 : 0,
316
+ $thumb: res.hasPoster ? 1 : 0,
317
+ $sprites: res.hasSprite ? 1 : 0,
318
+ $duration: res.durationSeconds || null,
319
+ $now: now(),
320
+ }));
321
+ });
322
+ };
323
+ // The local image-derivative implementation: sharp first (fast path), ffmpeg fallback for
324
+ // formats sharp's prebuilt binary can't decode (notably HEIC/HEVC iPhone photos). Browsers
325
+ // can't render HEIC either, so the JPEG poster is what the grid AND any <img> fallback display;
326
+ // non-renderable formats additionally get a full-size display JPEG for the lightbox.
327
+ const localImageDerivatives = async (args) => {
328
+ const bytes = await args.store.get(args.binaryKey);
329
+ if (!bytes)
330
+ return { hasThumbnail: false };
331
+ let thumb = null;
332
+ try {
333
+ thumb = new Uint8Array(await renderImage({
334
+ input: bytes,
335
+ operations: [{ type: 'resize', width: 512 }], // width-only ⇒ aspect preserved
336
+ output: { format: 'jpeg', quality: 78 },
337
+ }));
338
+ }
339
+ catch {
340
+ thumb = await ffmpegImageThumbnail(bytes, args.workDir, args.id);
341
+ }
342
+ if (!thumb)
343
+ return { hasThumbnail: false }; // both paths failed; leave has_thumbnail 0
344
+ await args.store.put(args.posterKey, thumb, 'image/jpeg');
345
+ // Write the display derivative BEFORE the caller flips has_thumbnail (the photo source only
346
+ // advertises the display URL once the poster flag is set — this ordering keeps it ready).
347
+ if (args.needsDisplay) {
348
+ const display = await ffmpegImageThumbnail(bytes, args.workDir, `${args.id}-display`, 2048);
349
+ if (display)
350
+ await args.store.put(args.displayKey, display, 'image/jpeg');
351
+ }
352
+ return { hasThumbnail: true };
353
+ };
354
+ // Fire-and-forget a grid thumbnail for an image at the shared poster key (the poster serve
355
+ // route is type-agnostic). Best-effort: a failed derivative keeps has_thumbnail 0 and the UI
356
+ // falls back to the full image. Returns a promise so the backfill can serialize image jobs
357
+ // (mirrors kickVideoProcessing); normal ingest ignores it (fire-and-forget).
358
+ const kickImageThumbnail = (owner, id, mime) => (async () => {
359
+ const { hasThumbnail } = await (opts.imageDerivatives ?? localImageDerivatives)({
360
+ store,
361
+ workDir,
362
+ owner,
363
+ id,
364
+ mime,
365
+ binaryKey: fileBinaryKey(owner, id),
366
+ posterKey: fsPosterKey(owner, id),
367
+ displayKey: fsDisplayKey(owner, id),
368
+ needsDisplay: !isBrowserRenderableImage(mime),
369
+ });
370
+ if (!hasThumbnail)
371
+ return;
372
+ setThumbnail.run({ $id: id, $owner: owner, $thumb: 1, $now: now() });
373
+ })().catch(() => { });
374
+ // Insert a public row + kick background processing (video transcode / image thumbnail). Shared
375
+ // by ingest (bytes stored here) and registerUploaded (bytes already at the key, e.g. a chunked
376
+ // upload direct to binary-server).
377
+ const finalizePublicRow = (owner, id, meta, size, ts) => {
378
+ const isVideo = meta.mime.startsWith('video/');
379
+ db.transaction(() => {
380
+ insertFile.run({
381
+ $id: id,
382
+ $owner: owner,
383
+ $name: meta.name,
384
+ $mime: meta.mime,
385
+ $size: size,
386
+ $type: classifyFileType(meta.mime),
387
+ $status: isVideo ? 'processing' : 'ready',
388
+ $progress: isVideo ? 0 : 100,
389
+ $rand: randKey(),
390
+ $now: ts,
391
+ });
392
+ for (const tag of meta.tags ?? [])
393
+ insertTag.run({ $file: id, $tag: tag, $owner: owner });
394
+ })();
395
+ if (isVideo)
396
+ void kickVideoProcessing(owner, id);
397
+ else if (meta.mime.startsWith('image/'))
398
+ void kickImageThumbnail(owner, id, meta.mime);
399
+ // Freshly ingested file has no collections yet.
400
+ return rowToRecord(getRow.get({ $id: id, $owner: owner }), meta.tags ?? [], [], false);
401
+ };
402
+ /** Lazily get-or-create the owner's Favorites collection. */
403
+ const getOrCreateFavorites = (owner, ts) => {
404
+ const existing = getFavoritesCollection.get({ $owner: owner });
405
+ if (existing)
406
+ return existing;
407
+ const id = crypto.randomUUID();
408
+ const rows = insertCollection.all({
409
+ $id: id,
410
+ $owner: owner,
411
+ $name: 'Favorites',
412
+ $kind: 'favorites',
413
+ $visibility: 'normal',
414
+ $now: ts,
415
+ });
416
+ return rows[0];
417
+ };
418
+ const statsByType = db.query(`SELECT file_type, COUNT(*) AS n, SUM(size) AS bytes FROM files
419
+ WHERE owner = $owner AND is_private = 0 GROUP BY file_type`);
420
+ const statsPrivate = db.query(`SELECT COUNT(*) AS n, SUM(size) AS bytes FROM files WHERE owner = $owner AND is_private = 1`);
421
+ const statsStatus = db.query(`SELECT processing_status, COUNT(*) AS n FROM files
422
+ WHERE owner = $owner GROUP BY processing_status`);
423
+ return {
424
+ async stats(ctx) {
425
+ const owner = ownerOf(ctx);
426
+ const byType = {};
427
+ let files = 0;
428
+ let bytes = 0;
429
+ for (const row of statsByType.all({ $owner: owner })) {
430
+ byType[row.file_type] = row.n;
431
+ files += row.n;
432
+ bytes += row.bytes ?? 0;
433
+ }
434
+ const priv = statsPrivate.get({ $owner: owner });
435
+ let processing = 0;
436
+ let failed = 0;
437
+ for (const row of statsStatus.all({ $owner: owner })) {
438
+ if (row.processing_status === 'processing' || row.processing_status === 'pending')
439
+ processing += row.n;
440
+ if (row.processing_status === 'failed')
441
+ failed += row.n;
442
+ }
443
+ return {
444
+ files,
445
+ bytes,
446
+ byType,
447
+ privateItems: priv?.n ?? 0,
448
+ privateBytes: priv?.bytes ?? 0,
449
+ processing,
450
+ failed,
451
+ };
452
+ },
453
+ async list(ctx, options = {}) {
454
+ const owner = ownerOf(ctx);
455
+ const limit = Math.min(options.limit ?? 40, 200);
456
+ const params = { $owner: owner, $limit: limit + 1 };
457
+ const where = [
458
+ 'f.owner = $owner',
459
+ 'f.is_private = 0',
460
+ options.trashedOnly ? 'f.trashed_at IS NOT NULL' : 'f.trashed_at IS NULL',
461
+ ];
462
+ let from = 'files f';
463
+ if (options.type) {
464
+ where.push('f.file_type = $type');
465
+ params.$type = options.type;
466
+ }
467
+ if (options.query) {
468
+ where.push('f.name LIKE $q');
469
+ params.$q = `%${options.query}%`;
470
+ }
471
+ if (options.tag) {
472
+ from = 'files f JOIN file_tags t ON t.file_id = f.id AND t.tag = $tag';
473
+ params.$tag = options.tag;
474
+ }
475
+ if (options.collectionId) {
476
+ // Unlock-gated collection: return empty page unless caller opts in.
477
+ const coll = db
478
+ .query(`SELECT visibility FROM file_collections WHERE id = $id AND owner = $owner`)
479
+ .get({ $id: options.collectionId, $owner: owner });
480
+ if (coll?.visibility === 'unlock' && !options.includeUnlockable) {
481
+ return { items: [] };
482
+ }
483
+ from = `files f JOIN file_collection_files fcf ON fcf.file_id = f.id AND fcf.collection_id = $cid`;
484
+ params.$cid = options.collectionId;
485
+ }
486
+ else if (options.favorite) {
487
+ // Filter to files in the owner's Favorites collection.
488
+ const fav = getFavoritesCollection.get({ $owner: owner });
489
+ if (!fav)
490
+ return { items: [] };
491
+ // Unlock-gated favorites: also respect the gate.
492
+ if (fav.visibility === 'unlock' && !options.includeUnlockable)
493
+ return { items: [] };
494
+ from = `files f JOIN file_collection_files fcf ON fcf.file_id = f.id AND fcf.collection_id = $cid`;
495
+ params.$cid = fav.id;
496
+ }
497
+ const sort = options.sort ?? 'recent';
498
+ const cur = options.cursor ? decodeCursor(options.cursor) : null;
499
+ // `random` mints a shuffle seed on the first page and threads it through the cursor so every
500
+ // page reshuffles identically; `lastViewed` joins the view ledger; the rest are static specs.
501
+ let seed;
502
+ let spec;
503
+ if (sort === 'random') {
504
+ seed = cur?.seed ?? randomSeed();
505
+ spec = randomSortSpec(seed);
506
+ }
507
+ else if (sort === 'lastViewed') {
508
+ spec = LAST_VIEWED_SPEC;
509
+ }
510
+ else {
511
+ spec = SORT_SPECS[sort];
512
+ }
513
+ if (spec.join)
514
+ from = `${from} ${spec.join}`;
515
+ if (spec.params)
516
+ Object.assign(params, spec.params);
517
+ if (cur) {
518
+ where.push(spec.keysetWhere);
519
+ params.$curKey = spec.numericKey ? Number(cur.key) : cur.key;
520
+ params.$curId = cur.id;
521
+ }
522
+ const selectExtra = spec.selectExtra ? `, ${spec.selectExtra}` : '';
523
+ const rows = db
524
+ .query(`SELECT f.*${selectExtra} FROM ${from} WHERE ${where.join(' AND ')}
525
+ ORDER BY ${spec.orderBy} LIMIT $limit`)
526
+ .all(params);
527
+ const hasMore = rows.length > limit;
528
+ const page = hasMore ? rows.slice(0, limit) : rows;
529
+ const tags = tagsFor(page.map((r) => r.id));
530
+ const { collMap, favSet } = collectionsFor(owner, page.map((r) => r.id));
531
+ const result = {
532
+ items: page.map((r) => rowToRecord(r, tags.get(r.id) ?? [], collMap.get(r.id) ?? [], favSet.has(r.id))),
533
+ };
534
+ if (hasMore) {
535
+ const last = page[page.length - 1];
536
+ result.nextCursor = encodeCursor(spec.key(last), last.id, seed);
537
+ }
538
+ return result;
539
+ },
540
+ async get(ctx, id) {
541
+ const owner = ownerOf(ctx);
542
+ const row = getRow.get({ $id: id, $owner: owner });
543
+ if (!row)
544
+ return null;
545
+ const { collMap, favSet } = collectionsFor(owner, [id]);
546
+ return rowToRecord(row, tagsFor([id]).get(id) ?? [], collMap.get(id) ?? [], favSet.has(id));
547
+ },
548
+ async tagFacets(ctx) {
549
+ const rows = db
550
+ .query(`SELECT tag, COUNT(*) as count FROM file_tags WHERE owner = $owner AND is_private = 0
551
+ GROUP BY tag ORDER BY count DESC, tag ASC`)
552
+ .all({ $owner: ownerOf(ctx) });
553
+ return rows;
554
+ },
555
+ async ingest(ctx, upload) {
556
+ const owner = ownerOf(ctx);
557
+ const id = crypto.randomUUID();
558
+ const isVideo = upload.mime.startsWith('video/');
559
+ // Video → stage the source for processing; other → store the primary binary directly.
560
+ const key = isVideo ? fsSourceKey(owner, id) : fileBinaryKey(owner, id);
561
+ await store.putStream(key, upload.body, upload.mime, { contentLength: upload.size });
562
+ const size = (await store.stat(key))?.size ?? upload.size ?? 0;
563
+ return finalizePublicRow(owner, id, upload, size, now());
564
+ },
565
+ async registerUploaded(ctx, upload) {
566
+ // Bytes were uploaded out-of-band (a chunked upload direct to binary-server) and already sit
567
+ // at the catalogue's key (fsSourceKey for video, fileBinaryKey otherwise). Just create the
568
+ // row + kick processing — no re-store. The caller owns having placed the bytes correctly.
569
+ return finalizePublicRow(ownerOf(ctx), upload.id, upload, upload.size, now());
570
+ },
571
+ async update(ctx, id, patch) {
572
+ const owner = ownerOf(ctx);
573
+ const row = getRow.get({ $id: id, $owner: owner });
574
+ if (!row)
575
+ return null;
576
+ db.transaction(() => {
577
+ if (patch.name !== undefined)
578
+ setMeta.run({ $id: id, $owner: owner, $name: patch.name, $now: now() });
579
+ if (patch.tags !== undefined) {
580
+ deleteTags.run({ $file: id });
581
+ for (const tag of patch.tags)
582
+ insertTag.run({ $file: id, $tag: tag, $owner: owner });
583
+ }
584
+ })();
585
+ const updated = getRow.get({ $id: id, $owner: owner });
586
+ const { collMap, favSet } = collectionsFor(owner, [id]);
587
+ return rowToRecord(updated, tagsFor([id]).get(id) ?? [], collMap.get(id) ?? [], favSet.has(id));
588
+ },
589
+ async replaceContent(ctx, id, upload) {
590
+ const owner = ownerOf(ctx);
591
+ const row = getRow.get({ $id: id, $owner: owner });
592
+ if (!row)
593
+ return null;
594
+ if (row.is_private === 1) {
595
+ throw new Error('replaceContent: private files cannot be replaced in place');
596
+ }
597
+ const isVideo = upload.mime.startsWith('video/');
598
+ // Store the new bytes FIRST — a failed write leaves the old row + bytes fully intact.
599
+ const key = isVideo ? fsSourceKey(owner, id) : fileBinaryKey(owner, id);
600
+ await store.putStream(key, upload.body, upload.mime, { contentLength: upload.size });
601
+ const size = (await store.stat(key))?.size ?? upload.size ?? 0;
602
+ // Wipe stale derivatives + the other-kind primary (all best-effort/idempotent), keeping the
603
+ // key the new bytes just landed at.
604
+ await Promise.all([
605
+ isVideo ? store.remove(fileBinaryKey(owner, id)) : store.remove(fsSourceKey(owner, id)),
606
+ store.remove(fsMp4Key(owner, id)),
607
+ store.remove(fsPosterKey(owner, id)),
608
+ store.remove(fsSpriteKey(owner, id)),
609
+ store.remove(fsVttKey(owner, id)),
610
+ store.removePrefix(fsHlsPrefix(owner, id)),
611
+ ]);
612
+ replaceMeta.run({
613
+ $id: id,
614
+ $owner: owner,
615
+ $name: upload.name ?? row.name,
616
+ $mime: upload.mime,
617
+ $size: size,
618
+ $type: classifyFileType(upload.mime),
619
+ $status: isVideo ? 'processing' : 'ready',
620
+ $progress: isVideo ? 0 : 100,
621
+ $now: now(),
622
+ });
623
+ if (isVideo)
624
+ void kickVideoProcessing(owner, id);
625
+ else if (upload.mime.startsWith('image/'))
626
+ void kickImageThumbnail(owner, id, upload.mime);
627
+ const updated = getRow.get({ $id: id, $owner: owner });
628
+ const { collMap, favSet } = collectionsFor(owner, [id]);
629
+ return rowToRecord(updated, tagsFor([id]).get(id) ?? [], collMap.get(id) ?? [], favSet.has(id));
630
+ },
631
+ async remove(ctx, id) {
632
+ const owner = ownerOf(ctx);
633
+ // Remove every key this id can have — removes are idempotent, and images have a
634
+ // poster too (kickImageThumbnail writes fsPosterKey), so no video-only branching:
635
+ // branching here is what used to orphan image thumbnails on disk.
636
+ await Promise.all([
637
+ store.remove(fileBinaryKey(owner, id)),
638
+ store.remove(fsSourceKey(owner, id)),
639
+ store.remove(fsMp4Key(owner, id)),
640
+ store.remove(fsPosterKey(owner, id)),
641
+ store.remove(fsSpriteKey(owner, id)),
642
+ store.remove(fsVttKey(owner, id)),
643
+ store.removePrefix(fsHlsPrefix(owner, id)),
644
+ ]);
645
+ db.transaction(() => {
646
+ deleteTags.run({ $file: id });
647
+ deleteFileMemberships.run({ $file: id, $owner: owner });
648
+ deleteFileViews.run({ $file: id });
649
+ deleteFile.run({ $id: id, $owner: owner });
650
+ })();
651
+ },
652
+ async trash(ctx, id) {
653
+ trashFile.run({ $id: id, $owner: ownerOf(ctx), $now: now() });
654
+ },
655
+ async restore(ctx, id) {
656
+ restoreFile.run({ $id: id, $owner: ownerOf(ctx), $now: now() });
657
+ },
658
+ async importFromPath(ctx, absPath, importOpts) {
659
+ const owner = ownerOf(ctx);
660
+ const extraTags = importOpts?.tags ?? [];
661
+ const imported = [];
662
+ const skipped = [];
663
+ const importOne = async (filePath, folderTag) => {
664
+ let st;
665
+ try {
666
+ st = await fsStat(filePath);
667
+ }
668
+ catch (err) {
669
+ skipped.push({ path: filePath, reason: err instanceof Error ? err.message : String(err) });
670
+ return;
671
+ }
672
+ if (!st.isFile())
673
+ return; // symlinks/devices/etc. — directories are handled by the caller
674
+ const name = path.basename(filePath);
675
+ const mime = mimeByExt(name);
676
+ const tags = folderTag ? [...extraTags, folderTag] : extraTags;
677
+ try {
678
+ const id = crypto.randomUUID();
679
+ const isVideo = mime.startsWith('video/');
680
+ const key = isVideo ? fsSourceKey(owner, id) : fileBinaryKey(owner, id);
681
+ await store.putStream(key, Bun.file(filePath).stream(), mime, {
682
+ contentLength: st.size,
683
+ });
684
+ const size = (await store.stat(key))?.size ?? st.size;
685
+ imported.push(finalizePublicRow(owner, id, { name, mime, tags }, size, now()));
686
+ }
687
+ catch (err) {
688
+ skipped.push({ path: filePath, reason: err instanceof Error ? err.message : String(err) });
689
+ }
690
+ };
691
+ let rootStat;
692
+ try {
693
+ rootStat = await fsStat(absPath);
694
+ }
695
+ catch (err) {
696
+ return {
697
+ imported: [],
698
+ skipped: [{ path: absPath, reason: err instanceof Error ? err.message : String(err) }],
699
+ };
700
+ }
701
+ if (rootStat.isFile()) {
702
+ await importOne(absPath);
703
+ return { imported, skipped };
704
+ }
705
+ // Directory: recurse, tagging each file with its immediate containing folder name so the
706
+ // origin structure survives as a (flat) tag facet rather than a tree the catalogue doesn't
707
+ // model. The top-level directory itself is NOT used as a tag (it's just the import root).
708
+ const walk = async (dir, isRoot) => {
709
+ let entries;
710
+ try {
711
+ entries = await readdir(dir, { withFileTypes: true, encoding: 'utf8' });
712
+ }
713
+ catch (err) {
714
+ skipped.push({ path: dir, reason: err instanceof Error ? err.message : String(err) });
715
+ return;
716
+ }
717
+ const folderTag = isRoot ? undefined : path.basename(dir);
718
+ for (const entry of entries) {
719
+ const entryPath = path.join(dir, entry.name);
720
+ if (entry.isDirectory())
721
+ await walk(entryPath, false);
722
+ else if (entry.isFile())
723
+ await importOne(entryPath, folderTag);
724
+ }
725
+ };
726
+ await walk(absPath, true);
727
+ return { imported, skipped };
728
+ },
729
+ recoverProcessing() {
730
+ // A restart mid-transcode leaves a row stuck in `processing`/`pending` — the in-process
731
+ // job is gone. For PUBLIC videos the source still sits at fsSourceKey, so re-kick the pipeline.
732
+ // PRIVATE rows can't be re-kicked at boot (the set key isn't available) — video or blob alike,
733
+ // mark them failed; the user re-unlocks + re-uploads. Public non-video rows are `ready` at
734
+ // insert and never appear here. (Ported from ca's recoverOnBoot; our jobs are in-process,
735
+ // so recovery is app-invoked at startup rather than a persistent queue.)
736
+ // Re-kicks are CAPPED at maxProcessingAttempts total kicks: if the transcode itself is what
737
+ // keeps killing the process (2026-07-04 prod outage: ffmpeg OOM on a 1GB host → systemd
738
+ // restart → unconditional re-kick → OOM again, forever), the row must land on `failed`
739
+ // instead of holding the whole service down.
740
+ const stuck = db
741
+ .query(`SELECT id, owner, is_private, processing_attempts FROM files
742
+ WHERE (file_type = 'video' OR is_private = 1)
743
+ AND processing_status IN ('pending', 'processing')`)
744
+ .all();
745
+ let requeued = 0;
746
+ let failed = 0;
747
+ for (const r of stuck) {
748
+ if (r.is_private === 1 || r.processing_attempts >= maxProcessingAttempts) {
749
+ setProcessing.run({ $id: r.id, $status: 'failed', $progress: 0, $now: now() });
750
+ failed++;
751
+ }
752
+ else {
753
+ void kickVideoProcessing(r.owner, r.id);
754
+ requeued++;
755
+ }
756
+ }
757
+ return { requeued, failed };
758
+ },
759
+ // Backfill grid thumbnails for PUBLIC video rows that finished (ready/failed) without a poster —
760
+ // e.g. rows that landed while transcodeVideos was off entirely, or that failed a full transcode
761
+ // before the poster step. Re-kicks the (now poster-capable) pipeline; the attempts counter is
762
+ // reset first so the maxProcessingAttempts cap doesn't pre-empt a fresh, cheaper poster-only run.
763
+ backfillVideoPosters() {
764
+ const missing = db
765
+ .query(`SELECT id, owner FROM files
766
+ WHERE file_type = 'video' AND is_private = 0 AND has_thumbnail = 0
767
+ AND processing_status IN ('ready', 'failed')`)
768
+ .all();
769
+ // Serialize: on the memory-constrained hosts this exists for, running every poster extract
770
+ // at once could spike RAM. Chain them so at most one ffmpeg job is live at a time.
771
+ void missing.reduce(async (prev, r) => {
772
+ await prev;
773
+ db.query(`UPDATE files SET processing_attempts = 0 WHERE id = $id`).run({ $id: r.id });
774
+ await kickVideoProcessing(r.owner, r.id).catch(() => { });
775
+ }, Promise.resolve());
776
+ return { requeued: missing.length };
777
+ },
778
+ // Backfill grid thumbnails for PUBLIC image rows with no poster — e.g. HEIC/HEVC iPhone photos
779
+ // whose sharp thumbnail silently failed before the ffmpeg fallback existed. Re-kicks the (now
780
+ // HEIC-capable) image pipeline, serialized so at most one ffmpeg decode runs at a time.
781
+ backfillImageThumbnails() {
782
+ // Candidates: images with no poster (has_thumbnail = 0), plus every browser-incompatible
783
+ // image (HEIC) — the latter may have a poster from before the display derivative existed but
784
+ // still lack the full-size display JPEG. The loop skips any HEIC whose display is already
785
+ // present, so a warm boot re-runs nothing.
786
+ const candidates = db
787
+ .query(`SELECT id, owner, mime, has_thumbnail FROM files
788
+ WHERE file_type = 'image' AND is_private = 0
789
+ AND (has_thumbnail = 0 OR mime IN ('image/heic', 'image/heif'))`)
790
+ .all();
791
+ void candidates.reduce(async (prev, r) => {
792
+ await prev;
793
+ if (r.has_thumbnail === 1 &&
794
+ !isBrowserRenderableImage(r.mime) &&
795
+ (await store.stat(fsDisplayKey(r.owner, r.id))))
796
+ return; // already fully derived — nothing to do
797
+ await kickImageThumbnail(r.owner, r.id, r.mime).catch(() => { });
798
+ }, Promise.resolve());
799
+ return { requeued: candidates.length };
800
+ },
801
+ // ── Collections ──────────────────────────────────────────────────────────────
802
+ async listCollections(ctx, opts2 = {}) {
803
+ const owner = ownerOf(ctx);
804
+ const visFilter = opts2.includeUnlockable ? '' : `AND fc.visibility != 'unlock'`;
805
+ const rows = db
806
+ .query(`SELECT fc.*, COUNT(fcf.file_id) as file_count
807
+ FROM file_collections fc
808
+ LEFT JOIN file_collection_files fcf ON fcf.collection_id = fc.id
809
+ WHERE fc.owner = $owner ${visFilter}
810
+ GROUP BY fc.id
811
+ ORDER BY fc.kind DESC, fc.name ASC`)
812
+ .all({ $owner: owner });
813
+ return rows.map((r) => collectionRowToRecord(r, r.file_count));
814
+ },
815
+ async createCollection(ctx, input) {
816
+ const owner = ownerOf(ctx);
817
+ const name = input.name.trim();
818
+ if (!name)
819
+ throw new Error('Collection name must not be empty');
820
+ const id = crypto.randomUUID();
821
+ const rows = insertCollection.all({
822
+ $id: id,
823
+ $owner: owner,
824
+ $name: name,
825
+ $kind: 'custom',
826
+ $visibility: input.visibility ?? 'normal',
827
+ $now: now(),
828
+ });
829
+ return collectionRowToRecord(rows[0], 0);
830
+ },
831
+ async updateCollection(ctx, id, patch) {
832
+ const owner = ownerOf(ctx);
833
+ const existing = getCollection.get({ $id: id, $owner: owner });
834
+ if (!existing)
835
+ return null;
836
+ const newName = patch.name !== undefined ? patch.name.trim() : existing.name;
837
+ if (patch.name !== undefined && !newName)
838
+ throw new Error('Collection name must not be empty');
839
+ updateCollectionRow.run({
840
+ $id: id,
841
+ $owner: owner,
842
+ $name: newName,
843
+ $visibility: patch.visibility ?? existing.visibility,
844
+ $now: now(),
845
+ });
846
+ const updated = getCollection.get({ $id: id, $owner: owner });
847
+ const count = db
848
+ .query(`SELECT COUNT(*) as c FROM file_collection_files WHERE collection_id = $id`)
849
+ .get({ $id: id })?.c ?? 0;
850
+ return collectionRowToRecord(updated, count);
851
+ },
852
+ async removeCollection(ctx, id) {
853
+ const owner = ownerOf(ctx);
854
+ const existing = getCollection.get({ $id: id, $owner: owner });
855
+ if (!existing)
856
+ return;
857
+ if (existing.kind === 'favorites')
858
+ throw new Error('The Favorites collection cannot be removed');
859
+ db.transaction(() => {
860
+ deleteCollectionMembers.run({ $id: id });
861
+ deleteCollection.run({ $id: id, $owner: owner });
862
+ })();
863
+ },
864
+ async addToCollection(ctx, collectionId, fileIds) {
865
+ const owner = ownerOf(ctx);
866
+ if (fileIds.length === 0)
867
+ return;
868
+ const ts = now();
869
+ db.transaction(() => {
870
+ for (const fid of fileIds) {
871
+ insertMember.run({ $cid: collectionId, $fid: fid, $owner: owner, $now: ts });
872
+ }
873
+ })();
874
+ },
875
+ async removeFromCollection(ctx, collectionId, fileIds) {
876
+ const owner = ownerOf(ctx);
877
+ if (fileIds.length === 0)
878
+ return;
879
+ db.transaction(() => {
880
+ for (const fid of fileIds) {
881
+ deleteMember.run({ $cid: collectionId, $fid: fid, $owner: owner });
882
+ }
883
+ })();
884
+ },
885
+ async setFavorite(ctx, fileId, favorite) {
886
+ const owner = ownerOf(ctx);
887
+ const ts = now();
888
+ const fav = getOrCreateFavorites(owner, ts);
889
+ if (favorite) {
890
+ insertMember.run({ $cid: fav.id, $fid: fileId, $owner: owner, $now: ts });
891
+ }
892
+ else {
893
+ deleteMember.run({ $cid: fav.id, $fid: fileId, $owner: owner });
894
+ }
895
+ },
896
+ async recordView(ctx, fileId) {
897
+ // Upsert-gated to the caller's own files; unknown/other-owner ids record nothing.
898
+ recordViewStmt.run({ $id: fileId, $owner: ownerOf(ctx), $now: now() });
899
+ },
900
+ async ingestPrivate(ctx, upload, cfg) {
901
+ const owner = ownerOf(ctx);
902
+ const id = crypto.randomUUID();
903
+ const ts = now();
904
+ // Seal the descriptor NOW — sealing is independent of the transcode (only the wrapped content
905
+ // KEY needs the pipeline). Storing enc_name at insert means a try-a-password reveal matches the
906
+ // item immediately, before its HLS finishes encrypting (no seal race).
907
+ const descriptor = {
908
+ name: upload.name,
909
+ mime: upload.mime,
910
+ tags: upload.tags ?? [],
911
+ };
912
+ const sealed = sealMeta(cfg.setKey, descriptor);
913
+ const isVideo = upload.mime.startsWith('video/');
914
+ insertPrivate.run({
915
+ $id: id,
916
+ $owner: owner,
917
+ $size: 0,
918
+ $type: classifyFileType(upload.mime),
919
+ $mode: cfg.keyMode,
920
+ $status: 'processing',
921
+ $progress: 0,
922
+ $sealed: sealed,
923
+ $wrapped: '',
924
+ $rand: randKey(),
925
+ $now: ts,
926
+ });
927
+ // Stage plaintext to a scratch file, then encrypt: video → AES-128 HLS ladder; anything
928
+ // else (photo/doc/audio) → one AES-128-CTR blob at privateSourceKey. Plaintext is never
929
+ // persisted either way. We MUST await: the wrapped key is produced by the pipeline and
930
+ // there is nothing to serve without it.
931
+ const srcPath = path.join(workDir, 'ingest', `${owner}-${id}.src`);
932
+ let size = 0;
933
+ try {
934
+ size = await streamToFile(upload.body, srcPath);
935
+ const res = isVideo
936
+ ? await processPrivateVideo({
937
+ store,
938
+ workDir,
939
+ owner,
940
+ id,
941
+ sourcePath: srcPath,
942
+ setKey: cfg.setKey,
943
+ ladderHeights: opts.ladderHeights,
944
+ onProgress: (status, pct) => setProcessing.run({ $id: id, $status: status, $progress: pct, $now: now() }),
945
+ })
946
+ : {
947
+ ...(await processPrivateBlob({
948
+ store,
949
+ owner,
950
+ id,
951
+ sourcePath: srcPath,
952
+ setKey: cfg.setKey,
953
+ })),
954
+ durationSeconds: 0,
955
+ hasHls: false,
956
+ };
957
+ finishPrivate.run({
958
+ $id: id,
959
+ $status: res.status,
960
+ $progress: res.status === 'ready' ? 100 : 0,
961
+ $hls: res.hasHls ? 1 : 0,
962
+ $duration: res.durationSeconds || null,
963
+ $size: size,
964
+ $sealed: sealed,
965
+ $wrapped: res.wrappedContentKey ?? '',
966
+ $now: now(),
967
+ });
968
+ }
969
+ finally {
970
+ await rm(srcPath, { force: true }).catch(() => { });
971
+ }
972
+ const row = getRow.get({ $id: id, $owner: owner });
973
+ return {
974
+ id,
975
+ size,
976
+ createdAt: ts,
977
+ processingStatus: row.processing_status,
978
+ processingProgress: row.processing_progress,
979
+ hasHls: row.has_hls === 1,
980
+ };
981
+ },
982
+ asPrivateIndex() {
983
+ // A PrivateIndex view over the catalogue's private rows — the seam mountPrivateRoutes reads.
984
+ // `owner` here is the private owner axis the routes pass (a userId), matching how ingestPrivate
985
+ // stored the row (both go through the files.owner column).
986
+ return {
987
+ async add(item) {
988
+ insertPrivate.run({
989
+ $id: item.id,
990
+ $owner: item.owner,
991
+ $size: item.sizeBytes,
992
+ // External index writers predate blob items and only ever added videos.
993
+ $type: 'video',
994
+ $mode: item.keyMode,
995
+ $status: item.status,
996
+ $progress: item.status === 'ready' ? 100 : 0,
997
+ $sealed: item.sealedMeta,
998
+ $wrapped: item.wrappedContentKey,
999
+ $rand: randKey(),
1000
+ $now: now(),
1001
+ });
1002
+ },
1003
+ async get(owner, id) {
1004
+ const row = getPrivateRow.get({ $id: id, $owner: owner });
1005
+ return row ? rowToPrivateItem(row, owner) : null;
1006
+ },
1007
+ async list(owner) {
1008
+ return listPrivateFullRows.all({ $owner: owner }).map((r) => rowToPrivateItem(r, owner));
1009
+ },
1010
+ async update(owner, id, patch) {
1011
+ const cur = getPrivateRow.get({ $id: id, $owner: owner });
1012
+ if (!cur)
1013
+ return;
1014
+ const merged = { ...rowToPrivateItem(cur, owner), ...patch };
1015
+ updatePrivateRow.run({
1016
+ $id: id,
1017
+ $owner: owner,
1018
+ $size: merged.sizeBytes,
1019
+ $status: merged.status,
1020
+ $sealed: merged.sealedMeta,
1021
+ $wrapped: merged.wrappedContentKey,
1022
+ $mode: merged.keyMode,
1023
+ $now: now(),
1024
+ });
1025
+ },
1026
+ async remove(owner, id) {
1027
+ await store.removePrefix(privateFsKey(owner, id));
1028
+ deletePrivateRow.run({ $id: id, $owner: owner });
1029
+ },
1030
+ };
1031
+ },
1032
+ };
1033
+ }