-
Notifications
You must be signed in to change notification settings - Fork 171
Expand file tree
/
Copy pathurls.go
More file actions
478 lines (416 loc) · 19.5 KB
/
urls.go
File metadata and controls
478 lines (416 loc) · 19.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
package admin
import (
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/rilldata/rill/admin/pkg/urlutil"
)
// URLs centralizes parsing and formatting of URLs for the admin service.
//
// There are several complexities around URL handling in Rill:
// 1. The frontend may run on a different host than the admin service (e.g. ui.rilldata.com vs. admin.rilldata.com).
// 2. We support custom domains for specific orgs (e.g. analytics.mycompany.com instead of ui.rilldata.com/mycompany).
// 3. The admin service sends transactional emails that link to the frontend, such as project invites.
// 4. The admin service is also responsible for sending transactional emails on behalf of the runtime, which also link to the frontend, such as for alerts and reports.
// 5. We need to ensure correct redirects and callbacks for the auth service (on auth.rilldata.com) and Github.
// These services have fixed callback URLs on the admin service's primary external URL, which complicates custom domain handling.
//
// For orgs with a custom domain configured (using the CLI command `rill sudo org set-custom-domain`),
// we require the admin service and frontend to be reachable on the custom domain using the following load balancer rules:
// 1. The admin service must be reachable at the `/api` path prefix on the custom domain.
// The `/api` prefix should be removed by the load balancer before proxying to the admin service.
// 2. The frontend must be reachable at all other paths on the custom domain.
type URLs struct {
external string // The primary external URL for the admin service (with scheme).
frontend string // The primary frontend URL for the admin service (with scheme).
custom string // Custom domain for the current org. Can optionally be set with WithCustomDomain.
https bool // True if HTTPS should be used.
}
// NewURLs creates a new URLs. The provided URLs should include the scheme, host, optional port, and optional path prefix.
// The provided URLs should be the primary external and frontend URL for the Rill service. The returned *URLs will rewrite them as needed for custom domains.
func NewURLs(externalURL, frontendURL string) (*URLs, error) {
eu, err := url.Parse(externalURL)
if err != nil {
return nil, fmt.Errorf("failed to parse external URL: %w", err)
}
_, err = url.Parse(frontendURL)
if err != nil {
return nil, fmt.Errorf("failed to parse frontend URL: %w", err)
}
return &URLs{
external: externalURL,
frontend: frontendURL,
https: eu.Scheme == "https",
}, nil
}
// WithCustomDomain returns a copy that generates URLs for the provided custom domain (as described in the type doc).
// The result automatically generates correct URLs also for the few endpoints that must always use the non-custom external URL (such as AuthLogin).
func (u *URLs) WithCustomDomain(domain string) *URLs {
if u.custom != "" {
panic(fmt.Errorf("nested calls to WithCustomDomain are not allowed"))
}
if domain == "" {
return u
}
custom := &url.URL{
Scheme: "https",
Host: domain,
}
if !u.https {
custom.Scheme = "http"
}
return &URLs{
external: u.external,
frontend: u.frontend,
custom: custom.String(),
https: u.https,
}
}
// IsSafeRedirectURL reports whether redirect is safe to redirect to after an auth flow.
// A redirect is safe when it is:
// - empty (caller defaults to the frontend URL)
// - a relative path (no scheme, no host); protocol-relative "//evil.com" and
// scheme-only "javascript:" / "data:" forms are rejected
// - an absolute URL whose host matches the primary external URL host, the primary
// frontend URL host, or the optionally supplied additionalHost (e.g. the custom
// domain of the current request)
func (u *URLs) IsSafeRedirectURL(redirect, additionalHost string) bool {
if redirect == "" {
return true
}
parsed, err := url.Parse(redirect)
if err != nil {
return false
}
// No host: safe only when there is also no scheme.
// This rejects javascript:, data:, mailto:, and //evil.com forms.
if parsed.Host == "" {
return parsed.Scheme == ""
}
// Absolute URL: host must match a trusted host.
externalURL, err := url.Parse(u.external)
if err == nil && strings.EqualFold(parsed.Host, externalURL.Host) {
return true
}
frontendURL, err := url.Parse(u.frontend)
if err == nil && strings.EqualFold(parsed.Host, frontendURL.Host) {
return true
}
return additionalHost != "" && strings.EqualFold(parsed.Host, additionalHost)
}
// WithCustomDomainFromRedirectURL attempts to infer a custom domain from a redirect URL.
// If it succeeds, it passes the custom domain to WithCustomDomain and returns the result.
// If it does not detect a custom domain in the redirect URL, or the redirect URL is invalid, it fails silently by returning itself unchanged.
func (u *URLs) WithCustomDomainFromRedirectURL(redirectURL string) *URLs {
u2, err := url.Parse(redirectURL)
if err != nil {
// Ignoring err as per docstring.
return u
}
// Skip if there's no host in the redirect URL.
if u2.Host == "" {
return u
}
// Skip if it points to the primary external or frontend URL.
if strings.HasPrefix(redirectURL, u.external) || strings.HasPrefix(redirectURL, u.frontend) {
return u
}
return u.WithCustomDomain(u2.Host)
}
// IsHTTPS returns true if the admin service's external URL uses HTTPS.
func (u *URLs) IsHTTPS() bool {
return u.https
}
// IsCustomDomain returns true if the given domain is a custom domain.
func (u *URLs) IsCustomDomain(domain string) bool {
externalURL, err := url.Parse(u.external)
if err != nil {
panic(fmt.Errorf("failed to parse external domain %q: %w", u.external, err))
}
return !strings.EqualFold(externalURL.Host, domain)
}
// External returns the external URL for the admin service.
func (u *URLs) External() string {
if u.custom != "" {
// As described in the type doc, the admin service is required to be reachable at the `/api` path prefix on a custom domain.
return urlutil.MustJoinURL(u.custom, "api")
}
return u.external
}
// Frontend returns the frontend URL for the admin service.
func (u *URLs) Frontend() string {
if u.custom != "" {
return u.custom
}
return u.frontend
}
// AuthLogin returns the URL that starts the redirects to the auth service for login.
func (u *URLs) AuthLogin(redirect string, customDomainFlow bool) string {
res := urlutil.MustJoinURL(u.external, "/auth/login") // NOTE: Always using the primary external URL.
q := map[string]string{}
if redirect != "" {
q["redirect"] = redirect
}
if customDomainFlow {
q["custom_domain_flow"] = "true"
}
if len(q) > 0 {
res = urlutil.MustWithQuery(res, q)
}
return res
}
// AuthLoginCallback returns the URL for the OAuth2 callback.
func (u *URLs) AuthLoginCallback() string {
return urlutil.MustJoinURL(u.external, "/auth/callback") // NOTE: Always using the primary external URL.
}
// AuthCustomDomainCallback returns the URL with state for custom domain callback
func (u *URLs) AuthCustomDomainCallback(state string) string {
res := urlutil.MustJoinURL(u.External(), "/auth/custom-domain-callback") // NOTE: Uses custom domain
if state != "" {
res = urlutil.MustWithQuery(res, map[string]string{"state": state})
}
return res
}
// AuthLogout returns the URL that starts the logout redirects.
func (u *URLs) AuthLogout() string {
return urlutil.MustJoinURL(u.External(), "/auth/logout") // NOTE: Uses custom domain if set to correctly clear cookies.
}
// AuthLogoutProvider returns the URL that starts the logout redirects against the external auth provider.
func (u *URLs) AuthLogoutProvider(redirect string) string {
res := urlutil.MustJoinURL(u.external, "/auth/logout/provider") // NOTE: Always using the primary external URL.
if redirect != "" {
res = urlutil.MustWithQuery(res, map[string]string{"redirect": redirect})
}
return res
}
// AuthLogoutCallback returns the URL for the logout callback.
func (u *URLs) AuthLogoutCallback() string {
return urlutil.MustJoinURL(u.external, "/auth/logout/callback") // NOTE: Always using the primary external URL.
}
// AuthWithToken returns a URL that sets the auth cookie to the provided token.
// Providing a redirect URL is optional.
func (u *URLs) AuthWithToken(tokenStr, redirect string) string {
res := urlutil.MustJoinURL(u.External(), "/auth/with-token") // NOTE: Uses custom domain if set.
res = urlutil.MustWithQuery(res, map[string]string{"token": tokenStr})
if redirect != "" {
res = urlutil.MustWithQuery(res, map[string]string{"redirect": redirect})
}
return res
}
// AuthVerifyEmailUI returns the frontend URL for the verify email page.
func (u *URLs) AuthVerifyEmailUI() string {
return urlutil.MustJoinURL(u.Frontend(), "/-/auth/verify-email")
}
// AuthVerifyDeviceUI returns the frontend URL for the device auth verification page.
func (u *URLs) AuthVerifyDeviceUI(query map[string]string) string {
return urlutil.MustWithQuery(urlutil.MustJoinURL(u.Frontend(), "/-/auth/device"), query)
}
// AuthCLISuccessUI returns the frontend URL to redirect to after successful CLI authentication.
func (u *URLs) AuthCLISuccessUI() string {
return urlutil.MustJoinURL(u.Frontend(), "/-/auth/cli/success")
}
// GithubConnect returns the URL that starts the Github connect redirects.
func (u *URLs) GithubConnect(remote string) string {
res := urlutil.MustJoinURL(u.external, "/github/connect") // NOTE: Always using the primary external URL.
if remote != "" {
res = urlutil.MustWithQuery(res, map[string]string{"remote": remote})
}
return res
}
// GithubAuth returns the URL that starts the Github auth redirects.
func (u *URLs) GithubAuth(remote string) string {
res := urlutil.MustJoinURL(u.external, "/github/auth/login") // NOTE: Always using the primary external URL.
if remote != "" {
res = urlutil.MustWithQuery(res, map[string]string{"remote": remote})
}
return res
}
// GithubAuthCallback returns the URL for the Github auth callback.
func (u *URLs) GithubAuthCallback() string {
return urlutil.MustJoinURL(u.external, "/github/auth/callback") // NOTE: Always using the primary external URL.
}
// GithubConnectUI returns the page in the Rill frontend for starting the Github connect flow.
func (u *URLs) GithubConnectUI(redirect string) string {
res := urlutil.MustJoinURL(u.frontend, "/-/github/connect") // NOTE: Always using the primary frontend URL.
if redirect != "" {
res = urlutil.MustWithQuery(res, map[string]string{"redirect": redirect})
}
return res
}
// GithubConnectRetryUI returns the page in the Rill frontend for retrying the Github connect flow.
func (u *URLs) GithubConnectRetryUI(remote, redirect string) string {
res := urlutil.MustJoinURL(u.frontend, "/-/github/connect/retry-install") // NOTE: Always using the primary frontend URL.
if remote != "" {
res = urlutil.MustWithQuery(res, map[string]string{"remote": remote})
}
if redirect != "" {
res = urlutil.MustWithQuery(res, map[string]string{"redirect": redirect})
}
return res
}
// GithubConnectRequestUI returns the page in the Rill frontend for requesting a Github connect.
func (u *URLs) GithubConnectRequestUI(remote string) string {
res := urlutil.MustJoinURL(u.frontend, "/-/github/connect/request") // NOTE: Always using the primary frontend URL.
if remote != "" {
res = urlutil.MustWithQuery(res, map[string]string{"remote": remote})
}
return res
}
// GithubConnectSuccessUI returns the page in the Rill frontend for a successful Github connect.
func (u *URLs) GithubConnectSuccessUI(autoclose bool) string {
res := urlutil.MustJoinURL(u.frontend, "/-/github/connect/success") // NOTE: Always using the primary frontend URL.
if autoclose {
res = urlutil.MustWithQuery(res, map[string]string{"autoclose": "true"})
}
return res
}
// GithubRetryAuthUI returns the page in the Rill frontend for retrying the Github auth flow.
func (u *URLs) GithubRetryAuthUI(remote, username, redirect string) string {
res := urlutil.MustJoinURL(u.frontend, "/-/github/connect/retry-auth") // NOTE: Always using the primary frontend URL.
if remote != "" {
res = urlutil.MustWithQuery(res, map[string]string{"remote": remote})
}
if username != "" {
res = urlutil.MustWithQuery(res, map[string]string{"githubUsername": username})
}
if redirect != "" {
res = urlutil.MustWithQuery(res, map[string]string{"redirect": redirect})
}
return res
}
// Asset creates a URL for downloading the user-uploaded asset with the given ID.
func (u *URLs) Asset(assetID string) string {
return urlutil.MustJoinURL(u.External(), "/v1/assets", assetID, "download")
}
// Embed creates a URL for embedding the frontend in an iframe.
func (u *URLs) Embed(query map[string]string) (string, error) {
return urlutil.WithQuery(urlutil.MustJoinURL(u.Frontend(), "-", "embed"), query)
}
// Organization returns the URL for an org in the frontend.
func (u *URLs) Organization(org string) string {
return urlutil.MustJoinURL(u.Frontend(), org)
}
// OrganizationInviteAccept returns the URL for accepting an organization invite.
func (u *URLs) OrganizationInviteAccept(org string) string {
redirect := urlutil.MustJoinURL(u.Frontend(), org) // NOTE: Redirecting to the custom domain if set.
return urlutil.MustWithQuery(urlutil.MustJoinURL(u.external, "/auth/signup"), map[string]string{"redirect": redirect}) // NOTE: Always using the primary external URL.
}
// Project returns the URL for a project in the frontend.
func (u *URLs) Project(org, project string) string {
return urlutil.MustJoinURL(u.Frontend(), org, project)
}
// ProjectInviteAccept returns the URL for accepting a project invite.
func (u *URLs) ProjectInviteAccept(org, project string) string {
redirect := urlutil.MustJoinURL(u.Frontend(), org, project) // NOTE: Redirecting to the custom domain if set.
return urlutil.MustWithQuery(urlutil.MustJoinURL(u.external, "/auth/signup"), map[string]string{"redirect": redirect}) // NOTE: Always using the primary external URL.
}
// MagicAuthTokenOpen returns the frontend URL for opening a magic auth token.
func (u *URLs) MagicAuthTokenOpen(org, project, token string) string {
return urlutil.MustJoinURL(u.Frontend(), org, project, "-", "share", token)
}
// ApproveProjectAccess returns the frontend URL for approving a project access request.
func (u *URLs) ApproveProjectAccess(org, project, id, role string) string {
return urlutil.MustWithQuery(urlutil.MustJoinURL(u.Frontend(), org, project, "-", "request-access", id, "approve"), map[string]string{"role": role})
}
// DenyProjectAccess returns the frontend URL for denying a project access request.
func (u *URLs) DenyProjectAccess(org, project, id string) string {
return urlutil.MustJoinURL(u.Frontend(), org, project, "-", "request-access", id, "deny")
}
// ReportOpen returns the URL for opening a report in the frontend.
func (u *URLs) ReportOpen(org, project, report, token string, executionTime time.Time) string {
if token == "" {
return urlutil.MustWithQuery(urlutil.MustJoinURL(u.Frontend(), org, project, "-", "reports", report, "open"), map[string]string{"execution_time": executionTime.UTC().Format(time.RFC3339)})
}
return urlutil.MustWithQuery(urlutil.MustJoinURL(u.Frontend(), org, project, "-", "reports", report, "open"), map[string]string{"execution_time": executionTime.UTC().Format(time.RFC3339), "token": token})
}
// ReportExport returns the URL for exporting a report in the frontend.
func (u *URLs) ReportExport(org, project, report, token string) string {
if token == "" {
return urlutil.MustJoinURL(u.Frontend(), org, project, "-", "reports", report, "export")
}
return urlutil.MustWithQuery(urlutil.MustJoinURL(u.Frontend(), org, project, "-", "reports", report, "export"), map[string]string{"token": token})
}
// ReportUnsubscribe returns the URL for unsubscribing from the report.
func (u *URLs) ReportUnsubscribe(org, project, report, token, email string) string {
queryParams := map[string]string{
"org": org,
"project": project,
"token": token,
}
if email != "" {
queryParams["email"] = email
}
// We always fetch org and project under `/<org>/<project>` path prefix, so here we use a separate path for unsubscribe reports.
// Unsub token only has access to unsub and not for org or project data.
return urlutil.MustWithQuery(urlutil.MustJoinURL(u.Frontend(), "-", "unsubscribe", "reports", report), queryParams)
}
// ReportEdit returns the URL for editing a report in the frontend.
func (u *URLs) ReportEdit(org, project, report string) string {
return urlutil.MustJoinURL(u.Frontend(), org, project, "-", "reports", report)
}
// AlertOpen returns the URL for opening an alert in the frontend.
func (u *URLs) AlertOpen(org, project, alert, token string) string {
if token != "" {
return urlutil.MustWithQuery(urlutil.MustJoinURL(u.Frontend(), org, project, "-", "alerts", alert, "open"), map[string]string{"token": token})
}
return urlutil.MustJoinURL(u.Frontend(), org, project, "-", "alerts", alert, "open")
}
// AlertEdit returns the URL for editing an alert in the frontend.
func (u *URLs) AlertEdit(org, project, alert string) string {
return urlutil.MustJoinURL(u.Frontend(), org, project, "-", "alerts", alert)
}
// AlertUnsubscribe returns the URL for unsubscribing from an alert.
func (u *URLs) AlertUnsubscribe(org, project, alert, token string) string {
if token != "" {
return urlutil.MustWithQuery(urlutil.MustJoinURL(u.Frontend(), org, project, "-", "alerts", alert, "unsubscribe"), map[string]string{"token": token})
}
return urlutil.MustJoinURL(u.Frontend(), org, project, "-", "alerts", alert, "unsubscribe")
}
// Billing returns the landing page url that optionally shows the upgrade modal.
func (u *URLs) Billing(org string, upgrade bool) string {
bu := urlutil.MustJoinURL(u.Frontend(), org, "-", "settings", "billing")
if upgrade {
return urlutil.MustWithQuery(bu, map[string]string{"upgrade": "true"})
}
return bu
}
// PaymentPortal returns the landing page url that redirects user to payment portal
// Since the payment link can expire it is generated in this landing page on demand.
func (u *URLs) PaymentPortal(org string) string {
return urlutil.MustJoinURL(u.Frontend(), org, "-", "settings", "billing", "payment")
}
// OAuthExternalResourceURL returns the external URL for OAuth 2.0 resource access.
// If a request is provided, it uses the request's Host header to construct the URL to make sure protected resource URLs origin matches with the resource URL being accessed by the client.
// This helps in cases, for example, where the MCP server url starts with api.rilldata.com instead of admin.rilldata.com.
func (u *URLs) OAuthExternalResourceURL(r *http.Request) string {
if r != nil {
scheme := "http"
if u.IsHTTPS() {
scheme = "https"
}
return fmt.Sprintf("%s://%s", scheme, r.Host)
}
return u.External()
}
// OAuthProtectedResourceMetadata returns the URL for the OAuth 2.0 Protected Resource Metadata endpoint.
// This endpoint is used by MCP clients to discover authorization server information.
func (u *URLs) OAuthProtectedResourceMetadata(r *http.Request) string {
return urlutil.MustJoinURL(u.OAuthExternalResourceURL(r), "/.well-known/oauth-protected-resource")
}
// OAuthRegister returns the URL for the OAuth 2.0 Dynamic Client Registration endpoint.
func (u *URLs) OAuthRegister() string {
return urlutil.MustJoinURL(u.External(), "/auth/oauth/register")
}
// OAuthAuthorize returns the URL for the OAuth 2.0 Authorization endpoint.
func (u *URLs) OAuthAuthorize() string {
return urlutil.MustJoinURL(u.External(), "/auth/oauth/authorize")
}
// OAuthToken returns the URL for the OAuth 2.0 Token endpoint.
func (u *URLs) OAuthToken() string {
return urlutil.MustJoinURL(u.External(), "/auth/oauth/token")
}
func (u *URLs) OAuthJWKS() string {
return urlutil.MustJoinURL(u.External(), "/.well-known/jwks.json")
}