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
+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,
)