@@ -2,6 +2,8 @@ import { Router, Request, Response, NextFunction } from 'express';
22import multer from 'multer' ;
33import path from 'path' ;
44import fs from 'fs' ;
5+ import https from 'https' ;
6+ import http from 'http' ;
57import { AppDataSource } from '../data-source' ;
68import {
79 DocumentEntity ,
@@ -47,6 +49,50 @@ const upload = multer({
4749 limits : { fileSize : 500 * 1024 * 1024 } , // 500 MB
4850} ) ;
4951
52+ // Multer instance for cover image uploads
53+ const coverUpload = multer ( {
54+ storage : multer . memoryStorage ( ) ,
55+ fileFilter : ( _req , file , cb ) => {
56+ if ( file . mimetype . startsWith ( 'image/' ) ) {
57+ cb ( null , true ) ;
58+ } else {
59+ cb ( new Error ( 'Only image files are allowed for covers' ) ) ;
60+ }
61+ } ,
62+ limits : { fileSize : 10 * 1024 * 1024 } , // 10 MB
63+ } ) ;
64+
65+ /**
66+ * Download an image from a URL and return the buffer + content type.
67+ */
68+ function downloadImage ( url : string ) : Promise < { buffer : Buffer ; contentType : string } > {
69+ return new Promise ( ( resolve , reject ) => {
70+ const client = url . startsWith ( 'https' ) ? https : http ;
71+ const request = client . get ( url , { timeout : 10000 } , ( response ) => {
72+ // Follow redirects
73+ if ( response . statusCode && response . statusCode >= 300 && response . statusCode < 400 && response . headers . location ) {
74+ return downloadImage ( response . headers . location ) . then ( resolve ) . catch ( reject ) ;
75+ }
76+ if ( response . statusCode !== 200 ) {
77+ return reject ( new Error ( `HTTP ${ response . statusCode } ` ) ) ;
78+ }
79+ const chunks : Buffer [ ] = [ ] ;
80+ response . on ( 'data' , ( chunk : Buffer ) => chunks . push ( chunk ) ) ;
81+ response . on ( 'end' , ( ) => {
82+ const buffer = Buffer . concat ( chunks ) ;
83+ const contentType = response . headers [ 'content-type' ] || 'image/jpeg' ;
84+ resolve ( { buffer, contentType } ) ;
85+ } ) ;
86+ response . on ( 'error' , reject ) ;
87+ } ) ;
88+ request . on ( 'error' , reject ) ;
89+ request . on ( 'timeout' , ( ) => {
90+ request . destroy ( ) ;
91+ reject ( new Error ( 'Download timeout' ) ) ;
92+ } ) ;
93+ } ) ;
94+ }
95+
5096// Helper: get document repo with full relations
5197function getDocRepo ( ) {
5298 return AppDataSource . getRepository ( DocumentEntity ) ;
@@ -191,6 +237,21 @@ router.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
191237 if ( metadata . publishYear !== undefined ) metaEntity . publishYear = metadata . publishYear ;
192238 if ( metadata . isbn !== undefined ) metaEntity . isbn = metadata . isbn ;
193239 if ( metadata . coverUrl !== undefined ) metaEntity . coverUrl = metadata . coverUrl ;
240+
241+ // If coverUrl is an external URL, download and store as blob
242+ if ( metadata . coverUrl && / ^ h t t p s ? : \/ \/ / . test ( metadata . coverUrl ) ) {
243+ try {
244+ const { buffer, contentType } = await downloadImage ( metadata . coverUrl ) ;
245+ metaEntity . coverImage = buffer ;
246+ metaEntity . coverImageType = contentType || 'image/jpeg' ;
247+ // Clear the external URL since we now have the blob
248+ metaEntity . coverUrl = null ;
249+ } catch ( downloadErr ) {
250+ logger . warn ( `Failed to download cover image from ${ metadata . coverUrl } :` , downloadErr ) ;
251+ // Keep the external URL as fallback
252+ }
253+ }
254+
194255 if ( metadata . description !== undefined ) metaEntity . description = metadata . description ;
195256 if ( metadata . pageCount !== undefined ) metaEntity . pageCount = metadata . pageCount ;
196257 if ( metadata . openLibraryKey !== undefined ) metaEntity . openLibraryKey = metadata . openLibraryKey ;
@@ -242,6 +303,80 @@ router.delete('/:id', async (req: Request, res: Response, next: NextFunction) =>
242303 }
243304} ) ;
244305
306+ // ===== COVER IMAGE =====
307+
308+ /** GET /api/documents/:id/cover — serve stored cover image */
309+ router . get ( '/:id/cover' , async ( req : Request , res : Response , next : NextFunction ) => {
310+ try {
311+ const metaRepo = AppDataSource . getRepository ( BookMetadataEntity ) ;
312+ const meta = await metaRepo . findOne ( { where : { documentId : req . params . id } } ) ;
313+ if ( ! meta || ! meta . coverImage ) {
314+ return res . status ( 404 ) . json ( { error : { message : 'No cover image stored' } } ) ;
315+ }
316+
317+ res . setHeader ( 'Content-Type' , meta . coverImageType || 'image/jpeg' ) ;
318+ res . setHeader ( 'Cache-Control' , 'public, max-age=86400' ) ;
319+ res . send ( meta . coverImage ) ;
320+ } catch ( err ) {
321+ next ( err ) ;
322+ }
323+ } ) ;
324+
325+ /** PUT /api/documents/:id/cover — upload a cover image directly */
326+ router . put ( '/:id/cover' , coverUpload . single ( 'cover' ) , async ( req : Request , res : Response , next : NextFunction ) => {
327+ try {
328+ const docRepo = getDocRepo ( ) ;
329+ const doc = await docRepo . findOne ( { where : { id : req . params . id } } ) ;
330+ if ( ! doc ) return res . status ( 404 ) . json ( { error : { message : 'Document not found' } } ) ;
331+
332+ const metaRepo = AppDataSource . getRepository ( BookMetadataEntity ) ;
333+ let meta = await metaRepo . findOne ( { where : { documentId : req . params . id } } ) ;
334+ if ( ! meta ) {
335+ meta = metaRepo . create ( { documentId : req . params . id } ) ;
336+ }
337+
338+ if ( req . file ) {
339+ // Direct file upload
340+ meta . coverImage = req . file . buffer ;
341+ meta . coverImageType = req . file . mimetype ;
342+ meta . coverUrl = null ; // Clear external URL
343+ } else if ( req . body . coverUrl && / ^ h t t p s ? : \/ \/ / . test ( req . body . coverUrl ) ) {
344+ // URL provided — download and store
345+ const { buffer, contentType } = await downloadImage ( req . body . coverUrl ) ;
346+ meta . coverImage = buffer ;
347+ meta . coverImageType = contentType ;
348+ meta . coverUrl = null ;
349+ } else {
350+ return res . status ( 400 ) . json ( { error : { message : 'Provide a cover file or coverUrl' } } ) ;
351+ }
352+
353+ await metaRepo . save ( meta ) ;
354+
355+ const fullDoc = await loadFullDocument ( req . params . id ) ;
356+ res . json ( toDocumentDTO ( fullDoc ! ) ) ;
357+ } catch ( err ) {
358+ next ( err ) ;
359+ }
360+ } ) ;
361+
362+ /** DELETE /api/documents/:id/cover — remove stored cover image */
363+ router . delete ( '/:id/cover' , async ( req : Request , res : Response , next : NextFunction ) => {
364+ try {
365+ const metaRepo = AppDataSource . getRepository ( BookMetadataEntity ) ;
366+ const meta = await metaRepo . findOne ( { where : { documentId : req . params . id } } ) ;
367+ if ( ! meta ) return res . status ( 404 ) . json ( { error : { message : 'Metadata not found' } } ) ;
368+
369+ meta . coverImage = null ;
370+ meta . coverImageType = null ;
371+ meta . coverUrl = null ;
372+ await metaRepo . save ( meta ) ;
373+
374+ res . status ( 204 ) . send ( ) ;
375+ } catch ( err ) {
376+ next ( err ) ;
377+ }
378+ } ) ;
379+
245380/** GET /api/documents/:id/file — download/stream document file */
246381router . get ( '/:id/file' , async ( req : Request , res : Response , next : NextFunction ) => {
247382 try {
0 commit comments