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:
@@ -1,24 +1,38 @@
|
||||
# BadNote Server (Optional)
|
||||
# BadNote Server (Self-hosted companion)
|
||||
|
||||
This directory contains an **optional** Python/FastAPI backend. The BadNote desktop app does **not** depend on it.
|
||||
Optional FastAPI backend for multi-device vault assist and deferred OCR.
|
||||
The Flutter app stays local-first: notes work fully offline. This server is
|
||||
for **your NAS / VPS**, not a hosted cloud product.
|
||||
|
||||
The Flutter client is local-first:
|
||||
## Architecture (v2 / API v1)
|
||||
|
||||
- Notes and documents are stored in SQLite on device
|
||||
- OCR runs locally via Windows built-in OCR
|
||||
- Full-text search uses on-device FTS5
|
||||
```
|
||||
Client vault (files + *.badnote.json)
|
||||
│
|
||||
├─ WebDAV (NAS) ───────────── file sync (existing)
|
||||
│
|
||||
└─ BadNote Server /api/v1 ─── assist layer
|
||||
├─ /auth JWT register/login
|
||||
├─ /vault manifest + PUT/GET/DELETE (tombstones)
|
||||
└─ /ocr upload ink PNG → job queue → EasyOCR worker
|
||||
```
|
||||
|
||||
## Why this exists
|
||||
**Source of truth = vault files**, not the legacy `notes.strokes_json` tables.
|
||||
Legacy routers remain under `/api/legacy/*` (and old `/api/notes` paths) for
|
||||
experiments only — new clients must use `/api/v1`.
|
||||
|
||||
This server was an early experiment for:
|
||||
### Storage layout
|
||||
|
||||
- Multi-device note sync (push/pull)
|
||||
- Server-side OCR with EasyOCR
|
||||
- JWT authentication
|
||||
```
|
||||
data/
|
||||
badnote_server.db # users
|
||||
.jwt_secret # if BADNOTE_JWT_SECRET unset
|
||||
vaults/<user_id>/files/ # mirrors client vault
|
||||
storage/ocr_blobs/… # uploaded ink rasters
|
||||
queue/{pending,processing,done,failed}/
|
||||
```
|
||||
|
||||
These features are **not wired into the current client**. The client previously had incomplete sync/OCR scaffolding that has been removed in favor of local processing.
|
||||
|
||||
## Running (if you want to experiment)
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd server
|
||||
@@ -28,25 +42,43 @@ pip install -r requirements.txt
|
||||
uvicorn badnote_server.main:app --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
API docs: http://localhost:8080/docs
|
||||
- Health: `GET /api/v1/health`
|
||||
- OpenAPI: http://localhost:8080/docs
|
||||
|
||||
The OCR worker has heavy extra dependencies (EasyOCR + torch). Install them only
|
||||
if you want to run it:
|
||||
### OCR worker (optional, heavy)
|
||||
|
||||
```bash
|
||||
pip install -r requirements-ocr.txt
|
||||
python -m badnote_server.ocr.worker
|
||||
```
|
||||
|
||||
### Security notes
|
||||
### Security
|
||||
|
||||
- Set `BADNOTE_JWT_SECRET` in production. If unset, a secret is generated once
|
||||
and persisted to `<data>/.jwt_secret` so tokens survive restarts.
|
||||
- Restrict origins with `BADNOTE_CORS_ORIGINS` (comma-separated). The default is
|
||||
permissive (`*`, without credentials) for local development.
|
||||
- Set `BADNOTE_JWT_SECRET` in production.
|
||||
- Restrict CORS with `BADNOTE_CORS_ORIGINS`.
|
||||
- Prefer HTTPS reverse proxy (Caddy/Nginx) in front of uvicorn.
|
||||
|
||||
### Env
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|----------|---------|---------|
|
||||
| `BADNOTE_HOST` / `PORT` | `0.0.0.0` / `8080` | Bind |
|
||||
| `BADNOTE_DB_PATH` | `./data/badnote_server.db` | Users DB |
|
||||
| `BADNOTE_VAULT_PATH` | `./data/vaults` | Per-user vault trees |
|
||||
| `BADNOTE_STORAGE_PATH` | `./data/storage` | Blobs |
|
||||
| `BADNOTE_QUEUE_PATH` | `./data/queue` | OCR jobs |
|
||||
| `BADNOTE_JWT_SECRET` | persisted file | Signing key |
|
||||
| `BADNOTE_CORS_ORIGINS` | `*` | Allowed origins |
|
||||
|
||||
## Client
|
||||
|
||||
In BadNote → Settings → **BadNote Server**, set base URL (e.g.
|
||||
`http://192.168.1.10:8080`), register/login, then **Test connection**.
|
||||
Vault file sync via the API is additive to WebDAV; OCR upload is opt-in when
|
||||
online/charging (future client job).
|
||||
|
||||
## Status
|
||||
|
||||
- Kept for reference and future optional sync work
|
||||
- Not part of the primary development path
|
||||
- No guarantee of API compatibility with future client versions
|
||||
- **v1 vault + health + OCR enqueue**: implemented
|
||||
- **Wiki / semantic search**: stubbed for later (`501` reserved)
|
||||
- Legacy notes push/pull: deprecated, not used by current Flutter app
|
||||
|
||||
@@ -68,6 +68,9 @@ class Settings:
|
||||
jwt_secret: str = _resolve_jwt_secret()
|
||||
jwt_expiry_hours: int = int(os.environ.get("BADNOTE_JWT_EXPIRY_HOURS", "720"))
|
||||
cors_origins: list[str] = _resolve_cors_origins()
|
||||
# Per-user vault trees (notebook folders + sidecars), independent of legacy
|
||||
# notes.strokes_json storage.
|
||||
vault_path: str = os.environ.get("BADNOTE_VAULT_PATH", "./data/vaults")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
"""BadNote FastAPI server — main application."""
|
||||
"""BadNote FastAPI server — main application.
|
||||
|
||||
Architecture (v1):
|
||||
- Vault files are the source of truth (same layout as the Flutter vault).
|
||||
- ``/api/v1/vault/*`` assists multi-device sync (manifest + PUT/GET/DELETE).
|
||||
- ``/api/v1/ocr/*`` accepts ink rasters for deferred server OCR.
|
||||
- Legacy ``/api/notes`` etc. remain mounted under ``/api/legacy/*`` for
|
||||
compatibility with old experiments; new clients must not use them.
|
||||
"""
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -13,12 +21,15 @@ from .routers.notes_router import router as notes_router
|
||||
from .routers.documents_router import router as documents_router
|
||||
from .routers.ocr_router import router as ocr_router
|
||||
from .routers.sync_router import router as sync_router
|
||||
from .routers.v1_vault_router import router as v1_vault_router
|
||||
from .routers.v1_ocr_router import router as v1_ocr_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Startup: create directories and init DB. Shutdown: close DB."""
|
||||
os.makedirs(settings.storage_path, exist_ok=True)
|
||||
os.makedirs(settings.vault_path, exist_ok=True)
|
||||
for subdir in ("pending", "processing", "done", "failed"):
|
||||
os.makedirs(os.path.join(settings.queue_path, subdir), exist_ok=True)
|
||||
await init_db()
|
||||
@@ -26,12 +37,17 @@ async def lifespan(app: FastAPI):
|
||||
await close_db()
|
||||
|
||||
|
||||
app = FastAPI(title="BadNote Server", version="1.0.0", lifespan=lifespan)
|
||||
app = FastAPI(
|
||||
title="BadNote Server",
|
||||
version="2.0.0",
|
||||
description=(
|
||||
"Self-hosted companion for BadNote. "
|
||||
"Primary API is /api/v1 (vault + OCR). "
|
||||
"Legacy notes CRUD lives under /api/legacy."
|
||||
),
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Authentication is Bearer-token based, so cookies/credentials are not needed.
|
||||
# `allow_origins=["*"]` together with `allow_credentials=True` is an invalid and
|
||||
# insecure combination, so we keep credentials disabled. Set BADNOTE_CORS_ORIGINS
|
||||
# (comma-separated) to lock the API down to specific front-end origins.
|
||||
_cors_origins = settings.cors_origins or ["*"]
|
||||
|
||||
app.add_middleware(
|
||||
@@ -42,14 +58,34 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# --- Auth (shared by v1 + legacy) ---
|
||||
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(notes_router, prefix="/api/notes", tags=["notes"])
|
||||
app.include_router(documents_router, prefix="/api/documents", tags=["documents"])
|
||||
app.include_router(ocr_router, prefix="/api/ocr", tags=["ocr"])
|
||||
app.include_router(sync_router, prefix="/api/sync", tags=["sync"])
|
||||
app.include_router(auth_router, prefix="/api/v1/auth", tags=["auth-v1"])
|
||||
|
||||
# --- Primary v1 API ---
|
||||
app.include_router(v1_vault_router, prefix="/api/v1/vault", tags=["vault-v1"])
|
||||
app.include_router(v1_ocr_router, prefix="/api/v1/ocr", tags=["ocr-v1"])
|
||||
|
||||
# --- Legacy (deprecated) ---
|
||||
app.include_router(notes_router, prefix="/api/legacy/notes", tags=["legacy-notes"])
|
||||
app.include_router(documents_router, prefix="/api/legacy/documents", tags=["legacy-documents"])
|
||||
app.include_router(ocr_router, prefix="/api/legacy/ocr", tags=["legacy-ocr"])
|
||||
app.include_router(sync_router, prefix="/api/legacy/sync", tags=["legacy-sync"])
|
||||
# Keep old paths temporarily so existing bookmarks to /docs experiments still work,
|
||||
# but they are the same legacy routers.
|
||||
app.include_router(notes_router, prefix="/api/notes", tags=["legacy-notes"], include_in_schema=False)
|
||||
app.include_router(documents_router, prefix="/api/documents", tags=["legacy-documents"], include_in_schema=False)
|
||||
app.include_router(ocr_router, prefix="/api/ocr", tags=["legacy-ocr"], include_in_schema=False)
|
||||
app.include_router(sync_router, prefix="/api/sync", tags=["legacy-sync"], include_in_schema=False)
|
||||
|
||||
|
||||
@app.get("/api/ping")
|
||||
async def ping() -> dict:
|
||||
"""Health check endpoint."""
|
||||
return {"status": "ok"}
|
||||
@app.get("/api/v1/health")
|
||||
async def health() -> dict:
|
||||
"""Liveness probe for clients and reverse proxies."""
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": "2.0.0",
|
||||
"api": "v1",
|
||||
"features": ["vault", "ocr", "auth"],
|
||||
}
|
||||
|
||||
74
server/badnote_server/routers/v1_ocr_router.py
Normal file
74
server/badnote_server/routers/v1_ocr_router.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""v1 OCR job API — upload ink raster, poll status, fetch text."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..config import settings
|
||||
from ..ocr import queue as ocr_queue
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/jobs", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def create_ocr_job(
|
||||
image: UploadFile = File(...),
|
||||
source_path: str = Form(""),
|
||||
page_index: int = Form(0),
|
||||
user_id: str = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Enqueue an OCR job from an uploaded PNG/JPEG of handwriting."""
|
||||
job_id = str(uuid.uuid4())
|
||||
blob_dir = os.path.join(settings.storage_path, "ocr_blobs", user_id)
|
||||
os.makedirs(blob_dir, exist_ok=True)
|
||||
ext = os.path.splitext(image.filename or "ink.png")[1] or ".png"
|
||||
blob_path = os.path.join(blob_dir, f"{job_id}{ext}")
|
||||
data = await image.read()
|
||||
if not data:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="empty image")
|
||||
with open(blob_path, "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
ocr_queue.enqueue(
|
||||
{
|
||||
"id": job_id,
|
||||
"user_id": user_id,
|
||||
"source_path": source_path,
|
||||
"note_id": source_path or job_id,
|
||||
"page_index": page_index,
|
||||
"image_path": blob_path,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"status": "pending",
|
||||
"source_path": source_path,
|
||||
"page_index": page_index,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}")
|
||||
async def get_ocr_job(
|
||||
job_id: str,
|
||||
user_id: str = Depends(get_current_user),
|
||||
) -> dict:
|
||||
job = ocr_queue.get_status(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="job not found")
|
||||
if job.get("user_id") not in (None, user_id):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="forbidden")
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"status": job.get("status"),
|
||||
"result": job.get("result_text") or job.get("result") or job.get("text"),
|
||||
"error": job.get("error_message") or job.get("error"),
|
||||
"source_path": job.get("source_path"),
|
||||
"page_index": job.get("page_index"),
|
||||
}
|
||||
63
server/badnote_server/routers/v1_vault_router.py
Normal file
63
server/badnote_server/routers/v1_vault_router.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""v1 vault sync-assist API — vault files are the source of truth."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import Response
|
||||
|
||||
from ..auth import get_current_user
|
||||
from .. import vault_store
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/manifest")
|
||||
async def get_manifest(user_id: str = Depends(get_current_user)) -> dict:
|
||||
"""List all files in the user's vault with size/mtime/sha256."""
|
||||
return vault_store.build_manifest(user_id)
|
||||
|
||||
|
||||
@router.get("/files/{file_path:path}")
|
||||
async def download_file(
|
||||
file_path: str,
|
||||
user_id: str = Depends(get_current_user),
|
||||
) -> Response:
|
||||
try:
|
||||
data = vault_store.read_file(user_id, file_path)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="file not found")
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="application/octet-stream",
|
||||
headers={"X-Vault-Path": file_path},
|
||||
)
|
||||
|
||||
|
||||
@router.put("/files/{file_path:path}")
|
||||
async def upload_file(
|
||||
file_path: str,
|
||||
upload: UploadFile = File(...),
|
||||
user_id: str = Depends(get_current_user),
|
||||
) -> dict:
|
||||
try:
|
||||
data = await upload.read()
|
||||
entry = vault_store.write_file(user_id, file_path, data)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
return entry.to_dict()
|
||||
|
||||
|
||||
@router.delete("/files/{file_path:path}")
|
||||
async def remove_file(
|
||||
file_path: str,
|
||||
user_id: str = Depends(get_current_user),
|
||||
) -> dict:
|
||||
try:
|
||||
vault_store.delete_file(user_id, file_path, tombstone=True)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="file not found")
|
||||
return {"deleted": file_path, "tombstone": True}
|
||||
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()
|
||||
@@ -14,6 +14,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
os.environ["BADNOTE_STORAGE_PATH"] = "/tmp/badnote_test_storage"
|
||||
os.environ["BADNOTE_QUEUE_PATH"] = "/tmp/badnote_test_queue"
|
||||
os.environ["BADNOTE_VAULT_PATH"] = "/tmp/badnote_test_vaults"
|
||||
os.environ["BADNOTE_JWT_SECRET"] = "test-secret-key-for-testing-only"
|
||||
|
||||
from badnote_server.config import settings # noqa: E402
|
||||
@@ -26,10 +27,13 @@ async def setup_db(tmp_path):
|
||||
"""Fresh DB and clean queue for each test."""
|
||||
db_path = str(tmp_path / "test.db")
|
||||
settings.db_path = db_path
|
||||
settings.vault_path = str(tmp_path / "vaults")
|
||||
# Clean the queue directory before each test
|
||||
queue_path = settings.queue_path
|
||||
if os.path.exists(queue_path):
|
||||
shutil.rmtree(queue_path)
|
||||
if os.path.exists(settings.vault_path):
|
||||
shutil.rmtree(settings.vault_path)
|
||||
await init_db()
|
||||
yield
|
||||
await close_db()
|
||||
|
||||
88
server/tests/test_vault_v1.py
Normal file
88
server/tests/test_vault_v1.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Tests for v1 vault API (async, uses shared conftest)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
async def _token(client) -> str:
|
||||
r = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"username": "vault_user", "password": "password123"},
|
||||
)
|
||||
if r.status_code == 409:
|
||||
r = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "vault_user", "password": "password123"},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
return r.json()["token"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health(client):
|
||||
r = await client.get("/api/v1/health")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["api"] == "v1"
|
||||
assert "vault" in body["features"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vault_roundtrip(client, tmp_path, monkeypatch):
|
||||
from badnote_server.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "vault_path", str(tmp_path / "vaults"))
|
||||
|
||||
token = await _token(client)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
files = {"upload": ("hello.txt", b"hello vault", "text/plain")}
|
||||
r = await client.put(
|
||||
"/api/v1/vault/files/NotebookA/hello.txt",
|
||||
headers=headers,
|
||||
files=files,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["path"] == "NotebookA/hello.txt"
|
||||
assert r.json()["size"] == 11
|
||||
|
||||
r = await client.get("/api/v1/vault/manifest", headers=headers)
|
||||
assert r.status_code == 200
|
||||
paths = [f["path"] for f in r.json()["files"]]
|
||||
assert "NotebookA/hello.txt" in paths
|
||||
|
||||
r = await client.get(
|
||||
"/api/v1/vault/files/NotebookA/hello.txt",
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.content == b"hello vault"
|
||||
|
||||
r = await client.delete(
|
||||
"/api/v1/vault/files/NotebookA/hello.txt",
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
r = await client.get(
|
||||
"/api/v1/vault/files/NotebookA/hello.txt",
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_path_escape_rejected(client, tmp_path, monkeypatch):
|
||||
from badnote_server.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "vault_path", str(tmp_path / "vaults"))
|
||||
|
||||
token = await _token(client)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
files = {"upload": ("x", b"nope", "application/octet-stream")}
|
||||
r = await client.put(
|
||||
"/api/v1/vault/files/../../etc/passwd",
|
||||
headers=headers,
|
||||
files=files,
|
||||
)
|
||||
assert r.status_code in (400, 404)
|
||||
Reference in New Issue
Block a user