mirror of
https://github.com/Nystik-gh/ignis.git
synced 2026-06-17 04:35:53 +00:00
move server into apps/ignis-server
This commit is contained in:
259
apps/ignis-server/server/routes/bootstrap.js
vendored
Normal file
259
apps/ignis-server/server/routes/bootstrap.js
vendored
Normal file
@@ -0,0 +1,259 @@
|
||||
// Bootstrap endpoint for cold start.
|
||||
//
|
||||
// Combines vault info, vault list, metadata tree, and plugin list into a single pre-compressed response.
|
||||
// Cache is per-vault and invalidated by directory mtime check + explicit invalidateVault() calls from the write/delete routes.
|
||||
|
||||
const express = require("express");
|
||||
const fs = require("fs");
|
||||
const fsp = fs.promises;
|
||||
const path = require("path");
|
||||
const zlib = require("zlib");
|
||||
const config = require("../config");
|
||||
const { isBridgePluginInstalled, getIgnisMeta } = require("../bridge-plugin");
|
||||
const { getDiscoveredPlugins } = require("../plugin-system/manager");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// vaultId -> { response, dirMtimes, compressed: { br, gz } }
|
||||
const cache = new Map();
|
||||
|
||||
// vaultId -> Promise<entry> (in-flight build dedup)
|
||||
const pendingBuilds = new Map();
|
||||
|
||||
function preCompress(buf) {
|
||||
return Promise.all([
|
||||
new Promise((resolve, reject) => {
|
||||
zlib.brotliCompress(
|
||||
buf,
|
||||
{ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } },
|
||||
(err, result) => (err ? reject(err) : resolve(result)),
|
||||
);
|
||||
}),
|
||||
new Promise((resolve, reject) => {
|
||||
zlib.gzip(buf, { level: 6 }, (err, result) =>
|
||||
err ? reject(err) : resolve(result),
|
||||
);
|
||||
}),
|
||||
]).then(([br, gz]) => ({ br, gz }));
|
||||
}
|
||||
|
||||
async function walkTree(rootPath) {
|
||||
const tree = {};
|
||||
const dirMtimes = {};
|
||||
|
||||
async function walk(dir, prefix) {
|
||||
const stat = await fsp.stat(dir);
|
||||
dirMtimes[prefix] = stat.mtimeMs;
|
||||
|
||||
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const rel = prefix ? prefix + "/" + entry.name : entry.name;
|
||||
const full = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
tree[rel] = { type: "directory" };
|
||||
await walk(full, rel);
|
||||
} else {
|
||||
try {
|
||||
const s = await fsp.stat(full);
|
||||
|
||||
tree[rel] = {
|
||||
type: "file",
|
||||
size: s.size,
|
||||
mtime: s.mtimeMs,
|
||||
ctime: s.ctimeMs,
|
||||
};
|
||||
} catch {
|
||||
tree[rel] = { type: "file" };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(rootPath, "");
|
||||
|
||||
return { tree, dirMtimes };
|
||||
}
|
||||
|
||||
async function buildVaultInfo(vaultId, vaultPath) {
|
||||
const pluginInstalled = await isBridgePluginInstalled(vaultPath);
|
||||
const ignisMeta = await getIgnisMeta(vaultPath);
|
||||
|
||||
return {
|
||||
id: vaultId,
|
||||
name: vaultId,
|
||||
path: vaultPath,
|
||||
platform: process.platform,
|
||||
version: config.obsidianVersion,
|
||||
ignisPlugin: {
|
||||
installed: pluginInstalled,
|
||||
prompted: ignisMeta.pluginPrompted || false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildVaultList() {
|
||||
return Object.entries(config.vaults).map(([id, vaultPath]) => ({
|
||||
id,
|
||||
name: id,
|
||||
path: vaultPath,
|
||||
}));
|
||||
}
|
||||
|
||||
async function dirMtimesUnchanged(vaultPath, dirMtimes) {
|
||||
const checks = await Promise.all(
|
||||
Object.entries(dirMtimes).map(async ([relDir, oldMtime]) => {
|
||||
const absDir = relDir
|
||||
? path.join(vaultPath, relDir.split("/").join(path.sep))
|
||||
: vaultPath;
|
||||
|
||||
try {
|
||||
const s = await fsp.stat(absDir);
|
||||
return s.mtimeMs === oldMtime;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return checks.every(Boolean);
|
||||
}
|
||||
|
||||
async function buildEntry(vaultId) {
|
||||
const vaultPath = config.getVaultPath(vaultId);
|
||||
|
||||
if (!vaultPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = cache.get(vaultId);
|
||||
|
||||
if (cached && (await dirMtimesUnchanged(vaultPath, cached.dirMtimes))) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
const [vault, { tree, dirMtimes }] = await Promise.all([
|
||||
buildVaultInfo(vaultId, vaultPath),
|
||||
walkTree(vaultPath),
|
||||
]);
|
||||
|
||||
const response = {
|
||||
vault,
|
||||
vaultList: buildVaultList(),
|
||||
tree,
|
||||
// In demo mode, hide server-side plugins from the client.
|
||||
plugins: config.demoMode ? [] : getDiscoveredPlugins(),
|
||||
};
|
||||
|
||||
const jsonBuf = Buffer.from(JSON.stringify(response));
|
||||
let compressed = {};
|
||||
|
||||
try {
|
||||
compressed = await preCompress(jsonBuf);
|
||||
} catch (e) {
|
||||
console.warn("[bootstrap] precompression failed:", e.message);
|
||||
}
|
||||
|
||||
const entry = { response, dirMtimes, compressed };
|
||||
cache.set(vaultId, entry);
|
||||
|
||||
const ms = Date.now() - t0;
|
||||
const fileCount = Object.keys(tree).filter(
|
||||
(k) => tree[k].type === "file",
|
||||
).length;
|
||||
const dirCount = Object.keys(dirMtimes).length;
|
||||
|
||||
console.log(
|
||||
`[bootstrap] vault=${vaultId} build files=${fileCount} dirs=${dirCount} time=${ms}ms`,
|
||||
);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function getOrBuild(vaultId) {
|
||||
if (pendingBuilds.has(vaultId)) {
|
||||
return pendingBuilds.get(vaultId);
|
||||
}
|
||||
|
||||
const promise = buildEntry(vaultId).finally(() => {
|
||||
pendingBuilds.delete(vaultId);
|
||||
});
|
||||
|
||||
pendingBuilds.set(vaultId, promise);
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
function invalidateVault(vaultId) {
|
||||
cache.delete(vaultId);
|
||||
}
|
||||
|
||||
async function warmUp() {
|
||||
const ids = Object.keys(config.vaults);
|
||||
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await buildEntry(id);
|
||||
} catch (e) {
|
||||
console.warn(`[bootstrap] warm-up failed for vault ${id}:`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
router.get("/", async (req, res) => {
|
||||
const vaultId = req.query.vault || config.defaultVaultId;
|
||||
|
||||
if (!vaultId || !config.getVaultPath(vaultId)) {
|
||||
return res.status(404).json({ error: "Vault not found", id: vaultId });
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = await getOrBuild(vaultId);
|
||||
|
||||
if (!entry) {
|
||||
return res.status(404).json({ error: "Vault not found" });
|
||||
}
|
||||
|
||||
// In demo mode, route through res.json so the demo middleware can translate vault names per-session.
|
||||
// The pre-compressed buffer path bakes the storage prefix in and would bypass the response wrapper.
|
||||
// Deep-clone so the demo translator's in-place mutation doesn't pollute the cached response object.
|
||||
if (req._demoSessionId) {
|
||||
return res.json(JSON.parse(JSON.stringify(entry.response)));
|
||||
}
|
||||
|
||||
const ae = req.headers["accept-encoding"] || "";
|
||||
const { compressed } = entry;
|
||||
let buf, encoding;
|
||||
|
||||
if (ae.includes("br") && compressed.br) {
|
||||
buf = compressed.br;
|
||||
encoding = "br";
|
||||
} else if (
|
||||
(ae.includes("gzip") || ae.includes("deflate")) &&
|
||||
compressed.gz
|
||||
) {
|
||||
buf = compressed.gz;
|
||||
encoding = "gzip";
|
||||
}
|
||||
|
||||
if (buf) {
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.setHeader("Content-Encoding", encoding);
|
||||
res.setHeader("Content-Length", buf.length);
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
|
||||
return res.status(200).end(buf);
|
||||
}
|
||||
|
||||
res.json(entry.response);
|
||||
} catch (e) {
|
||||
console.error("[bootstrap] error:", e);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.invalidateVault = invalidateVault;
|
||||
module.exports.warmUp = warmUp;
|
||||
598
apps/ignis-server/server/routes/fs.js
Normal file
598
apps/ignis-server/server/routes/fs.js
Normal file
@@ -0,0 +1,598 @@
|
||||
const express = require("express");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const archiver = require("archiver");
|
||||
const config = require("../config");
|
||||
const {
|
||||
writeCoalescer,
|
||||
encodeContentDispositionFilename,
|
||||
resolveVaultPath,
|
||||
} = require("@ignis/server-core");
|
||||
const { writeCoalesced, getPending } = writeCoalescer;
|
||||
const bootstrapRoutes = require("./bootstrap");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 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);
|
||||
|
||||
if (!vaultPath) {
|
||||
res.status(404).json({ error: "Vault not found", id: vaultId });
|
||||
return null;
|
||||
}
|
||||
|
||||
req._vaultId = vaultId;
|
||||
return vaultPath;
|
||||
}
|
||||
|
||||
function invalidateBootstrap(req) {
|
||||
if (req._vaultId) {
|
||||
bootstrapRoutes.invalidateVault(req._vaultId);
|
||||
}
|
||||
}
|
||||
|
||||
function guardPath(req, res, source = "query") {
|
||||
const vaultRoot = getVaultRoot(req, res);
|
||||
|
||||
if (!vaultRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const p = source === "body" ? req.body?.path : req.query.path;
|
||||
|
||||
if (p === undefined || p === null) {
|
||||
res.status(400).json({ error: "Missing path parameter" });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Empty string = vault root, which is valid
|
||||
const resolved = resolveVaultPath(vaultRoot, p);
|
||||
|
||||
if (!resolved) {
|
||||
res.status(403).json({ error: "Path traversal rejected" });
|
||||
return null;
|
||||
}
|
||||
|
||||
req._vaultRoot = vaultRoot;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// GET /api/fs/stat?path=...
|
||||
router.get("/stat", async (req, res) => {
|
||||
const resolved = guardPath(req, res);
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// If a coalesced write is pending, report its size instead of stale disk data
|
||||
const buffered = getPending(resolved);
|
||||
|
||||
if (buffered) {
|
||||
const diskStat = await fs.promises.stat(resolved).catch(() => null);
|
||||
const size = Buffer.isBuffer(buffered.data)
|
||||
? buffered.data.length
|
||||
: Buffer.byteLength(buffered.data, buffered.encoding || "utf-8");
|
||||
|
||||
res.json({
|
||||
type: "file",
|
||||
size,
|
||||
mtime: Date.now(),
|
||||
ctime: diskStat ? diskStat.ctimeMs : Date.now(),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const stat = await fs.promises.stat(resolved);
|
||||
|
||||
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);
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if path is a file. return ENOTDIR instead of crashing
|
||||
const stat = await fs.promises.stat(resolved);
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "ENOTDIR: not a directory", code: "ENOTDIR" });
|
||||
}
|
||||
|
||||
const entries = await fs.promises.readdir(resolved, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.stat(resolved);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
return res.status(400).json({
|
||||
error: "EISDIR: illegal operation on a directory",
|
||||
code: "EISDIR",
|
||||
});
|
||||
}
|
||||
|
||||
// Serve buffered content if a coalesced write is pending for this path
|
||||
const buffered = getPending(resolved);
|
||||
|
||||
if (buffered) {
|
||||
const encoding = req.query.encoding;
|
||||
|
||||
if (encoding === "utf8" || encoding === "utf-8") {
|
||||
res.type("text/plain").send(buffered.data);
|
||||
} else {
|
||||
res.type("application/octet-stream").send(buffered.data);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const encoding = req.query.encoding;
|
||||
|
||||
if (encoding === "utf8" || encoding === "utf-8") {
|
||||
const data = await fs.promises.readFile(resolved, "utf-8");
|
||||
|
||||
res.type("text/plain").send(data);
|
||||
} else {
|
||||
const data = await fs.promises.readFile(resolved);
|
||||
|
||||
res.type("application/octet-stream").send(data);
|
||||
}
|
||||
} catch (e) {
|
||||
res
|
||||
.status(e.code === "ENOENT" ? 404 : 500)
|
||||
.json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/fs/writeFile { path, content, encoding?, vault? }
|
||||
router.post("/writeFile", async (req, res) => {
|
||||
const resolved = guardPath(req, res, "body");
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (req.body.base64) {
|
||||
data = Buffer.from(req.body.content, "base64");
|
||||
}
|
||||
|
||||
const result = await writeCoalesced(resolved, data, encoding);
|
||||
|
||||
invalidateBootstrap(req);
|
||||
res.json({ ok: true, mtime: result.mtime, size: result.size });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/fs/appendFile { path, content, vault? }
|
||||
router.post("/appendFile", async (req, res) => {
|
||||
const resolved = guardPath(req, res, "body");
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.appendFile(resolved, req.body.content, "utf-8");
|
||||
|
||||
invalidateBootstrap(req);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/fs/mkdir { path, recursive?, vault? }
|
||||
router.post("/mkdir", async (req, res) => {
|
||||
const resolved = guardPath(req, res, "body");
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(resolved, {
|
||||
recursive: !!req.body.recursive,
|
||||
});
|
||||
|
||||
invalidateBootstrap(req);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/fs/rename { oldPath, newPath, vault? }
|
||||
router.post("/rename", async (req, res) => {
|
||||
const vaultRoot = getVaultRoot(req, res);
|
||||
|
||||
if (!vaultRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldResolved = resolveVaultPath(vaultRoot, req.body?.oldPath);
|
||||
const newResolved = resolveVaultPath(vaultRoot, req.body?.newPath);
|
||||
|
||||
if (!oldResolved || !newResolved) {
|
||||
return res.status(403).json({ error: "Invalid path" });
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.rename(oldResolved, newResolved);
|
||||
|
||||
invalidateBootstrap(req);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/fs/copyFile { src, dest, vault? }
|
||||
router.post("/copyFile", async (req, res) => {
|
||||
const vaultRoot = getVaultRoot(req, res);
|
||||
|
||||
if (!vaultRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const srcResolved = resolveVaultPath(vaultRoot, req.body?.src);
|
||||
const destResolved = resolveVaultPath(vaultRoot, req.body?.dest);
|
||||
|
||||
if (!srcResolved || !destResolved) {
|
||||
return res.status(403).json({ error: "Invalid path" });
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.copyFile(srcResolved, destResolved);
|
||||
|
||||
invalidateBootstrap(req);
|
||||
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);
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.unlink(resolved);
|
||||
|
||||
invalidateBootstrap(req);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT") {
|
||||
// File already gone - desired outcome achieved
|
||||
res.json({ ok: true });
|
||||
} else {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/fs/rmdir?path=...
|
||||
router.delete("/rmdir", async (req, res) => {
|
||||
const resolved = guardPath(req, res);
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.rmdir(resolved);
|
||||
|
||||
invalidateBootstrap(req);
|
||||
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);
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.rm(resolved, {
|
||||
recursive: req.query.recursive === "true",
|
||||
});
|
||||
|
||||
invalidateBootstrap(req);
|
||||
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);
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.access(resolved);
|
||||
|
||||
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);
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const real = await fs.promises.realpath(resolved);
|
||||
|
||||
res.json({ path: path.relative(req._vaultRoot, real) });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/fs/utimes { path, atime, mtime, vault? }
|
||||
router.post("/utimes", async (req, res) => {
|
||||
const resolved = guardPath(req, res, "body");
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.utimes(
|
||||
resolved,
|
||||
req.body.atime / 1000,
|
||||
req.body.mtime / 1000,
|
||||
);
|
||||
|
||||
invalidateBootstrap(req);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/fs/batch-read { paths, vault } - bulk read text file contents
|
||||
// Used by the indexer pre-fetcher to avoid N round trips during startup.
|
||||
router.post("/batch-read", async (req, res) => {
|
||||
const vaultRoot = getVaultRoot(req, res);
|
||||
|
||||
if (!vaultRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const paths = Array.isArray(req.body?.paths) ? req.body.paths : [];
|
||||
|
||||
if (paths.length === 0) {
|
||||
return res.json({ files: {} });
|
||||
}
|
||||
|
||||
const files = {};
|
||||
|
||||
await Promise.all(
|
||||
paths.map(async (relPath) => {
|
||||
const resolved = resolveVaultPath(vaultRoot, relPath);
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const buffered = getPending(resolved);
|
||||
|
||||
if (buffered) {
|
||||
if (typeof buffered.data === "string") {
|
||||
files[relPath] = buffered.data;
|
||||
} else if (
|
||||
buffered.encoding === "utf8" ||
|
||||
buffered.encoding === "utf-8"
|
||||
) {
|
||||
files[relPath] = buffered.data.toString("utf-8");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await fs.promises.readFile(resolved, "utf-8");
|
||||
files[relPath] = data;
|
||||
} catch {
|
||||
// Skip unreadable files silently. The client falls back to a
|
||||
// normal readFile when a path isn't in the response.
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
res.json({ files });
|
||||
});
|
||||
|
||||
// GET /api/fs/tree?path=...&vault=... returns full recursive file tree with metadata
|
||||
router.get("/tree", async (req, res) => {
|
||||
const vaultRoot = getVaultRoot(req, res);
|
||||
|
||||
if (!vaultRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rootPath = req.query.path
|
||||
? resolveVaultPath(vaultRoot, req.query.path)
|
||||
: vaultRoot;
|
||||
|
||||
if (!rootPath) {
|
||||
return res.status(403).json({ error: "Invalid path" });
|
||||
}
|
||||
|
||||
try {
|
||||
const tree = {};
|
||||
|
||||
async function walk(dir, prefix) {
|
||||
const entries = await fs.promises.readdir(dir, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
const rel = prefix ? prefix + "/" + entry.name : entry.name;
|
||||
const full = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
tree[rel] = { type: "directory" };
|
||||
|
||||
await walk(full, rel);
|
||||
} else {
|
||||
const stat = await fs.promises.stat(full);
|
||||
|
||||
tree[rel] = {
|
||||
type: "file",
|
||||
size: stat.size,
|
||||
mtime: stat.mtimeMs,
|
||||
ctime: stat.ctimeMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(rootPath, "");
|
||||
|
||||
res.json(tree);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/fs/download?path=...&vault=...
|
||||
router.get("/download", async (req, res) => {
|
||||
const resolved = guardPath(req, res);
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.stat(resolved);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Use /download-zip for directories" });
|
||||
}
|
||||
|
||||
const filename = path.basename(resolved);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
encodeContentDispositionFilename(filename),
|
||||
);
|
||||
res.sendFile(resolved);
|
||||
} catch (e) {
|
||||
res
|
||||
.status(e.code === "ENOENT" ? 404 : 500)
|
||||
.json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/fs/download-zip?path=...&vault=...
|
||||
router.get("/download-zip", async (req, res) => {
|
||||
const resolved = guardPath(req, res);
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.stat(resolved);
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
return res.status(400).json({ error: "Not a directory" });
|
||||
}
|
||||
|
||||
const folderName = path.basename(resolved);
|
||||
res.setHeader("Content-Type", "application/zip");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
encodeContentDispositionFilename(folderName + ".zip"),
|
||||
);
|
||||
|
||||
const archive = archiver("zip", { zlib: { level: 5 } });
|
||||
|
||||
archive.on("error", (err) => {
|
||||
res.status(500).end();
|
||||
});
|
||||
|
||||
archive.pipe(res);
|
||||
archive.directory(resolved, folderName);
|
||||
archive.finalize();
|
||||
} catch (e) {
|
||||
res
|
||||
.status(e.code === "ENOENT" ? 404 : 500)
|
||||
.json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
109
apps/ignis-server/server/routes/fs.test.mjs
Normal file
109
apps/ignis-server/server/routes/fs.test.mjs
Normal file
@@ -0,0 +1,109 @@
|
||||
import path from "path";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const {
|
||||
resolveVaultPath,
|
||||
encodeContentDispositionFilename,
|
||||
} = require("@ignis/server-core");
|
||||
|
||||
// -- encodeContentDispositionFilename --------------------------------
|
||||
|
||||
describe("encodeContentDispositionFilename", () => {
|
||||
it("handles a plain ASCII filename", () => {
|
||||
expect(encodeContentDispositionFilename("report.pdf")).toBe(
|
||||
'attachment; filename="report.pdf"',
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves spaces in quotes", () => {
|
||||
expect(encodeContentDispositionFilename("my report.pdf")).toBe(
|
||||
'attachment; filename="my report.pdf"',
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes double quotes", () => {
|
||||
const result = encodeContentDispositionFilename('file"name.txt');
|
||||
expect(result).toBe('attachment; filename="file\\"name.txt"');
|
||||
});
|
||||
|
||||
it("escapes backslashes", () => {
|
||||
const result = encodeContentDispositionFilename("path\\to\\file.txt");
|
||||
expect(result).toBe('attachment; filename="path\\\\to\\\\file.txt"');
|
||||
});
|
||||
|
||||
it("produces ASCII fallback and filename* for unicode", () => {
|
||||
const result = encodeContentDispositionFilename(
|
||||
"\u65E5\u672C\u8A9Enotes.md",
|
||||
);
|
||||
expect(result).toContain('filename="___notes.md"');
|
||||
expect(result).toContain("filename*=UTF-8''");
|
||||
expect(result).toContain("%E6%97%A5");
|
||||
});
|
||||
|
||||
it("replaces only non-ASCII in the fallback for mixed filenames", () => {
|
||||
const result = encodeContentDispositionFilename("report_2024\u5E74.pdf");
|
||||
expect(result).toContain('filename="report_2024_.pdf"');
|
||||
expect(result).toContain("filename*=UTF-8''");
|
||||
});
|
||||
|
||||
it("strips control characters", () => {
|
||||
const result = encodeContentDispositionFilename("bad\x00file\x1F.txt");
|
||||
expect(result).toBe('attachment; filename="badfile.txt"');
|
||||
});
|
||||
|
||||
it("does not crash on empty string", () => {
|
||||
const result = encodeContentDispositionFilename("");
|
||||
expect(result).toBe('attachment; filename=""');
|
||||
});
|
||||
});
|
||||
|
||||
// -- resolveVaultPath ------------------------------------------------
|
||||
|
||||
describe("resolveVaultPath", () => {
|
||||
const root = "/vaults/test";
|
||||
|
||||
it("resolves a simple relative path", () => {
|
||||
const result = resolveVaultPath(root, "notes/daily.md");
|
||||
expect(result).toBe(path.resolve(root, "notes/daily.md"));
|
||||
});
|
||||
|
||||
it("resolves empty string to vault root", () => {
|
||||
expect(resolveVaultPath(root, "")).toBe(path.resolve(root));
|
||||
});
|
||||
|
||||
it("allows a path that equals the vault root exactly", () => {
|
||||
expect(resolveVaultPath(root, "")).toBe(path.resolve(root));
|
||||
});
|
||||
|
||||
it("treats null input as vault root", () => {
|
||||
expect(resolveVaultPath(root, null)).toBe(path.resolve(root));
|
||||
});
|
||||
|
||||
it("treats undefined input as vault root", () => {
|
||||
expect(resolveVaultPath(root, undefined)).toBe(path.resolve(root));
|
||||
});
|
||||
|
||||
it("strips leading slashes", () => {
|
||||
const result = resolveVaultPath(root, "///notes/daily.md");
|
||||
expect(result).toBe(path.resolve(root, "notes/daily.md"));
|
||||
});
|
||||
|
||||
it("resolves ./ segments correctly", () => {
|
||||
const result = resolveVaultPath(root, "./notes/../notes/daily.md");
|
||||
expect(result).toBe(path.resolve(root, "notes/daily.md"));
|
||||
});
|
||||
|
||||
it("rejects ../ that escapes vault root", () => {
|
||||
expect(resolveVaultPath(root, "../")).toBe(null);
|
||||
});
|
||||
|
||||
it("rejects deep traversal", () => {
|
||||
expect(resolveVaultPath(root, "a/b/c/../../../../etc/passwd")).toBe(null);
|
||||
});
|
||||
|
||||
it("rejects traversal to a sibling vault with a shared prefix", () => {
|
||||
expect(resolveVaultPath(root, "../testing/foo")).toBe(null);
|
||||
});
|
||||
});
|
||||
44
apps/ignis-server/server/routes/plugins.js
Normal file
44
apps/ignis-server/server/routes/plugins.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const express = require("express");
|
||||
const {
|
||||
getDiscoveredPlugins,
|
||||
enablePluginForVault,
|
||||
disablePluginForVault,
|
||||
} = require("../plugin-system/manager");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", (req, res) => {
|
||||
res.json(getDiscoveredPlugins());
|
||||
});
|
||||
|
||||
router.post("/:pluginId/enable", async (req, res) => {
|
||||
const vaultId = req.body?.vault;
|
||||
|
||||
if (!vaultId) {
|
||||
return res.status(400).json({ error: "Missing vault ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
await enablePluginForVault(req.params.pluginId, vaultId);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:pluginId/disable", async (req, res) => {
|
||||
const vaultId = req.body?.vault;
|
||||
|
||||
if (!vaultId) {
|
||||
return res.status(400).json({ error: "Missing vault ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
await disablePluginForVault(req.params.pluginId, vaultId);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
56
apps/ignis-server/server/routes/proxy.js
Normal file
56
apps/ignis-server/server/routes/proxy.js
Normal file
@@ -0,0 +1,56 @@
|
||||
const express = require("express");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// POST /api/proxy - forward a request to an external URL to bypass CORS
|
||||
// Used by the requestUrl shim for plugin installation, etc.
|
||||
router.post("/", async (req, res) => {
|
||||
const { url, method, headers, body, binary } = req.body;
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: "Missing url" });
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchOpts = {
|
||||
method: method || "GET",
|
||||
headers: headers || {},
|
||||
};
|
||||
|
||||
if (body && method !== "GET" && method !== "HEAD") {
|
||||
if (binary && typeof body === "string") {
|
||||
fetchOpts.body = Buffer.from(body, "base64");
|
||||
} else {
|
||||
fetchOpts.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
const upstream = await fetch(url, fetchOpts);
|
||||
const respBody = Buffer.from(await upstream.arrayBuffer());
|
||||
|
||||
// Forward response headers, stripping hop-by-hop / encoding headers
|
||||
// since the body is already decompressed by Node's fetch
|
||||
const skipHeaders = new Set([
|
||||
"content-encoding",
|
||||
"transfer-encoding",
|
||||
"content-length",
|
||||
"connection",
|
||||
]);
|
||||
const respHeaders = {};
|
||||
upstream.headers.forEach((val, key) => {
|
||||
if (!skipHeaders.has(key)) {
|
||||
respHeaders[key] = val;
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
status: upstream.status,
|
||||
headers: respHeaders,
|
||||
body: respBody.toString("base64"),
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
179
apps/ignis-server/server/routes/vault.js
Normal file
179
apps/ignis-server/server/routes/vault.js
Normal file
@@ -0,0 +1,179 @@
|
||||
const express = require("express");
|
||||
const fs = require("fs");
|
||||
const config = require("../config");
|
||||
const path = require("path");
|
||||
const {
|
||||
isBridgePluginInstalled,
|
||||
getIgnisMeta,
|
||||
setIgnisMeta,
|
||||
installBridgePlugin,
|
||||
} = require("../bridge-plugin");
|
||||
const bootstrapRoutes = require("./bootstrap");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/vault/list - returns all discovered vaults (re-scans on each call)
|
||||
router.get("/list", (req, res) => {
|
||||
config.refreshVaults();
|
||||
|
||||
const list = Object.entries(config.vaults).map(([id, vaultPath]) => ({
|
||||
id,
|
||||
name: id,
|
||||
path: vaultPath,
|
||||
}));
|
||||
|
||||
res.json(list);
|
||||
});
|
||||
|
||||
// GET /api/vault/info?vault=<id> - returns info for a specific vault
|
||||
router.get("/info", async (req, res) => {
|
||||
const vaultId = req.query.vault || config.defaultVaultId;
|
||||
const vaultPath = config.getVaultPath(vaultId);
|
||||
|
||||
if (!vaultPath) {
|
||||
return res.status(404).json({ error: "Vault not found", id: vaultId });
|
||||
}
|
||||
|
||||
const pluginInstalled = await isBridgePluginInstalled(vaultPath);
|
||||
const ignisMeta = await getIgnisMeta(vaultPath);
|
||||
|
||||
res.json({
|
||||
id: vaultId,
|
||||
name: vaultId,
|
||||
path: vaultPath,
|
||||
platform: process.platform,
|
||||
version: config.obsidianVersion,
|
||||
ignisPlugin: {
|
||||
installed: pluginInstalled,
|
||||
prompted: ignisMeta.pluginPrompted || false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/vault/create { name } - create a new vault in VAULT_ROOT
|
||||
router.post("/create", async (req, res) => {
|
||||
const name = req.body?.name;
|
||||
|
||||
if (!name || /[\/\\:*?"<>|]/.test(name)) {
|
||||
return res.status(400).json({ error: "Invalid vault name" });
|
||||
}
|
||||
|
||||
const vaultPath = path.join(config.vaultRoot, name);
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(vaultPath, { recursive: false });
|
||||
await fs.promises.mkdir(path.join(vaultPath, ".obsidian"), {
|
||||
recursive: false,
|
||||
});
|
||||
|
||||
await installBridgePlugin(vaultPath);
|
||||
|
||||
config.refreshVaults();
|
||||
bootstrapRoutes.invalidateVault(name);
|
||||
|
||||
res.json({ ok: true, id: name, path: vaultPath });
|
||||
} catch (e) {
|
||||
if (e.code === "EEXIST") {
|
||||
return res.status(409).json({ error: "Vault already exists" });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/vault/rename { vault, name } - rename a vault
|
||||
router.post("/rename", async (req, res) => {
|
||||
const vaultId = req.body?.vault;
|
||||
const newName = req.body?.name;
|
||||
|
||||
if (!newName || /[\/\\:*?"<>|]/.test(newName)) {
|
||||
return res.status(400).json({ error: "Invalid vault name" });
|
||||
}
|
||||
|
||||
const vaultPath = config.getVaultPath(vaultId);
|
||||
|
||||
if (!vaultPath) {
|
||||
return res.status(404).json({ error: "Vault not found" });
|
||||
}
|
||||
|
||||
const newPath = path.join(config.vaultRoot, newName);
|
||||
|
||||
try {
|
||||
await fs.promises.rename(vaultPath, newPath);
|
||||
|
||||
config.refreshVaults();
|
||||
bootstrapRoutes.invalidateVault(vaultId);
|
||||
bootstrapRoutes.invalidateVault(newName);
|
||||
|
||||
res.json({ ok: true, id: newName, path: newPath });
|
||||
} catch (e) {
|
||||
if (e.code === "ENOTEMPTY" || e.code === "EEXIST") {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: "A vault with that name already exists" });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/vault/remove?vault=<id> - remove a vault from disk
|
||||
router.delete("/remove", async (req, res) => {
|
||||
const vaultId = req.query.vault;
|
||||
const vaultPath = config.getVaultPath(vaultId);
|
||||
|
||||
if (!vaultPath) {
|
||||
return res.status(404).json({ error: "Vault not found" });
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.rm(vaultPath, { recursive: true });
|
||||
|
||||
config.refreshVaults();
|
||||
bootstrapRoutes.invalidateVault(vaultId);
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/vault/install-plugin { vault, dismiss } - install plugin or mark as prompted
|
||||
router.post("/install-plugin", async (req, res) => {
|
||||
const vaultId = req.body?.vault;
|
||||
const dismiss = req.body?.dismiss || false;
|
||||
|
||||
if (!vaultId) {
|
||||
return res.status(400).json({ error: "Missing vault ID" });
|
||||
}
|
||||
|
||||
const vaultPath = config.getVaultPath(vaultId);
|
||||
|
||||
if (!vaultPath) {
|
||||
return res.status(404).json({ error: "Vault not found" });
|
||||
}
|
||||
|
||||
try {
|
||||
const meta = await getIgnisMeta(vaultPath);
|
||||
|
||||
if (dismiss) {
|
||||
// User clicked "Don't Ask Again" or "Not Now"
|
||||
meta.pluginPrompted = true;
|
||||
await setIgnisMeta(vaultPath, meta);
|
||||
|
||||
return res.json({ ok: true, prompted: true });
|
||||
} else {
|
||||
// User wants to install the plugin
|
||||
const installed = await installBridgePlugin(vaultPath);
|
||||
|
||||
meta.pluginPrompted = true;
|
||||
await setIgnisMeta(vaultPath, meta);
|
||||
|
||||
return res.json({ ok: true, installed, prompted: true });
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
17
apps/ignis-server/server/routes/version.js
Normal file
17
apps/ignis-server/server/routes/version.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const express = require("express");
|
||||
const { getVersion } = require("../version");
|
||||
const config = require("../config");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", (req, res) => {
|
||||
const pkg = require("../../package.json");
|
||||
|
||||
res.json({
|
||||
version: getVersion(),
|
||||
semver: pkg.version,
|
||||
obsidianVersion: config.obsidianVersion,
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user