@@ -41,6 +41,12 @@ import { createRouter, searchBooks } from "./routes.tsx";
4141import sqliteKv from "./sqlite-kv.ts" ;
4242import type { HiveId } from "./types.ts" ;
4343import { createBatchTransform } from "./utils/batchTransform.ts" ;
44+ import {
45+ cleanupExportPaths ,
46+ createExportReadStream ,
47+ createSanitizedExportArchive ,
48+ isAuthorizedExportRequest ,
49+ } from "./utils/dbExport.ts" ;
4450import {
4551 getGoodreadsCsvParser ,
4652 getStorygraphCsvParser ,
@@ -55,6 +61,8 @@ import {
5561
5662import { lazy } from "./utils/lazy.ts" ;
5763import { readThroughCache } from "./utils/readThroughCache.ts" ;
64+ import fs from "node:fs" ;
65+ import path from "node:path" ;
5866
5967// Application state passed to the router and elsewhere
6068export type AppContext = {
@@ -255,6 +263,133 @@ export class Server {
255263 app . use ( "*" , registerMetrics ) ;
256264 app . get ( "/metrics" , printMetrics ) ;
257265
266+ // Download a sanitized SQLite export bundle (db + kv without auth tables)
267+ app . get ( "/admin/export" , async ( c ) => {
268+ const ctx = c . get ( "ctx" ) ;
269+ const clientIp =
270+ c . req . header ( "x-forwarded-for" ) ?. split ( "," ) [ 0 ] . trim ( ) ||
271+ c . req . header ( "x-real-ip" ) ||
272+ "unknown" ;
273+
274+ try {
275+ // Hide endpoint if not configured
276+ if ( ! env . EXPORT_SHARED_SECRET ) {
277+ ctx . logger . warn (
278+ { ip : clientIp , reason : "endpoint_not_configured" } ,
279+ "export endpoint access attempt - endpoint disabled" ,
280+ ) ;
281+ return c . json ( { message : "Not Found" } , 404 ) ;
282+ }
283+
284+ // Check authorization
285+ const authorization = c . req . header ( "authorization" ) ;
286+ if (
287+ ! isAuthorizedExportRequest ( {
288+ authorizationHeader : authorization ,
289+ sharedSecret : env . EXPORT_SHARED_SECRET ,
290+ } )
291+ ) {
292+ ctx . logger . warn (
293+ { ip : clientIp , reason : "invalid_authorization" } ,
294+ "export endpoint unauthorized access attempt" ,
295+ ) ;
296+ return c . json ( { message : "Not Found" } , 404 ) ;
297+ }
298+
299+ // Validate database path
300+ if ( ! env . DB_PATH || env . DB_PATH === ":memory:" ) {
301+ ctx . logger . error (
302+ { ip : clientIp , dbPath : env . DB_PATH } ,
303+ "export endpoint called but DB_PATH is not a file path" ,
304+ ) ;
305+ return c . json (
306+ { message : "DB exports require DB_PATH to be a file path" } ,
307+ 400 ,
308+ ) ;
309+ }
310+
311+ const exportDir =
312+ env . DB_EXPORT_DIR ?. trim ( ) ||
313+ path . join ( path . dirname ( env . DB_PATH ) , "exports" ) ;
314+
315+ ctx . logger . info (
316+ { ip : clientIp , exportDir } ,
317+ "starting database export" ,
318+ ) ;
319+
320+ const startTime = Date . now ( ) ;
321+ let result ;
322+
323+ try {
324+ await fs . promises . mkdir ( exportDir , { recursive : true } ) ;
325+
326+ const includeKv =
327+ Boolean ( env . KV_DB_PATH ) &&
328+ env . KV_DB_PATH !== ":memory:" &&
329+ fs . existsSync ( env . KV_DB_PATH ) ;
330+
331+ result = await createSanitizedExportArchive ( {
332+ dbPath : env . DB_PATH ,
333+ kvPath : includeKv ? env . KV_DB_PATH : undefined ,
334+ exportDir,
335+ includeKv,
336+ } ) ;
337+ } catch ( err ) {
338+ const duration = Date . now ( ) - startTime ;
339+ ctx . logger . error (
340+ {
341+ ip : clientIp ,
342+ duration,
343+ error : err instanceof Error ? err . message : String ( err ) ,
344+ stack : err instanceof Error ? err . stack : undefined ,
345+ } ,
346+ "database export failed" ,
347+ ) ;
348+ return c . json ( { message : "Failed to create export archive" } , 500 ) ;
349+ }
350+
351+ const duration = Date . now ( ) - startTime ;
352+ const stream = createExportReadStream ( result . archivePath , {
353+ onClose : ( ) => {
354+ ctx . logger . info (
355+ { ip : clientIp , filename : result . filename , duration } ,
356+ "database export completed successfully" ,
357+ ) ;
358+ cleanupExportPaths ( {
359+ archivePath : result . archivePath ,
360+ tmpDir : result . tmpDir ,
361+ } ) ;
362+ } ,
363+ onError : ( err ) => {
364+ ctx . logger . error (
365+ { ip : clientIp , filename : result . filename , error : err . message } ,
366+ "error streaming export file" ,
367+ ) ;
368+ cleanupExportPaths ( {
369+ archivePath : result . archivePath ,
370+ tmpDir : result . tmpDir ,
371+ } ) ;
372+ } ,
373+ } ) ;
374+
375+ return c . body ( stream , 200 , {
376+ "Content-Type" : "application/gzip" ,
377+ "Content-Encoding" : "gzip" ,
378+ "Content-Disposition" : `attachment; filename="${ result . filename } "` ,
379+ "Cache-Control" : "no-store" ,
380+ } ) ;
381+ } catch ( err ) {
382+ ctx . logger . error (
383+ {
384+ ip : clientIp ,
385+ error : err instanceof Error ? err . message : String ( err ) ,
386+ } ,
387+ "unexpected error in export endpoint" ,
388+ ) ;
389+ return c . json ( { message : "Internal server error" } , 500 ) ;
390+ }
391+ } ) ;
392+
258393 // This is to import a Goodreads CSV export
259394 // It is here because we don't want it behind the etag middleware
260395 app . post (
0 commit comments