feat(api,ai): #72 OpenAPI 3.1 enrichie + fiabilisation de l'outil AI UI
CI / lint (push) Successful in 55s
CI / security (push) Successful in 37s
CI / test (push) Successful in 1m19s
CI / build (push) Successful in 34s
CI / e2e (push) Successful in 10m27s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 55s
CI / security (push) Successful in 37s
CI / test (push) Successful in 1m19s
CI / build (push) Successful in 34s
CI / e2e (push) Successful in 10m27s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
API (#72): - backend/openapi_docs.py: 18 tags documentes, assignation auto par prefixe, securite bearerAuth/cookieAuth, erreurs 401/403/404/422/500, exemples, page /api autonome (liens /docs, /redoc, /openapi.json) - backend/schemas.py: response_model Pydantic pour ~35 endpoints sans modele - main.py: FastAPI enrichi + override app.openapi; routes /api et /api/ - ai_routes/bookslm_routes: response_model + doc SSE - frontend: entree 'API' du menu (i18n FR/EN) - tests/test_openapi.py (58 tests) AI UI: - BooksLM: requetes authentifiees (AuthManager), parsing SSE {token}/error, message utilisateur correct (plus le placeholder vide), barre de contexte - AI Editor: Ctrl/Cmd+J lie une seule fois, libelles i18n, modale de reecriture accessible a la place de window.prompt() - tests/frontend/ai.test.mjs (7 tests) + CI
This commit is contained in:
+164
-58
@@ -56,6 +56,47 @@ from backend.indexer import (
|
||||
remove_vault_from_index,
|
||||
update_single_file,
|
||||
)
|
||||
from backend.openapi_docs import (
|
||||
API_DESCRIPTION,
|
||||
TAGS_METADATA,
|
||||
enrich_openapi_schema,
|
||||
render_api_landing,
|
||||
)
|
||||
from backend.schemas import (
|
||||
AIKeyDeleteResponse,
|
||||
AIKeysResponse,
|
||||
AIModelsResponse,
|
||||
AITestResponse,
|
||||
AllVaultSettingsResponse,
|
||||
AppConfigResponse,
|
||||
AttachmentRescanResponse,
|
||||
AttachmentStatsResponse,
|
||||
BacklinksResponse,
|
||||
BackupContentResponse,
|
||||
BackupsAutoResponse,
|
||||
BackupsCompressResponse,
|
||||
BackupsDeletedResponse,
|
||||
BackupsListResponse,
|
||||
BackupsResponse,
|
||||
BookmarksResponse,
|
||||
BookmarkToggleResponse,
|
||||
ConflictResolveResponse,
|
||||
ConflictsResponse,
|
||||
DashboardResponse,
|
||||
DiagnosticsResponse,
|
||||
PdfInfoResponse,
|
||||
RecentResponse,
|
||||
ReplaceResponse,
|
||||
SavedSearch,
|
||||
ShareModel,
|
||||
StatusResponse,
|
||||
VaultActionResponse,
|
||||
VaultFilesResponse,
|
||||
VaultSettingsResponse,
|
||||
VaultsStatusResponse,
|
||||
VaultStatsResponse,
|
||||
WebhookModel,
|
||||
)
|
||||
from backend.search import (
|
||||
advanced_search,
|
||||
get_all_tags,
|
||||
@@ -668,7 +709,33 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
from backend.version import get_git_commit, get_git_describe, get_version
|
||||
|
||||
app = FastAPI(title="ObsiGate", version=get_version(), lifespan=lifespan)
|
||||
app = FastAPI(
|
||||
title="ObsiGate API",
|
||||
version=get_version(),
|
||||
lifespan=lifespan,
|
||||
description=API_DESCRIPTION.strip(),
|
||||
openapi_tags=TAGS_METADATA,
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_url="/openapi.json",
|
||||
contact={"name": "ObsiGate", "url": "https://git.dracodev.net/Projets/ObsiGate"},
|
||||
license_info={"name": "MIT"},
|
||||
)
|
||||
|
||||
# Enrich the auto-generated OpenAPI 3.1 schema (#72): tags per category,
|
||||
# examples, security schemes and documented error responses.
|
||||
_original_openapi = app.openapi
|
||||
|
||||
|
||||
def _custom_openapi():
|
||||
if app.openapi_schema:
|
||||
return app.openapi_schema
|
||||
schema = _original_openapi()
|
||||
app.openapi_schema = enrich_openapi_schema(schema)
|
||||
return app.openapi_schema
|
||||
|
||||
|
||||
app.openapi = _custom_openapi
|
||||
|
||||
# GZip compression — reduces bandwidth by ~70% for text responses
|
||||
# Custom wrapper: skip compression for SSE streams (/api/events)
|
||||
@@ -766,6 +833,17 @@ except ImportError as e:
|
||||
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API documentation landing page (#72)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api", include_in_schema=False, response_class=HTMLResponse)
|
||||
@app.get("/api/", include_in_schema=False, response_class=HTMLResponse)
|
||||
async def api_docs_landing():
|
||||
"""Human-friendly API documentation landing page (links to /docs, /redoc)."""
|
||||
return HTMLResponse(render_api_landing(get_version()))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path safety helper
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1220,7 +1298,7 @@ def humanize_mtime(mtime: float) -> str:
|
||||
return datetime.fromtimestamp(mtime).strftime("%d %b %Y")
|
||||
|
||||
|
||||
@app.get("/api/recent")
|
||||
@app.get("/api/recent", response_model=RecentResponse)
|
||||
async def api_recent(limit: int | None = Query(None), vault: str | None = Query(None), mode: str | None = Query("opened"), current_user=Depends(require_auth)):
|
||||
config = _load_config()
|
||||
actual_limit = limit if limit is not None else config.get("recent_files_limit", 20)
|
||||
@@ -1314,7 +1392,7 @@ async def api_recent(limit: int | None = Query(None), vault: str | None = Query(
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/bookmarks")
|
||||
@app.get("/api/bookmarks", response_model=BookmarksResponse)
|
||||
async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(require_auth)):
|
||||
username = current_user.get("username")
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
@@ -1362,7 +1440,7 @@ class BookmarkToggleRequest(BaseModel):
|
||||
path: str
|
||||
title: str | None = None
|
||||
|
||||
@app.post("/api/bookmarks/toggle")
|
||||
@app.post("/api/bookmarks/toggle", response_model=BookmarkToggleResponse)
|
||||
async def api_toggle_bookmark(req: BookmarkToggleRequest, current_user=Depends(require_auth)):
|
||||
username = current_user.get("username")
|
||||
if not username:
|
||||
@@ -1396,7 +1474,7 @@ async def api_toggle_bookmark(req: BookmarkToggleRequest, current_user=Depends(r
|
||||
return {"bookmarked": is_now_bookmarked}
|
||||
|
||||
|
||||
@app.get("/api/saved-searches")
|
||||
@app.get("/api/saved-searches", response_model=list[SavedSearch])
|
||||
async def api_saved_searches(current_user=Depends(require_auth)):
|
||||
username = current_user.get("username")
|
||||
if not username:
|
||||
@@ -1404,7 +1482,7 @@ async def api_saved_searches(current_user=Depends(require_auth)):
|
||||
return get_saved(username)
|
||||
|
||||
|
||||
@app.post("/api/saved-searches")
|
||||
@app.post("/api/saved-searches", response_model=SavedSearch)
|
||||
async def api_save_search(body: dict = Body(...), current_user=Depends(require_auth)):
|
||||
username = current_user.get("username")
|
||||
if not username:
|
||||
@@ -1412,7 +1490,7 @@ async def api_save_search(body: dict = Body(...), current_user=Depends(require_a
|
||||
return save_search(username, body)
|
||||
|
||||
|
||||
@app.delete("/api/saved-searches/{search_id}")
|
||||
@app.delete("/api/saved-searches/{search_id}", response_model=StatusResponse)
|
||||
async def api_delete_saved_search(search_id: str, current_user=Depends(require_auth)):
|
||||
username = current_user.get("username")
|
||||
if not username:
|
||||
@@ -1550,7 +1628,7 @@ async def api_file_raw(vault_name: str, path: str = Query(..., description="Rela
|
||||
return {"vault": vault_name, "path": path, "raw": raw}
|
||||
|
||||
|
||||
@app.get("/api/file/{vault_name}/download")
|
||||
@app.get("/api/file/{vault_name}/download", response_class=FileResponse)
|
||||
async def api_file_download(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
||||
"""Download a file as an attachment.
|
||||
|
||||
@@ -1583,7 +1661,11 @@ async def api_file_download(vault_name: str, path: str = Query(..., description=
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/file/{vault_name}/pdf")
|
||||
@app.get(
|
||||
"/api/file/{vault_name}/pdf",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/pdf": {}}, "description": "PDF document"}},
|
||||
)
|
||||
async def api_file_pdf(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
||||
"""Download a markdown file as PDF."""
|
||||
if generate_pdf is None:
|
||||
@@ -1631,7 +1713,11 @@ def _resolve_export_target(vault_name: str, path: str, current_user: dict) -> tu
|
||||
return vault_root, target
|
||||
|
||||
|
||||
@app.get("/api/export/html")
|
||||
@app.get(
|
||||
"/api/export/html",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"text/html": {}}, "description": "Standalone HTML file"}},
|
||||
)
|
||||
async def api_export_html(
|
||||
vault: str = Query(..., description="Vault name"),
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
@@ -1652,7 +1738,11 @@ async def api_export_html(
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/export/md-bundle")
|
||||
@app.get(
|
||||
"/api/export/md-bundle",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/zip": {}}, "description": "Markdown ZIP bundle"}},
|
||||
)
|
||||
async def api_export_md_bundle(
|
||||
vault: str = Query(..., description="Vault name"),
|
||||
path: str = Query(..., description="Relative path to directory or file"),
|
||||
@@ -1672,7 +1762,11 @@ async def api_export_md_bundle(
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/export/epub")
|
||||
@app.get(
|
||||
"/api/export/epub",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/epub+zip": {}}, "description": "ePub document"}},
|
||||
)
|
||||
async def api_export_epub(
|
||||
vault: str = Query(..., description="Vault name"),
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
@@ -2379,7 +2473,7 @@ def _list_backup_files(vault_name: str, relative_path: str) -> list[dict]:
|
||||
return backups
|
||||
|
||||
|
||||
@app.get("/api/file/{vault_name}/backups")
|
||||
@app.get("/api/file/{vault_name}/backups", response_model=BackupsResponse)
|
||||
async def api_file_backups(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
@@ -2416,7 +2510,7 @@ async def api_file_backups(
|
||||
return {"vault": vault_name, "path": path, "backups": backups}
|
||||
|
||||
|
||||
@app.get("/api/file/{vault_name}/diff")
|
||||
@app.get("/api/file/{vault_name}/diff", response_model=DiffResponse)
|
||||
async def api_file_diff(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
@@ -2504,7 +2598,7 @@ async def api_file_diff(
|
||||
raise HTTPException(status_code=500, detail=f"Erreur lors de la génération du diff: {e!s}")
|
||||
|
||||
|
||||
@app.post("/api/file/{vault_name}/restore")
|
||||
@app.post("/api/file/{vault_name}/restore", response_model=RestoreResponse)
|
||||
async def api_file_restore(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
@@ -2586,7 +2680,7 @@ async def api_file_restore(
|
||||
raise HTTPException(status_code=500, detail=f"Error restoring file: {e!s}")
|
||||
|
||||
|
||||
@app.get("/api/file/{vault_name}/backlinks")
|
||||
@app.get("/api/file/{vault_name}/backlinks", response_model=BacklinksResponse)
|
||||
async def api_file_backlinks(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to file"),
|
||||
@@ -2881,7 +2975,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/file/{vault_name}/pdf/stream")
|
||||
@app.get("/api/file/{vault_name}/pdf/stream", response_class=FileResponse)
|
||||
async def api_pdf_stream(
|
||||
request: Request,
|
||||
vault_name: str,
|
||||
@@ -2965,7 +3059,7 @@ async def api_pdf_stream(
|
||||
"Content-Disposition": _content_disposition("inline", file_path.name)})
|
||||
|
||||
|
||||
@app.get("/api/file/{vault_name}/pdf/info")
|
||||
@app.get("/api/file/{vault_name}/pdf/info", response_model=PdfInfoResponse)
|
||||
async def api_pdf_info(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to PDF file"),
|
||||
@@ -3144,7 +3238,7 @@ async def api_advanced_search(
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/search/replace")
|
||||
@app.post("/api/search/replace", response_model=ReplaceResponse)
|
||||
async def api_search_replace(
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
@@ -3491,7 +3585,7 @@ def _add_wikilink_edges(nodes: list, edges: list, node_ids: set, vault_name: str
|
||||
break
|
||||
|
||||
|
||||
@app.get("/api/index/reload/{vault_name}")
|
||||
@app.get("/api/index/reload/{vault_name}", response_model=VaultStatsResponse)
|
||||
async def api_reload_vault(vault_name: str, current_user=Depends(require_admin)):
|
||||
"""Force a re-index of a single vault.
|
||||
|
||||
@@ -3517,7 +3611,11 @@ async def api_reload_vault(vault_name: str, current_user=Depends(require_admin))
|
||||
# SSE endpoint — Server-Sent Events stream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/events")
|
||||
@app.get(
|
||||
"/api/events",
|
||||
response_class=StreamingResponse,
|
||||
responses={200: {"content": {"text/event-stream": {}}, "description": "Server-Sent Events stream"}},
|
||||
)
|
||||
async def api_events(current_user=Depends(require_auth)):
|
||||
"""SSE stream for real-time index update notifications.
|
||||
|
||||
@@ -3560,7 +3658,7 @@ async def api_events(current_user=Depends(require_auth)):
|
||||
# Dynamic vault management endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post("/api/vaults/add")
|
||||
@app.post("/api/vaults/add", response_model=VaultStatsResponse)
|
||||
async def api_add_vault(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
"""Add a new vault dynamically without restarting.
|
||||
|
||||
@@ -3590,7 +3688,7 @@ async def api_add_vault(body: dict = Body(...), current_user=Depends(require_adm
|
||||
return {"status": "ok", "vault": name, "stats": stats}
|
||||
|
||||
|
||||
@app.delete("/api/vaults/{vault_name}")
|
||||
@app.delete("/api/vaults/{vault_name}", response_model=VaultActionResponse)
|
||||
async def api_remove_vault(vault_name: str, current_user=Depends(require_admin)):
|
||||
"""Remove a vault from the index and stop watching it.
|
||||
|
||||
@@ -3609,7 +3707,7 @@ async def api_remove_vault(vault_name: str, current_user=Depends(require_admin))
|
||||
return {"status": "ok", "vault": vault_name}
|
||||
|
||||
|
||||
@app.get("/api/vaults/status")
|
||||
@app.get("/api/vaults/status", response_model=VaultsStatusResponse)
|
||||
async def api_vaults_status(current_user=Depends(require_auth)):
|
||||
"""Detailed status of all vaults including watcher state.
|
||||
|
||||
@@ -3631,7 +3729,11 @@ async def api_vaults_status(current_user=Depends(require_auth)):
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/image/{vault_name}")
|
||||
@app.get(
|
||||
"/api/image/{vault_name}",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/octet-stream": {}}, "description": "Image bytes"}},
|
||||
)
|
||||
async def api_image(vault_name: str, path: str = Query(..., description="Relative path to image"), current_user=Depends(require_auth)):
|
||||
"""Serve an image file with proper MIME type.
|
||||
|
||||
@@ -3671,7 +3773,7 @@ async def api_image(vault_name: str, path: str = Query(..., description="Relativ
|
||||
raise HTTPException(status_code=500, detail=f"Error serving image: {e!s}")
|
||||
|
||||
|
||||
@app.post("/api/attachments/rescan/{vault_name}")
|
||||
@app.post("/api/attachments/rescan/{vault_name}", response_model=AttachmentRescanResponse)
|
||||
async def api_rescan_attachments(vault_name: str, current_user=Depends(require_admin)):
|
||||
"""Rescan attachments for a specific vault.
|
||||
|
||||
@@ -3692,7 +3794,7 @@ async def api_rescan_attachments(vault_name: str, current_user=Depends(require_a
|
||||
return {"status": "ok", "vault": vault_name, "attachment_count": count}
|
||||
|
||||
|
||||
@app.get("/api/attachments/stats")
|
||||
@app.get("/api/attachments/stats", response_model=AttachmentStatsResponse)
|
||||
async def api_attachment_stats(vault: str | None = Query(None, description="Vault filter"), current_user=Depends(require_auth)):
|
||||
"""Get attachment statistics for vaults.
|
||||
|
||||
@@ -3710,7 +3812,7 @@ async def api_attachment_stats(vault: str | None = Query(None, description="Vaul
|
||||
# Vault Settings API — Display preferences
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/vaults/{vault_name}/settings")
|
||||
@app.get("/api/vaults/{vault_name}/settings", response_model=VaultSettingsResponse)
|
||||
async def api_get_vault_settings(vault_name: str, current_user=Depends(require_auth)):
|
||||
"""Get UI display settings for a specific vault.
|
||||
|
||||
@@ -3735,7 +3837,7 @@ async def api_get_vault_settings(vault_name: str, current_user=Depends(require_a
|
||||
return settings
|
||||
|
||||
|
||||
@app.post("/api/vaults/{vault_name}/settings")
|
||||
@app.post("/api/vaults/{vault_name}/settings", response_model=VaultSettingsResponse)
|
||||
async def api_update_vault_settings(vault_name: str, body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
"""Update UI display settings for a specific vault.
|
||||
|
||||
@@ -3778,7 +3880,7 @@ async def api_update_vault_settings(vault_name: str, body: dict = Body(...), cur
|
||||
return updated
|
||||
|
||||
|
||||
@app.get("/api/vault/{vault_name}/files")
|
||||
@app.get("/api/vault/{vault_name}/files", response_model=VaultFilesResponse)
|
||||
async def api_vault_recent_files(
|
||||
vault_name: str,
|
||||
dir: str = Query("", description="Directory path within the vault (empty = root)"),
|
||||
@@ -3889,7 +3991,7 @@ async def api_vault_recent_files(
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/vaults/settings/all")
|
||||
@app.get("/api/vaults/settings/all", response_model=AllVaultSettingsResponse)
|
||||
async def api_get_all_vault_settings(current_user=Depends(require_auth)):
|
||||
"""Get UI display settings for all vaults.
|
||||
|
||||
@@ -3914,7 +4016,7 @@ async def api_get_all_vault_settings(current_user=Depends(require_auth)):
|
||||
# Backup Management API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/backups")
|
||||
@app.get("/api/backups", response_model=BackupsListResponse)
|
||||
async def api_backups_list(
|
||||
vault: str | None = Query(None, description="Filter by vault name"),
|
||||
current_user=Depends(require_auth),
|
||||
@@ -3966,7 +4068,7 @@ async def api_backups_list(
|
||||
raise HTTPException(status_code=500, detail=f"Erreur listing backups: {e!s}")
|
||||
|
||||
|
||||
@app.post("/api/backups/delete")
|
||||
@app.post("/api/backups/delete", response_model=BackupsDeletedResponse)
|
||||
async def api_backups_delete(
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
@@ -3992,7 +4094,7 @@ async def api_backups_delete(
|
||||
return {"deleted": deleted}
|
||||
|
||||
|
||||
@app.post("/api/backups/purge")
|
||||
@app.post("/api/backups/purge", response_model=BackupsDeletedResponse)
|
||||
async def api_backups_purge(
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
@@ -4042,7 +4144,7 @@ async def api_backups_purge(
|
||||
|
||||
|
||||
|
||||
@app.get("/api/backups/content")
|
||||
@app.get("/api/backups/content", response_model=BackupContentResponse)
|
||||
async def api_backups_content(
|
||||
path: str = Query(..., description="Full path to backup file"),
|
||||
current_user=Depends(require_auth),
|
||||
@@ -4065,7 +4167,7 @@ async def api_backups_content(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/backups/compress")
|
||||
@app.post("/api/backups/compress", response_model=BackupsCompressResponse)
|
||||
async def api_backups_compress(
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
@@ -4121,7 +4223,7 @@ async def api_backups_compress(
|
||||
return {"compressed": compressed, "saved_bytes": saved_bytes, "dry_run": dry_run}
|
||||
|
||||
|
||||
@app.post("/api/backups/auto")
|
||||
@app.post("/api/backups/auto", response_model=BackupsAutoResponse)
|
||||
async def api_backups_auto(
|
||||
body: dict = Body(...),
|
||||
current_user=Depends(require_auth),
|
||||
@@ -4211,13 +4313,13 @@ def _save_config(config: dict) -> None:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to save config: {e}")
|
||||
|
||||
|
||||
@app.get("/api/config")
|
||||
@app.get("/api/config", response_model=AppConfigResponse)
|
||||
async def api_get_config(current_user=Depends(require_auth)):
|
||||
"""Return current configuration with defaults for missing keys."""
|
||||
return _load_config()
|
||||
|
||||
|
||||
@app.post("/api/config")
|
||||
@app.post("/api/config", response_model=AppConfigResponse)
|
||||
async def api_set_config(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
"""Update configuration. Only known keys are accepted.
|
||||
|
||||
@@ -4257,7 +4359,7 @@ def _write_ai_keys(data: dict):
|
||||
tmp.write_text(_json.dumps(data, indent=2), encoding="utf-8")
|
||||
tmp.replace(AI_KEYS_FILE)
|
||||
|
||||
@app.get("/api/config/ai-keys")
|
||||
@app.get("/api/config/ai-keys", response_model=AIKeysResponse)
|
||||
async def api_get_ai_keys(current_user=Depends(require_admin)):
|
||||
"""Return stored AI keys (values masked)."""
|
||||
keys = _read_ai_keys()
|
||||
@@ -4270,7 +4372,7 @@ async def api_get_ai_keys(current_user=Depends(require_admin)):
|
||||
masked[k] = ""
|
||||
return masked
|
||||
|
||||
@app.post("/api/config/ai-keys")
|
||||
@app.post("/api/config/ai-keys", response_model=StatusResponse)
|
||||
async def api_set_ai_keys(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
"""Save AI keys. Pass {"DEEPSEEK_API_KEY":"sk-...","OPENROUTER_API_KEY":"...","GEMINI_API_KEY":"..."}"""
|
||||
keys = _read_ai_keys()
|
||||
@@ -4282,7 +4384,7 @@ async def api_set_ai_keys(body: dict = Body(...), current_user=Depends(require_a
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/api/config/ai-keys/{provider_env}")
|
||||
@app.delete("/api/config/ai-keys/{provider_env}", response_model=AIKeyDeleteResponse)
|
||||
async def api_delete_ai_key(provider_env: str, current_user=Depends(require_admin)):
|
||||
"""Delete a specific AI provider key from storage."""
|
||||
allowed = {"DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY",
|
||||
@@ -4300,7 +4402,7 @@ async def api_delete_ai_key(provider_env: str, current_user=Depends(require_admi
|
||||
return {"status": "deleted", "key": key_name}
|
||||
|
||||
|
||||
@app.post("/api/config/ai-keys/test")
|
||||
@app.post("/api/config/ai-keys/test", response_model=AITestResponse)
|
||||
async def api_test_ai_keys(current_user=Depends(require_admin)):
|
||||
"""Test which AI providers are configured.
|
||||
|
||||
@@ -4345,7 +4447,7 @@ async def api_test_ai_keys(current_user=Depends(require_admin)):
|
||||
# AI Models — list available models per provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/config/ai-models")
|
||||
@app.get("/api/config/ai-models", response_model=AIModelsResponse)
|
||||
async def api_list_ai_models(provider: str = Query(...), current_user=Depends(require_admin)):
|
||||
"""List available models for a given AI provider.
|
||||
|
||||
@@ -4492,7 +4594,7 @@ _FALLBACK_MODELS: dict[str, list[str]] = {
|
||||
# Diagnostics API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/diagnostics")
|
||||
@app.get("/api/diagnostics", response_model=DiagnosticsResponse)
|
||||
async def api_diagnostics(current_user=Depends(require_admin)):
|
||||
"""Return index statistics and system diagnostics.
|
||||
|
||||
@@ -4550,7 +4652,7 @@ async def api_diagnostics(current_user=Depends(require_admin)):
|
||||
# Dashboard endpoint (aggregated stats)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/dashboard")
|
||||
@app.get("/api/dashboard", response_model=DashboardResponse)
|
||||
async def api_dashboard(current_user=Depends(require_auth)):
|
||||
"""Aggregated dashboard statistics across all accessible vaults."""
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
@@ -4579,12 +4681,12 @@ async def api_dashboard(current_user=Depends(require_auth)):
|
||||
# Webhook CRUD endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/webhooks")
|
||||
@app.get("/api/webhooks", response_model=list[WebhookModel])
|
||||
async def api_webhooks_list(current_user=Depends(require_admin)):
|
||||
return get_webhooks()
|
||||
|
||||
|
||||
@app.post("/api/webhooks")
|
||||
@app.post("/api/webhooks", response_model=WebhookModel)
|
||||
async def api_webhooks_create(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
name = body.get("name", "Unnamed")
|
||||
url = body.get("url", "")
|
||||
@@ -4595,7 +4697,7 @@ async def api_webhooks_create(body: dict = Body(...), current_user=Depends(requi
|
||||
return create_webhook(name, url, events, secret)
|
||||
|
||||
|
||||
@app.patch("/api/webhooks/{webhook_id}")
|
||||
@app.patch("/api/webhooks/{webhook_id}", response_model=WebhookModel)
|
||||
async def api_webhooks_update(webhook_id: str, body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
result = update_webhook(webhook_id, body)
|
||||
if not result:
|
||||
@@ -4603,7 +4705,7 @@ async def api_webhooks_update(webhook_id: str, body: dict = Body(...), current_u
|
||||
return result
|
||||
|
||||
|
||||
@app.delete("/api/webhooks/{webhook_id}")
|
||||
@app.delete("/api/webhooks/{webhook_id}", response_model=StatusResponse)
|
||||
async def api_webhooks_delete(webhook_id: str, current_user=Depends(require_admin)):
|
||||
if not delete_webhook(webhook_id):
|
||||
raise HTTPException(404, "Webhook not found")
|
||||
@@ -4614,7 +4716,7 @@ async def api_webhooks_delete(webhook_id: str, current_user=Depends(require_admi
|
||||
# Share (public document) endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post("/api/share/{vault_name}")
|
||||
@app.post("/api/share/{vault_name}", response_model=ShareModel)
|
||||
async def api_share_create(
|
||||
vault_name: str,
|
||||
body: dict = Body(...),
|
||||
@@ -4653,7 +4755,7 @@ async def api_share_create(
|
||||
return share
|
||||
|
||||
|
||||
@app.get("/api/shares")
|
||||
@app.get("/api/shares", response_model=list[ShareModel])
|
||||
async def api_shares_list(vault: str | None = Query(None), current_user=Depends(require_auth)):
|
||||
"""List all shares (optionally filtered by vault)."""
|
||||
shares = list_shares(vault)
|
||||
@@ -4662,13 +4764,17 @@ async def api_shares_list(vault: str | None = Query(None), current_user=Depends(
|
||||
return shares
|
||||
|
||||
|
||||
@app.delete("/api/share/{share_id}")
|
||||
@app.delete("/api/share/{share_id}", response_model=StatusResponse)
|
||||
async def api_share_revoke(share_id: str, current_user=Depends(require_auth)):
|
||||
if not revoke_share(share_id):
|
||||
raise HTTPException(404, "Share not found")
|
||||
return {"status": "revoked"}
|
||||
|
||||
@app.get("/s/{token}/pdf")
|
||||
@app.get(
|
||||
"/s/{token}/pdf",
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/pdf": {}}, "description": "Shared document as PDF"}},
|
||||
)
|
||||
async def public_share_pdf_download(token: str):
|
||||
"""Download shared document as real PDF via WeasyPrint."""
|
||||
if generate_pdf is None:
|
||||
@@ -4702,7 +4808,7 @@ async def public_share_pdf_download(token: str):
|
||||
return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{safe_name}.pdf"'})
|
||||
|
||||
|
||||
@app.get("/s/{token}/raw")
|
||||
@app.get("/s/{token}/raw", response_class=FileResponse)
|
||||
async def public_share_raw(token: str):
|
||||
"""Download the raw (original) shared document."""
|
||||
share = get_share_by_token(token)
|
||||
@@ -4719,7 +4825,7 @@ async def public_share_raw(token: str):
|
||||
return FileResponse(path=str(file_path), filename=file_path.name, media_type="application/octet-stream")
|
||||
|
||||
|
||||
@app.get("/s/{token}")
|
||||
@app.get("/s/{token}", response_class=HTMLResponse)
|
||||
async def public_share_view(token: str):
|
||||
"""Public share view — no authentication required."""
|
||||
share = get_share_by_token(token)
|
||||
@@ -4839,7 +4945,7 @@ function exportMD(){{var raw=JSON.parse(document.getElementById("raw-content").t
|
||||
# Syncthing conflict endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/conflicts")
|
||||
@app.get("/api/conflicts", response_model=ConflictsResponse)
|
||||
async def api_conflicts(current_user=Depends(require_auth)):
|
||||
"""List sync-conflict files across accessible vaults."""
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
@@ -4849,7 +4955,7 @@ async def api_conflicts(current_user=Depends(require_auth)):
|
||||
return {"conflicts": all_conflicts, "total": len(all_conflicts)}
|
||||
|
||||
|
||||
@app.post("/api/conflicts/resolve")
|
||||
@app.post("/api/conflicts/resolve", response_model=ConflictResolveResponse)
|
||||
async def api_conflict_resolve(body: dict = Body(...), current_user=Depends(require_auth)):
|
||||
"""Resolve a conflict: keep_local (delete conflict file) or keep_conflict (replace original)."""
|
||||
vault_name = body.get("vault")
|
||||
|
||||
Reference in New Issue
Block a user