add server
This commit is contained in:
parent
0c040f1e1e
commit
1c5aaa8b45
5 changed files with 419 additions and 0 deletions
7
server/config.js
Normal file
7
server/config.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
port: process.env.PORT || 8080,
|
||||||
|
vaultPath: process.env.VAULT_PATH || path.join(__dirname, '..', 'test-vault'),
|
||||||
|
obsidianAssetsPath: path.join(__dirname, '..', 'investigation', 'obsidian.asar.unpacked'),
|
||||||
|
};
|
||||||
62
server/index.js
Normal file
62
server/index.js
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
const express = require("express");
|
||||||
|
const path = require("path");
|
||||||
|
const config = require("./config");
|
||||||
|
const { setupWebSocket } = require("./ws");
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(express.json({ limit: "50mb" }));
|
||||||
|
|
||||||
|
// --- Request logging ---
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const origEnd = res.end;
|
||||||
|
res.end = function (...args) {
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
const status = res.statusCode;
|
||||||
|
const color =
|
||||||
|
status >= 500 ? "\x1b[31m" : status >= 400 ? "\x1b[33m" : "\x1b[32m";
|
||||||
|
const reset = "\x1b[0m";
|
||||||
|
const path =
|
||||||
|
req.originalUrl.length > 80
|
||||||
|
? req.originalUrl.slice(0, 80) + "..."
|
||||||
|
: req.originalUrl;
|
||||||
|
console.log(
|
||||||
|
`${color}${req.method} ${status}${reset} ${path} (${duration}ms)`,
|
||||||
|
);
|
||||||
|
origEnd.apply(this, args);
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Routes ---
|
||||||
|
const fsRoutes = require("./routes/fs");
|
||||||
|
const vaultRoutes = require("./routes/vault");
|
||||||
|
|
||||||
|
app.use("/api/fs", fsRoutes);
|
||||||
|
app.use("/api/vault", vaultRoutes);
|
||||||
|
|
||||||
|
// --- Static serving ---
|
||||||
|
// Serve the built shim-loader.js
|
||||||
|
app.use(
|
||||||
|
"/shim-loader.js",
|
||||||
|
express.static(path.join(__dirname, "..", "dist", "shim-loader.js")),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Serve patched index.html at root
|
||||||
|
app.get("/", (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, "..", "dist", "index.html"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve obsidian assets
|
||||||
|
app.use(express.static(config.obsidianAssetsPath));
|
||||||
|
|
||||||
|
// --- Start ---
|
||||||
|
const server = app.listen(config.port, () => {
|
||||||
|
console.log(
|
||||||
|
`[obsidian-bridge] Server running on http://localhost:${config.port}`,
|
||||||
|
);
|
||||||
|
console.log(`[obsidian-bridge] Vault path: ${config.vaultPath}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
setupWebSocket(server);
|
||||||
308
server/routes/fs.js
Normal file
308
server/routes/fs.js
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
const express = require("express");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const config = require("../config");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Resolve a client-provided path to an absolute path within the vault.
|
||||||
|
// Strips leading slashes so paths from the client are always treated as
|
||||||
|
// relative to the vault root. Rejects path traversal attempts.
|
||||||
|
function resolveVaultPath(relativePath) {
|
||||||
|
// Strip leading slashes - client paths like "/.obsidian" should resolve
|
||||||
|
// relative to the vault, not as absolute filesystem paths.
|
||||||
|
const cleaned = (relativePath || "").replace(/^\/+/, "");
|
||||||
|
const resolved = path.resolve(config.vaultPath, cleaned);
|
||||||
|
if (!resolved.startsWith(path.resolve(config.vaultPath))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function guardPath(req, res) {
|
||||||
|
const p = req.query.path ?? req.body?.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(p);
|
||||||
|
if (!resolved) {
|
||||||
|
res.status(403).json({ error: "Path traversal rejected" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/fs/stat?path=...
|
||||||
|
router.get("/stat", async (req, res) => {
|
||||||
|
const resolved = guardPath(req, res);
|
||||||
|
if (!resolved) return;
|
||||||
|
try {
|
||||||
|
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 {
|
||||||
|
// Check if path is a directory
|
||||||
|
const stat = await fs.promises.stat(resolved);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({
|
||||||
|
error: "EISDIR: illegal operation on a directory",
|
||||||
|
code: "EISDIR",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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? }
|
||||||
|
router.post("/writeFile", async (req, res) => {
|
||||||
|
const resolved = resolveVaultPath(req.body?.path);
|
||||||
|
if (!resolved) return res.status(403).json({ error: "Invalid path" });
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
await fs.promises.writeFile(
|
||||||
|
resolved,
|
||||||
|
data,
|
||||||
|
encoding === "binary" ? undefined : encoding,
|
||||||
|
);
|
||||||
|
const stat = await fs.promises.stat(resolved);
|
||||||
|
res.json({ ok: true, mtime: stat.mtimeMs, size: stat.size });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message, code: e.code });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/fs/appendFile { path, content }
|
||||||
|
router.post("/appendFile", async (req, res) => {
|
||||||
|
const resolved = resolveVaultPath(req.body?.path);
|
||||||
|
if (!resolved) return res.status(403).json({ error: "Invalid path" });
|
||||||
|
try {
|
||||||
|
await fs.promises.appendFile(resolved, req.body.content, "utf-8");
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message, code: e.code });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/fs/mkdir { path, recursive? }
|
||||||
|
router.post("/mkdir", async (req, res) => {
|
||||||
|
const resolved = resolveVaultPath(req.body?.path);
|
||||||
|
if (!resolved) return res.status(403).json({ error: "Invalid path" });
|
||||||
|
try {
|
||||||
|
await fs.promises.mkdir(resolved, { recursive: !!req.body.recursive });
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message, code: e.code });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/fs/rename { oldPath, newPath }
|
||||||
|
router.post("/rename", async (req, res) => {
|
||||||
|
const oldResolved = resolveVaultPath(req.body?.oldPath);
|
||||||
|
const newResolved = resolveVaultPath(req.body?.newPath);
|
||||||
|
if (!oldResolved || !newResolved)
|
||||||
|
return res.status(403).json({ error: "Invalid path" });
|
||||||
|
try {
|
||||||
|
await fs.promises.rename(oldResolved, newResolved);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message, code: e.code });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/fs/copyFile { src, dest }
|
||||||
|
router.post("/copyFile", async (req, res) => {
|
||||||
|
const srcResolved = resolveVaultPath(req.body?.src);
|
||||||
|
const destResolved = resolveVaultPath(req.body?.dest);
|
||||||
|
if (!srcResolved || !destResolved)
|
||||||
|
return res.status(403).json({ error: "Invalid path" });
|
||||||
|
try {
|
||||||
|
await fs.promises.copyFile(srcResolved, destResolved);
|
||||||
|
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);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
const status = e.code === "ENOENT" ? 404 : 500;
|
||||||
|
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);
|
||||||
|
if (!resolved) return;
|
||||||
|
try {
|
||||||
|
await fs.promises.rmdir(resolved);
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message, code: e.code });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/fs/access?path=...
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/fs/realpath?path=...
|
||||||
|
router.get("/realpath", async (req, res) => {
|
||||||
|
const resolved = guardPath(req, res);
|
||||||
|
if (!resolved) return;
|
||||||
|
try {
|
||||||
|
const real = await fs.promises.realpath(resolved);
|
||||||
|
// Return path relative to vault root
|
||||||
|
res.json({ path: path.relative(config.vaultPath, real) });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message, code: e.code });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/fs/utimes { path, atime, mtime }
|
||||||
|
router.post("/utimes", async (req, res) => {
|
||||||
|
const resolved = resolveVaultPath(req.body?.path);
|
||||||
|
if (!resolved) return res.status(403).json({ error: "Invalid path" });
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/fs/tree?path=... - returns full recursive file tree with metadata
|
||||||
|
router.get("/tree", async (req, res) => {
|
||||||
|
const rootPath = req.query.path
|
||||||
|
? resolveVaultPath(req.query.path)
|
||||||
|
: config.vaultPath;
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
16
server/routes/vault.js
Normal file
16
server/routes/vault.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
const express = require('express');
|
||||||
|
const config = require('../config');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// GET /api/vault/info
|
||||||
|
router.get('/info', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
name: path.basename(config.vaultPath),
|
||||||
|
platform: process.platform,
|
||||||
|
version: '0.1.0',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
26
server/ws.js
Normal file
26
server/ws.js
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
const { WebSocketServer } = require('ws');
|
||||||
|
|
||||||
|
function setupWebSocket(server) {
|
||||||
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||||
|
|
||||||
|
wss.on('connection', (ws) => {
|
||||||
|
console.log('[ws] Client connected');
|
||||||
|
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
// TODO: handle watch/unwatch subscriptions from client
|
||||||
|
const msg = JSON.parse(data);
|
||||||
|
console.log('[ws] Received:', msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
console.log('[ws] Client disconnected');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: integrate chokidar file watching and broadcast changes
|
||||||
|
// This will be implemented once the sync strategy is finalized.
|
||||||
|
|
||||||
|
return wss;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { setupWebSocket };
|
||||||
Loading…
Add table
Reference in a new issue