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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 */ } });
|
||||
});
|
||||
Reference in New Issue
Block a user