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