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:
0
server/webhooks/__init__.py
Normal file
0
server/webhooks/__init__.py
Normal file
138
server/webhooks/endpoints.py
Normal file
138
server/webhooks/endpoints.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
webhooks/endpoints.py — CRUD for inbound webhook trigger endpoints.
|
||||
|
||||
Each endpoint has a secret token. When the token is presented via GET ?q=...
|
||||
or POST {"message": "..."}, the associated agent is triggered.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..database import _rowcount, get_pool
|
||||
|
||||
|
||||
def _utcnow() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
async def create_endpoint(
|
||||
name: str,
|
||||
agent_id: str,
|
||||
description: str = "",
|
||||
allow_get: bool = True,
|
||||
owner_user_id: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a new webhook endpoint. Returns the full row including the plaintext token."""
|
||||
token = secrets.token_urlsafe(32)
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
"""
|
||||
INSERT INTO webhook_endpoints
|
||||
(name, token, agent_id, description, allow_get, owner_user_id, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *
|
||||
""",
|
||||
name, token, agent_id or None, description, allow_get, owner_user_id, _utcnow(),
|
||||
)
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def list_endpoints(owner_user_id: str | None = None) -> list[dict]:
|
||||
"""List endpoints. Pass owner_user_id to scope to one user; None returns all (admin). Token never included."""
|
||||
pool = await get_pool()
|
||||
if owner_user_id:
|
||||
rows = await pool.fetch(
|
||||
"SELECT * FROM webhook_endpoints WHERE owner_user_id = $1 ORDER BY created_at DESC",
|
||||
owner_user_id,
|
||||
)
|
||||
else:
|
||||
rows = await pool.fetch(
|
||||
"SELECT * FROM webhook_endpoints ORDER BY created_at DESC"
|
||||
)
|
||||
result = []
|
||||
for row in rows:
|
||||
d = dict(row)
|
||||
d.pop("token", None)
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
|
||||
async def get_endpoint(endpoint_id: str, owner_user_id: str | None = None) -> dict | None:
|
||||
"""Get one endpoint by ID. Token is not included. Pass owner_user_id to enforce ownership."""
|
||||
pool = await get_pool()
|
||||
if owner_user_id:
|
||||
row = await pool.fetchrow(
|
||||
"SELECT * FROM webhook_endpoints WHERE id = $1::uuid AND owner_user_id = $2",
|
||||
endpoint_id, owner_user_id,
|
||||
)
|
||||
else:
|
||||
row = await pool.fetchrow(
|
||||
"SELECT * FROM webhook_endpoints WHERE id = $1::uuid", endpoint_id
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
d.pop("token", None)
|
||||
return d
|
||||
|
||||
|
||||
async def get_by_token(token: str) -> dict | None:
|
||||
"""Look up an enabled endpoint by its secret token (includes token field)."""
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
"SELECT * FROM webhook_endpoints WHERE token = $1 AND enabled = TRUE", token
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def update_endpoint(endpoint_id: str, **fields) -> dict | None:
|
||||
allowed = {"name", "agent_id", "description", "allow_get", "enabled"}
|
||||
updates = {k: v for k, v in fields.items() if k in allowed}
|
||||
if not updates:
|
||||
return await get_endpoint(endpoint_id)
|
||||
pool = await get_pool()
|
||||
set_clauses = ", ".join(f"{k} = ${i + 2}" for i, k in enumerate(updates))
|
||||
await pool.execute(
|
||||
f"UPDATE webhook_endpoints SET {set_clauses} WHERE id = $1::uuid",
|
||||
endpoint_id, *updates.values(),
|
||||
)
|
||||
return await get_endpoint(endpoint_id)
|
||||
|
||||
|
||||
async def rotate_token(endpoint_id: str) -> str:
|
||||
"""Generate and store a new token. Returns the new plaintext token."""
|
||||
new_token = secrets.token_urlsafe(32)
|
||||
pool = await get_pool()
|
||||
await pool.execute(
|
||||
"UPDATE webhook_endpoints SET token = $1 WHERE id = $2::uuid",
|
||||
new_token, endpoint_id,
|
||||
)
|
||||
return new_token
|
||||
|
||||
|
||||
async def delete_endpoint(endpoint_id: str, owner_user_id: str | None = None) -> bool:
|
||||
pool = await get_pool()
|
||||
if owner_user_id:
|
||||
status = await pool.execute(
|
||||
"DELETE FROM webhook_endpoints WHERE id = $1::uuid AND owner_user_id = $2",
|
||||
endpoint_id, owner_user_id,
|
||||
)
|
||||
else:
|
||||
status = await pool.execute(
|
||||
"DELETE FROM webhook_endpoints WHERE id = $1::uuid", endpoint_id
|
||||
)
|
||||
return _rowcount(status) > 0
|
||||
|
||||
|
||||
async def record_trigger(endpoint_id: str) -> None:
|
||||
"""Increment trigger_count and update last_triggered_at."""
|
||||
pool = await get_pool()
|
||||
await pool.execute(
|
||||
"""
|
||||
UPDATE webhook_endpoints
|
||||
SET last_triggered_at = $1, trigger_count = COALESCE(trigger_count, 0) + 1
|
||||
WHERE id = $2::uuid
|
||||
""",
|
||||
_utcnow(), endpoint_id,
|
||||
)
|
||||
Reference in New Issue
Block a user