Create standalone browser port of LTX Athena with authenticated native API transport
This commit is contained in:
+166
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import http from 'node:http'
|
||||
import { mkdtemp, mkdir, writeFile, readFile, symlink, rm } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { createApp } from './app.mjs'
|
||||
const password='test-only-password-123456'
|
||||
async function fixture(t) {
|
||||
const dir=await mkdtemp(path.join(os.tmpdir(),'deskweb-test-'))
|
||||
await mkdir(path.join(dir,'dist'));await writeFile(path.join(dir,'dist/index.html'),'<html>DeskWEB test</html>')
|
||||
await mkdir(path.join(dir,'output'))
|
||||
const requests=[]
|
||||
const upstream=http.createServer(async(req,res)=>{
|
||||
const chunks=[];for await(const chunk of req)chunks.push(chunk)
|
||||
requests.push({url:req.url,method:req.method,authorization:req.headers.authorization,body:Buffer.concat(chunks).toString()})
|
||||
res.setHeader('Content-Type','application/json');res.setHeader('Set-Cookie','upstream-secret=hidden')
|
||||
if(req.url==='/api/fail'){res.writeHead(422);res.end('{"detail":"native validation"}');return}
|
||||
res.end('{"status":"ok"}')
|
||||
})
|
||||
await new Promise(r=>upstream.listen(0,'127.0.0.1',r))
|
||||
const app=await createApp({password,backendUrl:`http://127.0.0.1:${upstream.address().port}`,backendToken:'test-upstream-token',dist:path.join(dir,'dist'),roots:[{local:path.join(dir,'input'),backend:'/ltx/inputs',writable:true},{local:path.join(dir,'output'),backend:'/ltx/outputs'}],uploadLimit:1024})
|
||||
await new Promise(r=>app.listen(0,'127.0.0.1',r))
|
||||
t.after(async()=>{app.closeAllConnections();upstream.closeAllConnections();await Promise.all([new Promise(r=>app.close(r)),new Promise(r=>upstream.close(r))]);await rm(dir,{recursive:true,force:true})})
|
||||
const base=`http://127.0.0.1:${app.address().port}`
|
||||
const login=await fetch(base+'/web/login',{method:'POST',headers:{'X-Deskweb-Request':'1'},body:JSON.stringify({password})})
|
||||
assert.equal(login.status,200)
|
||||
const cookie=login.headers.get('set-cookie').split(';')[0]
|
||||
const call=(route,init={})=>fetch(base+route,{...init,headers:{Cookie:cookie,'X-Deskweb-Request':'1',...init.headers}})
|
||||
return {dir,base,call,requests,cookie}
|
||||
}
|
||||
test('sign-in required; secret never returned; logout invalidates session',async t=>{
|
||||
const {base,call}=await fixture(t)
|
||||
assert.equal((await fetch(base+'/web/session')).status,401)
|
||||
const session=await call('/web/session');assert.equal(session.status,200);assert.ok(!(await session.text()).includes('test-upstream-token'))
|
||||
assert.equal((await call('/web/logout',{method:'POST'})).status,200)
|
||||
assert.equal((await call('/web/session')).status,401)
|
||||
})
|
||||
test('native API bytes, query, method and validation errors pass through; browser token replaced',async t=>{
|
||||
const {call,requests}=await fixture(t)
|
||||
const body='{"prompt":"synthetic test","width":768,"custom_native_field":true}'
|
||||
const response=await call('/ltx/api/generate?native=1',{method:'POST',headers:{Authorization:'Bearer browser-value','Content-Type':'application/json'},body})
|
||||
assert.equal(response.status,200);assert.equal(response.headers.get('set-cookie'),null)
|
||||
assert.deepEqual(requests[0],{url:'/api/generate?native=1',method:'POST',authorization:'Bearer test-upstream-token',body})
|
||||
const invalid=await call('/ltx/api/fail');assert.equal(invalid.status,422);assert.equal(await invalid.text(),'{"detail":"native validation"}')
|
||||
})
|
||||
test('reject cross-site and arbitrary upstream routes',async t=>{
|
||||
const {call}=await fixture(t)
|
||||
assert.equal((await call('/ltx/api/generate',{method:'POST',headers:{Origin:'https://evil.example'}})).status,403)
|
||||
assert.equal((await call('/web/media?path=/ltx/outputs/a.mp4',{headers:{'Sec-Fetch-Site':'cross-site'}})).status,403)
|
||||
assert.equal((await call('/ltx//evil.example/api/generate')).status,404)
|
||||
})
|
||||
test('upload, backend path, range playback and existence check',async t=>{
|
||||
const {call,dir}=await fixture(t)
|
||||
const upload=await call('/web/upload?name=clip.mp4',{method:'POST',body:'0123456789'})
|
||||
assert.equal(upload.status,201);const result=await upload.json();assert.ok(result.path.startsWith('/ltx/inputs/'))
|
||||
assert.equal(await readFile(path.join(dir,'input',path.basename(result.path)),'utf8'),'0123456789')
|
||||
const media=await call('/web/media?path='+encodeURIComponent(result.path),{headers:{Range:'bytes=2-5'}})
|
||||
assert.equal(media.status,206);assert.equal(media.headers.get('content-range'),'bytes 2-5/10');assert.equal(await media.text(),'2345')
|
||||
const exists=await call('/web/files',{method:'POST',body:JSON.stringify({action:'exists',paths:[result.path,'/etc/passwd']})})
|
||||
assert.deepEqual(await exists.json(),{[result.path]:true,'/etc/passwd':false})
|
||||
})
|
||||
test('prevent file traversal, symlink escape, active file uploads, oversized uploads',async t=>{
|
||||
const {call,dir}=await fixture(t)
|
||||
await writeFile(path.join(dir,'secret.mp4'),'hidden');await symlink(path.join(dir,'secret.mp4'),path.join(dir,'output','link.mp4'))
|
||||
for(const p of ['/etc/passwd','/ltx/outputs/../../secret.mp4','/ltx/outputs/link.mp4'])assert.equal((await call('/web/media?path='+encodeURIComponent(p))).status,403)
|
||||
assert.equal((await call('/web/upload?name=script.html',{method:'POST',body:'<script/>'})).status,400)
|
||||
assert.equal((await call('/web/upload?name=big.mp4',{method:'POST',body:'x'.repeat(1025)})).status,413)
|
||||
})
|
||||
test('login rate limit',async t=>{
|
||||
const {base}=await fixture(t)
|
||||
for(let i=0;i<10;i++)assert.equal((await fetch(base+'/web/login',{method:'POST',headers:{'X-Deskweb-Request':'1'},body:JSON.stringify({password:'wrong'})})).status,401)
|
||||
assert.equal((await fetch(base+'/web/login',{method:'POST',headers:{'X-Deskweb-Request':'1'},body:JSON.stringify({password})})).status,429)
|
||||
})
|
||||
test('shared media can be inspected, copied and thumbnailed without an inference runtime',async t=>{
|
||||
const {call,dir}=await fixture(t)
|
||||
// Deliberately tiny synthetic PPM pixels; ffprobe detects content, not extension.
|
||||
await writeFile(path.join(dir,'output','test.png'),Buffer.concat([Buffer.from('P6\n2 2\n255\n'),Buffer.from([0,0,255,0,0,255,0,0,255,0,0,255])]))
|
||||
const invoke=async action=>{
|
||||
const response=await call('/web/files',{method:'POST',body:JSON.stringify({action,path:'/ltx/outputs/test.png'})})
|
||||
assert.equal(response.status,200);return response.json()
|
||||
}
|
||||
const metadata=await invoke('metadata');assert.equal(metadata.width,2);assert.equal(metadata.height,2)
|
||||
const copy=await invoke('copy');assert.ok(copy.path.startsWith('/ltx/inputs/'))
|
||||
const thumbnail=await invoke('thumbnail');assert.ok(thumbnail.path.endsWith('.jpg'))
|
||||
const response=await call('/web/media?path='+encodeURIComponent(thumbnail.path));assert.equal(response.status,200)
|
||||
assert.equal(response.headers.get('content-type'),'image/jpeg');assert.ok((await response.arrayBuffer()).byteLength>100)
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { createApp } from './app.mjs'
|
||||
const secret = async name => process.env[name] ? (await readFile(process.env[name], 'utf8')).trim() : ''
|
||||
const input = process.env.LOCAL_INPUT_DIR || '/data/inputs'
|
||||
const config = {
|
||||
password: await secret('WEB_PASSWORD_FILE'),
|
||||
backendToken: await secret('LTX_TOKEN_FILE'),
|
||||
backendUrl: process.env.LTX_BACKEND_URL || 'http://host.docker.internal:8120',
|
||||
publicOrigin: process.env.PUBLIC_ORIGIN,
|
||||
dist: new URL('../dist', import.meta.url).pathname,
|
||||
roots: [
|
||||
{ local:input, backend:process.env.BACKEND_INPUT_DIR || '/data/LTXDesktop/remote-inputs', writable:true },
|
||||
{ local:process.env.LOCAL_OUTPUT_DIR || '/data/outputs', backend:process.env.BACKEND_OUTPUT_DIR || '/data/LTXDesktop/outputs', writable:false },
|
||||
],
|
||||
}
|
||||
const server=await createApp(config)
|
||||
server.listen(Number(process.env.PORT||8118),process.env.HOST||'127.0.0.1',()=>console.log('LTX DeskWEB is ready'))
|
||||
Reference in New Issue
Block a user