feat: 작업실 파일·폴더 삭제를 영구 rm → 휴지통 이동으로 변경

- 계기: 작업실 화면에서 CAD/ 폴더를 지우면 되돌릴 수 없었다(LeRobot SO-101
  프로젝트 CAD/ 소실, 2026-09-25 실측).
- 삭제 시 workshop/.trash/<projectId>/<타임스탬프-접미사>/ 로 rename만
  (같은 볼륨이라 즉시, UI 변경 없음). 14일 지난 항목은 다음 삭제 시 자동 정리.
- 프로젝트 삭제 시 첨부 폴더도 휴지통으로(기존엔 통째 rm).
- 휴지통 이동 실패 시에만 기존 rm으로 폴백. 스탬프에 고유 접미사로 연속
  삭제 덮어쓰기 방지. 테스트 5건 추가.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-25 18:25:54 +09:00
co-authored by Claude Code
parent 2a72388f9c
commit 7e22a3074f
2 changed files with 107 additions and 4 deletions
+42 -4
View File
@@ -28,6 +28,37 @@ import { caseFilesDir, resolveUploadPath, sanitizePathSegment, walkCaseFiles, fi
const APP_TYPE = 'workshop';
// ── 휴지통(soft delete) ────────────────────────────────────────────────────
// 2026-09-25 실측 사고: 작업실 화면에서 CAD/ 폴더를 지우면 즉시 rm이라 되돌릴 수
// 없었다(LeRobot SO-101 프로젝트 CAD/ 소실). 영구 삭제 대신 워크스페이스 안
// workshop/.trash/<projectId>/<타임스탬프>/ 로 rename만 한다(같은 볼륨이라 즉시).
// 14일이 지난 항목은 다음 삭제가 일어날 때 자동으로 영구 삭제된다.
const TRASH_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
// targetPath를 휴지통으로 옮긴다. 성공 true, 실패(크로스 디바이스 등) false —
// 호출부는 false일 때만 기존 rm으로 폴백한다. (테스트를 위해 export)
export function moveToWorkshopTrash(workspace: string, projectId: string, targetPath: string): boolean {
try {
// 스탬프는 밀리초 단위라 연속 삭제 때 겹칠 수 있다 — 고유 접미사로 분리(같은 이름을
// 여러 번 지워도 덮어쓰지 않게).
const stamp = `${new Date().toISOString().replace(/[:.]/g, '-')}-${crypto.randomBytes(3).toString('hex')}`;
const dest = path.join(workspace, APP_TYPE, '.trash', projectId, stamp, path.basename(targetPath));
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.renameSync(targetPath, dest);
// 만료 정리 — 이 프로젝트의 휴지통에서 14일 지난 항목을 함께 영구 삭제.
const projTrash = path.join(workspace, APP_TYPE, '.trash', projectId);
for (const entry of fs.readdirSync(projTrash)) {
const p = path.join(projTrash, entry);
try {
if (Date.now() - fs.statSync(p).mtimeMs > TRASH_RETENTION_MS) fs.rmSync(p, { recursive: true, force: true });
} catch { /* 개별 항목 정리 실패는 무시 */ }
}
return true;
} catch {
return false;
}
}
// multipart filename 디코딩. 옛 코드는 decodeURIComponent를 그냥 불러 "100%.stl" 같은
// 파일명에서 URIError가 났고, 그 위치가 req 'end' 콜백이라 게이트웨이 프로세스가 죽었다
// (2026-09-24 검토에서 재현). RFC5987(filename*=UTF-8''%..)일 때만 퍼센트 디코딩하고,
@@ -111,7 +142,10 @@ export function registerWorkshopRoutes(
let filesRemoved = true;
try {
const dir = caseFilesDir(session.username, APP_TYPE, id);
if (isPathInsideDir(path.join(workspace, APP_TYPE), dir) && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
if (isPathInsideDir(path.join(workspace, APP_TYPE), dir) && fs.existsSync(dir)) {
// 폴더째 휴지통으로 — 프로젝트 복구 시 수동으로 돌려놓을 수 있게.
if (!moveToWorkshopTrash(workspace, id, dir)) fs.rmSync(dir, { recursive: true, force: true });
}
} catch { filesRemoved = false; }
try { clearHistory(`ws_${id}`, session.username); } catch { /* 세션이 없으면 무시 */ }
res.json({ success: true, filesRemoved });
@@ -480,13 +514,17 @@ export function registerWorkshopRoutes(
return res.status(400).json({ error: 'invalid' });
}
const dir = caseFilesDir(session.username, APP_TYPE, projectId);
const workspace = getUserWorkspace(session.username);
const targetPath = path.join(dir, ...segments);
if (!isPathInsideDir(dir, targetPath)) return res.status(403).json({ error: 'Forbidden' });
if (!fs.existsSync(targetPath)) { res.json({ success: true, alreadyGone: true }); return; }
try {
const stat = fs.statSync(targetPath);
if (stat.isDirectory()) fs.rmSync(targetPath, { recursive: true, force: true });
else fs.unlinkSync(targetPath);
// 영구 삭제 대신 휴지통 이동(2026-09-25) — 이동 실패 시에만 기존 rm으로 폴백.
if (!moveToWorkshopTrash(workspace, projectId, targetPath)) {
const stat = fs.statSync(targetPath);
if (stat.isDirectory()) fs.rmSync(targetPath, { recursive: true, force: true });
else fs.unlinkSync(targetPath);
}
} catch (e: any) {
res.status(500).json({ success: false, error: String(e?.message || e) }); return;
}
+65
View File
@@ -0,0 +1,65 @@
// 작업실 휴지통(soft delete, 2026-09-25) 테스트.
// 계기: 작업실 화면에서 CAD/ 폴더를 지우면 rm이라 되돌릴 수 없었다(LeRobot SO-101
// 프로젝트 CAD/ 소실). 삭제는 workshop/.trash/<projectId>/<타임스탬프>/ 이동이고,
// 14일 지난 항목은 다음 삭제 시점에 자동 영구 삭제된다.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { moveToWorkshopTrash } from '../src/gateway/routes/routes-workshop';
describe('moveToWorkshopTrash — 작업실 삭제를 휴지통 이동으로', () => {
const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'ws-trash-'));
const pid = 'proj_test1234';
const projDir = path.join(ws, 'workshop', pid);
const trashProj = path.join(ws, 'workshop', '.trash', pid);
const RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
test('폴더 삭제 → 휴지통 이동(내용물 보존, 원래 자리는 비움)', () => {
fs.mkdirSync(path.join(projDir, 'CAD'), { recursive: true });
fs.writeFileSync(path.join(projDir, 'CAD', 'part.stl'), 'BINARY');
const ok = moveToWorkshopTrash(ws, pid, path.join(projDir, 'CAD'));
assert.ok(ok);
assert.ok(!fs.existsSync(path.join(projDir, 'CAD')), '원래 폴더가 남아있음');
const stamps = fs.readdirSync(trashProj);
assert.equal(stamps.length, 1);
const moved = path.join(trashProj, stamps[0], 'CAD', 'part.stl');
assert.ok(fs.existsSync(moved), '휴지통에 내용물이 없음');
assert.equal(fs.readFileSync(moved, 'utf8'), 'BINARY');
});
test('단일 파일 삭제도 휴지통으로', () => {
fs.writeFileSync(path.join(projDir, 'note.txt'), 'x');
assert.ok(moveToWorkshopTrash(ws, pid, path.join(projDir, 'note.txt')));
assert.ok(!fs.existsSync(path.join(projDir, 'note.txt')));
assert.equal(fs.readdirSync(trashProj).length, 2, '타임스탬프 폴더가 삭제 건수만큼');
});
test('보존기간(14일) 지난 항목은 다음 삭제 시점에 자동 영구 삭제', () => {
// 새 폴더를 하나 더 지우고 → 직전 삭제 건이 14일+ 된 것처럼 mtime을 조작 → 다시 삭제
const stamps = fs.readdirSync(trashProj);
fs.utimesSync(path.join(trashProj, stamps[0]), new Date(Date.now() - RETENTION_MS - 60_000), new Date(Date.now() - RETENTION_MS - 60_000));
fs.mkdirSync(path.join(projDir, '사진'), { recursive: true });
fs.writeFileSync(path.join(projDir, '사진', 'a.jpg'), 'y');
assert.ok(moveToWorkshopTrash(ws, pid, path.join(projDir, '사진')));
const after = fs.readdirSync(trashProj);
assert.ok(!after.includes(stamps[0]), '14일 지난 항목이 정리되지 않음');
assert.equal(after.length, 2, '남은 항목 수가 다름(직전 건 + 방금 건)');
});
test('같은 이름을 여러 번 지워도 덮어쓰지 않는다(타임스탬프 분리)', () => {
for (const name of ['a.stl', 'a.stl', 'a.stl']) {
fs.writeFileSync(path.join(projDir, name), name);
assert.ok(moveToWorkshopTrash(ws, pid, path.join(projDir, name)));
}
const copies = fs.readdirSync(trashProj).flatMap(s => fs.readdirSync(path.join(trashProj, s)));
assert.equal(copies.filter(c => c === 'a.stl').length, 3);
});
test('대상이 없으면 false(호출부가 rm 폴백하지 않도록)', () => {
assert.equal(moveToWorkshopTrash(ws, pid, path.join(projDir, '없는파일.stl')), false);
});
test.after(() => { try { fs.rmSync(ws, { recursive: true, force: true }); } catch { /* noop */ } });
});