Initial commit
This commit is contained in:
+345
@@ -0,0 +1,345 @@
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { Transform } from 'node:stream';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { openAppDb, resolveTimeZone, listBooks, getBook, setBookReadOverride, getDashboard, getCalendar, createPairingCode, getPairingStatus, consumePairingCode, authenticateDevice, listDevices, revokeDevice, updateDevicePluginVersion, recordDeviceSync, getStatus } from './lib/db.mjs';
|
||||
import { importKoReaderDb, importPluginPayload, removeExcludedBooks } from './lib/importer.mjs';
|
||||
import { scheduleCoverWork, saveEmbeddedCover, saveManualCover, queueOnlineCoverRetry, getBookCoverState, findCoverCandidates, selectCoverCandidate, selectCoverHistory } from './lib/covers.mjs';
|
||||
import { json, randomId, cleanText } from './lib/security.mjs';
|
||||
import { info, warn, error as logError } from './lib/logger.mjs';
|
||||
import { createBackupArchive, sendBooksCsv, sendHighlightsMarkdown } from './lib/exports.mjs';
|
||||
|
||||
const VERSION = '2026.09.25';
|
||||
const LATEST_PLUGIN_VERSION = '2026.09.25';
|
||||
const MIN_COVER_PLUGIN_VERSION = '2026.09.25';
|
||||
const MIN_INCREMENTAL_PLUGIN_VERSION = '2026.09.25';
|
||||
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = path.resolve(process.env.DATA_PATH || path.join(ROOT, 'data'));
|
||||
const PUBLIC_DIR = path.join(ROOT, 'public');
|
||||
const UPLOAD_DIR = path.join(DATA_DIR, 'uploads');
|
||||
const COVER_DIR = path.join(DATA_DIR, 'covers');
|
||||
const HOST = process.env.HOST || '127.0.0.1';
|
||||
const PORT = Number(process.env.PORT || 3000);
|
||||
const TIME_ZONE = resolveTimeZone(process.env.TZ);
|
||||
const MAX_FILE_BYTES = Number(process.env.MAX_FILE_SIZE_MB || 100) * 1024 * 1024;
|
||||
const MAX_JSON_BYTES = Number(process.env.MAX_JSON_MB || 20) * 1024 * 1024;
|
||||
const MAX_COVER_BYTES = 4 * 1024 * 1024;
|
||||
const WEB_CSRF = randomId(24);
|
||||
const pairAttempts = new Map();
|
||||
|
||||
function versionParts(value){return String(value||'0').split('.').map(x=>Number.parseInt(x,10)||0).slice(0,3)}
|
||||
function versionAtLeast(value,minimum){const a=versionParts(value),b=versionParts(minimum);for(let i=0;i<3;i++){if((a[i]||0)>(b[i]||0))return true;if((a[i]||0)<(b[i]||0))return false}return true}
|
||||
function deviceView(d){return {...d,needs_update:!versionAtLeast(d.plugin_version,LATEST_PLUGIN_VERSION),supports_cover_sync:versionAtLeast(d.plugin_version,MIN_COVER_PLUGIN_VERSION),supports_incremental_sync:versionAtLeast(d.plugin_version,MIN_INCREMENTAL_PLUGIN_VERSION),latest_plugin_version:LATEST_PLUGIN_VERSION,min_cover_plugin_version:MIN_COVER_PLUGIN_VERSION,min_incremental_plugin_version:MIN_INCREMENTAL_PLUGIN_VERSION}}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
fs.mkdirSync(COVER_DIR, { recursive: true });
|
||||
fs.accessSync(DATA_DIR, fs.constants.R_OK | fs.constants.W_OK);
|
||||
} catch (err) {
|
||||
if (err?.code === 'EACCES') {
|
||||
logError('startup.storage','kovi cannot write to its data directory.',{dataDir:DATA_DIR});
|
||||
logError('startup.storage.hint','For Docker bind mounts, make sure the host data directory is writable by the container user, or use the default named-volume docker-compose.yml.');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const db = openAppDb(DATA_DIR, {timeZone:TIME_ZONE});
|
||||
const excludedOnStartup = removeExcludedBooks(db);
|
||||
if (excludedOnStartup) info('library.cleanup','Removed excluded KOReader manual entries already present in the library.',{removed:excludedOnStartup});
|
||||
scheduleCoverWork(db, DATA_DIR, [], {reason:'startup'});
|
||||
|
||||
function securityHeaders(extra={}) {
|
||||
return {
|
||||
'X-Content-Type-Options':'nosniff',
|
||||
'X-Frame-Options':'DENY',
|
||||
'Referrer-Policy':'no-referrer',
|
||||
'Permissions-Policy':'camera=(), microphone=(), geolocation=()',
|
||||
'Cross-Origin-Resource-Policy':'same-origin',
|
||||
'Content-Security-Policy':"default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
async function readJson(req, max=MAX_JSON_BYTES) {
|
||||
const chunks=[]; let total=0;
|
||||
for await (const chunk of req) {
|
||||
total += chunk.length;
|
||||
if(total > max) throw Object.assign(new Error('JSON payload too large.'),{status:413});
|
||||
chunks.push(chunk);
|
||||
}
|
||||
if(!total) return {};
|
||||
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); }
|
||||
catch { throw Object.assign(new Error('Invalid JSON.'),{status:400}); }
|
||||
}
|
||||
|
||||
async function readBinary(req, max=MAX_COVER_BYTES) {
|
||||
const declared=Number(req.headers['content-length']||0);
|
||||
if(declared && declared>max) throw Object.assign(new Error('Cover upload is too large.'),{status:413});
|
||||
const chunks=[]; let total=0;
|
||||
for await (const chunk of req) {
|
||||
total += chunk.length;
|
||||
if(total > max) throw Object.assign(new Error('Cover upload is too large.'),{status:413});
|
||||
chunks.push(chunk);
|
||||
}
|
||||
if(!total) throw Object.assign(new Error('Cover upload was empty.'),{status:400});
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function routeParam(pathname, pattern) {
|
||||
const m = pattern.exec(pathname); return m ? m.slice(1).map(decodeURIComponent) : null;
|
||||
}
|
||||
|
||||
function requireWebCsrf(req) {
|
||||
if (req.headers['x-kovi-csrf'] !== WEB_CSRF) throw Object.assign(new Error('Missing browser request token.'), {status:403});
|
||||
}
|
||||
function checkPairRate(req) {
|
||||
const key=req.socket.remoteAddress || 'unknown', now=Date.now(), windowMs=10*60_000;
|
||||
const row=pairAttempts.get(key) || {start:now,count:0};
|
||||
if(now-row.start>windowMs){row.start=now;row.count=0}
|
||||
row.count++;pairAttempts.set(key,row);
|
||||
if(row.count>30) throw Object.assign(new Error('Too many pairing attempts. Try again later.'),{status:429});
|
||||
}
|
||||
|
||||
function storageStats(){
|
||||
let databaseBytes=0,coverBytes=0,coverFiles=0;
|
||||
try{databaseBytes=fs.statSync(path.join(DATA_DIR,'kovi.sqlite')).size}catch{}
|
||||
try{for(const name of fs.readdirSync(COVER_DIR)){try{const st=fs.statSync(path.join(COVER_DIR,name));if(st.isFile()){coverBytes+=st.size;coverFiles++}}catch{}}}catch{}
|
||||
return {database_bytes:databaseBytes,cover_bytes:coverBytes,cover_files:coverFiles,total_bytes:databaseBytes+coverBytes};
|
||||
}
|
||||
|
||||
async function handleApi(req,res,url) {
|
||||
if(req.method==='GET' && url.pathname==='/api/session') return json(res,200,{csrf:WEB_CSRF},securityHeaders());
|
||||
if(req.method==='GET' && url.pathname==='/api/health') return json(res,200,{ok:true,name:'kovi',version:VERSION},securityHeaders());
|
||||
if(req.method==='GET' && url.pathname==='/api/plugin/ping') {
|
||||
info('plugin.ping','KOReader ping received.',{remote:req.socket.remoteAddress || 'unknown',userAgent:cleanText(req.headers['user-agent'],120) || 'none'});
|
||||
return json(res,200,{ok:true,name:'kovi',version:VERSION},securityHeaders());
|
||||
}
|
||||
if(req.method==='GET' && url.pathname==='/api/books') return json(res,200,{books:listBooks(db)},securityHeaders());
|
||||
const bookRoute=routeParam(url.pathname,/^\/api\/books\/([^/]+)$/);
|
||||
if(req.method==='GET' && bookRoute){ const book=getBook(db,bookRoute[0]); if(!book)return json(res,404,{error:'Book not found.'},securityHeaders()); const covers=getBookCoverState(db,book.id); return json(res,200,{...book,cover_history:covers?.history||[],cover_candidates:covers?.candidates||[],cover_job:covers?.job||null},securityHeaders()); }
|
||||
const bookReadRoute=routeParam(url.pathname,/^\/api\/books\/([^/]+)\/read$/);
|
||||
if(req.method==='PUT' && bookReadRoute){
|
||||
requireWebCsrf(req);
|
||||
const body=await readJson(req,4096);
|
||||
if(body.read!==null && typeof body.read!=='boolean') return json(res,400,{error:'read must be true, false, or null.'},securityHeaders());
|
||||
const book=setBookReadOverride(db,bookReadRoute[0],body.read);
|
||||
return json(res,book?200:404,book||{error:'Book not found.'},securityHeaders());
|
||||
}
|
||||
if(req.method==='GET' && url.pathname==='/api/dashboard') return json(res,200,getDashboard(db,{from:url.searchParams.get('from'),to:url.searchParams.get('to')}),securityHeaders());
|
||||
if(req.method==='GET' && url.pathname==='/api/calendar') return json(res,200,getCalendar(db,{from:url.searchParams.get('from'),to:url.searchParams.get('to')}),securityHeaders());
|
||||
if(req.method==='GET' && url.pathname==='/api/devices') return json(res,200,{devices:listDevices(db).map(deviceView),latest_plugin_version:LATEST_PLUGIN_VERSION},securityHeaders());
|
||||
if(req.method==='GET' && url.pathname==='/api/status') return json(res,200,{...getStatus(db),storage:storageStats(),version:VERSION,latest_plugin_version:LATEST_PLUGIN_VERSION,time_zone:TIME_ZONE,generated_at:new Date().toISOString()},securityHeaders({'Cache-Control':'no-store'}));
|
||||
if(req.method==='GET' && url.pathname==='/api/export/books.csv'){sendBooksCsv(res,db,securityHeaders());return true}
|
||||
if(req.method==='GET' && url.pathname==='/api/export/highlights.md'){sendHighlightsMarkdown(res,db,securityHeaders());return true}
|
||||
if(req.method==='GET' && url.pathname==='/api/backup'){
|
||||
const backupPath=path.join(UPLOAD_DIR,`kovi-backup-${Date.now()}-${randomId(6)}.tar.gz`);
|
||||
await createBackupArchive(db,DATA_DIR,backupPath,{version:VERSION});
|
||||
try{const st=await fsp.stat(backupPath);res.writeHead(200,securityHeaders({'Content-Type':'application/gzip','Content-Disposition':`attachment; filename="kovi-backup-${new Date().toISOString().slice(0,10)}.tar.gz"`,'Content-Length':st.size,'Cache-Control':'no-store'}));await pipeline(fs.createReadStream(backupPath),res)}finally{await fsp.rm(backupPath,{force:true}).catch(()=>{})}
|
||||
return true;
|
||||
}
|
||||
|
||||
if(req.method==='POST' && url.pathname==='/api/pairing-codes') {
|
||||
requireWebCsrf(req);
|
||||
const body=await readJson(req,4096);
|
||||
const label=cleanText(body.label,80)||'KOReader';
|
||||
const pair=createPairingCode(db,label);
|
||||
info('pairing.code.created','Created a short-lived pairing request.',{requestId:pair.requestId,label,expiresAt:new Date(pair.expiresAt).toISOString()});
|
||||
return json(res,201,pair,securityHeaders());
|
||||
}
|
||||
|
||||
const pairingStatus=routeParam(url.pathname,/^\/api\/pairing-codes\/([^/]+)$/);
|
||||
if(req.method==='GET' && pairingStatus){
|
||||
const status=getPairingStatus(db,pairingStatus[0]);
|
||||
return json(res,status?200:404,status||{error:'Pairing request not found.'},securityHeaders());
|
||||
}
|
||||
|
||||
const revoke=routeParam(url.pathname,/^\/api\/devices\/([^/]+)\/revoke$/);
|
||||
if(req.method==='POST' && revoke) {
|
||||
requireWebCsrf(req);
|
||||
const changed=revokeDevice(db,revoke[0]);
|
||||
if(changed) info('device.revoked','Revoked a KOReader device token.',{deviceId:revoke[0]});
|
||||
return json(res,changed?200:404,{ok:changed},securityHeaders());
|
||||
}
|
||||
|
||||
if(req.method==='POST' && url.pathname==='/api/plugin/pair') {
|
||||
checkPairRate(req);
|
||||
const body=await readJson(req,32*1024);
|
||||
const paired=consumePairingCode(db,body.code,{deviceId:body.device_id,model:cleanText(body.model,120),version:cleanText(body.version,40)});
|
||||
if(!paired) {
|
||||
warn('pairing.rejected','Pairing attempt rejected because the code was invalid, expired, or already used.',{remote:req.socket.remoteAddress || 'unknown',deviceId:cleanText(body.device_id,128),model:cleanText(body.model,120)});
|
||||
return json(res,401,{error:'Pairing code is invalid, expired, or already used.'},securityHeaders());
|
||||
}
|
||||
info('device.paired','KOReader paired successfully.',{deviceId:paired.deviceId,model:cleanText(body.model,120),pluginVersion:cleanText(body.version,40),remote:req.socket.remoteAddress || 'unknown'});
|
||||
if(!versionAtLeast(body.version,MIN_COVER_PLUGIN_VERSION)) warn('plugin.outdated','Paired KOReader plugin is too old for embedded-cover and ISBN sync.',{deviceId:paired.deviceId,pluginVersion:cleanText(body.version,40),minimumVersion:MIN_COVER_PLUGIN_VERSION,latestVersion:LATEST_PLUGIN_VERSION});
|
||||
return json(res,200,{message:'KOReader paired with kovi.',token:paired.token,device_id:paired.deviceId,latest_plugin_version:LATEST_PLUGIN_VERSION,update_recommended:!versionAtLeast(body.version,LATEST_PLUGIN_VERSION)},securityHeaders());
|
||||
}
|
||||
|
||||
if(req.method==='POST' && url.pathname==='/api/plugin/import') {
|
||||
const device=authenticateDevice(db,req.headers.authorization);
|
||||
if(!device) {
|
||||
warn('plugin.sync.rejected','Rejected plugin sync with missing or invalid device token.',{remote:req.socket.remoteAddress || 'unknown'});
|
||||
return json(res,401,{error:'Device token is missing or invalid.'},securityHeaders());
|
||||
}
|
||||
const body=await readJson(req);
|
||||
const reportedVersion=cleanText(body?.version,40)||device.plugin_version||'';
|
||||
if(reportedVersion) updateDevicePluginVersion(db,device.id,reportedVersion);
|
||||
info('plugin.sync.start','KOReader sync started.',{deviceId:device.id,books:Array.isArray(body?.books)?body.books.length:0,stats:Array.isArray(body?.stats)?body.stats.length:0,annotationSets:Array.isArray(body?.annotation_sets)?body.annotation_sets.length:0});
|
||||
const result=importPluginPayload(db,body,device);
|
||||
const coverQueueIds=[...new Set([...(result.newBookIds||[]),...(result.coverRefreshIds||[])])];
|
||||
scheduleCoverWork(db,DATA_DIR,coverQueueIds,{reason:`plugin-sync:${device.id}`,delayMs:(result.coverRequests?.length||0)>0?4000:50});
|
||||
if((result.coverRequests?.length||0)>0 && !versionAtLeast(reportedVersion,MIN_COVER_PLUGIN_VERSION)) warn('plugin.outdated.cover-sync','KOReader ignored server cover requests because its plugin predates embedded-cover sync. Update the kovi plugin on the reader.',{deviceId:device.id,pluginVersion:reportedVersion||'unknown',minimumVersion:MIN_COVER_PLUGIN_VERSION,missingCoverRequests:result.coverRequests.length});
|
||||
recordDeviceSync(db,device.id,result,{mode:result.syncMode,cursorBefore:result.syncCursorBefore,cursorAfter:result.syncCursorAfter});
|
||||
info('plugin.sync.complete','KOReader sync completed.',{deviceId:device.id,...result,coverRequests:result.coverRequests?.length||0,newBookIds:undefined,coverRefreshIds:undefined});
|
||||
return json(res,200,{
|
||||
message:`Synced ${result.booksSeen} books, ${result.sessionsSeen} reading rows, and ${result.annotationsStored} annotations.`,
|
||||
...result,
|
||||
sync_cursor:result.syncCursorAfter,
|
||||
sync_mode:result.syncMode,
|
||||
cover_requests:result.coverRequests||[],
|
||||
latest_plugin_version:LATEST_PLUGIN_VERSION,
|
||||
update_recommended:!versionAtLeast(reportedVersion,LATEST_PLUGIN_VERSION),
|
||||
coverRequests:undefined,
|
||||
},securityHeaders());
|
||||
}
|
||||
|
||||
if(req.method==='POST' && url.pathname==='/api/plugin/cover') {
|
||||
const device=authenticateDevice(db,req.headers.authorization);
|
||||
if(!device) {
|
||||
warn('cover.embedded.rejected','Rejected embedded cover upload with missing or invalid device token.',{remote:req.socket.remoteAddress || 'unknown'});
|
||||
return json(res,401,{error:'Device token is missing or invalid.'},securityHeaders());
|
||||
}
|
||||
const md5=cleanText(req.headers['x-kovi-book-md5'],128);
|
||||
if(!md5) return json(res,400,{error:'Book MD5 header is required.'},securityHeaders());
|
||||
const payload=await readBinary(req);
|
||||
const saved=saveEmbeddedCover(db,DATA_DIR,md5,payload);
|
||||
if(saved.ignored) {
|
||||
info('cover.embedded.ignored','Ignored embedded cover because a manual cover has priority.',{deviceId:device.id,bookId:saved.book?.id,title:saved.book?.title,reason:saved.reason});
|
||||
return json(res,200,{ok:true,ignored:true,reason:saved.reason},securityHeaders());
|
||||
}
|
||||
info('cover.embedded.saved','Stored an embedded cover supplied by KOReader.',{deviceId:device.id,bookId:saved.bookId,title:saved.title,bytes:saved.bytes,type:saved.type});
|
||||
return json(res,201,{ok:true,book_id:saved.bookId,path:saved.path},securityHeaders());
|
||||
}
|
||||
|
||||
if(req.method==='PUT' && url.pathname==='/api/import/sqlite') {
|
||||
requireWebCsrf(req);
|
||||
const contentLength=Number(req.headers['content-length']||0);
|
||||
const filename=cleanText(req.headers['x-kovi-filename'],255)||'statistics.sqlite3';
|
||||
if(contentLength && contentLength>MAX_FILE_BYTES) return json(res,413,{error:`File exceeds ${Math.round(MAX_FILE_BYTES/1024/1024)} MB limit.`},securityHeaders());
|
||||
const tempPath=path.join(UPLOAD_DIR,`${Date.now()}-${randomId(12)}.sqlite`);
|
||||
let size=0; let first=Buffer.alloc(0);
|
||||
const limiter=new Transform({
|
||||
transform(chunk,enc,cb){
|
||||
size+=chunk.length;
|
||||
if(first.length<16) first=Buffer.concat([first,chunk]).subarray(0,16);
|
||||
if(size>MAX_FILE_BYTES) return cb(Object.assign(new Error('File too large.'),{status:413}));
|
||||
cb(null,chunk);
|
||||
}
|
||||
});
|
||||
info('import.sqlite.start','Manual KOReader database upload started.',{filename,contentLength:contentLength||undefined,remote:req.socket.remoteAddress || 'unknown'});
|
||||
try {
|
||||
await pipeline(req,limiter,fs.createWriteStream(tempPath,{flags:'wx',mode:0o600}));
|
||||
if(size<16 || first.toString('ascii',0,16)!=='SQLite format 3\u0000') throw Object.assign(new Error('The selected file is not a SQLite database.'),{status:400});
|
||||
const result=importKoReaderDb(db,tempPath,{filename,fileSize:size});
|
||||
scheduleCoverWork(db,DATA_DIR,result.newBookIds||[],{reason:'manual-import'});
|
||||
info(result.duplicate?'import.sqlite.duplicate':'import.sqlite.complete',result.duplicate?'Manual import matched an already-imported database.':'Manual KOReader database import completed.',{filename,fileSize:size,...result,newBookIds:undefined,sourceHash:undefined,warnings:result.warnings?.length||0});
|
||||
return json(res,200,{message:result.duplicate?'This exact database was already imported.':'Import complete.',...result},securityHeaders());
|
||||
} finally {
|
||||
await fsp.rm(tempPath,{force:true}).catch(()=>{});
|
||||
}
|
||||
}
|
||||
|
||||
if(req.method==='POST' && url.pathname==='/api/covers/retry') {
|
||||
requireWebCsrf(req);
|
||||
const ids=db.prepare(`SELECT id FROM books WHERE cover_path IS NULL AND cover_status IN ('none','error','pending')`).all().map(r=>r.id);
|
||||
info('cover.retry.all','User requested retry for missing covers.',{queued:ids.length});
|
||||
scheduleCoverWork(db,DATA_DIR,ids,{reason:'user-retry-all'});
|
||||
return json(res,202,{message:'Cover lookup queued.',queued:ids.length},securityHeaders());
|
||||
}
|
||||
|
||||
const manualBookCover=routeParam(url.pathname,/^\/api\/books\/([^/]+)\/cover$/);
|
||||
if(req.method==='PUT' && manualBookCover) {
|
||||
requireWebCsrf(req);
|
||||
const payload=await readBinary(req);
|
||||
const saved=saveManualCover(db,DATA_DIR,manualBookCover[0],payload);
|
||||
info('cover.manual.saved','Stored a cover uploaded from the browser.',{bookId:saved.bookId,title:saved.title,bytes:saved.bytes,type:saved.type});
|
||||
return json(res,201,{ok:true,book_id:saved.bookId,path:saved.path,cover_source:'manual-upload'},securityHeaders());
|
||||
}
|
||||
|
||||
const candidateSearch=routeParam(url.pathname,/^\/api\/books\/([^/]+)\/cover\/candidates$/);
|
||||
if(req.method==='POST' && candidateSearch){
|
||||
requireWebCsrf(req);
|
||||
info('cover.candidates.start','User requested alternative cover candidates.',{bookId:candidateSearch[0]});
|
||||
const candidates=await findCoverCandidates(db,DATA_DIR,candidateSearch[0]);
|
||||
if(candidates===null)return json(res,404,{error:'Book not found.'},securityHeaders());
|
||||
info('cover.candidates.complete','Alternative cover search completed.',{bookId:candidateSearch[0],candidates:candidates.length});
|
||||
return json(res,200,{candidates},securityHeaders());
|
||||
}
|
||||
|
||||
const candidateSelect=routeParam(url.pathname,/^\/api\/books\/([^/]+)\/cover\/candidates\/([^/]+)$/);
|
||||
if(req.method==='PUT' && candidateSelect){
|
||||
requireWebCsrf(req);const selected=selectCoverCandidate(db,candidateSelect[0],candidateSelect[1]);
|
||||
if(selected===null)return json(res,404,{error:'Book not found.'},securityHeaders());
|
||||
if(!selected)return json(res,404,{error:'Cover candidate not found.'},securityHeaders());
|
||||
info('cover.candidate.selected','User selected an alternative cover.',{bookId:candidateSelect[0],candidateId:candidateSelect[1]});
|
||||
return json(res,200,{ok:true},securityHeaders());
|
||||
}
|
||||
|
||||
const historySelect=routeParam(url.pathname,/^\/api\/books\/([^/]+)\/cover\/history\/([^/]+)$/);
|
||||
if(req.method==='PUT' && historySelect){
|
||||
requireWebCsrf(req);const selected=selectCoverHistory(db,historySelect[0],historySelect[1]);
|
||||
if(selected===null)return json(res,404,{error:'Book not found.'},securityHeaders());
|
||||
if(!selected)return json(res,404,{error:'Cover history item not found.'},securityHeaders());
|
||||
info('cover.history.selected','User restored a previous cover.',{bookId:historySelect[0],coverId:historySelect[1]});
|
||||
return json(res,200,{ok:true},securityHeaders());
|
||||
}
|
||||
|
||||
const retryBookCover=routeParam(url.pathname,/^\/api\/books\/([^/]+)\/cover\/retry$/);
|
||||
if(req.method==='POST' && retryBookCover) {
|
||||
requireWebCsrf(req);
|
||||
const book=queueOnlineCoverRetry(db,DATA_DIR,retryBookCover[0],{reason:'user-retry-book'});
|
||||
if(!book) return json(res,404,{error:'Book not found.'},securityHeaders());
|
||||
info('cover.retry.book','User requested a cover retry for one book.',{bookId:book.id,title:book.title});
|
||||
return json(res,202,{message:'Cover lookup queued for this book.'},securityHeaders());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const MIME={'.html':'text/html; charset=utf-8','.css':'text/css; charset=utf-8','.js':'text/javascript; charset=utf-8','.svg':'image/svg+xml','.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.webp':'image/webp','.gif':'image/gif','.ico':'image/x-icon','.zip':'application/zip'};
|
||||
async function serveFile(res,filePath,{cache=false}={}) {
|
||||
try {
|
||||
const st=await fsp.stat(filePath); if(!st.isFile()) return false;
|
||||
res.writeHead(200,securityHeaders({'Content-Type':MIME[path.extname(filePath).toLowerCase()]||'application/octet-stream','Content-Length':st.size,'Cache-Control':cache?'public, max-age=86400':'no-cache'}));
|
||||
fs.createReadStream(filePath).pipe(res); return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
const server=http.createServer(async(req,res)=>{
|
||||
try{
|
||||
const url=new URL(req.url,`http://${req.headers.host||'localhost'}`);
|
||||
if(url.pathname.startsWith('/api/')){
|
||||
const handled=await handleApi(req,res,url); if(handled!==false) return;
|
||||
return json(res,404,{error:'Not found.'},securityHeaders());
|
||||
}
|
||||
if(url.pathname.startsWith('/covers/')){
|
||||
const name=path.basename(url.pathname); if(name!==url.pathname.slice('/covers/'.length)) return json(res,400,{error:'Bad path.'},securityHeaders());
|
||||
if(await serveFile(res,path.join(COVER_DIR,name),{cache:true})) return;
|
||||
return json(res,404,{error:'Cover not found.'},securityHeaders());
|
||||
}
|
||||
const requested=url.pathname==='/'?'index.html':url.pathname.slice(1);
|
||||
const normalized=path.normalize(requested);
|
||||
if(normalized.startsWith('..')) return json(res,400,{error:'Bad path.'},securityHeaders());
|
||||
if(await serveFile(res,path.join(PUBLIC_DIR,normalized))) return;
|
||||
if(await serveFile(res,path.join(PUBLIC_DIR,'index.html'))) return;
|
||||
res.writeHead(404); res.end('Not found');
|
||||
}catch(err){
|
||||
const status=err.status||500;
|
||||
if(status>=500) logError('request.error','Request failed unexpectedly.',{method:req.method,url:req.url,error:err.message,stack:err.stack});
|
||||
else warn('request.rejected','Request rejected.',{method:req.method,url:req.url,status,error:err.message});
|
||||
if(!res.headersSent) json(res,status,{error:err.status?err.message:'Something went wrong.'},securityHeaders()); else res.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT,HOST,()=>info('startup.ready','kovi is ready.',{version:VERSION,url:`http://${HOST}:${PORT}`,dataDir:DATA_DIR,timeZone:TIME_ZONE,maxUploadMb:Math.round(MAX_FILE_BYTES/1024/1024)}));
|
||||
Reference in New Issue
Block a user