Initial commit

This commit is contained in:
liumangmang
2026-05-12 17:51:53 +08:00
commit b564ca4797
55 changed files with 6407 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# routers package
+28
View File
@@ -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"}
+178
View File
@@ -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,
)
+46
View File
@@ -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]
+282
View File
@@ -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
+110
View File
@@ -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}