Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,8 @@
"moveToTrashWarning": "The item(s) will be moved to the Termix Trash and can be restored before the retention period expires.",
"itemsMovedToTrash_one": "{{count}} item moved to Trash",
"itemsMovedToTrash_other": "{{count}} items moved to Trash",
"deletingItems": "Deleting: {{done}} / {{total}}. Wait until it finishes before pasting or uploading.",
"movingToTrash": "Moving to Trash: {{done}} / {{total}}. Wait until it finishes before pasting or uploading.",
"trash": "Trash",
"trashDescription": "Deleted items are retained for {{days}} day(s) on this host.",
"trashLoadFailed": "Failed to load Trash",
Expand Down
2 changes: 1 addition & 1 deletion src/backend/operation-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export interface DeleteCommand {
commandWithSuccess: string;
}

function quotePosixPath(path: string): string {
export function quotePosixPath(path: string): string {
return `'${path.replace(/'/g, "'\"'\"'")}'`;
}

Expand Down
99 changes: 99 additions & 0 deletions src/backend/operation-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import {
} from "./session.js";
import { ensureDirectoryTreeSftp } from "./transfer-sftp-dir.js";
import { buildDeleteCommand } from "./operation-commands.js";
import { removePermanently } from "./permanent-delete.js";
import {
emptyTrash,
listTrash,
moveToTrash,
moveManyToTrash,
permanentlyDeleteTrashItem,
restoreTrashItem,
} from "./trash-service.js";
Expand All @@ -26,6 +28,11 @@ type FileOperationRoutesDeps = {
const TRASH_RETENTION_DAYS_KEY = "trashRetentionDays";

const TRASH_PURGE_INTERVAL_MS = 60 * 60 * 1000;
// 100 moves in flight finished with no failures on the test host; 16 leaves
// headroom for servers that cap in-flight SFTP requests.
const TRASH_BATCH_CONCURRENCY = 16;
const MAX_TRASH_BATCH = 500;
const CANCELLED_OP_TTL_MS = 10 * 60 * 1000;

export function registerFileOperationRoutes(
app: Express,
Expand All @@ -45,6 +52,15 @@ export function registerFileOperationRoutes(
// Deleting used to purge expired trash first, reading every trash entry
// before answering. Do it after the response, at most hourly per session.
const lastPurge = new WeakMap<SSHSession, number>();
// Delete operations the user cancelled: batches still queued stop starting
// items, so Cancel does not leave hundreds of requests running.
const cancelledOps = new Map<string, number>();
const opKey = (session: SSHSession, req: Request) => {
const opId = req.body?.opId;
return typeof opId === "string" && opId
? `${session.userId}:${req.body?.sessionId}:${opId}`
: null;
};
const purgeExpiredTrash = (session: SSHSession, sftp: SFTPWrapper) => {
const now = Date.now();
if (now - (lastPurge.get(session) ?? 0) < TRASH_PURGE_INTERVAL_MS) return;
Expand Down Expand Up @@ -644,6 +660,89 @@ export function registerFileOperationRoutes(
await executeDelete(false);
});

/**
* @openapi
* /plugin-api/file-manager/deleteItems:
* post:
* summary: Move many files or directories to the trash
* description: Moves up to 500 paths per call to the trash, or deletes them permanently when permanent is true. Each path succeeds or fails on its own and is reported in results.
* tags:
* - File Manager
* responses:
* 200: { description: Per-path results, in request order. }
* 400: { description: Bad session or paths. }
* 403: { description: The session belongs to someone else. }
*/
app.post("/deleteItems", async (req, res) => {
const session = await ownedSession(req, res);
if (!session) return;
const paths: unknown = req.body?.paths;
if (
!Array.isArray(paths) ||
paths.length === 0 ||
paths.length > MAX_TRASH_BATCH ||
!paths.every((p) => typeof p === "string" && p)
) {
return res.status(400).json({
error: `paths must be 1 to ${MAX_TRASH_BATCH} non-empty strings`,
});
}
const unique = [...new Set(paths as string[])];
const key = opKey(session, req);
const shouldStop = () => (key ? cancelledOps.has(key) : false);
const dirs = new Set<unknown>(
Array.isArray(req.body?.directories) ? req.body.directories : [],
);
if (req.body?.permanent === true) {
return res.json({
results: await removePermanently(
session,
unique.map((p) => ({ path: p, isDirectory: dirs.has(p) })),
shouldStop,
),
});
}
try {
const sftp = await getSessionSftp(session);
const results = await moveManyToTrash(
sftp,
unique,
TRASH_BATCH_CONCURRENCY,
shouldStop,
);
purgeExpiredTrash(session, sftp);
res.json({ results });
} catch (error) {
res
.status(409)
.json({ error: (error as Error).message, trashUnavailable: true });
}
});

/**
* @openapi
* /plugin-api/file-manager/deleteItems/cancel:
* post:
* summary: Stop a bulk delete from starting more items
* tags:
* - File Manager
* responses:
* 200: { description: The operation will start no further items. }
* 400: { description: Bad session or operation id. }
* 403: { description: The session belongs to someone else. }
*/
app.post("/deleteItems/cancel", async (req, res) => {
const session = await ownedSession(req, res);
if (!session) return;
const key = opKey(session, req);
if (!key) return res.status(400).json({ error: "opId is required" });
const now = Date.now();
for (const [k, at] of cancelledOps)
if (now - at > CANCELLED_OP_TTL_MS) cancelledOps.delete(k);
cancelledOps.set(key, now);
res.json({ ok: true });
});

/**
* @openapi
* /plugin-api/file-manager/renameItem:
Expand Down
146 changes: 146 additions & 0 deletions src/backend/permanent-delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { execBuffer, execWithSudo, type SSHSession } from "./session.js";
import { buildDeleteCommand, quotePosixPath } from "./operation-commands.js";
import { isWindowsSftpPath } from "./transfer-paths.js";

export type RemoveItem = { path: string; isDirectory: boolean };
export type RemoveResult = {
path: string;
error?: string;
needsSudo?: boolean;
};

// One shell command removes many paths; keep each under typical exec limits.
const MAX_SCRIPT_CHARS = 16_000;
const WINDOWS_CONCURRENCY = 4;

/** Refuses roots, empty paths and any . or .. segment: a batch rm -rf must never reach them. */
export function isRemovablePath(itemPath: string): boolean {
const slashed = itemPath.replace(/\\/g, "/");
if (slashed.split("/").some((s) => s === "." || s === "..")) return false;
const normalized = path.posix.normalize(slashed);
return (
normalized !== "." &&
normalized !== "/" &&
!/^\/?[A-Za-z]:\/?$/.test(normalized)
);
}

async function runPosixGroup(
session: SSHSession,
group: RemoveItem[],
useSudo: boolean,
): Promise<Map<number, "OK" | "FAIL" | "DENIED">> {
// The nonce keeps file names or error text from forging a status line, and
// rm's own stderr is captured so only these markers reach stdout.
const nonce = randomUUID();
const script = group
.map((item, i) => {
const rm = item.isDirectory ? "rm -rf --" : "rm -f --";
return `e=$(LC_ALL=C ${rm} ${quotePosixPath(item.path)} 2>&1) && echo "${nonce} OK ${i}" || { if printf %s "$e" | grep -q ": Permission denied$"; then echo "${nonce} DENIED ${i}"; else echo "${nonce} FAIL ${i}"; fi; }`;
})
.join("; ");
const command = `sh -c ${quotePosixPath(script)}`;
const result = useSudo
? await execWithSudo(session, command, session.sudoPassword!)
: await execBuffer(session, command);
const status = new Map<number, "OK" | "FAIL" | "DENIED">();
const re = new RegExp(`^${nonce} (OK|FAIL|DENIED) (\\d+)$`, "gm");
for (const m of result.stdout.toString().matchAll(re))
if (!status.has(Number(m[2])))
status.set(Number(m[2]), m[1] as "OK" | "FAIL" | "DENIED");
return status;
}

/** Permanently removes paths, one shell command per chunk, with per-path results. */
export async function removePermanently(
session: SSHSession,
items: RemoveItem[],
shouldStop: () => boolean = () => false,
): Promise<RemoveResult[]> {
const results: RemoveResult[] = items.map((x) => ({ path: x.path }));
const posix: number[] = [];
const windows: number[] = [];
items.forEach((x, i) => {
if (!isRemovablePath(x.path))
results[i].error = "This path cannot be deleted";
else (isWindowsSftpPath(x.path) ? windows : posix).push(i);
});

const groups: number[][] = [];
let current: number[] = [];
let size = 0;
for (const i of posix) {
const cost = items[i].path.length * 2 + 120;
if (current.length > 0 && size + cost > MAX_SCRIPT_CHARS) {
groups.push(current);
current = [];
size = 0;
}
current.push(i);
size += cost;
}
if (current.length > 0) groups.push(current);

for (const group of groups) {
if (shouldStop()) {
for (const i of group) results[i].error = "Cancelled";
continue;
}
const status = await runPosixGroup(
session,
group.map((i) => items[i]),
false,
);
// Only paths rm said were denied are retried with sudo, never other failures.
const denied: number[] = [];
group.forEach((i, k) => {
const s = status.get(k);
if (s === "OK") return;
if (s === "DENIED") denied.push(i);
else results[i].error = "Delete failed";
});
if (denied.length > 0 && session.sudoPassword && shouldStop()) {
for (const i of denied) results[i].error = "Cancelled";
} else if (denied.length > 0 && session.sudoPassword) {
const retry = await runPosixGroup(
session,
denied.map((i) => items[i]),
true,
);
denied.forEach((i, k) => {
if (retry.get(k) !== "OK") results[i].error = "Delete failed";
});
} else {
for (const i of denied) {
results[i].error = "Permission denied";
results[i].needsSudo = true;
}
}
}

let next = 0;
await Promise.all(
Array.from(
{ length: Math.min(WINDOWS_CONCURRENCY, windows.length) },
async () => {
while (next < windows.length) {
const i = windows[next++];
if (shouldStop()) {
results[i].error = "Cancelled";
continue;
}
const { commandWithSuccess } = buildDeleteCommand(
items[i].path,
items[i].isDirectory,
);
const result = await execBuffer(session, commandWithSuccess);
if (!result.stdout.toString().includes("SUCCESS"))
results[i].error = result.stderr.trim() || "Delete failed";
}
},
),
);
return results;
}
33 changes: 33 additions & 0 deletions src/backend/trash-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,39 @@ export async function moveToTrash(
return publicItem(item);
}

// Results keep input order; a failing item never affects the others. Once
// shouldStop() is true no further item is started; those report "Cancelled".
export async function moveManyToTrash(
sftp: SFTPWrapper,
paths: string[],
concurrency: number,
shouldStop: () => boolean = () => false,
): Promise<Array<{ path: string; item?: TrashItem; error?: string }>> {
const results: Array<{ path: string; item?: TrashItem; error?: string }> =
paths.map((p) => ({ path: p }));
let next = 0;
await Promise.all(
Array.from(
{ length: Math.min(Math.max(1, concurrency), paths.length) },
async () => {
while (next < paths.length) {
const i = next++;
if (shouldStop()) {
results[i].error = "Cancelled";
continue;
}
try {
results[i].item = await moveToTrash(sftp, paths[i]);
} catch (error) {
results[i].error = (error as Error).message;
}
}
},
),
);
return results;
}

export async function listTrash(
sftp: SFTPWrapper,
retentionDays: number,
Expand Down
Loading