179 lines
5.6 KiB
Python
179 lines
5.6 KiB
Python
"""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,
|
|
)
|
|
|