Initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# routers package
|
||||
@@ -0,0 +1,28 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.schemas.auth import LoginRequest, TokenResponse, UserInfo
|
||||
from app.utils.auth import verify_password, create_access_token, get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(req: LoginRequest, db: Session = Depends(get_db)):
|
||||
user = db.query(AdminUser).filter(AdminUser.email == req.email).first()
|
||||
if not user or not verify_password(req.password, user.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="邮箱或密码错误")
|
||||
token = create_access_token(user.email)
|
||||
return TokenResponse(access_token=token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserInfo)
|
||||
def me(current_user: AdminUser = Depends(get_current_user)):
|
||||
return UserInfo(email=current_user.email)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout():
|
||||
# JWT is stateless — client discards token
|
||||
return {"message": "logged out"}
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Custom pages CRUD router + transparent iframe proxy."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
from urllib.parse import urljoin, urlparse, urlencode, quote
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.custom_page import CustomPage
|
||||
from app.utils.auth import get_current_user, get_user_from_token_param
|
||||
|
||||
router = APIRouter(prefix="/api/custom-pages", tags=["custom-pages"])
|
||||
|
||||
# Headers that prevent iframe embedding — strip them from proxied responses
|
||||
_STRIP_RESPONSE_HEADERS = {
|
||||
"x-frame-options",
|
||||
"content-security-policy",
|
||||
"content-security-policy-report-only",
|
||||
}
|
||||
# Headers we should NOT forward to the upstream (hop-by-hop + host)
|
||||
_STRIP_REQUEST_HEADERS = {"host", "connection", "transfer-encoding", "te",
|
||||
"trailers", "upgrade", "proxy-authorization"}
|
||||
|
||||
|
||||
# ---- Schemas ----
|
||||
class CustomPageCreate(BaseModel):
|
||||
name: str
|
||||
url: str
|
||||
icon: str = "Link"
|
||||
sort_order: int = 0
|
||||
enabled: bool = True
|
||||
use_proxy: bool = False
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class CustomPageUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
enabled: Optional[bool] = None
|
||||
use_proxy: Optional[bool] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class CustomPageResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
url: str
|
||||
icon: str
|
||||
sort_order: int
|
||||
enabled: bool
|
||||
use_proxy: bool
|
||||
description: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ---- CRUD Endpoints ----
|
||||
|
||||
@router.get("", response_model=List[CustomPageResponse])
|
||||
def list_pages(db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
return db.query(CustomPage).order_by(CustomPage.sort_order, CustomPage.id).all()
|
||||
|
||||
|
||||
@router.post("", response_model=CustomPageResponse, status_code=201)
|
||||
def create_page(body: CustomPageCreate, db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
page = CustomPage(**body.model_dump())
|
||||
db.add(page)
|
||||
db.commit()
|
||||
db.refresh(page)
|
||||
return page
|
||||
|
||||
|
||||
@router.put("/{pid}", response_model=CustomPageResponse)
|
||||
def update_page(pid: int, body: CustomPageUpdate, db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
page = db.query(CustomPage).filter(CustomPage.id == pid).first()
|
||||
if not page:
|
||||
raise HTTPException(404, "page not found")
|
||||
for k, v in body.model_dump(exclude_none=True).items():
|
||||
setattr(page, k, v)
|
||||
page.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(page)
|
||||
return page
|
||||
|
||||
|
||||
@router.delete("/{pid}", status_code=204)
|
||||
def delete_page(pid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
page = db.query(CustomPage).filter(CustomPage.id == pid).first()
|
||||
if not page:
|
||||
raise HTTPException(404, "page not found")
|
||||
db.delete(page)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---- Frame Proxy (simple: strip X-Frame-Options / CSP, pass through content) ----
|
||||
|
||||
_STRIP_RESP = {
|
||||
"x-frame-options",
|
||||
"content-security-policy",
|
||||
"content-security-policy-report-only",
|
||||
}
|
||||
_STRIP_REQ = {
|
||||
"host", "connection", "transfer-encoding", "te",
|
||||
"trailers", "upgrade", "proxy-authorization", "authorization",
|
||||
}
|
||||
|
||||
|
||||
@router.api_route("/frame-proxy", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"])
|
||||
async def frame_proxy(
|
||||
request: Request,
|
||||
url: str = Query(..., description="Target URL to proxy"),
|
||||
token: Optional[str] = Query(default=None),
|
||||
_=Depends(get_user_from_token_param),
|
||||
):
|
||||
"""
|
||||
Simple transparent proxy: strips X-Frame-Options and CSP headers so the
|
||||
response can be embedded in an iframe.
|
||||
|
||||
NOTE: For full SPA (React/Vue) sites, install the 'Requestly' browser
|
||||
extension and set a rule to remove X-Frame-Options on the target domain —
|
||||
that works reliably without any server-side complexity.
|
||||
"""
|
||||
if not url.startswith(("http://", "https://")):
|
||||
raise HTTPException(400, "Only http/https URLs are allowed")
|
||||
|
||||
# Forward browser headers (cookies, language, accept, etc.)
|
||||
fwd: dict[str, str] = {}
|
||||
for k, v in request.headers.items():
|
||||
if k.lower() in _STRIP_REQ or k.lower().startswith("x-forwarded"):
|
||||
continue
|
||||
fwd[k] = v
|
||||
fwd["user-agent"] = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
fwd.setdefault("accept", "text/html,application/xhtml+xml,*/*;q=0.8")
|
||||
|
||||
body = await request.body() if request.method not in ("GET", "HEAD") else None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=30) as client:
|
||||
resp = await client.request(
|
||||
method=request.method,
|
||||
url=url,
|
||||
headers=fwd,
|
||||
content=body,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise HTTPException(502, f"Proxy error: {exc}")
|
||||
|
||||
# Pass through content unchanged — just strip the iframe-blocking headers
|
||||
out: dict[str, str] = {}
|
||||
for k, v in resp.headers.items():
|
||||
kl = k.lower()
|
||||
if kl in _STRIP_RESP:
|
||||
continue
|
||||
if kl in ("content-encoding", "transfer-encoding", "content-length"):
|
||||
continue
|
||||
out[k] = v
|
||||
|
||||
return Response(
|
||||
content=resp.content,
|
||||
status_code=resp.status_code,
|
||||
media_type=resp.headers.get("content-type"),
|
||||
headers=out,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Notification logs listing with filters."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.notification_log import NotificationLog
|
||||
from app.schemas.log import NotificationLogResponse
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/notification-logs", tags=["logs"])
|
||||
|
||||
|
||||
def _to_response(log: NotificationLog) -> NotificationLogResponse:
|
||||
return NotificationLogResponse(
|
||||
id=log.id,
|
||||
webhook_config_id=log.webhook_config_id,
|
||||
webhook_name=log.webhook_name,
|
||||
event_type=log.event_type,
|
||||
payload=json.loads(log.payload_json or "{}"),
|
||||
status=log.status,
|
||||
response_text=log.response_text,
|
||||
created_at=log.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=List[NotificationLogResponse])
|
||||
def list_logs(
|
||||
status: Optional[str] = Query(None),
|
||||
event_type: Optional[str] = Query(None),
|
||||
limit: int = Query(100, le=500),
|
||||
offset: int = Query(0),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
):
|
||||
q = db.query(NotificationLog)
|
||||
if status:
|
||||
q = q.filter(NotificationLog.status == status)
|
||||
if event_type:
|
||||
q = q.filter(NotificationLog.event_type == event_type)
|
||||
logs = q.order_by(NotificationLog.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return [_to_response(log) for log in logs]
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Upstream management CRUD + test + check-now + snapshots."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.models.upstream import Upstream
|
||||
from app.models.snapshot import UpstreamRateSnapshot
|
||||
from app.schemas.upstream import (
|
||||
UpstreamCreate, UpstreamUpdate, UpstreamResponse, SnapshotResponse, TestResult
|
||||
)
|
||||
from app.services.upstream_client import UpstreamClient, UpstreamError, build_snapshot
|
||||
from app.services.snapshot_service import diff_snapshots
|
||||
from app.services import scheduler as sched_svc
|
||||
from app.services import webhook_service
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/upstreams", tags=["upstreams"])
|
||||
|
||||
MASK = "***"
|
||||
SECRET_KEYS = {"password", "token", "key", "secret"}
|
||||
|
||||
|
||||
def _mask_auth_config(auth_type: str, cfg: dict) -> dict:
|
||||
masked = {}
|
||||
for k, v in cfg.items():
|
||||
if k.lower() in SECRET_KEYS and v:
|
||||
masked[k] = MASK
|
||||
else:
|
||||
masked[k] = v
|
||||
return masked
|
||||
|
||||
|
||||
def _to_response(u: Upstream) -> UpstreamResponse:
|
||||
cfg = json.loads(u.auth_config_json or "{}")
|
||||
return UpstreamResponse(
|
||||
id=u.id,
|
||||
name=u.name,
|
||||
base_url=u.base_url,
|
||||
api_prefix=u.api_prefix,
|
||||
auth_type=u.auth_type,
|
||||
auth_config_masked=_mask_auth_config(u.auth_type, cfg),
|
||||
rate_endpoint=u.rate_endpoint,
|
||||
groups_endpoint=u.groups_endpoint,
|
||||
enabled=u.enabled,
|
||||
check_interval_seconds=u.check_interval_seconds,
|
||||
timeout_seconds=u.timeout_seconds,
|
||||
last_status=u.last_status,
|
||||
last_checked_at=u.last_checked_at,
|
||||
last_error=u.last_error,
|
||||
created_at=u.created_at,
|
||||
updated_at=u.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=List[UpstreamResponse])
|
||||
def list_upstreams(db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
return [_to_response(u) for u in db.query(Upstream).order_by(Upstream.id).all()]
|
||||
|
||||
|
||||
@router.post("", response_model=UpstreamResponse, status_code=201)
|
||||
def create_upstream(
|
||||
body: UpstreamCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
):
|
||||
u = Upstream(
|
||||
name=body.name,
|
||||
base_url=body.base_url.rstrip("/"),
|
||||
api_prefix=body.api_prefix,
|
||||
auth_type=body.auth_type,
|
||||
auth_config_json=json.dumps(body.auth_config, ensure_ascii=False),
|
||||
rate_endpoint=body.rate_endpoint,
|
||||
groups_endpoint=body.groups_endpoint,
|
||||
enabled=body.enabled,
|
||||
check_interval_seconds=body.check_interval_seconds,
|
||||
timeout_seconds=body.timeout_seconds,
|
||||
)
|
||||
db.add(u)
|
||||
db.commit()
|
||||
db.refresh(u)
|
||||
sched_svc.refresh_upstream(u.id, u.check_interval_seconds, u.enabled)
|
||||
return _to_response(u)
|
||||
|
||||
|
||||
@router.get("/{uid}", response_model=UpstreamResponse)
|
||||
def get_upstream(uid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
u = db.query(Upstream).filter(Upstream.id == uid).first()
|
||||
if not u:
|
||||
raise HTTPException(404, "upstream not found")
|
||||
return _to_response(u)
|
||||
|
||||
|
||||
@router.put("/{uid}", response_model=UpstreamResponse)
|
||||
def update_upstream(
|
||||
uid: int,
|
||||
body: UpstreamUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
):
|
||||
u = db.query(Upstream).filter(Upstream.id == uid).first()
|
||||
if not u:
|
||||
raise HTTPException(404, "upstream not found")
|
||||
data = body.model_dump(exclude_none=True)
|
||||
if "auth_config" in data:
|
||||
# merge with existing config to avoid overwriting masked fields
|
||||
existing = json.loads(u.auth_config_json or "{}")
|
||||
incoming = data.pop("auth_config")
|
||||
for k, v in incoming.items():
|
||||
if v != MASK: # don't overwrite with mask placeholder
|
||||
existing[k] = v
|
||||
u.auth_config_json = json.dumps(existing, ensure_ascii=False)
|
||||
if "base_url" in data:
|
||||
data["base_url"] = data["base_url"].rstrip("/")
|
||||
for k, v in data.items():
|
||||
setattr(u, k, v)
|
||||
u.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(u)
|
||||
sched_svc.refresh_upstream(u.id, u.check_interval_seconds, u.enabled)
|
||||
return _to_response(u)
|
||||
|
||||
|
||||
@router.delete("/{uid}", status_code=204)
|
||||
def delete_upstream(uid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
u = db.query(Upstream).filter(Upstream.id == uid).first()
|
||||
if not u:
|
||||
raise HTTPException(404, "upstream not found")
|
||||
sched_svc.refresh_upstream(uid, 0, False) # remove job
|
||||
db.delete(u)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/{uid}/test", response_model=TestResult)
|
||||
def test_upstream(uid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
u = db.query(Upstream).filter(Upstream.id == uid).first()
|
||||
if not u:
|
||||
raise HTTPException(404, "upstream not found")
|
||||
auth_config = json.loads(u.auth_config_json or "{}")
|
||||
client = UpstreamClient(
|
||||
base_url=u.base_url,
|
||||
api_prefix=u.api_prefix,
|
||||
auth_type=u.auth_type,
|
||||
auth_config=auth_config,
|
||||
timeout=float(u.timeout_seconds),
|
||||
)
|
||||
try:
|
||||
client.login()
|
||||
groups = client.get_available_groups(u.groups_endpoint)
|
||||
return TestResult(success=True, message=f"连接成功,获取到 {len(groups)} 个分组")
|
||||
except Exception as exc:
|
||||
return TestResult(success=False, message="连接失败", detail=str(exc))
|
||||
|
||||
|
||||
@router.post("/{uid}/check-now", response_model=TestResult)
|
||||
def check_now(uid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
u = db.query(Upstream).filter(Upstream.id == uid).first()
|
||||
if not u:
|
||||
raise HTTPException(404, "upstream not found")
|
||||
auth_config = json.loads(u.auth_config_json or "{}")
|
||||
client = UpstreamClient(
|
||||
base_url=u.base_url,
|
||||
api_prefix=u.api_prefix,
|
||||
auth_type=u.auth_type,
|
||||
auth_config=auth_config,
|
||||
timeout=float(u.timeout_seconds),
|
||||
)
|
||||
try:
|
||||
client.login()
|
||||
groups = client.get_available_groups(u.groups_endpoint)
|
||||
raw_rates = client.get_group_rates(u.rate_endpoint)
|
||||
snapshot = build_snapshot(u.id, u.base_url, u.api_prefix, groups, raw_rates)
|
||||
except Exception as exc:
|
||||
u.consecutive_failures = (u.consecutive_failures or 0) + 1
|
||||
u.last_error = str(exc)
|
||||
u.last_checked_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return TestResult(success=False, message="检测失败", detail=str(exc))
|
||||
|
||||
prev_row = (
|
||||
db.query(UpstreamRateSnapshot)
|
||||
.filter(UpstreamRateSnapshot.upstream_id == uid)
|
||||
.order_by(UpstreamRateSnapshot.captured_at.desc())
|
||||
.first()
|
||||
)
|
||||
previous = json.loads(prev_row.snapshot_json) if prev_row else None
|
||||
changes = diff_snapshots(previous, snapshot)
|
||||
|
||||
new_row = UpstreamRateSnapshot(
|
||||
upstream_id=uid,
|
||||
snapshot_json=json.dumps(snapshot, ensure_ascii=False),
|
||||
captured_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(new_row)
|
||||
was_unhealthy = u.last_status == "unhealthy"
|
||||
u.last_status = "healthy"
|
||||
u.last_checked_at = datetime.now(timezone.utc)
|
||||
u.last_error = None
|
||||
u.consecutive_failures = 0
|
||||
db.commit()
|
||||
|
||||
if was_unhealthy:
|
||||
webhook_service.send_status_event(db, u.id, u.name, u.base_url, "upstream_recovered")
|
||||
if changes:
|
||||
webhook_service.send_rate_changed(db, u.id, u.name, u.base_url, changes)
|
||||
|
||||
msg = f"检测成功,{len(groups)} 个分组"
|
||||
if changes:
|
||||
msg += f",发现 {len(changes)} 处倍率变化"
|
||||
elif previous is None:
|
||||
msg += ",初始化快照完成"
|
||||
else:
|
||||
msg += ",无变化"
|
||||
return TestResult(success=True, message=msg)
|
||||
|
||||
|
||||
@router.get("/{uid}/snapshots/latest", response_model=SnapshotResponse)
|
||||
def latest_snapshot(uid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
row = (
|
||||
db.query(UpstreamRateSnapshot)
|
||||
.filter(UpstreamRateSnapshot.upstream_id == uid)
|
||||
.order_by(UpstreamRateSnapshot.captured_at.desc())
|
||||
.first()
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(404, "no snapshot found")
|
||||
return SnapshotResponse(
|
||||
id=row.id,
|
||||
upstream_id=row.upstream_id,
|
||||
snapshot=json.loads(row.snapshot_json),
|
||||
captured_at=row.captured_at,
|
||||
)
|
||||
|
||||
|
||||
from fastapi import Query as QueryParam
|
||||
|
||||
@router.get("/{uid}/snapshots", response_model=List[SnapshotResponse])
|
||||
def list_snapshots(
|
||||
uid: int,
|
||||
limit: int = QueryParam(20, le=100),
|
||||
offset: int = QueryParam(0),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
):
|
||||
"""Return paginated snapshot history with diff vs previous snapshot embedded."""
|
||||
rows = (
|
||||
db.query(UpstreamRateSnapshot)
|
||||
.filter(UpstreamRateSnapshot.upstream_id == uid)
|
||||
.order_by(UpstreamRateSnapshot.captured_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit + 1) # fetch one extra to get the "previous" for diffing
|
||||
.all()
|
||||
)
|
||||
# We need the snapshot just before each one to compute changes count.
|
||||
# rows are desc order; rows[i+1] is older than rows[i]
|
||||
results = []
|
||||
for i, row in enumerate(rows[:limit]):
|
||||
snap = json.loads(row.snapshot_json)
|
||||
# try to diff against the next row (which is older)
|
||||
changes_count: int | None = None
|
||||
if i + 1 < len(rows):
|
||||
older = json.loads(rows[i + 1].snapshot_json)
|
||||
from app.services.snapshot_service import diff_snapshots
|
||||
ch = diff_snapshots(older, snap)
|
||||
changes_count = len(ch)
|
||||
groups_count = len(snap.get("groups", {}))
|
||||
# embed lightweight summary into snapshot dict so frontend can display it
|
||||
snap["_groups_count"] = groups_count
|
||||
snap["_changes_count"] = changes_count # None means first ever snapshot
|
||||
results.append(SnapshotResponse(
|
||||
id=row.id,
|
||||
upstream_id=row.upstream_id,
|
||||
snapshot=snap,
|
||||
captured_at=row.captured_at,
|
||||
))
|
||||
return results
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Webhook configuration CRUD + test notification."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.webhook_config import WebhookConfig
|
||||
from app.schemas.webhook import WebhookCreate, WebhookUpdate, WebhookResponse
|
||||
from app.services.webhook_service import send_test_notification
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
|
||||
|
||||
MASK = "***"
|
||||
|
||||
|
||||
def _to_response(w: WebhookConfig) -> WebhookResponse:
|
||||
return WebhookResponse(
|
||||
id=w.id,
|
||||
name=w.name,
|
||||
type=w.type,
|
||||
url=w.url,
|
||||
secret_masked=MASK if w.secret else "",
|
||||
enabled=w.enabled,
|
||||
events=json.loads(w.events_json or "[]"),
|
||||
created_at=w.created_at,
|
||||
updated_at=w.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=List[WebhookResponse])
|
||||
def list_webhooks(db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
return [_to_response(w) for w in db.query(WebhookConfig).order_by(WebhookConfig.id).all()]
|
||||
|
||||
|
||||
@router.post("", response_model=WebhookResponse, status_code=201)
|
||||
def create_webhook(
|
||||
body: WebhookCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
):
|
||||
w = WebhookConfig(
|
||||
name=body.name,
|
||||
type=body.type,
|
||||
url=body.url,
|
||||
secret=body.secret,
|
||||
enabled=body.enabled,
|
||||
events_json=json.dumps(body.events, ensure_ascii=False),
|
||||
)
|
||||
db.add(w)
|
||||
db.commit()
|
||||
db.refresh(w)
|
||||
return _to_response(w)
|
||||
|
||||
|
||||
@router.get("/{wid}", response_model=WebhookResponse)
|
||||
def get_webhook(wid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
w = db.query(WebhookConfig).filter(WebhookConfig.id == wid).first()
|
||||
if not w:
|
||||
raise HTTPException(404, "webhook not found")
|
||||
return _to_response(w)
|
||||
|
||||
|
||||
@router.put("/{wid}", response_model=WebhookResponse)
|
||||
def update_webhook(
|
||||
wid: int,
|
||||
body: WebhookUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
):
|
||||
w = db.query(WebhookConfig).filter(WebhookConfig.id == wid).first()
|
||||
if not w:
|
||||
raise HTTPException(404, "webhook not found")
|
||||
data = body.model_dump(exclude_none=True)
|
||||
if "events" in data:
|
||||
w.events_json = json.dumps(data.pop("events"), ensure_ascii=False)
|
||||
if "secret" in data:
|
||||
if data["secret"] != MASK: # only update if not mask placeholder
|
||||
w.secret = data.pop("secret")
|
||||
else:
|
||||
data.pop("secret")
|
||||
for k, v in data.items():
|
||||
setattr(w, k, v)
|
||||
w.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(w)
|
||||
return _to_response(w)
|
||||
|
||||
|
||||
@router.delete("/{wid}", status_code=204)
|
||||
def delete_webhook(wid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
w = db.query(WebhookConfig).filter(WebhookConfig.id == wid).first()
|
||||
if not w:
|
||||
raise HTTPException(404, "webhook not found")
|
||||
db.delete(w)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/{wid}/test")
|
||||
def test_webhook(wid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
w = db.query(WebhookConfig).filter(WebhookConfig.id == wid).first()
|
||||
if not w:
|
||||
raise HTTPException(404, "webhook not found")
|
||||
ok, msg = send_test_notification(db, w)
|
||||
return {"success": ok, "message": msg}
|
||||
Reference in New Issue
Block a user