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

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:
2026-09-11 01:40:21 -04:00
parent 75ef597ece
commit 62023acd4d
18 changed files with 2001 additions and 154 deletions
+164 -58
View File
@@ -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")