Settings: add dedicated DAV/Pushover tabs, fix CalDAV/CardDAV bugs
- Add admin DAV tab (rename from CalDAV/CardDAV) and Pushover tab
- Add per-user Pushover tab (User Key only; App Token stays admin-managed)
- Remove system-wide CalDAV/CardDAV fallback — per-user config only
- Rewrite contacts_tool.py using httpx directly (caldav 2.x dropped AddressBook)
- Fix CardDAV REPORT/PROPFIND using SOGo URL pattern
- Fix CalDAV/CardDAV test endpoints (POST method, URL scheme normalization)
- Fix Show Password button — API now returns actual credential values
- Convert Credentials tab to generic key-value store; dedicated keys
(CalDAV, Pushover, trusted_proxy) excluded via _DEDICATED_CRED_KEYS
This commit is contained in:
@@ -312,6 +312,7 @@ async def models_info(request: Request):
|
||||
class LimitsIn(BaseModel):
|
||||
max_tool_calls: Optional[int] = None
|
||||
max_autonomous_runs_per_hour: Optional[int] = None
|
||||
max_concurrent_runs: Optional[int] = None
|
||||
|
||||
|
||||
class ProxyTrustIn(BaseModel):
|
||||
@@ -328,16 +329,19 @@ async def get_limits(request: Request):
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
mtc, mar = await asyncio_gather(
|
||||
mtc, mar, mcr = await asyncio_gather(
|
||||
_get("system:max_tool_calls", settings.max_tool_calls),
|
||||
_get("system:max_autonomous_runs_per_hour", settings.max_autonomous_runs_per_hour),
|
||||
_get("system:max_concurrent_runs", 3),
|
||||
)
|
||||
return {
|
||||
"max_tool_calls": mtc,
|
||||
"max_autonomous_runs_per_hour": mar,
|
||||
"max_concurrent_runs": mcr,
|
||||
"defaults": {
|
||||
"max_tool_calls": settings.max_tool_calls,
|
||||
"max_autonomous_runs_per_hour": settings.max_autonomous_runs_per_hour,
|
||||
"max_concurrent_runs": 3,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -359,9 +363,23 @@ async def set_limits(request: Request, body: LimitsIn):
|
||||
"system:max_autonomous_runs_per_hour", str(body.max_autonomous_runs_per_hour),
|
||||
"Max autonomous scheduler runs per hour",
|
||||
)
|
||||
if body.max_concurrent_runs is not None:
|
||||
if body.max_concurrent_runs < 1:
|
||||
raise HTTPException(status_code=400, detail="max_concurrent_runs must be >= 1")
|
||||
await credential_store.set(
|
||||
"system:max_concurrent_runs", str(body.max_concurrent_runs),
|
||||
"Max concurrent agent runs",
|
||||
)
|
||||
return await get_limits()
|
||||
|
||||
|
||||
@router.get("/queue")
|
||||
async def get_queue_status(request: Request):
|
||||
"""Return current run queue status."""
|
||||
_require_auth(request)
|
||||
return agent_runner.queue_status
|
||||
|
||||
|
||||
@router.get("/settings/default-models")
|
||||
async def get_default_models(request: Request):
|
||||
_require_admin(request)
|
||||
@@ -447,6 +465,128 @@ async def set_users_base_folder(request: Request, body: UserBaseFolderIn):
|
||||
return {"path": path}
|
||||
|
||||
|
||||
# ── Admin CalDAV / CardDAV settings ──────────────────────────────────────────
|
||||
|
||||
@router.get("/settings/caldav")
|
||||
async def get_admin_caldav(request: Request):
|
||||
_require_admin(request)
|
||||
get = credential_store.get
|
||||
return {
|
||||
"host": await get("mailcow_host") or "",
|
||||
"username": await get("mailcow_username") or "",
|
||||
"password_set": bool(await get("mailcow_password")),
|
||||
"calendar_name": await get("caldav_calendar_name") or "",
|
||||
"contacts_allow_write": (await get("contacts:allow_write")) == "1",
|
||||
"carddav_same_as_caldav": (await get("carddav_same_as_caldav")) == "1",
|
||||
"carddav_url": await get("carddav_url") or "",
|
||||
"carddav_username": await get("carddav_username") or "",
|
||||
"carddav_password_set": bool(await get("carddav_password")),
|
||||
"imap_host": await get("mailcow_imap_host") or "",
|
||||
"smtp_host": await get("mailcow_smtp_host") or "",
|
||||
"smtp_port": await get("mailcow_smtp_port") or "",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/settings/caldav")
|
||||
async def set_admin_caldav(request: Request):
|
||||
_require_admin(request)
|
||||
body = await request.json()
|
||||
|
||||
async def _set(key, val, desc=""):
|
||||
val = (val or "").strip()
|
||||
if val:
|
||||
await credential_store.set(key, val, desc)
|
||||
else:
|
||||
await credential_store.delete(key)
|
||||
|
||||
async def _set_bool(key, val):
|
||||
if val:
|
||||
await credential_store.set(key, "1")
|
||||
else:
|
||||
await credential_store.delete(key)
|
||||
|
||||
await _set("mailcow_host", body.get("host"), "Mailcow hostname")
|
||||
await _set("mailcow_username", body.get("username"), "Mailcow username")
|
||||
# Only update password if a new value is provided
|
||||
pwd = (body.get("password") or "").strip()
|
||||
if pwd:
|
||||
await credential_store.set("mailcow_password", pwd, "Mailcow password")
|
||||
await _set("caldav_calendar_name", body.get("calendar_name"), "Default calendar name")
|
||||
await _set_bool("contacts:allow_write", body.get("contacts_allow_write"))
|
||||
|
||||
same = bool(body.get("carddav_same_as_caldav"))
|
||||
await _set_bool("carddav_same_as_caldav", same)
|
||||
if same:
|
||||
for k in ("carddav_url", "carddav_username", "carddav_password"):
|
||||
await credential_store.delete(k)
|
||||
else:
|
||||
await _set("carddav_url", body.get("carddav_url"), "CardDAV server URL")
|
||||
await _set("carddav_username", body.get("carddav_username"), "CardDAV username")
|
||||
cpwd = (body.get("carddav_password") or "").strip()
|
||||
if cpwd:
|
||||
await credential_store.set("carddav_password", cpwd, "CardDAV password")
|
||||
|
||||
# Email tool overrides (optional host overrides for IMAP/SMTP)
|
||||
await _set("mailcow_imap_host", body.get("imap_host"), "IMAP host override")
|
||||
await _set("mailcow_smtp_host", body.get("smtp_host"), "SMTP host override")
|
||||
await _set("mailcow_smtp_port", body.get("smtp_port"), "SMTP port override")
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Admin Pushover settings ───────────────────────────────────────────────────
|
||||
|
||||
@router.get("/settings/pushover")
|
||||
async def get_admin_pushover(request: Request):
|
||||
_require_admin(request)
|
||||
return {
|
||||
"app_token_set": bool(await credential_store.get("pushover_app_token")),
|
||||
"user_key_set": bool(await credential_store.get("pushover_user_key")),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/settings/pushover")
|
||||
async def set_admin_pushover(request: Request):
|
||||
_require_admin(request)
|
||||
body = await request.json()
|
||||
for field, key in [("app_token", "pushover_app_token"), ("user_key", "pushover_user_key")]:
|
||||
val = (body.get(field) or "").strip()
|
||||
if val:
|
||||
await credential_store.set(key, val)
|
||||
# Never clear on empty — must explicitly use delete endpoint
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── User Pushover settings ────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/my/pushover")
|
||||
async def get_my_pushover(request: Request):
|
||||
_require_auth(request)
|
||||
user = request.state.current_user
|
||||
app_ok = bool(await credential_store.get("pushover_app_token"))
|
||||
user_key = await _user_settings_store.get(user["id"], "pushover_user_key")
|
||||
return {"app_token_configured": app_ok, "user_key_set": bool(user_key)}
|
||||
|
||||
|
||||
@router.post("/my/pushover")
|
||||
async def set_my_pushover(request: Request):
|
||||
_require_auth(request)
|
||||
user = request.state.current_user
|
||||
body = await request.json()
|
||||
key = (body.get("user_key") or "").strip()
|
||||
if key:
|
||||
await _user_settings_store.set(user["id"], "pushover_user_key", key)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/my/pushover")
|
||||
async def delete_my_pushover(request: Request):
|
||||
_require_auth(request)
|
||||
user = request.state.current_user
|
||||
await _user_settings_store.delete(user["id"], "pushover_user_key")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Agents ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class AgentIn(BaseModel):
|
||||
@@ -1937,6 +2077,81 @@ async def delete_conversation(request: Request, conv_id: str):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/conversations/{conv_id}/export")
|
||||
async def export_conversation(request: Request, conv_id: str, format: str = "markdown"):
|
||||
"""Download a conversation as Markdown or JSON."""
|
||||
import json as _json
|
||||
from fastapi.responses import Response
|
||||
from datetime import datetime, timezone
|
||||
|
||||
user = _require_auth(request)
|
||||
from ..database import get_pool
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
"SELECT * FROM conversations WHERE id = $1", conv_id
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
if not user.is_admin and row.get("user_id") != user.id:
|
||||
raise HTTPException(status_code=403, detail="Not your conversation")
|
||||
|
||||
messages = row.get("messages") or []
|
||||
if isinstance(messages, str):
|
||||
try:
|
||||
messages = _json.loads(messages)
|
||||
except Exception:
|
||||
messages = []
|
||||
|
||||
started_at = row.get("started_at") or ""
|
||||
title = row.get("title") or "Conversation"
|
||||
model = row.get("model") or ""
|
||||
|
||||
from ..config import settings as _settings
|
||||
agent_name = _settings.agent_name
|
||||
|
||||
if format == "json":
|
||||
content = _json.dumps({
|
||||
"id": str(row["id"]),
|
||||
"title": title,
|
||||
"model": model,
|
||||
"started_at": str(started_at),
|
||||
"messages": messages,
|
||||
}, indent=2, default=str)
|
||||
filename = f"conversation-{conv_id[:8]}.json"
|
||||
media_type = "application/json"
|
||||
else:
|
||||
# Markdown
|
||||
lines = [f"# {title}", ""]
|
||||
if model:
|
||||
lines.append(f"**Model:** {model} ")
|
||||
if started_at:
|
||||
lines.append(f"**Date:** {str(started_at)[:19]} ")
|
||||
lines += ["", "---", ""]
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
content_parts = msg.get("content", "")
|
||||
if isinstance(content_parts, list):
|
||||
text = " ".join(
|
||||
p.get("text", "") for p in content_parts
|
||||
if isinstance(p, dict) and p.get("type") == "text"
|
||||
)
|
||||
else:
|
||||
text = str(content_parts)
|
||||
if not text.strip():
|
||||
continue
|
||||
speaker = "You" if role == "user" else agent_name
|
||||
lines += [f"**{speaker}**", "", text, "", "---", ""]
|
||||
content = "\n".join(lines)
|
||||
filename = f"conversation-{conv_id[:8]}.md"
|
||||
media_type = "text/markdown"
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
# ── Agent templates ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/agent-templates")
|
||||
@@ -2532,15 +2747,26 @@ async def list_folders_for_account(request: Request, account_id: str):
|
||||
@router.get("/my/caldav/config")
|
||||
async def get_my_caldav_config(request: Request):
|
||||
user = _require_auth(request)
|
||||
url = await _user_settings_store.get(user.id, "caldav_url")
|
||||
username = await _user_settings_store.get(user.id, "caldav_username")
|
||||
password = await _user_settings_store.get(user.id, "caldav_password")
|
||||
calendar_name = await _user_settings_store.get(user.id, "caldav_calendar_name")
|
||||
get = lambda k: _user_settings_store.get(user.id, k)
|
||||
url = await get("caldav_url")
|
||||
username = await get("caldav_username")
|
||||
password = await get("caldav_password")
|
||||
calendar_name = await get("caldav_calendar_name")
|
||||
carddav_same = await get("carddav_same_as_caldav")
|
||||
carddav_url = await get("carddav_url")
|
||||
carddav_user = await get("carddav_username")
|
||||
carddav_pass = await get("carddav_password")
|
||||
allow_write = await get("contacts_allow_write")
|
||||
return {
|
||||
"url": url or "",
|
||||
"username": username or "",
|
||||
"password_set": bool(password),
|
||||
"password": password or "",
|
||||
"calendar_name": calendar_name or "",
|
||||
"carddav_same_as_caldav": carddav_same == "1",
|
||||
"carddav_url": carddav_url or "",
|
||||
"carddav_username": carddav_user or "",
|
||||
"carddav_password": carddav_pass or "",
|
||||
"contacts_allow_write": allow_write == "1",
|
||||
}
|
||||
|
||||
|
||||
@@ -2548,6 +2774,8 @@ async def get_my_caldav_config(request: Request):
|
||||
async def set_my_caldav_config(request: Request):
|
||||
user = _require_auth(request)
|
||||
body = await request.json()
|
||||
|
||||
# CalDAV fields
|
||||
for key, setting_key in [
|
||||
("url", "caldav_url"),
|
||||
("username", "caldav_username"),
|
||||
@@ -2557,8 +2785,36 @@ async def set_my_caldav_config(request: Request):
|
||||
val = (body.get(key) or "").strip()
|
||||
if val:
|
||||
await _user_settings_store.set(user.id, setting_key, val)
|
||||
elif key != "password": # never clear password on empty
|
||||
elif key != "password":
|
||||
await _user_settings_store.delete(user.id, setting_key)
|
||||
|
||||
# CardDAV fields
|
||||
same = bool(body.get("carddav_same_as_caldav"))
|
||||
if same:
|
||||
await _user_settings_store.set(user.id, "carddav_same_as_caldav", "1")
|
||||
# Clear separate CardDAV creds — not needed when using same server
|
||||
for k in ("carddav_url", "carddav_username", "carddav_password"):
|
||||
await _user_settings_store.delete(user.id, k)
|
||||
else:
|
||||
await _user_settings_store.delete(user.id, "carddav_same_as_caldav")
|
||||
for key, setting_key in [
|
||||
("carddav_url", "carddav_url"),
|
||||
("carddav_username", "carddav_username"),
|
||||
("carddav_password", "carddav_password"),
|
||||
]:
|
||||
val = (body.get(key) or "").strip()
|
||||
if val:
|
||||
await _user_settings_store.set(user.id, setting_key, val)
|
||||
elif key != "carddav_password":
|
||||
await _user_settings_store.delete(user.id, setting_key)
|
||||
|
||||
# Per-user contacts write permission
|
||||
allow_write = bool(body.get("contacts_allow_write"))
|
||||
if allow_write:
|
||||
await _user_settings_store.set(user.id, "contacts_allow_write", "1")
|
||||
else:
|
||||
await _user_settings_store.delete(user.id, "contacts_allow_write")
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -2572,6 +2828,8 @@ async def test_my_caldav_config(request: Request):
|
||||
try:
|
||||
import caldav
|
||||
url = cfg["url"]
|
||||
if not url.startswith(("http://", "https://")):
|
||||
url = "https://" + url
|
||||
if "/SOGo/dav/" not in url:
|
||||
url = f"{url.rstrip('/')}/SOGo/dav/{cfg['username']}/"
|
||||
client = caldav.DAVClient(url=url, username=cfg["username"], password=cfg["password"])
|
||||
@@ -2582,10 +2840,43 @@ async def test_my_caldav_config(request: Request):
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
|
||||
@router.post("/my/caldav/test-carddav")
|
||||
async def test_my_carddav_config(request: Request):
|
||||
user = _require_auth(request)
|
||||
from ..tools.contacts_tool import _get_carddav_config, _sogo_carddav_url
|
||||
cfg = await _get_carddav_config(user_id=user.id)
|
||||
if not cfg.get("url") or not cfg.get("username") or not cfg.get("password"):
|
||||
return {"success": False, "message": "CardDAV credentials not configured"}
|
||||
try:
|
||||
import httpx
|
||||
abook_url = _sogo_carddav_url(cfg["url"], cfg["username"])
|
||||
body = (
|
||||
'<?xml version="1.0" encoding="utf-8"?>'
|
||||
'<D:propfind xmlns:D="DAV:"><D:prop><D:resourcetype/></D:prop></D:propfind>'
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
auth=(cfg["username"], cfg["password"]), timeout=10
|
||||
) as client:
|
||||
r = await client.request(
|
||||
"PROPFIND", abook_url,
|
||||
content=body,
|
||||
headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "0"},
|
||||
)
|
||||
if r.status_code in (200, 207):
|
||||
return {"success": True, "message": f"Connected to {abook_url}"}
|
||||
return {"success": False, "message": f"Server returned HTTP {r.status_code}"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
|
||||
@router.delete("/my/caldav/config")
|
||||
async def delete_my_caldav_config(request: Request):
|
||||
user = _require_auth(request)
|
||||
for key in ("caldav_url", "caldav_username", "caldav_password", "caldav_calendar_name"):
|
||||
for key in (
|
||||
"caldav_url", "caldav_username", "caldav_password", "caldav_calendar_name",
|
||||
"carddav_same_as_caldav", "carddav_url", "carddav_username", "carddav_password",
|
||||
"contacts_allow_write",
|
||||
):
|
||||
await _user_settings_store.delete(user.id, key)
|
||||
return {"ok": True}
|
||||
|
||||
@@ -2742,3 +3033,489 @@ async def update_my_profile(request: Request, body: ProfileUpdateIn):
|
||||
from ..users import update_user
|
||||
await update_user(user.id, display_name=body.display_name.strip() or None)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Webhook endpoints (inbound triggers) ──────────────────────────────────────
|
||||
|
||||
class WebhookEndpointIn(BaseModel):
|
||||
name: str
|
||||
agent_id: str = ""
|
||||
description: str = ""
|
||||
allow_get: bool = True
|
||||
|
||||
|
||||
class WebhookEndpointUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
agent_id: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
allow_get: Optional[bool] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("/webhooks")
|
||||
async def list_webhooks(request: Request):
|
||||
_require_admin(request)
|
||||
from ..webhooks.endpoints import list_endpoints
|
||||
return await list_endpoints()
|
||||
|
||||
|
||||
@router.post("/webhooks", status_code=201)
|
||||
async def create_webhook(request: Request, body: WebhookEndpointIn):
|
||||
_require_admin(request)
|
||||
from ..webhooks.endpoints import create_endpoint
|
||||
if not body.name.strip():
|
||||
raise HTTPException(status_code=400, detail="Name is required")
|
||||
ep = await create_endpoint(
|
||||
name=body.name.strip(),
|
||||
agent_id=body.agent_id or "",
|
||||
description=body.description,
|
||||
allow_get=body.allow_get,
|
||||
)
|
||||
return ep # includes token — only time it's returned
|
||||
|
||||
|
||||
@router.put("/webhooks/{endpoint_id}")
|
||||
async def update_webhook(request: Request, endpoint_id: str, body: WebhookEndpointUpdate):
|
||||
_require_admin(request)
|
||||
from ..webhooks.endpoints import update_endpoint
|
||||
fields = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
ep = await update_endpoint(endpoint_id, **fields)
|
||||
if ep is None:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
return ep
|
||||
|
||||
|
||||
@router.delete("/webhooks/{endpoint_id}")
|
||||
async def delete_webhook(request: Request, endpoint_id: str):
|
||||
_require_admin(request)
|
||||
from ..webhooks.endpoints import delete_endpoint
|
||||
deleted = await delete_endpoint(endpoint_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/webhooks/{endpoint_id}/rotate")
|
||||
async def rotate_webhook_token(request: Request, endpoint_id: str):
|
||||
_require_admin(request)
|
||||
from ..webhooks.endpoints import get_endpoint, rotate_token
|
||||
ep = await get_endpoint(endpoint_id)
|
||||
if ep is None:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
new_token = await rotate_token(endpoint_id)
|
||||
return {"ok": True, "token": new_token}
|
||||
|
||||
|
||||
# ── User-scoped webhook endpoints (non-admin) ─────────────────────────────────
|
||||
|
||||
@router.get("/my/webhooks")
|
||||
async def list_my_webhooks(request: Request):
|
||||
_require_auth(request)
|
||||
user_id = request.state.current_user["id"]
|
||||
from ..webhooks.endpoints import list_endpoints
|
||||
return await list_endpoints(owner_user_id=user_id)
|
||||
|
||||
|
||||
@router.post("/my/webhooks", status_code=201)
|
||||
async def create_my_webhook(request: Request, body: WebhookEndpointIn):
|
||||
_require_auth(request)
|
||||
user_id = request.state.current_user["id"]
|
||||
from ..webhooks.endpoints import create_endpoint
|
||||
if not body.name.strip():
|
||||
raise HTTPException(status_code=400, detail="Name is required")
|
||||
ep = await create_endpoint(
|
||||
name=body.name.strip(),
|
||||
agent_id=body.agent_id or "",
|
||||
description=body.description,
|
||||
allow_get=body.allow_get,
|
||||
owner_user_id=user_id,
|
||||
)
|
||||
return ep # includes token — only time it's returned
|
||||
|
||||
|
||||
@router.put("/my/webhooks/{endpoint_id}")
|
||||
async def update_my_webhook(request: Request, endpoint_id: str, body: WebhookEndpointUpdate):
|
||||
_require_auth(request)
|
||||
user_id = request.state.current_user["id"]
|
||||
from ..webhooks.endpoints import update_endpoint, get_endpoint
|
||||
ep = await get_endpoint(endpoint_id, owner_user_id=user_id)
|
||||
if ep is None:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
fields = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
updated = await update_endpoint(endpoint_id, **fields)
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/my/webhooks/{endpoint_id}")
|
||||
async def delete_my_webhook(request: Request, endpoint_id: str):
|
||||
_require_auth(request)
|
||||
user_id = request.state.current_user["id"]
|
||||
from ..webhooks.endpoints import delete_endpoint
|
||||
deleted = await delete_endpoint(endpoint_id, owner_user_id=user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/my/webhooks/{endpoint_id}/rotate")
|
||||
async def rotate_my_webhook_token(request: Request, endpoint_id: str):
|
||||
_require_auth(request)
|
||||
user_id = request.state.current_user["id"]
|
||||
from ..webhooks.endpoints import get_endpoint, rotate_token
|
||||
ep = await get_endpoint(endpoint_id, owner_user_id=user_id)
|
||||
if ep is None:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
new_token = await rotate_token(endpoint_id)
|
||||
return {"ok": True, "token": new_token}
|
||||
|
||||
|
||||
# ── Webhook targets (outbound) ────────────────────────────────────────────────
|
||||
|
||||
class WebhookTargetIn(BaseModel):
|
||||
name: str
|
||||
url: str
|
||||
secret_header: str = ""
|
||||
|
||||
|
||||
class WebhookTargetUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
secret_header: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("/webhook-targets")
|
||||
async def list_webhook_targets(request: Request):
|
||||
_require_admin(request)
|
||||
from ..database import get_pool
|
||||
pool = await get_pool()
|
||||
rows = await pool.fetch("SELECT * FROM webhook_targets ORDER BY name")
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/webhook-targets", status_code=201)
|
||||
async def create_webhook_target(request: Request, body: WebhookTargetIn):
|
||||
_require_admin(request)
|
||||
if not body.name.strip():
|
||||
raise HTTPException(status_code=400, detail="Name is required")
|
||||
if not body.url.strip():
|
||||
raise HTTPException(status_code=400, detail="URL is required")
|
||||
from ..database import get_pool
|
||||
from datetime import datetime, timezone
|
||||
pool = await get_pool()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
try:
|
||||
row = await pool.fetchrow(
|
||||
"""
|
||||
INSERT INTO webhook_targets (name, url, secret_header, created_at)
|
||||
VALUES ($1, $2, $3, $4) RETURNING *
|
||||
""",
|
||||
body.name.strip(), body.url.strip(), body.secret_header or None,
|
||||
now,
|
||||
)
|
||||
except Exception as e:
|
||||
if "unique" in str(e).lower():
|
||||
raise HTTPException(status_code=409, detail="A target with that name already exists")
|
||||
raise
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.put("/webhook-targets/{target_id}")
|
||||
async def update_webhook_target(request: Request, target_id: str, body: WebhookTargetUpdate):
|
||||
_require_admin(request)
|
||||
fields = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
from ..database import get_pool
|
||||
pool = await get_pool()
|
||||
set_clauses = ", ".join(f"{k} = ${i + 2}" for i, k in enumerate(fields))
|
||||
await pool.execute(
|
||||
f"UPDATE webhook_targets SET {set_clauses} WHERE id = $1::uuid",
|
||||
target_id, *fields.values(),
|
||||
)
|
||||
row = await pool.fetchrow("SELECT * FROM webhook_targets WHERE id = $1::uuid", target_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Target not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.delete("/webhook-targets/{target_id}")
|
||||
async def delete_webhook_target(request: Request, target_id: str):
|
||||
_require_admin(request)
|
||||
from ..database import get_pool, _rowcount
|
||||
pool = await get_pool()
|
||||
status = await pool.execute(
|
||||
"DELETE FROM webhook_targets WHERE id = $1::uuid", target_id
|
||||
)
|
||||
if _rowcount(status) == 0:
|
||||
raise HTTPException(status_code=404, detail="Target not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── User-scoped webhook targets (non-admin) ───────────────────────────────────
|
||||
|
||||
@router.get("/my/webhook-targets")
|
||||
async def list_my_webhook_targets(request: Request):
|
||||
_require_auth(request)
|
||||
user_id = request.state.current_user["id"]
|
||||
from ..database import get_pool
|
||||
pool = await get_pool()
|
||||
rows = await pool.fetch(
|
||||
"SELECT * FROM webhook_targets WHERE owner_user_id = $1 ORDER BY name", user_id
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/my/webhook-targets", status_code=201)
|
||||
async def create_my_webhook_target(request: Request, body: WebhookTargetIn):
|
||||
_require_auth(request)
|
||||
user_id = request.state.current_user["id"]
|
||||
if not body.name.strip():
|
||||
raise HTTPException(status_code=400, detail="Name is required")
|
||||
if not body.url.strip():
|
||||
raise HTTPException(status_code=400, detail="URL is required")
|
||||
from ..database import get_pool
|
||||
from datetime import datetime, timezone
|
||||
pool = await get_pool()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
try:
|
||||
row = await pool.fetchrow(
|
||||
"""
|
||||
INSERT INTO webhook_targets (name, url, secret_header, owner_user_id, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *
|
||||
""",
|
||||
body.name.strip(), body.url.strip(), body.secret_header or None, user_id, now,
|
||||
)
|
||||
except Exception as e:
|
||||
if "unique" in str(e).lower():
|
||||
raise HTTPException(status_code=409, detail="A target with that name already exists")
|
||||
raise
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.put("/my/webhook-targets/{target_id}")
|
||||
async def update_my_webhook_target(request: Request, target_id: str, body: WebhookTargetUpdate):
|
||||
_require_auth(request)
|
||||
user_id = request.state.current_user["id"]
|
||||
fields = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
from ..database import get_pool
|
||||
pool = await get_pool()
|
||||
# Verify ownership first
|
||||
existing = await pool.fetchrow(
|
||||
"SELECT id FROM webhook_targets WHERE id = $1::uuid AND owner_user_id = $2",
|
||||
target_id, user_id,
|
||||
)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Target not found")
|
||||
set_clauses = ", ".join(f"{k} = ${i + 2}" for i, k in enumerate(fields))
|
||||
await pool.execute(
|
||||
f"UPDATE webhook_targets SET {set_clauses} WHERE id = $1::uuid",
|
||||
target_id, *fields.values(),
|
||||
)
|
||||
row = await pool.fetchrow("SELECT * FROM webhook_targets WHERE id = $1::uuid", target_id)
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.delete("/my/webhook-targets/{target_id}")
|
||||
async def delete_my_webhook_target(request: Request, target_id: str):
|
||||
_require_auth(request)
|
||||
user_id = request.state.current_user["id"]
|
||||
from ..database import get_pool, _rowcount
|
||||
pool = await get_pool()
|
||||
status = await pool.execute(
|
||||
"DELETE FROM webhook_targets WHERE id = $1::uuid AND owner_user_id = $2",
|
||||
target_id, user_id,
|
||||
)
|
||||
if _rowcount(status) == 0:
|
||||
raise HTTPException(status_code=404, detail="Target not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Page Change Monitors ───────────────────────────────────────────────────────
|
||||
|
||||
class WatchedPageIn(BaseModel):
|
||||
name: str
|
||||
url: str
|
||||
schedule: str = "0 * * * *"
|
||||
css_selector: Optional[str] = None
|
||||
agent_id: Optional[str] = None
|
||||
notification_mode: str = "agent"
|
||||
|
||||
|
||||
class WatchedPageUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
schedule: Optional[str] = None
|
||||
css_selector: Optional[str] = None
|
||||
agent_id: Optional[str] = None
|
||||
notification_mode: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("/watched-pages")
|
||||
async def list_watched_pages(request: Request):
|
||||
user = _require_auth(request)
|
||||
from ..monitors.store import list_watched_pages as _list
|
||||
owner = None if user.is_admin else user.id
|
||||
return await _list(owner_user_id=owner)
|
||||
|
||||
|
||||
@router.post("/watched-pages", status_code=201)
|
||||
async def create_watched_page(request: Request, body: WatchedPageIn):
|
||||
user = _require_auth(request)
|
||||
if not body.name.strip() or not body.url.strip():
|
||||
raise HTTPException(status_code=400, detail="Name and URL are required")
|
||||
from ..monitors.store import create_watched_page as _create
|
||||
from ..monitors.page_monitor import page_monitor
|
||||
page = await _create(
|
||||
name=body.name.strip(),
|
||||
url=body.url.strip(),
|
||||
schedule=body.schedule,
|
||||
css_selector=body.css_selector or None,
|
||||
agent_id=body.agent_id or None,
|
||||
notification_mode=body.notification_mode,
|
||||
owner_user_id=user.id,
|
||||
)
|
||||
page_monitor.reschedule(page)
|
||||
return page
|
||||
|
||||
|
||||
@router.put("/watched-pages/{page_id}")
|
||||
async def update_watched_page(request: Request, page_id: str, body: WatchedPageUpdate):
|
||||
user = _require_auth(request)
|
||||
from ..monitors.store import get_watched_page, update_watched_page as _update
|
||||
page = await get_watched_page(page_id)
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
if not user.is_admin and str(page.get("owner_user_id")) != user.id:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
fields = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
updated = await _update(page_id, **fields)
|
||||
from ..monitors.page_monitor import page_monitor
|
||||
page_monitor.reschedule(updated)
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/watched-pages/{page_id}")
|
||||
async def delete_watched_page(request: Request, page_id: str):
|
||||
user = _require_auth(request)
|
||||
from ..monitors.store import get_watched_page, delete_watched_page as _delete
|
||||
page = await get_watched_page(page_id)
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
if not user.is_admin and str(page.get("owner_user_id")) != user.id:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
await _delete(page_id)
|
||||
from ..monitors.page_monitor import page_monitor
|
||||
page_monitor.remove(page_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/watched-pages/{page_id}/check-now")
|
||||
async def check_page_now(request: Request, page_id: str):
|
||||
user = _require_auth(request)
|
||||
from ..monitors.store import get_watched_page
|
||||
page = await get_watched_page(page_id)
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
if not user.is_admin and str(page.get("owner_user_id")) != user.id:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
from ..monitors.page_monitor import page_monitor
|
||||
result = await page_monitor.check_now(page_id)
|
||||
return result
|
||||
|
||||
|
||||
# ── RSS Feed Monitors ──────────────────────────────────────────────────────────
|
||||
|
||||
class RssFeedIn(BaseModel):
|
||||
name: str
|
||||
url: str
|
||||
schedule: str = "0 */4 * * *"
|
||||
agent_id: Optional[str] = None
|
||||
notification_mode: str = "agent"
|
||||
max_items_per_run: int = 5
|
||||
|
||||
|
||||
class RssFeedUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
schedule: Optional[str] = None
|
||||
agent_id: Optional[str] = None
|
||||
notification_mode: Optional[str] = None
|
||||
max_items_per_run: Optional[int] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("/rss-feeds")
|
||||
async def list_rss_feeds(request: Request):
|
||||
user = _require_auth(request)
|
||||
from ..monitors.store import list_rss_feeds as _list
|
||||
owner = None if user.is_admin else user.id
|
||||
return await _list(owner_user_id=owner)
|
||||
|
||||
|
||||
@router.post("/rss-feeds", status_code=201)
|
||||
async def create_rss_feed(request: Request, body: RssFeedIn):
|
||||
user = _require_auth(request)
|
||||
if not body.name.strip() or not body.url.strip():
|
||||
raise HTTPException(status_code=400, detail="Name and URL are required")
|
||||
from ..monitors.store import create_rss_feed as _create
|
||||
from ..monitors.rss_monitor import rss_monitor
|
||||
feed = await _create(
|
||||
name=body.name.strip(),
|
||||
url=body.url.strip(),
|
||||
schedule=body.schedule,
|
||||
agent_id=body.agent_id or None,
|
||||
notification_mode=body.notification_mode,
|
||||
max_items_per_run=body.max_items_per_run,
|
||||
owner_user_id=user.id,
|
||||
)
|
||||
rss_monitor.reschedule(feed)
|
||||
return feed
|
||||
|
||||
|
||||
@router.put("/rss-feeds/{feed_id}")
|
||||
async def update_rss_feed(request: Request, feed_id: str, body: RssFeedUpdate):
|
||||
user = _require_auth(request)
|
||||
from ..monitors.store import get_rss_feed, update_rss_feed as _update
|
||||
feed = await get_rss_feed(feed_id)
|
||||
if not feed:
|
||||
raise HTTPException(status_code=404, detail="Feed not found")
|
||||
if not user.is_admin and str(feed.get("owner_user_id")) != user.id:
|
||||
raise HTTPException(status_code=404, detail="Feed not found")
|
||||
fields = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
updated = await _update(feed_id, **fields)
|
||||
from ..monitors.rss_monitor import rss_monitor
|
||||
rss_monitor.reschedule(updated)
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/rss-feeds/{feed_id}")
|
||||
async def delete_rss_feed(request: Request, feed_id: str):
|
||||
user = _require_auth(request)
|
||||
from ..monitors.store import get_rss_feed, delete_rss_feed as _delete
|
||||
feed = await get_rss_feed(feed_id)
|
||||
if not feed:
|
||||
raise HTTPException(status_code=404, detail="Feed not found")
|
||||
if not user.is_admin and str(feed.get("owner_user_id")) != user.id:
|
||||
raise HTTPException(status_code=404, detail="Feed not found")
|
||||
await _delete(feed_id)
|
||||
from ..monitors.rss_monitor import rss_monitor
|
||||
rss_monitor.remove(feed_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/rss-feeds/{feed_id}/fetch-now")
|
||||
async def fetch_feed_now(request: Request, feed_id: str):
|
||||
user = _require_auth(request)
|
||||
from ..monitors.store import get_rss_feed
|
||||
feed = await get_rss_feed(feed_id)
|
||||
if not feed:
|
||||
raise HTTPException(status_code=404, detail="Feed not found")
|
||||
if not user.is_admin and str(feed.get("owner_user_id")) != user.id:
|
||||
raise HTTPException(status_code=404, detail="Feed not found")
|
||||
from ..monitors.rss_monitor import rss_monitor
|
||||
result = await rss_monitor.fetch_now(feed_id)
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user