|
| 1 | +from urllib.parse import parse_qs |
| 2 | + |
1 | 3 | from django.contrib import messages |
2 | | -from django.http import HttpRequest, HttpResponseRedirect |
| 4 | +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect |
3 | 5 | from django.http.response import HttpResponseBase |
4 | 6 | from django.urls import reverse |
5 | 7 | from django.utils.translation import gettext_lazy as _ |
6 | 8 |
|
| 9 | +from sentry import features |
7 | 10 | from sentry.identity.pipeline import IdentityPipeline |
8 | 11 | from sentry.integrations.pipeline import IntegrationPipeline |
9 | 12 | from sentry.integrations.types import IntegrationProviderSlug |
10 | 13 | from sentry.organizations.absolute_url import generate_organization_url |
11 | 14 | from sentry.utils.http import absolute_uri, create_redirect_url |
| 15 | +from sentry.utils.json import dumps_htmlsafe |
12 | 16 | from sentry.web.frontend.base import BaseView, all_silo_view |
13 | 17 |
|
14 | 18 | # The request doesn't contain the pipeline type (pipeline information is stored |
15 | 19 | # in redis keyed by the pipeline name), so we try to construct multiple pipelines |
16 | 20 | # and use whichever one works. |
17 | 21 | PIPELINE_CLASSES = (IntegrationPipeline, IdentityPipeline) |
18 | 22 |
|
| 23 | +TRAMPOLINE_HTML = """\ |
| 24 | +<!DOCTYPE html> |
| 25 | +<html> |
| 26 | +<head><meta charset="utf-8"></head> |
| 27 | +<body |
| 28 | + style="margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh; |
| 29 | + font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; |
| 30 | + flex-direction:column;padding:2rem"> |
| 31 | +<script type="module"> |
| 32 | + const data = {data_json}; |
| 33 | + if (window.opener) {{ |
| 34 | + window.opener.postMessage(data, {origin}); |
| 35 | + window.close(); |
| 36 | + }} else {{ |
| 37 | + document.getElementById("fallback").style.display = "flex"; |
| 38 | + }} |
| 39 | +</script> |
| 40 | +<div id="fallback" style="display:none;flex-direction:column;align-items:center;gap:1.5rem;max-width:600px"> |
| 41 | + <p style="font-size:1.1rem;margin:0">Unable to continue. Please restart the flow.</p> |
| 42 | +</div> |
| 43 | +</body> |
| 44 | +</html>""" |
| 45 | + |
| 46 | + |
| 47 | +def _render_trampoline(request: HttpRequest, pipeline: object) -> HttpResponse: |
| 48 | + """Render a minimal page that posts callback params back to the opener.""" |
| 49 | + params: dict[str, str] = {"source": "sentry-pipeline"} |
| 50 | + for key, values in parse_qs(request.META.get("QUERY_STRING", "")).items(): |
| 51 | + if values: |
| 52 | + params[key] = values[0] |
| 53 | + |
| 54 | + data_json = dumps_htmlsafe(params) |
| 55 | + |
| 56 | + # In multi-region the opener may be on a different origin (e.g. |
| 57 | + # org-slug.sentry.io) than the trampoline (sentry.io/extensions/...), |
| 58 | + # so we need the org-specific URL. In single-region document.origin works. |
| 59 | + if features.has("system:multi-region"): |
| 60 | + org = getattr(pipeline, "organization", None) |
| 61 | + origin = dumps_htmlsafe(generate_organization_url(org.slug if org else "")) |
| 62 | + else: |
| 63 | + origin = "document.origin" |
| 64 | + |
| 65 | + return HttpResponse( |
| 66 | + TRAMPOLINE_HTML.format(data_json=str(data_json), origin=str(origin)), |
| 67 | + content_type="text/html", |
| 68 | + ) |
| 69 | + |
19 | 70 |
|
20 | 71 | @all_silo_view |
21 | 72 | class PipelineAdvancerView(BaseView): |
22 | | - """Gets the current pipeline from the request and executes the current step.""" |
| 73 | + """ |
| 74 | + Gets the current pipeline from the request and executes the current step. |
| 75 | +
|
| 76 | + External services (e.g. GitHub OAuth) redirect back to this view after the |
| 77 | + user completes an action. For legacy template-driven pipelines this view |
| 78 | + processes the callback server-side via pipeline.current_step(). |
| 79 | +
|
| 80 | + For API-driven pipelines (is_api_mode) this view does NOT process the |
| 81 | + callback. Instead it renders a lightweight trampoline page that relays the |
| 82 | + callback URL query params (code, state, installation_id, etc.) back to the |
| 83 | + opener window via postMessage and closes itself. The frontend is |
| 84 | + responsible for POSTing those params to the pipeline API endpoint to |
| 85 | + advance the pipeline. |
| 86 | + """ |
23 | 87 |
|
24 | 88 | auth_required = False |
25 | 89 |
|
@@ -50,6 +114,12 @@ def handle(self, request: HttpRequest, provider_id: str) -> HttpResponseBase: |
50 | 114 | messages.add_message(request, messages.ERROR, _("Invalid request.")) |
51 | 115 | return self.redirect("/") |
52 | 116 |
|
| 117 | + # If the pipeline was initiated via the API, render a trampoline page |
| 118 | + # that relays the callback params back to the opener window via |
| 119 | + # postMessage instead of processing the callback server-side. |
| 120 | + if pipeline.is_api_mode: |
| 121 | + return _render_trampoline(request, pipeline) |
| 122 | + |
53 | 123 | subdomain = pipeline.fetch_state("subdomain") |
54 | 124 | if subdomain is not None and request.subdomain != subdomain: |
55 | 125 | url_prefix = generate_organization_url(subdomain) |
|
0 commit comments