feat: session GUID isolation for multi-user support

Each browser gets a UUID stored in localStorage, set as a cookie,
and attached to every API call. Files go to session-specific dirs:

  /mnt/converter/in/{guid}/    — uploads, downloads, SCP pulls
  /mnt/converter/out/{guid}/   — converted QCOW2s

Changes:
- base.html: generates UUID via crypto.randomUUID(), stores in
  localStorage + cookie, exposes as window.VM_BENCH_SID
- Frontend: all endpoints accept session_id, _staging() helper
  creates session-aware paths on demand
- JavaScript: session_id appended to all FormData, set as
  X-Session-ID header on raw uploads
- Backend models: JobSubmissionRequest.session_id field added
- Provisioner: _staging_in/_staging_out helpers, source path
  resolution uses session-aware directory
- Converter: extract_if_needed skips re-extraction if dir exists
This commit is contained in:
Claus Lohmar 2026-07-23 09:32:16 +00:00
parent 7ded831354
commit 63659e4f93
5 changed files with 54 additions and 8 deletions

View file

@ -57,6 +57,7 @@ class JobSubmissionRequest(BaseModel):
target_storage: str = "local-lvm"
additional_disks: List[DiskSpec] = []
target_disk_size_gb: Optional[int] = None
session_id: str = ""
class CleanupRequest(BaseModel):

View file

