Files

346 lines
23 KiB
JavaScript

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','.webmanifest':'application/manifest+json','.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)}));