Lumi/plugins/lumi_ai/backend/downloader.js
2026-06-11 06:35:43 +02:00

104 lines
6.9 KiB
JavaScript

const fs = require("fs");
const crypto = require("crypto");
const path = require("path");
const { spawn } = require("child_process");
const AdmZip = require("adm-zip");
const { resolveData } = require("./paths");
class DownloadManager {
constructor(onEvent){ this.jobs=new Map(); this.onEvent=onEvent; }
status(id){ return this.jobs.get(id)||null; }
start({id,url,filename,sha256,kind,archive=false,size=0}){
if(this.jobs.get(id)?.state==="downloading") throw new Error("Download already running.");
if(size&&freeDiskBytes()<size*1.2)throw new Error("not enough disk space");
const job={id,state:"queued",downloaded:0,total:0,error:null,started_at:Date.now()};this.jobs.set(id,job);
this.download({job,url,filename,sha256,kind,archive}).catch(error=>{const classified=classifyError(error);job.state="error";job.error=classified.message;job.error_category=classified.category;this.onEvent?.({kind:"download",status:"failed",download_id:id,error:job.error,category:classified.category});});
return job;
}
async download({job,url,filename,sha256,kind,archive}){
job.state="downloading";
const tmp=resolveData("tmp",`${filename}.part`), finalDir=resolveData(kind==="model"?"models":"runtime");
const existing=fs.existsSync(tmp)?fs.statSync(tmp).size:0;
const headers=existing?{Range:`bytes=${existing}-`}:{};
const response=await fetch(url,{headers}); if(!response.ok && response.status!==206) throw new Error(`source unavailable (${response.status})`);
const resumed=existing>0&&response.status===206;
const total=Number(response.headers.get("content-length")||0)+(resumed?existing:0); job.total=total; job.downloaded=resumed?existing:0;
const stream=fs.createWriteStream(tmp,{flags:resumed?"a":"w"});
for await(const chunk of response.body){ if(!stream.write(chunk)) await new Promise(r=>stream.once("drain",r)); job.downloaded+=chunk.length; }
await new Promise((resolve,reject)=>stream.end(error=>error?reject(error):resolve()));
job.state="verifying"; const actual=await hashFile(tmp); if(actual!==sha256.toLowerCase()){fs.unlinkSync(tmp);throw new Error("hash mismatch");}
if(archive){
job.state="extracting";
const staging=resolveData("tmp",`runtime-extract-${Date.now()}`);
fs.mkdirSync(staging,{recursive:true});
try{
await extractArchive(tmp,staging,filename);
await makeRuntimeExecutable(staging);
const executable=findRuntimeExecutable(staging);
if(!executable)throw new Error("runtime executable missing after extraction");
for(const entry of fs.readdirSync(finalDir))fs.rmSync(path.join(finalDir,entry),{recursive:true,force:true});
for(const entry of fs.readdirSync(staging))fs.renameSync(path.join(staging,entry),path.join(finalDir,entry));
fs.unlinkSync(tmp);
job.executable=findRuntimeExecutable(finalDir);
}finally{
fs.rmSync(staging,{recursive:true,force:true});
}
}
else { const final=path.join(finalDir,filename); if(fs.existsSync(final))fs.unlinkSync(final); fs.renameSync(tmp,final); }
job.state="complete";job.finished_at=Date.now();job.sha256=actual;this.onEvent?.({kind:"download",status:"success",download_id:job.id,sha256:actual,duration_ms:job.finished_at-job.started_at});
}
}
async function makeRuntimeExecutable(dir){
if(process.platform==="win32")return;
for(const entry of fs.readdirSync(dir,{withFileTypes:true})){
const target=path.join(dir,entry.name);
if(entry.isDirectory())await makeRuntimeExecutable(target);
else if(entry.name==="llama-server")fs.chmodSync(target,0o755);
}
}
function findRuntimeExecutable(dir){
const name=process.platform==="win32"?"llama-server.exe":"llama-server";
for(const entry of fs.readdirSync(dir,{withFileTypes:true})){
const target=path.join(dir,entry.name);
if(entry.isFile()&&entry.name===name)return target;
if(entry.isDirectory()){const found=findRuntimeExecutable(target);if(found)return found;}
}
return null;
}
async function hashFile(file){const hash=crypto.createHash("sha256");for await(const chunk of fs.createReadStream(file))hash.update(chunk);return hash.digest("hex");}
async function extractArchive(file,dest,name){
if(name.endsWith(".zip")){
const zip=new AdmZip(file);
for(const entry of zip.getEntries())validateArchivePath(entry.entryName);
zip.extractAllTo(dest,true);
return;
}
const entries=await capture("tar",["-tzf",file]);
if(entries.code!==0)throw new Error(`archive corrupt (${entries.code})`);
for(const entry of entries.stdout.split(/\r?\n/).filter(Boolean))validateArchivePath(entry);
await new Promise((resolve,reject)=>{const child=spawn("tar",["-xzf",file,"-C",dest],{windowsHide:true,shell:false});child.on("exit",c=>c===0?resolve():reject(new Error(`archive extraction failed (${c})`)));child.on("error",reject);});
}
function validateArchivePath(entry){
const normalized=path.posix.normalize(String(entry).replace(/\\/g,"/"));
if(path.posix.isAbsolute(normalized)||normalized===".."||normalized.startsWith("../"))throw new Error("archive path traversal");
}
function capture(command,args){return new Promise((resolve,reject)=>{const child=spawn(command,args,{windowsHide:true,shell:false});let stdout="",stderr="";child.stdout.on("data",c=>stdout+=c);child.stderr.on("data",c=>stderr+=c);child.on("error",reject);child.on("exit",code=>resolve({code,stdout,stderr}));});}
function classifyError(error){
const message=error?.message||String(error);
if(/ENOSPC|not enough disk/i.test(message))return{category:"disk_full",message:"Not enough disk space."};
if(/EACCES|EPERM|permission denied/i.test(message))return{category:"permission_denied",message:"Permission denied."};
if(/hash mismatch/i.test(message))return{category:"hash_mismatch",message:"Downloaded file failed SHA-256 verification."};
if(/archive path traversal/i.test(message))return{category:"archive_path_traversal",message:"Archive contains an unsafe path."};
if(/archive corrupt|extraction failed/i.test(message))return{category:"archive_corrupt",message};
if(/\(404\)/.test(message))return{category:"http_404",message:"Download source was not found (404)."};
if(/\(403\)/.test(message))return{category:"http_403",message:"Download source denied access (403)."};
if(/\(429\)/.test(message))return{category:"http_429",message:"Download source rate limit reached (429)."};
if(/\(5\d\d\)/.test(message))return{category:"server_error",message};
if(/timeout|abort/i.test(message))return{category:"timeout",message:"Download timed out."};
if(/fetch|network|ENOTFOUND|EAI_AGAIN/i.test(message))return{category:"network_unavailable",message};
if(/runtime executable missing/i.test(message))return{category:"install_validation_failed",message};
return{category:"download_failed",message};
}
function freeDiskBytes(){try{const stat=fs.statfsSync(resolveData("tmp"));return Number(stat.bavail)*Number(stat.bsize);}catch{return Number.MAX_SAFE_INTEGER;}}
module.exports={DownloadManager,hashFile,validateArchivePath,classifyError};