diff --git a/core/urlbuild.py b/core/urlbuild.py new file mode 100644 index 0000000..338b1a4 --- /dev/null +++ b/core/urlbuild.py @@ -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 diff --git a/polisplexity/settings.py b/polisplexity/settings.py index 0262f3c..fb30634 100644 --- a/polisplexity/settings.py +++ b/polisplexity/settings.py @@ -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("/") diff --git a/polisplexity/urls.py b/polisplexity/urls.py index bb91889..4d2787e 100644 --- a/polisplexity/urls.py +++ b/polisplexity/urls.py @@ -54,6 +54,8 @@ urlpatterns = [ path('', include('pxy_agents_coral.urls')), + + ] diff --git a/pxy_sami/api/views.py b/pxy_sami/api/views.py index 3602e30..7d11f86 100644 --- a/pxy_sami/api/views.py +++ b/pxy_sami/api/views.py @@ -11,6 +11,8 @@ from pxy_contracts.contracts import SAMIRunRequest from pxy_sami.estimators.sami_core import run_sami 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): return Response( @@ -18,22 +20,15 @@ def _err(code: str, message: str, hint: str | None = None, http_status: int = 40 status=http_status, ) - @api_view(["GET"]) @throttle_classes([ScopedRateThrottle]) def sami_health(request): sami_health.throttle_scope = "sami_health" try: - # If you have deeper checks, put them here. Keep simple/fast. return Response({"ok": True, "service": "sami"}) except Exception as e: - return _err( - "sami_health_error", - "SAMI health check failed", - str(e), - http_status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - + return _err("sami_health_error", "SAMI health check failed", str(e), + http_status=status.HTTP_500_INTERNAL_SERVER_ERROR) @api_view(["POST"]) @throttle_classes([ScopedRateThrottle]) @@ -48,7 +43,7 @@ def sami_run(request): resp = run_sami(req) data = resp.model_dump() - # Inject share URL (signed, expiring) + # inject share URL (signed) rid = data.get("run_id") if rid: meta = { @@ -59,6 +54,12 @@ def sami_run(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) 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) diff --git a/pxy_sites/api/views.py b/pxy_sites/api/views.py index ad4016a..a9697cc 100644 --- a/pxy_sites/api/views.py +++ b/pxy_sites/api/views.py @@ -25,7 +25,7 @@ from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.exceptions import ValidationError as DRFValidationError 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 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_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__) # -------- uniform error envelope helpers -------- @@ -59,25 +62,16 @@ def _pyify(o): return o.tolist() 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 -------- class SitesHealth(APIView): authentication_classes = [] - throttle_classes = [ScopedRateThrottle] # πŸ‘ˆ enable throttling + throttle_classes = [ScopedRateThrottle] throttle_scope = "sites_health" def get(self, request, *args, **kwargs): return Response({"ok": True, "app": "pxy_sites"}) class SiteSearchView(APIView): - # DRF ScopedRateThrottle is active via project settings; scope name here: throttle_scope = "sites_search" def post(self, request, *args, **kwargs): @@ -86,11 +80,9 @@ class SiteSearchView(APIView): try: req = SiteSearchRequest(**(request.data or {})) 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) - # 2) Run scoring (catch provider/upstream failures -> 502 envelope) + # 2) Run scoring try: resp: SiteSearchResponse = run_site_search(req) except Exception as e: @@ -104,13 +96,21 @@ class SiteSearchView(APIView): data = resp.model_dump() - # 3) Build absolute URLs (proxy-friendly) - base = _build_base_url(request) + # 3) Public base + absolutize any URLs returned by the scorer + base = public_base(request) sid = data.get("search_id") - if sid: - data["share_url"] = mint_sites_share_url(sid, request=request) + # ensure top-level map URLs & share are absolute + 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 _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}"