Skip to content

Commit 3e3fe08

Browse files
authored
Allow setting multiple public URLs (baserow#4434)
* Allow settings multiple public URLs * Fixed feedback
1 parent 64b9232 commit 3e3fe08

File tree

11 files changed

+193
-11
lines changed

11 files changed

+193
-11
lines changed

Caddyfile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@
1212
}
1313

1414
@is_baserow_tool {
15-
expression "{$BASEROW_PUBLIC_URL}".contains({http.request.host})
15+
expression `
16+
"{$BASEROW_PUBLIC_URL}".contains({http.request.host}) ||
17+
"{$BASEROW_EXTRA_PUBLIC_URLS}".split(",")
18+
.filter(u, u != "" && u.contains({http.request.host}))
19+
.size() > 0
20+
`
1621
}
1722

1823
handle @is_baserow_tool {

backend/src/baserow/config/settings/base.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,41 @@ def __setitem__(self, key, value):
777777
if PRIVATE_BACKEND_HOSTNAME:
778778
ALLOWED_HOSTS.append(PRIVATE_BACKEND_HOSTNAME)
779779

780+
# Parse BASEROW_EXTRA_PUBLIC_URLS - comma-separated list of additional public URLs
781+
# where Baserow will be accessible. It's the same as the `BASEROW_PUBLIC_URL`, the
782+
# only difference is that the `BASEROW_PUBLIC_URL` is used in emails.
783+
BASEROW_EXTRA_PUBLIC_URLS = os.getenv("BASEROW_EXTRA_PUBLIC_URLS", "")
784+
EXTRA_PUBLIC_BACKEND_HOSTNAMES = []
785+
EXTRA_PUBLIC_WEB_FRONTEND_HOSTNAMES = []
786+
787+
if BASEROW_EXTRA_PUBLIC_URLS:
788+
extra_urls = [
789+
url.strip() for url in BASEROW_EXTRA_PUBLIC_URLS.split(",") if url.strip()
790+
]
791+
792+
for url in extra_urls:
793+
# Validate URL format - must start with http:// or https://
794+
if not url.startswith(("http://", "https://")):
795+
print(
796+
f"WARNING: BASEROW_EXTRA_PUBLIC_URLS contains invalid URL '{url}'. "
797+
"URLs must start with http:// or https://. Skipping."
798+
)
799+
continue
800+
801+
parsed_url = urlparse(url)
802+
hostname = parsed_url.hostname
803+
804+
if not hostname:
805+
print(f"WARNING: URL '{url}' has no hostname. Skipping.")
806+
continue
807+
808+
if hostname not in ALLOWED_HOSTS:
809+
ALLOWED_HOSTS.append(hostname)
810+
if hostname not in EXTRA_PUBLIC_BACKEND_HOSTNAMES:
811+
EXTRA_PUBLIC_BACKEND_HOSTNAMES.append(hostname)
812+
if hostname not in EXTRA_PUBLIC_WEB_FRONTEND_HOSTNAMES:
813+
EXTRA_PUBLIC_WEB_FRONTEND_HOSTNAMES.append(hostname)
814+
780815
FROM_EMAIL = os.getenv("FROM_EMAIL", "no-reply@localhost")
781816
RESET_PASSWORD_TOKEN_MAX_AGE = 60 * 60 * 48 # 48 hours
782817
CHANGE_EMAIL_TOKEN_MAX_AGE = 60 * 60 * 12 # 12 hours

backend/src/baserow/contrib/builder/api/domains/views.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -389,10 +389,14 @@ def get(self, request):
389389
# certificate for the one domain, and ask for the rest of the domains.
390390
# Because the backend and web-frontend hostname are not builder domains,
391391
# we must add these as accepted domains.
392-
allowed_domain = [
393-
settings.PUBLIC_BACKEND_HOSTNAME,
394-
settings.PUBLIC_WEB_FRONTEND_HOSTNAME,
395-
]
392+
allowed_domain = set(
393+
[
394+
settings.PUBLIC_BACKEND_HOSTNAME,
395+
settings.PUBLIC_WEB_FRONTEND_HOSTNAME,
396+
]
397+
+ settings.EXTRA_PUBLIC_BACKEND_HOSTNAMES
398+
+ settings.EXTRA_PUBLIC_WEB_FRONTEND_HOSTNAMES
399+
)
396400

397401
if domain_name in allowed_domain:
398402
return Response(None, status=200)

backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,40 @@ def test_ask_public_builder_domain_exists_with_public_backend_and_web_frontend_d
628628
assert response.status_code == 200
629629

630630

631+
@pytest.mark.django_db
632+
@override_settings(
633+
PUBLIC_BACKEND_HOSTNAME="backend.localhost",
634+
PUBLIC_WEB_FRONTEND_HOSTNAME="web-frontend.localhost",
635+
EXTRA_PUBLIC_BACKEND_HOSTNAMES=["extra1.localhost", "extra2.localhost"],
636+
EXTRA_PUBLIC_WEB_FRONTEND_HOSTNAMES=["extra1.localhost", "extra2.localhost"],
637+
)
638+
def test_ask_public_builder_domain_exists_with_extra_public_urls(api_client):
639+
# Should reject unknown domain
640+
url = reverse("api:builder:domains:ask_exists") + "?domain=unknown.localhost"
641+
response = api_client.get(url)
642+
assert response.status_code == 404
643+
644+
# Should accept main backend hostname
645+
url = reverse("api:builder:domains:ask_exists") + "?domain=backend.localhost"
646+
response = api_client.get(url)
647+
assert response.status_code == 200
648+
649+
# Should accept main web-frontend hostname
650+
url = reverse("api:builder:domains:ask_exists") + "?domain=web-frontend.localhost"
651+
response = api_client.get(url)
652+
assert response.status_code == 200
653+
654+
# Should accept first extra hostname
655+
url = reverse("api:builder:domains:ask_exists") + "?domain=extra1.localhost"
656+
response = api_client.get(url)
657+
assert response.status_code == 200
658+
659+
# Should accept second extra hostname
660+
url = reverse("api:builder:domains:ask_exists") + "?domain=extra2.localhost"
661+
response = api_client.get(url)
662+
assert response.status_code == 200
663+
664+
631665
@pytest.mark.django_db
632666
@patch("baserow.contrib.builder.api.domains.public_views.BuilderDispatchContext")
633667
@patch(
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"type": "feature",
3+
"message": "Allow setting multiple BASEROW_PUBLIC_URL #2593",
4+
"domain": "core",
5+
"issue_number": 2593,
6+
"issue_origin": "github",
7+
"bullet_points": [],
8+
"created_at": "2025-12-12"
9+
}

docker-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ x-backend-variables:
3232
# If you manually change this line make sure you also change the duplicate line in
3333
# the web-frontend service.
3434
BASEROW_PUBLIC_URL: ${BASEROW_PUBLIC_URL-http://localhost}
35+
BASEROW_EXTRA_PUBLIC_URLS:
3536

3637
# Set these if you want to use an external postgres instead of the db service below.
3738
DATABASE_USER: ${DATABASE_USER:-baserow}
@@ -283,6 +284,7 @@ services:
283284
restart: unless-stopped
284285
environment:
285286
BASEROW_PUBLIC_URL: ${BASEROW_PUBLIC_URL-http://localhost}
287+
BASEROW_EXTRA_PUBLIC_URLS:
286288
PRIVATE_BACKEND_URL: ${PRIVATE_BACKEND_URL:-http://backend:8000}
287289
PUBLIC_BACKEND_URL:
288290
PUBLIC_WEB_FRONTEND_URL:

docs/installation/configuration.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,12 @@ The installation methods referred to in the variable descriptions are:
2323
### Access Configuration
2424
| Name | Description | Defaults |
2525
|------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|
26-
| BASEROW\_PUBLIC\_URL | The public URL or IP that will be used to access baserow. Always should start with http:// https:// even if accessing via an IP address. If you are accessing Baserow over a non-standard (80) http port then make sure you append :YOUR\_PORT to this variable.<br><br>Setting this will override PUBLIC\_BACKEND\_URL and PUBLIC\_WEB\_FRONTEND\_URL with BASEROW\_PUBLIC\_URL’s value.<br>Set to empty to disable default of http://localhost in the compose to instead set PUBLIC\_X\_URLs. | http://localhost |
27-
| BASEROW\_CADDY\_ADDRESSES | **Not supported by standalone images.** A comma separated list of supported Caddy addresses( [https://caddyserver.com/docs/caddyfile/concepts#addresses](https://caddyserver.com/docs/caddyfile/concepts#addresses)). If a https:// url is provided the Caddy reverse proxy will attempt to [automatically setup HTTPS](https://caddyserver.com/docs/automatic-https) with lets encrypt for you. If you wish your Baserow to still be accessible on localhost and you set this value away from the default of :80 ensure you append “,[http://localhost](http://localhost/)| :80 |
26+
| BASEROW\_PUBLIC\_URL | The public URL or IP that will be used to access the Baserow tool. Always should start with http:// https:// even if accessing via an IP address. If you are accessing Baserow over a non-standard (80) http port then make sure you append :YOUR\_PORT to this variable.<br><br>Setting this will override PUBLIC\_BACKEND\_URL and PUBLIC\_WEB\_FRONTEND\_URL with BASEROW\_PUBLIC\_URL’s value.<br>Set to empty to disable default of http://localhost in the compose to instead set PUBLIC\_X\_URLs. Note that other URL will be treated as a published application builder domains. | http://localhost |
27+
| BASEROW\_EXTRA\_PUBLIC\_URLS | An optional comma-separated list of additional public URLs where Baserow tool will be accessible. These URLs will have the same behavior as BASEROW\_PUBLIC\_URL (not treated as published applications). The difference is that `BASEROW\_PUBLIC\_URL` is used in email communication. This is useful when running Baserow behind multiple domain names or when using different URLs for different network access (e.g., both public and private). Example: `http://app.example.com,https://baserow.company.local`. | |
28+
| BASEROW\_CADDY\_ADDRESSES | **Not supported by standalone images.** A comma separated list of supported Caddy addresses( [https://caddyserver.com/docs/caddyfile/concepts#addresses](https://caddyserver.com/docs/caddyfile/concepts#addresses)). If a https:// url is provided the Caddy reverse proxy will attempt to [automatically setup HTTPS](https://caddyserver.com/docs/automatic-https) with lets encrypt for you. If you wish your Baserow to still be accessible on localhost and you set this value away from the default of :80 ensure you append “,[http://localhost](http://localhost/)| :80 |
2829
| PUBLIC\_BACKEND\_URL | Please use BASEROW\_PUBLIC\_URL unless you are using the standalone baserow/backend or baserow/web-frontend images. The publicly accessible URL of the backend. Should include the port if non-standard. Ensure BASEROW\_PUBLIC\_URL is set to an empty value to use this variable in the compose setup. | $BASEROW\_PUBLIC\_URL, http://localhost:8000/ in the standalone images. |
2930
| PUBLIC\_WEB\_FRONTEND\_URL | Please use BASEROW\_PUBLIC\_URL unless you are using the standalone baserow/backend or baserow/web-frontend images. The publicly accessible URL of the web-frontend. Should include the port if non-standard. Ensure BASEROW\_PUBLIC\_URL is set to an empty value to use this variable in the compose setup. | $BASEROW\_PUBLIC\_URL, http://localhost:3000/ in the standalone images. |
30-
| BASEROW\_EMBEDDED\_SHARE\_URL | Optional URL for public sharing and email links that can be used if Baserow is used inside an iframe on a different URL.|$PUBLIC\_WEB\_FRONTEND\_URL|
31+
| BASEROW\_EMBEDDED\_SHARE\_URL | Optional URL for public sharing and email links that can be used if Baserow is used inside an iframe on a different URL. | $PUBLIC\_WEB\_FRONTEND\_URL |
3132
| WEB\_FRONTEND\_PORT | The HTTP port that is being used to access Baserow using. Only used by the docker-compose files. | Only used by the docker-compose.yml files, defaults to 80 but prior to 1.9 defaulted to 3000. |
3233
| BASEROW\_EXTRA\_ALLOWED\_HOSTS | An optional comma separated list of hostnames which will be added to the Baserow's Django backend [ALLOWED_HOSTS](https://docs.djangoproject.com/en/4.0/ref/settings/#allowed-hosts) setting. In most situations you will not need to set this as the hostnames from BASEROW\_PUBLIC\_URL or PUBLIC\_BACKEND\_URL will be added to the ALLOWED_HOSTS automatically. This is only needed if you need to allow additional different hosts to be able to access your Baserow. | |
3334
| PRIVATE\_BACKEND\_URL | **Only change this with standalone images.** This is the URL used when the web-frontend server directly queries the backend itself when doing server side rendering. As such not only the browser, but also<br>the web-frontend server should be able to make HTTP requests to the backend. The web-frontend nuxt server might not have access to the \`PUBLIC\_BACKEND\_URL\` or there could be a more direct route, (e.g. from container to container instead of via the internet). For example if the web-frontend and backend were containers on the same docker network this could be set to http://backend:8000. | |

web-frontend/modules/builder/plugins/router.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,14 @@ export function createRouter(ssrContext, config) {
3131
runtimeConfig.public.PUBLIC_WEB_FRONTEND_URL
3232
).hostname
3333
const requestHostname = new URL(`http://${req.headers.host}`).hostname
34+
const extraPublicHostnames =
35+
runtimeConfig.public.EXTRA_PUBLIC_WEB_FRONTEND_HOSTNAMES || []
3436

35-
// We allow published routes only if the builder feature flag is on
36-
isWebFrontendHostname = frontendHostname === requestHostname
37+
// Check if request hostname matches main hostname or any extra hostname so we know
38+
// whether the tool or a published application must be served.
39+
isWebFrontendHostname =
40+
frontendHostname === requestHostname ||
41+
extraPublicHostnames.includes(requestHostname)
3742

3843
// Send the variable to the frontend using the `__NUXT__` property
3944
ssrContext.nuxt.isWebFrontendHostname = isWebFrontendHostname

web-frontend/modules/core/module.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import it from './locales/it.json'
1313
import pl from './locales/pl.json'
1414
import ko from './locales/ko.json'
1515
import { setDefaultResultOrder } from 'dns'
16+
import { parseHostnamesFromUrls } from './utils/url'
1617
const { readFileSync } = require('fs')
1718

1819
export default function CoreModule(options) {
@@ -76,6 +77,9 @@ export default function CoreModule(options) {
7677
process.env.PUBLIC_BACKEND_URL ?? 'http://localhost:8000',
7778
PUBLIC_WEB_FRONTEND_URL:
7879
process.env.PUBLIC_WEB_FRONTEND_URL ?? 'http://localhost:3000',
80+
EXTRA_PUBLIC_WEB_FRONTEND_HOSTNAMES: parseHostnamesFromUrls(
81+
process.env.BASEROW_EXTRA_PUBLIC_URLS ?? ''
82+
),
7983
MEDIA_URL: process.env.MEDIA_URL ?? 'http://localhost:4000/media/',
8084
INITIAL_TABLE_DATA_LIMIT: process.env.INITIAL_TABLE_DATA_LIMIT ?? null,
8185
DOWNLOAD_FILE_VIA_XHR: process.env.DOWNLOAD_FILE_VIA_XHR ?? '0',

web-frontend/modules/core/utils/url.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,26 @@ export function ensureUrlProtocol(value) {
3333
}
3434
return value
3535
}
36+
37+
/**
38+
* Parses a comma-separated list of URLs and extracts their hostnames.
39+
* @param {string} urlsString - Comma-separated list of URLs
40+
* @returns {string[]} Array of hostnames extracted from valid URLs
41+
*/
42+
export function parseHostnamesFromUrls(urlsString) {
43+
if (!urlsString) return []
44+
45+
return urlsString
46+
.split(',')
47+
.map((url) => url.trim())
48+
.filter((url) => url !== '')
49+
.map((url) => {
50+
try {
51+
return new URL(url).hostname
52+
} catch (e) {
53+
console.warn(`Invalid URL in BASEROW_EXTRA_PUBLIC_URLS: ${url}`)
54+
return null
55+
}
56+
})
57+
.filter((hostname) => hostname !== null)
58+
}

0 commit comments

Comments
 (0)