Create standalone browser port of LTX Athena with authenticated native API transport

This commit is contained in:
Mikei386
2026-09-29 17:59:23 +02:00
commit 1333b0c36b
202 changed files with 63054 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
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<now)sessions.delete(id)
if(sessions.size>=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)<Date.now())throw fail(401,'Please sign in')
if(url.pathname==='/web/session'&&req.method==='GET')return json(res,200,{authenticated:true,backendUrl:config.backendUrl,version:'0.1.0'})
if(url.pathname==='/web/logout'&&req.method==='POST'){sessions.delete(session);res.setHeader('Set-Cookie','deskweb=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0');return json(res,200,{ok:true})}
if(url.pathname.startsWith('/ltx/'))return await proxy(req,res)
if(url.pathname==='/web/media'&&['GET','HEAD'].includes(req.method))return await serveFile(req,res,await resolveMedia(url.searchParams.get('path')),true)
if(url.pathname==='/web/upload'&&req.method==='POST') {
const ext=path.extname(url.searchParams.get('name')||'').toLowerCase()
if(!uploadExtensions.has(ext))throw fail(400,'Unsupported file type')
const limit=config.uploadLimit||512*1024*1024
if(Number(req.headers['content-length'])>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
}