Initial commit
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user