feat: vault-aligned server v1 + UX polish
All checks were successful
CI / Windows build (push) Successful in 7m47s
All checks were successful
CI / Windows build (push) Successful in 7m47s
Redesign the optional FastAPI companion around vault files (manifest / PUT/GET/DELETE + OCR jobs) instead of legacy strokes_json notes. Wire a client Server settings panel for health/login. Polish shell UX: l10n for settings/home/board, sticky-board empty state, and a narrow-screen diagnostics FAB. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
124
server/badnote_server/vault_store.py
Normal file
124
server/badnote_server/vault_store.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Per-user vault file store — mirrors the client vault layout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class VaultFileEntry:
|
||||
path: str
|
||||
size: int
|
||||
mtime: float
|
||||
sha256: str
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"path": self.path,
|
||||
"size": self.size,
|
||||
"mtime": self.mtime,
|
||||
"sha256": self.sha256,
|
||||
}
|
||||
|
||||
|
||||
def vault_root(user_id: str) -> Path:
|
||||
root = Path(settings.vault_path) / user_id / "files"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def _safe_relpath(rel: str) -> str:
|
||||
"""Normalize and reject path escape attempts."""
|
||||
cleaned = rel.replace("\\", "/").lstrip("/")
|
||||
if not cleaned or cleaned.startswith("..") or "/../" in f"/{cleaned}/":
|
||||
raise ValueError(f"invalid vault path: {rel!r}")
|
||||
parts = Path(cleaned).parts
|
||||
if ".." in parts:
|
||||
raise ValueError(f"invalid vault path: {rel!r}")
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def resolve_path(user_id: str, rel: str) -> Path:
|
||||
rel_n = _safe_relpath(rel)
|
||||
full = (vault_root(user_id) / rel_n).resolve()
|
||||
root = vault_root(user_id).resolve()
|
||||
if not str(full).startswith(str(root) + os.sep) and full != root:
|
||||
raise ValueError(f"path escapes vault: {rel!r}")
|
||||
return full
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def build_manifest(user_id: str) -> dict:
|
||||
root = vault_root(user_id)
|
||||
entries: list[VaultFileEntry] = []
|
||||
for path in sorted(root.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if path.name.startswith("."):
|
||||
continue
|
||||
rel = path.relative_to(root).as_posix()
|
||||
st = path.stat()
|
||||
entries.append(
|
||||
VaultFileEntry(
|
||||
path=rel,
|
||||
size=st.st_size,
|
||||
mtime=st.st_mtime,
|
||||
sha256=_sha256_file(path),
|
||||
)
|
||||
)
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"generated_at": time.time(),
|
||||
"files": [e.to_dict() for e in entries],
|
||||
}
|
||||
|
||||
|
||||
def write_file(user_id: str, rel: str, data: bytes) -> VaultFileEntry:
|
||||
path = resolve_path(user_id, rel)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_bytes(data)
|
||||
tmp.replace(path)
|
||||
st = path.stat()
|
||||
return VaultFileEntry(
|
||||
path=_safe_relpath(rel),
|
||||
size=st.st_size,
|
||||
mtime=st.st_mtime,
|
||||
sha256=_sha256_file(path),
|
||||
)
|
||||
|
||||
|
||||
def read_file(user_id: str, rel: str) -> bytes:
|
||||
path = resolve_path(user_id, rel)
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(rel)
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
def delete_file(user_id: str, rel: str, *, tombstone: bool = True) -> None:
|
||||
path = resolve_path(user_id, rel)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(rel)
|
||||
if tombstone:
|
||||
tomb = path.parent / f".tombstone-{path.name}"
|
||||
meta = {
|
||||
"path": _safe_relpath(rel),
|
||||
"deleted_at": time.time(),
|
||||
"sha256": _sha256_file(path) if path.is_file() else None,
|
||||
}
|
||||
tomb.write_text(json.dumps(meta), encoding="utf-8")
|
||||
path.unlink()
|
||||
Reference in New Issue
Block a user