diff --git a/app/policy.py b/app/policy.py index 0ae1615..10b4824 100644 --- a/app/policy.py +++ b/app/policy.py @@ -1,6 +1,8 @@ from dataclasses import dataclass +from datetime import datetime from pathlib import Path from typing import Any +import json from app.config import DEFAULT_VAULT_NAME, REGISTERED_VAULTS @@ -35,6 +37,7 @@ PATH_REQUIRED_WHEN_RESTRICTED = { "aliases", "backlinks", "links", "outlinks", "tags", "tasks", "task", } PATHLESS_ALLOWED_WHEN_RESTRICTED = {"help", "version"} +DAILY_NOTE_COMMANDS = {"daily:path", "daily:read", "daily:append", "daily:prepend"} def tuple_claim(value:Any, default:tuple[str, ...]) -> tuple[str, ...]: if value is None: @@ -120,6 +123,53 @@ def extract_command_target(args:list[str]) -> CommandTarget: return CommandTarget(command=command, vault=vault, paths=tuple(paths)) +def moment_to_strftime(format_string:str) -> str: + replacements = [ + ("YYYY", "%Y"), + ("YY", "%y"), + ("MMMM", "%B"), + ("MMM", "%b"), + ("MM", "%m"), + ("DDDD", "%j"), + ("DD", "%d"), + ("dddd", "%A"), + ("ddd", "%a"), + ] + converted = format_string or "YYYY-MM-DD" + for moment_token, strftime_token in replacements: + converted = converted.replace(moment_token, strftime_token) + return converted + +def daily_note_path_for_vault(vault_root:Path, now:datetime|None = None) -> str: + settings_path = vault_root / ".obsidian" / "daily-notes.json" + folder = "" + format_string = "YYYY-MM-DD" + + if settings_path.exists() and settings_path.is_file(): + try: + settings = json.loads(settings_path.read_text(encoding="utf-8")) + if isinstance(settings, dict): + folder_value = settings.get("folder") + format_value = settings.get("format") + if isinstance(folder_value, str): + folder = folder_value.strip().strip("/") + if isinstance(format_value, str) and format_value.strip(): + format_string = format_value.strip() + except (OSError, json.JSONDecodeError): + pass + + note_name = (now or datetime.now()).strftime(moment_to_strftime(format_string)) + if not note_name.endswith(".md"): + note_name += ".md" + return f"{folder}/{note_name}" if folder else note_name + +def vault_root_for_target(target:CommandTarget, cwd:Path) -> Path: + if target.vault and target.vault in REGISTERED_VAULTS: + return Path(REGISTERED_VAULTS[target.vault]).resolve() + + vault_name = vault_for_path(cwd) or DEFAULT_VAULT_NAME + return Path(REGISTERED_VAULTS.get(vault_name, cwd)).resolve() + def authorize_command(policy:TokenPolicy|None, args:list[str], cwd:Path) -> None: if policy is None: return @@ -138,13 +188,17 @@ def authorize_command(policy:TokenPolicy|None, args:list[str], cwd:Path) -> None if policy.allows_all_paths(): return - if not target.paths: + paths = list(target.paths) + if not paths and command in DAILY_NOTE_COMMANDS: + paths.append(daily_note_path_for_vault(vault_root_for_target(target, cwd))) + + if not paths: if command in PATHLESS_ALLOWED_WHEN_RESTRICTED: return if command in PATH_REQUIRED_WHEN_RESTRICTED: raise PermissionError("token has path restrictions; command must include an allowed path/file/folder/name argument") raise PermissionError(f"token has path restrictions; command is not allowed without an explicit path: {command}") - denied = [path for path in target.paths if not is_path_allowed(path, policy.paths)] + denied = [path for path in paths if not is_path_allowed(path, policy.paths)] if denied: raise PermissionError(f"token is not allowed to access path: {denied[0]}") diff --git a/packages/n8n-nodes-obsidian-api/package-lock.json b/packages/n8n-nodes-obsidian-api/package-lock.json index f67379c..6842d57 100644 --- a/packages/n8n-nodes-obsidian-api/package-lock.json +++ b/packages/n8n-nodes-obsidian-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "n8n-nodes-obsidian-cli-api", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "n8n-nodes-obsidian-cli-api", - "version": "0.1.1", + "version": "0.2.0", "license": "MIT", "devDependencies": { "@types/node": "^24.0.0", diff --git a/packages/n8n-nodes-obsidian-api/package.json b/packages/n8n-nodes-obsidian-api/package.json index 001b410..778c77e 100644 --- a/packages/n8n-nodes-obsidian-api/package.json +++ b/packages/n8n-nodes-obsidian-api/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-obsidian-cli-api", - "version": "0.1.1", + "version": "0.2.0", "description": "n8n community nodes for the Obsidian API Docker service backed by the official Obsidian CLI.", "license": "MIT", "homepage": "https://git.yiprawr.dev/Docker/obsidian-api", diff --git a/packages/n8n-nodes-obsidian-api/shared/ObsidianApiClient.ts b/packages/n8n-nodes-obsidian-api/shared/ObsidianApiClient.ts index 0de92d3..5a37672 100644 --- a/packages/n8n-nodes-obsidian-api/shared/ObsidianApiClient.ts +++ b/packages/n8n-nodes-obsidian-api/shared/ObsidianApiClient.ts @@ -61,9 +61,11 @@ export async function executeObsidianCommand( timeout: options.timeout, }, json: true, + returnFullResponse: false, }; - const result = await context.helpers.httpRequest(request) as ObsidianCommandResult; + const response = await context.helpers.httpRequest(request) as unknown; + const result = normalizeCommandResult(response); if (result.exit_code !== 0) { throw new NodeOperationError( @@ -75,3 +77,38 @@ export async function executeObsidianCommand( return result; } + +function normalizeCommandResult(response: unknown): ObsidianCommandResult { + const data = extractResponseBody(response); + const result = typeof data === 'string' ? JSON.parse(data) : data; + + if (!isObject(result)) { + throw new Error('Obsidian API returned a non-object response'); + } + + return { + id: String(result.id ?? ''), + command: Array.isArray(result.command) ? result.command.map(String) : [], + cwd: String(result.cwd ?? ''), + exit_code: Number(result.exit_code ?? 0), + stdout: String(result.stdout ?? ''), + stderr: String(result.stderr ?? ''), + duration_ms: Number(result.duration_ms ?? 0), + timed_out: Boolean(result.timed_out), + }; +} + +function extractResponseBody(response: unknown): unknown { + if (!isObject(response)) return response; + + // Some n8n versions/helpers can return a full response object. Never pass that + // through to node output because it contains circular fields like res.socket. + if ('body' in response) return response.body; + if ('data' in response) return response.data; + + return response; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/tests/test_api.py b/tests/test_api.py index 23a446e..fdc8223 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -165,3 +165,41 @@ def test_token_policy_allows_allowed_path(monkeypatch): # "read" is not a Python flag, which is fine for this policy test. assert response.status_code == 200 assert response.json()["command"] == ["python", "read", "path=Inbox/Allowed.md"] + +def test_path_restricted_token_allows_daily_command_from_obsidian_config(monkeypatch, tmp_path): + vault = tmp_path / "vault" + config = vault / ".obsidian" + config.mkdir(parents=True) + (config / "daily-notes.json").write_text(json.dumps({"folder": "00. Journal", "format": "YYYY-MM-DD"})) + + monkeypatch.setattr("app.policy.REGISTERED_VAULTS", {"work": str(vault)}) + monkeypatch.setattr("app.policy.DEFAULT_VAULT_NAME", "work") + token = use_jwt_auth(monkeypatch, {"sub": "daily", "commands": ["daily:append"], "vaults": ["work"], "paths": ["00. Journal/"]}) + + response = client.post( + "/commands", + headers={"Authorization": f"Bearer {token}"}, + json={"args": ["vault=work", "daily:append", "content=ok"], "cwd": str(vault)}, + ) + + assert response.status_code == 200 + assert response.json()["command"] == ["python", "vault=work", "daily:append", "content=ok"] + +def test_path_restricted_token_denies_daily_command_outside_obsidian_config(monkeypatch, tmp_path): + vault = tmp_path / "vault" + config = vault / ".obsidian" + config.mkdir(parents=True) + (config / "daily-notes.json").write_text(json.dumps({"folder": "Journal", "format": "YYYY-MM-DD"})) + + monkeypatch.setattr("app.policy.REGISTERED_VAULTS", {"work": str(vault)}) + monkeypatch.setattr("app.policy.DEFAULT_VAULT_NAME", "work") + token = use_jwt_auth(monkeypatch, {"sub": "daily", "commands": ["daily:append"], "vaults": ["work"], "paths": ["00. Journal/"]}) + + response = client.post( + "/commands", + headers={"Authorization": f"Bearer {token}"}, + json={"args": ["vault=work", "daily:append", "content=ok"], "cwd": str(vault)}, + ) + + assert response.status_code == 403 + assert response.json()["detail"].startswith("token is not allowed to access path: Journal/")