core url changer
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Ekaropolus 2025-09-18 23:55:52 -06:00
parent 3de09ff074
commit d57548f273
5 changed files with 66 additions and 29 deletions

28
core/urlbuild.py Normal file
View File

@ -0,0 +1,28 @@
# polisplexity/core/urlbuild.py
from django.conf import settings
def public_base(request=None) -> str:
"""
1) settings.PUBLIC_BASE_URL (recommended in prod)
2) request scheme/host (proxy aware)
3) http://localhost:8000 fallback
"""
base = getattr(settings, "PUBLIC_BASE_URL", "").rstrip("/")
if base:
return base
if request is not None:
proto = (request.META.get("HTTP_X_FORWARDED_PROTO")
or ("https" if request.is_secure() else "http"))
host = request.get_host()
return f"{proto.split(',')[0].strip()}://{host}"
return "http://localhost:8000"
def public_url(path_or_abs: str, request=None) -> str:
"""Join public base to a path; pass through absolute URLs untouched."""
if not path_or_abs:
return path_or_abs
if "://" in path_or_abs:
return path_or_abs
if not path_or_abs.startswith("/"):
path_or_abs = "/" + path_or_abs
return public_base(request) + path_or_abs

View File

@ -251,3 +251,9 @@ LOGGING = {
}, },
} }
# honor reverse proxy + allow “Host” from proxy
USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# optional hard override (recommended in prod)
PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "").rstrip("/")

View File

@ -56,6 +56,8 @@ urlpatterns = [
] ]

View File

