Files
ignis/server/routes/fs.js

429 lines
9.7 KiB
JavaScript
Raw Normal View History

2026-03-07 09:51:37 +01:00
const express = require("express");
const fs = require("fs");
const path = require("path");
const config = require("../config");
const router = express.Router();
2026-03-10 22:31:01 +01:00
// Resolve the vault root for a request. Reads vault ID from query or body.
function getVaultRoot(req, res) {
const vaultId = req.query.vault || req.body?.vault || config.defaultVaultId;
const vaultPath = config.getVaultPath(vaultId);
2026-03-17 12:38:30 +01:00
2026-03-10 22:31:01 +01:00
if (!vaultPath) {
res.status(404).json({ error: "Vault not found", id: vaultId });
return null;
}
return vaultPath;
}
// Resolve a client-provided path to an absolute path within a vault.
2026-03-11 22:08:30 +01:00
// Strips leading slashes so paths from the client are always treated as relative to the vault root.
2026-03-10 22:31:01 +01:00
function resolveVaultPath(vaultRoot, relativePath) {
2026-03-07 09:51:37 +01:00
const cleaned = (relativePath || "").replace(/^\/+/, "");
2026-03-10 22:31:01 +01:00
const resolved = path.resolve(vaultRoot, cleaned);
2026-03-17 12:38:30 +01:00
2026-03-10 22:31:01 +01:00
if (!resolved.startsWith(path.resolve(vaultRoot))) {
2026-03-07 09:51:37 +01:00
return null;
}
return resolved;
}
2026-03-17 12:38:30 +01:00
function guardPath(req, res, source = "query") {
2026-03-10 22:31:01 +01:00
const vaultRoot = getVaultRoot(req, res);
2026-03-17 12:38:30 +01:00
if (!vaultRoot) {
return null;
}
const p = source === "body" ? req.body?.path : req.query.path;
2026-03-07 09:51:37 +01:00
if (p === undefined || p === null) {
res.status(400).json({ error: "Missing path parameter" });
return null;
}
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
// Empty string = vault root, which is valid
2026-03-10 22:31:01 +01:00
const resolved = resolveVaultPath(vaultRoot, p);
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
if (!resolved) {
res.status(403).json({ error: "Path traversal rejected" });
return null;
}
2026-03-11 22:08:30 +01:00
req._vaultRoot = vaultRoot;
return resolved;
}
2026-03-07 09:51:37 +01:00
// GET /api/fs/stat?path=...
router.get("/stat", async (req, res) => {
const resolved = guardPath(req, res);
2026-03-17 12:38:30 +01:00
if (!resolved) {
return;
}
2026-03-07 09:51:37 +01:00
try {
const stat = await fs.promises.stat(resolved);
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
res.json({
type: stat.isDirectory() ? "directory" : "file",
size: stat.size,
mtime: stat.mtimeMs,
ctime: stat.ctimeMs,
});
} catch (e) {
res
.status(e.code === "ENOENT" ? 404 : 500)
.json({ error: e.message, code: e.code });
}
});
// GET /api/fs/readdir?path=...
router.get("/readdir", async (req, res) => {
const resolved = guardPath(req, res);
2026-03-17 12:38:30 +01:00
if (!resolved) {
return;
}
2026-03-07 09:51:37 +01:00
try {
2026-03-11 22:08:30 +01:00
// Check if path is a file. return ENOTDIR instead of crashing
2026-03-07 09:51:37 +01:00
const stat = await fs.promises.stat(resolved);
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
if (!stat.isDirectory()) {
return res
.status(400)
.json({ error: "ENOTDIR: not a directory", code: "ENOTDIR" });
}
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
const entries = await fs.promises.readdir(resolved, {
withFileTypes: true,
});
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
res.json(
entries.map((e) => ({
name: e.name,
type: e.isDirectory() ? "directory" : "file",
})),
);
} catch (e) {
res
.status(e.code === "ENOENT" ? 404 : 500)
.json({ error: e.message, code: e.code });
}
});
// GET /api/fs/readFile?path=...&encoding=...
router.get("/readFile", async (req, res) => {
const resolved = guardPath(req, res);
2026-03-17 12:38:30 +01:00
if (!resolved) {
return;
}
2026-03-07 09:51:37 +01:00
try {
const stat = await fs.promises.stat(resolved);
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
if (stat.isDirectory()) {
2026-03-10 22:31:01 +01:00
return res.status(400).json({
error: "EISDIR: illegal operation on a directory",
code: "EISDIR",
});
2026-03-07 09:51:37 +01:00
}
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
const encoding = req.query.encoding;
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
if (encoding === "utf8" || encoding === "utf-8") {
const data = await fs.promises.readFile(resolved, "utf-8");
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
res.type("text/plain").send(data);
} else {
const data = await fs.promises.readFile(resolved);
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
res.type("application/octet-stream").send(data);
}
} catch (e) {
res
.status(e.code === "ENOENT" ? 404 : 500)
.json({ error: e.message, code: e.code });
}
});
2026-03-10 22:31:01 +01:00
// POST /api/fs/writeFile { path, content, encoding?, vault? }
2026-03-07 09:51:37 +01:00
router.post("/writeFile", async (req, res) => {
2026-03-17 12:38:30 +01:00
const resolved = guardPath(req, res, "body");
if (!resolved) {
return;
}
2026-03-07 09:51:37 +01:00
try {
// Ensure parent directory exists
const dir = path.dirname(resolved);
await fs.promises.mkdir(dir, { recursive: true });
const encoding = req.body.encoding || "utf-8";
let data = req.body.content;
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
if (req.body.base64) {
data = Buffer.from(req.body.content, "base64");
}
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
await fs.promises.writeFile(
resolved,
data,
encoding === "binary" ? undefined : encoding,
);
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
const stat = await fs.promises.stat(resolved);
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
res.json({ ok: true, mtime: stat.mtimeMs, size: stat.size });
} catch (e) {
res.status(500).json({ error: e.message, code: e.code });
}
});
2026-03-10 22:31:01 +01:00
// POST /api/fs/appendFile { path, content, vault? }
2026-03-07 09:51:37 +01:00
router.post("/appendFile", async (req, res) => {
2026-03-17 12:38:30 +01:00
const resolved = guardPath(req, res, "body");
if (!resolved) {
return;
}
2026-03-07 09:51:37 +01:00
try {
await fs.promises.appendFile(resolved, req.body.content, "utf-8");
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: e.message, code: e.code });
}
});
2026-03-10 22:31:01 +01:00
// POST /api/fs/mkdir { path, recursive?, vault? }
2026-03-07 09:51:37 +01:00
router.post("/mkdir", async (req, res) => {
2026-03-17 12:38:30 +01:00
const resolved = guardPath(req, res, "body");
if (!resolved) {
return;
}
2026-03-07 09:51:37 +01:00
try {
await fs.promises.mkdir(resolved, { recursive: !!req.body.recursive });
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: e.message, code: e.code });
}
});
2026-03-10 22:31:01 +01:00
// POST /api/fs/rename { oldPath, newPath, vault? }
2026-03-07 09:51:37 +01:00
router.post("/rename", async (req, res) => {
2026-03-10 22:31:01 +01:00
const vaultRoot = getVaultRoot(req, res);
2026-03-17 12:38:30 +01:00
if (!vaultRoot) {
return;
}
2026-03-10 22:31:01 +01:00
const oldResolved = resolveVaultPath(vaultRoot, req.body?.oldPath);
const newResolved = resolveVaultPath(vaultRoot, req.body?.newPath);
2026-03-17 12:38:30 +01:00
if (!oldResolved || !newResolved) {
2026-03-07 09:51:37 +01:00
return res.status(403).json({ error: "Invalid path" });
2026-03-17 12:38:30 +01:00
}
2026-03-07 09:51:37 +01:00
try {
await fs.promises.rename(oldResolved, newResolved);
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: e.message, code: e.code });
}
});
2026-03-10 22:31:01 +01:00
// POST /api/fs/copyFile { src, dest, vault? }
2026-03-07 09:51:37 +01:00
router.post("/copyFile", async (req, res) => {
2026-03-10 22:31:01 +01:00
const vaultRoot = getVaultRoot(req, res);
2026-03-17 12:38:30 +01:00
if (!vaultRoot) {
return;
}
2026-03-10 22:31:01 +01:00
const srcResolved = resolveVaultPath(vaultRoot, req.body?.src);
const destResolved = resolveVaultPath(vaultRoot, req.body?.dest);
2026-03-17 12:38:30 +01:00
if (!srcResolved || !destResolved) {
2026-03-07 09:51:37 +01:00
return res.status(403).json({ error: "Invalid path" });
2026-03-17 12:38:30 +01:00
}
2026-03-07 09:51:37 +01:00
try {
await fs.promises.copyFile(srcResolved, destResolved);
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: e.message, code: e.code });
}
});
// DELETE /api/fs/unlink?path=...
router.delete("/unlink", async (req, res) => {
const resolved = guardPath(req, res);
2026-03-17 12:38:30 +01:00
if (!resolved) {
return;
}
2026-03-07 09:51:37 +01:00
try {
await fs.promises.unlink(resolved);
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
res.json({ ok: true });
} catch (e) {
const status = e.code === "ENOENT" ? 404 : 500;
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
res.status(status).json({ error: e.message, code: e.code });
}
});
// DELETE /api/fs/rmdir?path=...
router.delete("/rmdir", async (req, res) => {
const resolved = guardPath(req, res);
2026-03-17 12:38:30 +01:00
if (!resolved) {
return;
}
2026-03-07 09:51:37 +01:00
try {
await fs.promises.rmdir(resolved);
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: e.message, code: e.code });
}
});
// DELETE /api/fs/rm?path=...&recursive=true
router.delete("/rm", async (req, res) => {
const resolved = guardPath(req, res);
2026-03-17 12:38:30 +01:00
if (!resolved) {
return;
}
2026-03-07 09:51:37 +01:00
try {
await fs.promises.rm(resolved, {
recursive: req.query.recursive === "true",
});
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: e.message, code: e.code });
}
});
router.get("/access", async (req, res) => {
const resolved = guardPath(req, res);
2026-03-17 12:38:30 +01:00
if (!resolved) {
return;
}
2026-03-07 09:51:37 +01:00
try {
await fs.promises.access(resolved);
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
res.json({ ok: true });
} catch (e) {
res
.status(e.code === "ENOENT" ? 404 : 500)
.json({ error: e.message, code: e.code });
}
});
router.get("/realpath", async (req, res) => {
const resolved = guardPath(req, res);
2026-03-17 12:38:30 +01:00
if (!resolved) {
return;
}
2026-03-07 09:51:37 +01:00
try {
const real = await fs.promises.realpath(resolved);
2026-03-17 12:38:30 +01:00
2026-03-10 22:31:01 +01:00
res.json({ path: path.relative(req._vaultRoot, real) });
2026-03-07 09:51:37 +01:00
} catch (e) {
res.status(500).json({ error: e.message, code: e.code });
}
});
2026-03-10 22:31:01 +01:00
// POST /api/fs/utimes { path, atime, mtime, vault? }
2026-03-07 09:51:37 +01:00
router.post("/utimes", async (req, res) => {
2026-03-17 12:38:30 +01:00
const resolved = guardPath(req, res, "body");
if (!resolved) {
return;
}
2026-03-07 09:51:37 +01:00
try {
await fs.promises.utimes(
resolved,
req.body.atime / 1000,
req.body.mtime / 1000,
);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: e.message, code: e.code });
}
});
2026-03-11 22:08:30 +01:00
// GET /api/fs/tree?path=...&vault=... returns full recursive file tree with metadata
2026-03-07 09:51:37 +01:00
router.get("/tree", async (req, res) => {
2026-03-10 22:31:01 +01:00
const vaultRoot = getVaultRoot(req, res);
2026-03-17 12:38:30 +01:00
if (!vaultRoot) {
return;
}
2026-03-07 09:51:37 +01:00
const rootPath = req.query.path
2026-03-10 22:31:01 +01:00
? resolveVaultPath(vaultRoot, req.query.path)
: vaultRoot;
2026-03-17 12:38:30 +01:00
if (!rootPath) {
return res.status(403).json({ error: "Invalid path" });
}
2026-03-07 09:51:37 +01:00
try {
const tree = {};
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
async function walk(dir, prefix) {
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
for (const entry of entries) {
const rel = prefix ? prefix + "/" + entry.name : entry.name;
const full = path.join(dir, entry.name);
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
if (entry.isDirectory()) {
tree[rel] = { type: "directory" };
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
await walk(full, rel);
} else {
const stat = await fs.promises.stat(full);
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
tree[rel] = {
type: "file",
size: stat.size,
mtime: stat.mtimeMs,
ctime: stat.ctimeMs,
};
}
}
}
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
await walk(rootPath, "");
2026-03-17 12:38:30 +01:00
2026-03-07 09:51:37 +01:00
res.json(tree);
} catch (e) {
res.status(500).json({ error: e.message, code: e.code });
}
});
module.exports = router;