feat(desktop): group uploads by session id; purge on session delete

Uploads now land in temp/desktop_uploads/<sid>/ instead of a flat dir, so a
session's attachments can be cleaned up when the session is deleted.

- upload_handler reads `sid` from the body and saves under desktop_uploads/<sid>/
  (sanitized; missing sid -> _misc bucket).
- delete_session purges the session's upload subdir.
- _sweep_stale_uploads recurses into sid subdirs (rglob) and drops empty dirs.
- Frontend: uploadOne sends the active session's bridgeSessionId; addFiles
  ensures a session exists (reusing newSession/ensureBridgeSession) before
  uploading, so a sid is available — the empty-app case creates one first.

Whitelist checks (upload_raw / upload_delete) already validate "under root",
so the deeper subdir paths pass unchanged.
This commit is contained in:
dd3xp committed 2026-05-26 00:41:40 +08:00
1 parent 4cb073ee05
commit bab5dec66f
2 files changed
+45 -9

No files matched your search

+8 -3
View File
@@ -1948,11 +1948,11 @@ function collectUsedFiles(text) {
return used;
}
async function uploadOne(name, dataUrl) {
async function uploadOne(name, dataUrl, sid) {
const res = await fetch(`http://${location.hostname}:14168/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, dataUrl }),
body: JSON.stringify({ name, dataUrl, sid: sid || '' }),
});
const j = await res.json();
if (!j.ok) throw new Error(j.error || 'upload failed');
@@ -1976,6 +1976,11 @@ async function addFiles(fileList) {
if (skipped) showChanToast(t('upload.tooLarge'), '', 'err');
return;
}
// group uploads under the active session (create one if there's none yet)
let upSess = activeSess();
if (!upSess) { await newSession(); upSess = activeSess(); }
if (upSess && !upSess.bridgeSessionId) { try { await ensureBridgeSession(upSess); } catch (_) {} }
const uploadSid = (upSess && upSess.bridgeSessionId) || '';
for (const f of accepted) {
try {
const dataUrl = await new Promise((resolve, reject) => {
@@ -1984,7 +1989,7 @@ async function addFiles(fileList) {
r.onerror = () => reject(r.error);
r.readAsDataURL(f);
});
const path = await uploadOne(f.name || 'file', dataUrl);
const path = await uploadOne(f.name || 'file', dataUrl, uploadSid);
state.fileSeq += 1;
const sid = state.fileSeq;
const isImage = isImageFile(f);
+37 -6
View File
@@ -359,6 +359,7 @@ class AgentManager:
sess.agent.abort()
emit_session_state(sess, "closed")
self._persist()
_purge_session_uploads(sid)
return {"ok": True, "sessionId": sid}
def submit_prompt(self, sid: str, prompt: Any, images: Optional[list] = None, llm_no: Optional[int] = None, display: Optional[str] = None, files_meta: Optional[list] = None, image_metas: Optional[list] = None) -> dict:
@@ -1097,27 +1098,57 @@ _WEB_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
UPLOAD_RETENTION_DAYS = 30
def _safe_session_dir(sid: str) -> str:
"""Sanitize a session id into a safe single-level folder name."""
s = re.sub(r"[^A-Za-z0-9_-]", "", str(sid or ""))
return s or "_misc"
def _session_upload_dir(sid: str) -> Path:
"""Per-session upload subdir under desktop_uploads/, created on demand."""
d = _WEB_UPLOAD_DIR / _safe_session_dir(sid)
d.mkdir(parents=True, exist_ok=True)
return d
def _purge_session_uploads(sid: str) -> None:
"""Best-effort: drop a session's whole upload subdir when the session is deleted."""
import shutil
with contextlib.suppress(Exception):
shutil.rmtree(_WEB_UPLOAD_DIR / _safe_session_dir(sid), ignore_errors=True)
def _sweep_stale_uploads(retention_days: int = UPLOAD_RETENTION_DAYS) -> None:
"""Best-effort: delete uploaded files older than retention_days (by mtime).
Replaces the old wholesale rmtree-on-startup so attachments persist across
restarts while temp storage can't grow without bound."""
"""Best-effort: delete uploaded files older than retention_days (by mtime),
then drop empty session subdirs. Replaces the old wholesale rmtree-on-startup
so attachments persist across restarts while temp storage can't grow forever."""
cutoff = time.time() - retention_days * 86400
try:
for f in _WEB_UPLOAD_DIR.iterdir():
for f in _WEB_UPLOAD_DIR.rglob("*"):
try:
if f.is_file() and f.stat().st_mtime < cutoff:
f.unlink()
except OSError:
pass
for d in _WEB_UPLOAD_DIR.iterdir():
try:
if d.is_dir() and not any(d.iterdir()):
d.rmdir()
except OSError:
pass
except OSError:
pass
_sweep_stale_uploads()
async def upload_handler(request):
"""Save a file uploaded by the web client and return its absolute path.
Body: {name: "<original filename>", dataUrl: "data:<mime>;base64,<...>"}
Body: {name: "<original filename>", dataUrl: "data:<mime>;base64,<...>", sid: "<session id>"}
Files are grouped per session under desktop_uploads/<sid>/ so deleting a
session can purge its attachments. Missing sid falls back to a _misc bucket.
Returns: {ok: true, path: "<abs path>"}
"""
try:
@@ -1141,7 +1172,7 @@ async def upload_handler(request):
if not blob:
return json_ok({"ok": False, "error": "empty file"})
safe_name = name or "file"
fpath = _WEB_UPLOAD_DIR / f"{uuid.uuid4().hex[:12]}__{safe_name}"
fpath = _session_upload_dir(data.get("sid") or "") / f"{uuid.uuid4().hex[:12]}__{safe_name}"
fpath.write_bytes(blob)
return json_ok({"ok": True, "path": str(fpath)})