902a3df8b5
Build and Push Docker Container / build-and-push (push) Successful in 4m26s
- Add Docker image for the official Obsidian desktop app and CLI. - Start Obsidian headlessly with Xvfb and DBus setup. - Expose a safe structured HTTP command API without shell execution. - Add JWT-based vault, path, and command authorization. - Support single-vault and multi-vault container mounts. - Add TypeScript SDK helpers for Obsidian CLI commands. - Add n8n community node package with Obsidian operations. - Add docs, compose config, tests, and production image workflow.
123 lines
3.6 KiB
Python
123 lines
3.6 KiB
Python
from pathlib import Path
|
|
from typing import Any
|
|
import json
|
|
|
|
from starlette.applications import Starlette
|
|
from starlette.requests import Request
|
|
from starlette.responses import JSONResponse
|
|
from starlette.routing import Route
|
|
|
|
from app import auth
|
|
from app.auth import require_auth
|
|
from app.config import CLI_BIN, DEFAULT_TIMEOUT, HOST, MAX_TIMEOUT, PORT, VAULT_PATH
|
|
from app.policy import authorize_command, vault_for_path
|
|
from app.runner import execute_command
|
|
|
|
def error(status_code:int, detail:str) -> JSONResponse:
|
|
return JSONResponse({"detail": detail}, status_code=status_code)
|
|
|
|
def parse_args(value:Any) -> list[str]:
|
|
if value is None:
|
|
return []
|
|
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
raise ValueError("args must be a list of strings")
|
|
return value
|
|
|
|
def parse_timeout(value:Any) -> float:
|
|
if value is None:
|
|
return DEFAULT_TIMEOUT
|
|
if not isinstance(value, int | float) or value <= 0:
|
|
raise ValueError("timeout must be a positive number")
|
|
return min(float(value), MAX_TIMEOUT)
|
|
|
|
def safe_cwd(cwd:str|None) -> Path:
|
|
path = Path(cwd).resolve() if cwd else VAULT_PATH
|
|
if not path.exists() or not path.is_dir():
|
|
raise ValueError(f"cwd does not exist or is not a directory: {path}")
|
|
if vault_for_path(path) is None:
|
|
raise ValueError(f"cwd must be inside a registered vault: {path}")
|
|
return path
|
|
|
|
async def health(request:Request) -> JSONResponse:
|
|
return JSONResponse(
|
|
{
|
|
"ok": True,
|
|
"vault": str(VAULT_PATH),
|
|
"cli": CLI_BIN,
|
|
"auth": bool(auth.JWT_SECRET),
|
|
"jwt": bool(auth.JWT_SECRET),
|
|
}
|
|
)
|
|
|
|
async def run_command(request:Request) -> JSONResponse:
|
|
auth_context = require_auth(request)
|
|
if isinstance(auth_context, JSONResponse):
|
|
return auth_context
|
|
|
|
try:
|
|
payload = await request.json()
|
|
except json.JSONDecodeError:
|
|
return error(400, "invalid JSON body")
|
|
|
|
try:
|
|
if "command" in payload:
|
|
raise ValueError("command strings are not allowed; use args as a list of strings")
|
|
if "mode" in payload:
|
|
raise ValueError("mode is not allowed; only the configured Obsidian CLI can be executed")
|
|
|
|
args = parse_args(payload.get("args"))
|
|
stdin = payload.get("stdin")
|
|
if stdin is not None and not isinstance(stdin, str):
|
|
raise ValueError("stdin must be a string")
|
|
|
|
cwd_value = payload.get("cwd")
|
|
if cwd_value is not None and not isinstance(cwd_value, str):
|
|
raise ValueError("cwd must be a string")
|
|
|
|
cwd = safe_cwd(cwd_value)
|
|
timeout = parse_timeout(payload.get("timeout"))
|
|
authorize_command(auth_context.policy, args, cwd)
|
|
except ValueError as exc:
|
|
return error(400, str(exc))
|
|
except PermissionError as exc:
|
|
return error(403, str(exc))
|
|
|
|
return await execute_command(args=args, cwd=cwd, stdin=stdin, timeout=timeout)
|
|
|
|
async def typescript_sdk_example(request:Request) -> JSONResponse:
|
|
return JSONResponse(
|
|
{
|
|
"example": """
|
|
import { ObsidianCliClient } from '@obsidian-api/sdk';
|
|
|
|
const obsidian = new ObsidianCliClient({
|
|
baseUrl: 'http://localhost:8080',
|
|
token: '<jwt>',
|
|
});
|
|
|
|
await obsidian.create({
|
|
path: 'Inbox/Hello.md',
|
|
content: '# Hello from n8n/Prism',
|
|
overwrite: true,
|
|
});
|
|
|
|
const note = await obsidian.read({ path: 'Inbox/Hello.md' });
|
|
console.log(note.stdout);
|
|
""".strip()
|
|
}
|
|
)
|
|
|
|
app = Starlette(
|
|
debug=False,
|
|
routes=[
|
|
Route("/health", health, methods=["GET"]),
|
|
Route("/commands", run_command, methods=["POST"]),
|
|
Route("/sdk/typescript", typescript_sdk_example, methods=["GET"]),
|
|
],
|
|
)
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
|
|
uvicorn.run("app.main:app", host=HOST, port=PORT)
|