@ -11,6 +11,8 @@ from pxy_contracts.contracts import SAMIRunRequest
from pxy_sami.estimators.sami_core import run_sami from pxy_sami.estimators.sami_core import run_sami
from pxy_dashboard.utils.share import mint_sami_share_url from pxy_dashboard.utils.share import mint_sami_share_url
# NEW:
from core.urlbuild import public_url
def _err(code: str, message: str, hint: str | None = None, http_status: int = 400): def _err(code: str, message: str, hint: str | None = None, http_status: int = 400):
return Response( return Response(
@ -18,22 +20,15 @@ def _err(code: str, message: str, hint: str | None = None, http_status: int = 40
status=http_status, status=http_status,
) )
@api_view(["GET"]) @api_view(["GET"])
@throttle_classes([ScopedRateThrottle]) @throttle_classes([ScopedRateThrottle])
def sami_health(request): def sami_health(request):
sami_health.throttle_scope = "sami_health" sami_health.throttle_scope = "sami_health"
try: try:
# If you have deeper checks, put them here. Keep simple/fast.
return Response({"ok": True, "service": "sami"}) return Response({"ok": True, "service": "sami"})
except Exception as e: except Exception as e:
return _err( return _err("sami_health_error", "SAMI health check failed", str(e),
"sami_health_error", http_status=status.HTTP_500_INTERNAL_SERVER_ERROR)
"SAMI health check failed",
str(e),
http_status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@api_view(["POST"]) @api_view(["POST"])
@throttle_classes([ScopedRateThrottle]) @throttle_classes([ScopedRateThrottle])
@ -48,7 +43,7 @@ def sami_run(request):
resp = run_sami(req) resp = run_sami(req)
data = resp.model_dump() data = resp.model_dump()
# Inject share URL (signed, expiring) # inject share URL (signed)
rid = data.get("run_id") rid = data.get("run_id")
if rid: if rid:
meta = { meta = {
@ -59,6 +54,12 @@ def sami_run(request):
} }
data["share_url"] = mint_sami_share_url(rid, meta=meta, request=request) data["share_url"] = mint_sami_share_url(rid, meta=meta, request=request)
# ABSOLUTIZE any path-like URLs (chart, share)
for k in ("chart_url", "share_url"):
if data.get(k):
data[k] = public_url(data[k], request)
return Response(data) return Response(data)
except Exception as e: except Exception as e:
return _err("sami_error", "SAMI run failed", hint=str(e), http_status=status.HTTP_502_BAD_GATEWAY) return _err("sami_error", "SAMI run failed", hint=str(e),
http_status=status.HTTP_502_BAD_GATEWAY)

View File

@ -25,7 +25,7 @@ from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.exceptions import ValidationError as DRFValidationError from rest_framework.exceptions import ValidationError as DRFValidationError
from rest_framework import status from rest_framework import status
from rest_framework.throttling import ScopedRateThrottle # 👈 add from rest_framework.throttling import ScopedRateThrottle
from pydantic import ValidationError as PydValidationError from pydantic import ValidationError as PydValidationError
from pxy_contracts.contracts.sites import SiteSearchRequest, SiteSearchResponse from pxy_contracts.contracts.sites import SiteSearchRequest, SiteSearchResponse
@ -33,6 +33,9 @@ from pxy_sites.models import SiteRun
from pxy_sites.services.site_scoring import run_site_search from pxy_sites.services.site_scoring import run_site_search
from pxy_dashboard.utils.share import mint_sites_share_url from pxy_dashboard.utils.share import mint_sites_share_url
# NEW: public URL helpers (proxy/HTTPS aware)
from core.urlbuild import public_base, public_url
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# -------- uniform error envelope helpers -------- # -------- uniform error envelope helpers --------
@ -59,25 +62,16 @@ def _pyify(o):
return o.tolist() return o.tolist()
return str(o) return str(o)
def _build_base_url(request) -> str:
forwarded_proto = request.META.get("HTTP_X_FORWARDED_PROTO")
scheme = (forwarded_proto.split(",")[0].strip() if forwarded_proto else None) or (
"https" if request.is_secure() else "http"
)
host = request.get_host() or settings.BASE_URL.replace("https://", "").replace("http://", "")
return f"{scheme}://{host}"
# -------- DRF API views -------- # -------- DRF API views --------
class SitesHealth(APIView): class SitesHealth(APIView):
authentication_classes = [] authentication_classes = []
throttle_classes = [ScopedRateThrottle] # 👈 enable throttling throttle_classes = [ScopedRateThrottle]
throttle_scope = "sites_health" throttle_scope = "sites_health"
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
return Response({"ok": True, "app": "pxy_sites"}) return Response({"ok": True, "app": "pxy_sites"})
class SiteSearchView(APIView): class SiteSearchView(APIView):
# DRF ScopedRateThrottle is active via project settings; scope name here:
throttle_scope = "sites_search" throttle_scope = "sites_search"
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@ -86,11 +80,9 @@ class SiteSearchView(APIView):
try: try:
req = SiteSearchRequest(**(request.data or {})) req = SiteSearchRequest(**(request.data or {}))
except PydValidationError as ve: except PydValidationError as ve:
# DRFValidationError would be handled by your global handler too,
# but we return the consistent envelope directly
return _env("invalid", "Validation error", hint=str(ve), http=status.HTTP_400_BAD_REQUEST) return _env("invalid", "Validation error", hint=str(ve), http=status.HTTP_400_BAD_REQUEST)
# 2) Run scoring (catch provider/upstream failures -> 502 envelope) # 2) Run scoring
try: try:
resp: SiteSearchResponse = run_site_search(req) resp: SiteSearchResponse = run_site_search(req)
except Exception as e: except Exception as e:
@ -104,13 +96,21 @@ class SiteSearchView(APIView):
data = resp.model_dump() data = resp.model_dump()
# 3) Build absolute URLs (proxy-friendly) # 3) Public base + absolutize any URLs returned by the scorer
base = _build_base_url(request) base = public_base(request)
sid = data.get("search_id") sid = data.get("search_id")
if sid: # ensure top-level map URLs & share are absolute
data["share_url"] = mint_sites_share_url(sid, request=request) for k in ("map_url", "demand_map_url", "competition_map_url", "share_url"):
if data.get(k):
data[k] = public_url(data[k], request)
# inject (signed) share_url if we minted one
if sid and not data.get("share_url"):
data["share_url"] = mint_sites_share_url(sid, request=request)
data["share_url"] = public_url(data["share_url"], request)
# derived artifact/geojson/download/preview URLs (absolute)
def _dl(kind: str) -> str: return f"{base}/api/sites/download/{kind}/{sid}" def _dl(kind: str) -> str: return f"{base}/api/sites/download/{kind}/{sid}"
def _gj(kind: str) -> str: return f"{base}/api/sites/geojson/{kind}/{sid}" def _gj(kind: str) -> str: return f"{base}/api/sites/geojson/{kind}/{sid}"
def _pv(kind: str) -> str: return f"{base}/api/sites/preview/{kind}/{sid}" def _pv(kind: str) -> str: return f"{base}/api/sites/preview/{kind}/{sid}"