feat: resolve daily-note path from Obsidian config for scoped tokens
Build and Push Docker Container / build-and-push (push) Successful in 4m15s
Build and Push Docker Container / build-and-push (push) Successful in 4m15s
- Add daily_note_path_for_vault to read .obsidian/daily-notes.json - Honor configured folder and date format for daily commands - Add moment_to_strftime to translate Obsidian date tokens - Authorize pathless daily:* commands against the resolved path - Pick vault root from token vault, cwd, or default vault - Normalize n8n httpRequest response to avoid circular JSON output - Force returnFullResponse false and parse body to plain result - Bump n8n-nodes-obsidian-cli-api to 0.2.0 - Add tests for allowed and denied scoped daily commands
This commit is contained in:
+56
-2
@@ -1,6 +1,8 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
import json
|
||||||
|
|
||||||
from app.config import DEFAULT_VAULT_NAME, REGISTERED_VAULTS
|
from app.config import DEFAULT_VAULT_NAME, REGISTERED_VAULTS
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ PATH_REQUIRED_WHEN_RESTRICTED = {
|
|||||||
"aliases", "backlinks", "links", "outlinks", "tags", "tasks", "task",
|
"aliases", "backlinks", "links", "outlinks", "tags", "tasks", "task",
|
||||||
}
|
}
|
||||||
PATHLESS_ALLOWED_WHEN_RESTRICTED = {"help", "version"}
|
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, ...]:
|
def tuple_claim(value:Any, default:tuple[str, ...]) -> tuple[str, ...]:
|
||||||
if value is None:
|
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))
|
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:
|
def authorize_command(policy:TokenPolicy|None, args:list[str], cwd:Path) -> None:
|
||||||
if policy is None:
|
if policy is None:
|
||||||
return
|
return
|
||||||
@@ -138,13 +188,17 @@ def authorize_command(policy:TokenPolicy|None, args:list[str], cwd:Path) -> None
|
|||||||
if policy.allows_all_paths():
|
if policy.allows_all_paths():
|
||||||
return
|
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:
|
if command in PATHLESS_ALLOWED_WHEN_RESTRICTED:
|
||||||
return
|
return
|
||||||
if command in PATH_REQUIRED_WHEN_RESTRICTED:
|
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("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}")
|
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:
|
if denied:
|
||||||
raise PermissionError(f"token is not allowed to access path: {denied[0]}")
|
raise PermissionError(f"token is not allowed to access path: {denied[0]}")
|
||||||
|
|||||||
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-nodes-obsidian-cli-api",
|
"name": "n8n-nodes-obsidian-cli-api",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "n8n-nodes-obsidian-cli-api",
|
"name": "n8n-nodes-obsidian-cli-api",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-nodes-obsidian-cli-api",
|
"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.",
|
"description": "n8n community nodes for the Obsidian API Docker service backed by the official Obsidian CLI.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://git.yiprawr.dev/Docker/obsidian-api",
|
"homepage": "https://git.yiprawr.dev/Docker/obsidian-api",
|
||||||
|
|||||||
@@ -61,9 +61,11 @@ export async function executeObsidianCommand(
|
|||||||
timeout: options.timeout,
|
timeout: options.timeout,
|
||||||
},
|
},
|
||||||
json: true,
|
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) {
|
if (result.exit_code !== 0) {
|
||||||
throw new NodeOperationError(
|
throw new NodeOperationError(
|
||||||
@@ -75,3 +77,38 @@ export async function executeObsidianCommand(
|
|||||||
|
|
||||||
return result;
|
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<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
# "read" is not a Python flag, which is fine for this policy test.
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["command"] == ["python", "read", "path=Inbox/Allowed.md"]
|
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/")
|
||||||
|
|||||||
Reference in New Issue
Block a user