import http from 'node:http' import https from 'node:https' import { createReadStream, createWriteStream } from 'node:fs' import { readFile, mkdir, realpath, stat, unlink, copyFile } from 'node:fs/promises' import path from 'node:path' import { randomBytes, randomUUID, timingSafeEqual, createHash } from 'node:crypto' import { pipeline } from 'node:stream/promises' import { Transform } from 'node:stream' import { execFile } from 'node:child_process' import { promisify } from 'node:util' const exec = promisify(execFile) const mime = { '.mp4':'video/mp4', '.webm':'video/webm', '.mov':'video/quicktime', '.png':'image/png', '.jpg':'image/jpeg', '.jpeg':'image/jpeg', '.webp':'image/webp', '.gif':'image/gif', '.wav':'audio/wav', '.mp3':'audio/mpeg', '.ogg':'audio/ogg', '.flac':'audio/flac', '.m4a':'audio/mp4', '.json':'application/json', '.xml':'application/xml', '.fcpxml':'application/xml', '.srt':'text/plain', '.txt':'text/plain', '.js':'text/javascript', '.css':'text/css', '.html':'text/html', '.svg':'image/svg+xml', '.woff2':'font/woff2', '.ico':'image/x-icon' } const uploadExtensions = new Set(['.mp4','.webm','.mov','.png','.jpg','.jpeg','.webp','.gif','.wav','.mp3','.ogg','.flac','.m4a','.json','.xml','.fcpxml','.srt','.txt']) const fail = (status, message) => Object.assign(new Error(message), { status }) const digest = value => createHash('sha256').update(value).digest() const equal = (a,b) => timingSafeEqual(digest(a),digest(b)) const inside = (root,p) => p.startsWith(root + path.sep) && p !== root const hop = new Set(['connection','keep-alive','proxy-authenticate','proxy-authorization','te','trailer','transfer-encoding','upgrade','set-cookie','access-control-allow-origin','access-control-allow-credentials']) export async function createApp(config) { if (!config.password || config.password.length < 16) throw Error('WEB_PASSWORD_FILE must contain at least 16 characters') const upstream = new URL(config.backendUrl) if (!['http:','https:'].includes(upstream.protocol) || upstream.username || upstream.password || upstream.pathname !== '/' || upstream.search || upstream.hash) throw Error('Backend URL must be an HTTP(S) origin without credentials or path') const roots = [] for (const root of config.roots) { if (root.writable) await mkdir(root.local, { recursive:true, mode:0o700 }) roots.push({ ...root, local: await realpath(root.local), backend: path.resolve(root.backend) }) } const writable = roots.find(r => r.writable) if (!writable) throw Error('An input directory shared with LTX is required') const sessions = new Map(), failures = new Map() async function resolveMedia(input) { if (typeof input !== 'string' || input.includes('\0')) throw fail(400,'Invalid media path') const normalized = path.resolve(input) const root = roots.find(r => inside(r.backend, normalized)) if (!root) throw fail(403,'Media path outside configured shared directories') const resolved = await realpath(path.join(root.local, path.relative(root.backend,normalized))).catch(() => { throw fail(404,'Media not found') }) if (!inside(root.local,resolved) || !uploadExtensions.has(path.extname(resolved).toLowerCase())) throw fail(403,'Media not allowed') if (!(await stat(resolved)).isFile()) throw fail(404,'Media not found') return resolved } function outputName(ext) { const name=randomUUID()+ext; return { local:path.join(writable.local,name), backend:path.join(writable.backend,name) } } function json(res,status,value) { res.writeHead(status,{'Content-Type':'application/json','Cache-Control':'no-store'});res.end(JSON.stringify(value)) } async function body(req,limit=1024*1024) { const chunks=[];let total=0 for await (const chunk of req) { total+=chunk.length;if(total>limit)throw fail(413,'Request too large');chunks.push(chunk) } return Buffer.concat(chunks) } async function payload(req) { try { return JSON.parse(await body(req)) } catch(e) { if(e.status)throw e;throw fail(400,'Invalid JSON') } } async function serveFile(req,res,file,media=false) { const info=await stat(file);if(!info.isFile())throw fail(404,'File not found') let start=0,end=info.size-1,status=200 if(req.headers.range) { const match=/^bytes=(\d*)-(\d*)$/.exec(req.headers.range) if(!match || (!match[1]&&!match[2]))throw fail(416,'Invalid range') if(match[1]) {start=Number(match[1]);if(match[2])end=Math.min(end,Number(match[2]))} else start=Math.max(0,info.size-Number(match[2])) if(start>end||start>=info.size) {res.setHeader('Content-Range',`bytes */${info.size}`);throw fail(416,'Range outside file')} status=206;res.setHeader('Content-Range',`bytes ${start}-${end}/${info.size}`) } res.setHeader('Content-Type',mime[path.extname(file).toLowerCase()]||'application/octet-stream') res.setHeader('Accept-Ranges','bytes');res.setHeader('Content-Length',Math.max(0,end-start+1)) if(media) {res.setHeader('Cache-Control','private, no-store'); if(req.url.includes('download=1'))res.setHeader('Content-Disposition',`attachment; filename="${path.basename(file)}"`)} res.writeHead(status) if(req.method==='HEAD'||info.size===0) return res.end() await pipeline(createReadStream(file,{start,end}),res) } async function proxy(req,res) { const suffix=req.url.slice(4) if (!/^\/(api\/|health(?:\?|$))/.test(suffix) || suffix.startsWith('//') || /[\r\n]/.test(suffix)) throw fail(404,'Unknown LTX API route') const headers={} for(const name of ['content-type','content-length','accept','range']) if(req.headers[name])headers[name]=req.headers[name] if(config.backendToken)headers.authorization=`Bearer ${config.backendToken}` await new Promise((resolve,reject)=>{ const request=(upstream.protocol==='https:'?https:http).request({protocol:upstream.protocol,hostname:upstream.hostname,port:upstream.port,method:req.method,path:suffix,headers}, response=>{ for(const [name,value] of Object.entries(response.headers))if(!hop.has(name)&&value!==undefined)res.setHeader(name,value) res.writeHead(response.statusCode||502) pipeline(response,res).then(resolve,reject) }) request.setTimeout(60*60*1000,()=>request.destroy(Error('Timeout'))) request.on('error',()=>reject(fail(502,'LTX backend unavailable. Activate Video mode in Athena Deck.'))) res.on('close',()=>{if(!res.writableEnded)request.destroy()}) req.pipe(request) }) } const server=http.createServer(async(req,res)=>{ res.setHeader('X-Content-Type-Options','nosniff');res.setHeader('Referrer-Policy','no-referrer') res.setHeader('X-Frame-Options','DENY') res.setHeader('Content-Security-Policy',"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data: https:; media-src 'self' blob: data:; connect-src 'self' blob:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'") try { const url=new URL(req.url,'http://localhost') const origin=config.publicOrigin || `http://${req.headers.host}` if(req.headers.origin && req.headers.origin!==origin)throw fail(403,'Cross-origin request rejected') if(req.headers['sec-fetch-site']==='cross-site')throw fail(403,'Cross-site request rejected') if(!['GET','HEAD'].includes(req.method) && !req.headers.origin && req.headers['x-deskweb-request']!=='1')throw fail(403,'Missing request origin') if(url.pathname==='/web/login' && req.method==='POST') { const ip=req.socket.remoteAddress, now=Date.now(), attempts=failures.get(ip) if(attempts && attempts.until>now && attempts.count>=10)throw fail(429,'Too many attempts. Try again in 15 minutes.') const value=await payload(req) if(typeof value.password!=='string'||!equal(value.password,config.password)) { const entry=attempts&&attempts.until>now?attempts:{count:0,until:now+900000};entry.count++;failures.set(ip,entry);throw fail(401,'Invalid password') } failures.delete(ip) for(const [id,expires] of sessions)if(expires=256)sessions.delete(sessions.keys().next().value) const session=randomBytes(32).toString('hex');sessions.set(session,now+12*60*60*1000) res.setHeader('Set-Cookie',`deskweb=${session}; HttpOnly; SameSite=Strict; Path=/; Max-Age=43200${origin.startsWith('https:')?'; Secure':''}`) return json(res,200,{ok:true}) } if(url.pathname.startsWith('/web/')||url.pathname.startsWith('/ltx/')) { const session=(req.headers.cookie||'').split(';').map(s=>s.trim()).find(s=>s.startsWith('deskweb='))?.slice(8) if(!session || (sessions.get(session)||0)limit)throw fail(413,'Upload too large') const target=outputName(ext);let size=0 const limiter=new Transform({transform(chunk,_encoding,done){size+=chunk.length;done(size>limit?fail(413,'Upload too large'):null,chunk)}}) try {await pipeline(req,limiter,createWriteStream(target.local,{flags:'wx',mode:0o600}))} catch(e){await unlink(target.local).catch(()=>{});throw e} return json(res,201,{success:true,path:target.backend}) } if(url.pathname==='/web/files'&&req.method==='POST') { const p=await payload(req) if(p.action==='exists') { if(!Array.isArray(p.paths)||p.paths.length>1000)throw fail(400,'Invalid paths') const result={};for(const item of p.paths){try{await resolveMedia(item);result[item]=true}catch{result[item]=false}}return json(res,200,result) } const source=await resolveMedia(p.path) if(p.action==='prepare')return json(res,200,{success:true,path:p.path}) if(p.action==='copy') { const target=outputName(path.extname(source));await copyFile(source,target.local);return json(res,200,{success:true,path:target.backend}) } if(p.action==='metadata'||p.action==='thumbnail'||p.action==='frame') { if(p.action==='metadata') { const {stdout}=await exec('ffprobe',['-v','error','-protocol_whitelist','file,pipe','-select_streams','v:0','-show_entries','stream=width,height','-of','json',source],{timeout:30000,maxBuffer:1024*1024}) const stream=JSON.parse(stdout).streams?.[0];if(!stream?.width||!stream?.height)throw fail(422,'No visual stream found') return json(res,200,{success:true,width:stream.width,height:stream.height}) } const time=p.action==='frame'?Number(p.seekTime||0):0 if(!Number.isFinite(time)||time<0)throw fail(400,'Invalid frame time') const target=outputName('.jpg') await exec('ffmpeg',['-nostdin','-v','error','-ss',String(time),'-protocol_whitelist','file,pipe','-i',source,'-frames:v','1','-vf','scale=960:-2','-y',target.local],{timeout:60000,maxBuffer:1024*1024}) return json(res,200,{success:true,path:target.backend,bigThumbnailPath:target.backend,smallThumbnailPath:target.backend}) } throw fail(400,'Unsupported file operation') } throw fail(404,'Unknown web route') } if(!['GET','HEAD'].includes(req.method))throw fail(405,'Method not allowed') const root=await realpath(config.dist) let file=path.resolve(root,'.'+decodeURIComponent(url.pathname)) if(file!==root&&!inside(root,file))throw fail(403,'Invalid path') try {file=await realpath(file);if(!(await stat(file)).isFile())file=path.join(root,'index.html')}catch{file=path.join(root,'index.html')} if(!inside(root,file))throw fail(403,'Invalid path') await serveFile(req,res,file) } catch(e) { if(res.headersSent){res.destroy();return} json(res,e.status||500,{error:e.status?e.message:'Web service operation failed. Check shared directories and ffmpeg installation.'}) } }) server.requestTimeout=0 return server }