diff --git a/server/config.js b/server/config.js new file mode 100644 index 0000000..3194ddf --- /dev/null +++ b/server/config.js @@ -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'), +}; diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..a6012e0 --- /dev/null +++ b/server/index.js @@ -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); diff --git a/server/routes/fs.js b/server/routes/fs.js new file mode 100644 index 0000000..ab0c049 --- /dev/null +++ b/server/routes/fs.js @@ -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; diff --git a/server/routes/vault.js b/server/routes/vault.js new file mode 100644 index 0000000..6e10419 --- /dev/null +++ b/server/routes/vault.js @@ -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; diff --git a/server/ws.js b/server/ws.js new file mode 100644 index 0000000..fe77716 --- /dev/null +++ b/server/ws.js @@ -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 };