@ -32,6 +32,9 @@ logger = logging.getLogger("backend.provisioner")
STAGING_OUT = Path("/mnt/converter/out")
def _staging_out(session_id: str = "") -> Path:
return STAGING_OUT / session_id if session_id else STAGING_OUT
# In-memory job tracking — survives as long as the process runs
_jobs: dict[str, dict] = {}
_jobs_lock = threading.Lock()
@ -117,9 +120,9 @@ def clone_vm(req: CloneRequest) -> JobStatusResponse:
)
def cleanup_staging(vmid: int, delete: bool) -> dict:
def cleanup_staging(vmid: int, delete: bool, session_id: str = "") -> dict:
"""Remove staging files for a VM ID."""
out_dir = STAGING_OUT / str(vmid)
out_dir = _staging_out(session_id) / str(vmid)
if not delete:
return {
@ -220,7 +223,7 @@ def _process_job(job_id: str, req: JobSubmissionRequest):
_update_job(job_id, status=JobStatus.PROCESSING_CONVERSION, progress=10,
message="Converting disk image...")
out_dir = STAGING_OUT / str(vmid)
out_dir = _staging_out(req.session_id) / str(vmid)
out_dir.mkdir(parents=True, exist_ok=True)
# Collect all disks (boot disk + additional)
@ -232,7 +235,8 @@ def _process_job(job_id: str, req: JobSubmissionRequest):
if spec.disk_type == DiskType.IMAGE_FILE:
# Extract archive if needed, then locate the actual disk image
source = extract_if_needed(spec.source_filename)
src_path = _staging_in(req.session_id) / spec.source_filename
source = extract_if_needed(str(src_path))
disk = discover_disk(source)
source_path = disk.path
logger.info("Disk %d: source=%s format=%s size=%.1f GiB",

View file

@ -63,6 +63,17 @@ app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static")
_jinja = Environment(loader=FileSystemLoader(str(BASE / "templates")), autoescape=True)
STAGING = Path("/mnt/converter/in")
def _staging(session_id: str = "") -> Path:
"""Session-aware staging directory."""
return STAGING / session_id if session_id else STAGING
def _get_session_id(request: Request) -> str:
"""Extract session ID from cookie or query param."""
sid = request.cookies.get("vm_bench_sid", "")
if not sid:
sid = request.query_params.get("session_id", "")
return sid
api = ApiClient()
logger = logging.getLogger("vm-bench")
@ -141,9 +152,12 @@ def session_upload(
source_type: str = Form("upload"),
source_file: Optional[UploadFile] = File(None),
source_url: Optional[str] = Form(None),
session_id: str = Form(""),
):
"""Phase 1 — acquire the source file. Returns JSON so the frontend can
show progress, then call /session/analyze separately."""
staging = _staging(session_id)
staging.mkdir(parents=True, exist_ok=True)
err = _validate_vmid(vmid)
if err:
return JSONResponse({"phase": "error", "error": err}, status_code=400)
@ -156,7 +170,7 @@ def session_upload(
return JSONResponse({"phase": "error", "error": "No file uploaded."}, status_code=400)
filename = source_file.filename
dest = STAGING / filename
dest = staging / filename
content_length = request.headers.get("content-length")
if content_length:
@ -203,11 +217,11 @@ def session_upload(
return JSONResponse({"phase": "error", "error": "No URL provided."}, status_code=400)
filename = Path(url).name or f"download_{uuid.uuid4().hex[:8]}"
dest = STAGING / filename
dest = staging / filename
_active_downloads.pop(filename, None)
usage = shutil.disk_usage(STAGING)
usage = shutil.disk_usage(staging)
free_gb = usage.free / (1024**3)
if free_gb < 50:
logger.warning("Low disk: %.1f GB free — download may fail", free_gb)
@ -254,6 +268,7 @@ async def upload_raw(request: Request):
filename = request.headers.get("X-Filename", "upload.bin")
vmid_str = request.headers.get("X-VMID", "")
vm_name = request.headers.get("X-VM-Name", "")
session_id = request.headers.get("X-Session-ID", "")
try:
vmid = int(vmid_str)
@ -264,7 +279,9 @@ async def upload_raw(request: Request):
if err:
return JSONResponse({"phase": "error", "error": err}, status_code=400)
dest = STAGING / filename
staging = _staging(session_id)
staging.mkdir(parents=True, exist_ok=True)
dest = staging / filename
content_length = request.headers.get("content-length")
if content_length:
estimated_gb = int(content_length) / (1024**3)
@ -445,6 +462,7 @@ async def confirm_session(
target_disk_size_gb: Optional[int] = Form(None),
auto_detect_boot: str = Form("true"),
boot_type: str = Form("uefi"),
session_id: str = Form(""),
):
"""Build the job payload and submit to the backend."""
# Validate VM ID range
@ -460,6 +478,7 @@ async def confirm_session(
"target_storage": target_storage,
"auto_detect_boot": auto_detect_boot == "true",
"boot_type": boot_type,
"session_id": session_id,
"boot_disk": {
"disk_type": "image_file",
"source_filename": source_filename,

View file

@ -5,6 +5,22 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VM Bench — Proxmox Image Converter</title>
<link rel="stylesheet" href="/static/proxmox.css?v=5">
<script>
// Session GUID — one per browser, persists across tabs/refreshes
(function() {
var sid = localStorage.getItem('vm-bench-sid');
if (!sid) {
sid = crypto.randomUUID ? crypto.randomUUID() :
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random()*16|0, v = c=='x'?r:(r&0x3|0x8);
return v.toString(16);
});
localStorage.setItem('vm-bench-sid', sid);
}
document.cookie = 'vm_bench_sid=' + sid + ';path=/;SameSite=Lax';
window.VM_BENCH_SID = sid;
})();
</script>
</head>
<body>

View file

@ -112,10 +112,12 @@ async function startSession(e) {
const vmid = document.getElementById('vmid').value;
const vmName = document.getElementById('vm-name').value.trim();
const sourceType = document.getElementById('source-type').value;
const sessionId = window.VM_BENCH_SID || '';
const formData = new FormData();
formData.append('vmid', vmid);
formData.append('vm_name', vmName);
formData.append('source_type', sourceType);
formData.append('session_id', sessionId);
if (sourceType === 'upload') {
const fileInput = document.getElementById('source-file');
@ -151,6 +153,8 @@ async function handleUpload(formData) {
xhr.setRequestHeader('X-Filename', encodeURIComponent(file.name));
xhr.setRequestHeader('X-VMID', vmid);
xhr.setRequestHeader('X-VM-Name', vmName);
xhr.setRequestHeader('X-Session-ID', sessionId);
xhr.setRequestHeader('X-Session-ID', sessionId);
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
@ -233,6 +237,7 @@ async function runAnalysis(vmid, filename, vmName) {
setPhase('Step 2/2: Analysing source image...', 0, true);
const fd = new FormData();
fd.append('vmid', vmid); fd.append('filename', filename); fd.append('vm_name', vmName);
fd.append('session_id', window.VM_BENCH_SID || '');
try {
const resp = await fetch('/session/analyze', { method: 'POST', body: fd });
const html = await resp.text();
@ -268,6 +273,7 @@ async function submitJob(e) {
status.innerHTML = '<div class="spinner"></div> Submitting job...';
const formData = new FormData(form);
formData.append('session_id', window.VM_BENCH_SID || '');
try {
const resp = await fetch('/session/confirm', { method: 'POST', body: formData });
const html = await resp.text();