#!/bin/sh
set -e
GEOLIBRE_SIDECAR_TOKEN="${GEOLIBRE_SIDECAR_TOKEN:-$(python -c 'import secrets; print(secrets.token_hex(16))')}"
export GEOLIBRE_SIDECAR_TOKEN
case "$GEOLIBRE_SIDECAR_TOKEN" in
"" | *[!A-Za-z0-9._-]*)
echo "GEOLIBRE_SIDECAR_TOKEN must be non-empty and contain only [A-Za-z0-9._-]" >&2
exit 1
;;
esac
python /usr/local/lib/geolibre/deployment_policy.py /usr/share/nginx/html/deployment.json
SIDECAR_DECISION=$(python /usr/local/lib/geolibre/sidecar_policy.py /usr/share/nginx/html/deployment.json)
case "$SIDECAR_DECISION" in
SIDECAR_START=0) SIDECAR_START=0 ;;
SIDECAR_START=1) SIDECAR_START=1 ;;
*) echo "ERROR: Invalid sidecar policy decision." >&2; exit 1 ;;
esac
AI_ENABLED=$(python -c 'import json; print(int(json.load(open("/usr/share/nginx/html/deployment.json")).get("ai", {}).get("enabled") is True))')
AI_PROXY_CONF=/etc/nginx/geolibre-ai-proxy.conf
if [ "$AI_ENABLED" = "1" ]; then
if [ -z "${GEOLIBRE_AI_URL:-}" ] || [ -z "${GEOLIBRE_AI_PROXY_URL:-}" ] || [ -z "${GEOLIBRE_AI_PROXY_TOKEN:-}" ]; then
echo "ERROR: GEOLIBRE_AI_URL, GEOLIBRE_AI_PROXY_URL, and GEOLIBRE_AI_PROXY_TOKEN must be set together." >&2
exit 1
fi
case "$GEOLIBRE_AI_URL" in
/ai|/ai/) GEOLIBRE_AI_URL=/ai ;;
*)
echo "ERROR: Docker GEOLIBRE_AI_URL must be the same-origin path /ai." >&2
exit 1
;;
esac
case "$GEOLIBRE_AI_PROXY_TOKEN" in
"" | *[!A-Za-z0-9._-]*)
echo "ERROR: GEOLIBRE_AI_PROXY_TOKEN must contain only [A-Za-z0-9._-]." >&2
exit 1
;;
esac
export GEOLIBRE_AI_URL
export GEOLIBRE_AI_MODEL="${GEOLIBRE_AI_MODEL:-openai/gpt-5.6-luna}"
export GEOLIBRE_AI_PROXY_URL GEOLIBRE_AI_PROXY_TOKEN
python -c '
import ipaddress
import os
from urllib.parse import urlsplit
upstream = os.environ["GEOLIBRE_AI_PROXY_URL"].rstrip("/")
parsed = urlsplit(upstream)
if (
parsed.scheme != "https"
or not parsed.hostname
or parsed.username
or parsed.password
or parsed.path not in ("", "/")
or parsed.query
or parsed.fragment
):
raise SystemExit(
"ERROR: GEOLIBRE_AI_PROXY_URL must be an HTTPS origin without credentials, path, query, or fragment."
)
host = parsed.hostname
# urlsplit strips the brackets from an IPv6 literal; Host and SNI need them back
# or the generated header is ambiguous and nginx rejects the config.
authority = f"[{host}]" if ":" in host else host
host_header = authority if parsed.port is None else f"{authority}:{parsed.port}"
# Behind a fronting TLS proxy $remote_addr is that proxy, which would collapse
# every user into a single rate-limit bucket upstream. Trust X-Forwarded-For
# only from explicitly listed proxy CIDRs; unset means trust nobody. Each entry
# is parsed as a network before it reaches the config, so nothing else can be
# smuggled into the generated directives.
trusted = []
for entry in os.environ.get("GEOLIBRE_TRUSTED_PROXIES", "").split(","):
entry = entry.strip()
if not entry:
continue
try:
trusted.append(str(ipaddress.ip_network(entry, strict=False)))
except ValueError:
raise SystemExit(
f"ERROR: GEOLIBRE_TRUSTED_PROXIES entry {entry!r} is not an IP address or CIDR."
)
real_ip = "".join(f" set_real_ip_from {entry};\n" for entry in trusted)
if real_ip:
real_ip += " real_ip_header X-Forwarded-For;\n real_ip_recursive on;\n"
token = os.environ["GEOLIBRE_AI_PROXY_TOKEN"]
config = f"""
location ^~ /ai/ {{
{real_ip}
proxy_pass {upstream}/;
proxy_http_version 1.1;
proxy_ssl_server_name on;
proxy_ssl_name {host};
proxy_set_header Host {host_header};
proxy_set_header Authorization "";
proxy_set_header Origin "";
proxy_set_header X-GeoLibre-Instance-Token "{token}";
proxy_set_header X-GeoLibre-Client-IP $remote_addr;
proxy_set_header X-Forwarded-For "";
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 600s;
}}
"""
open("/etc/nginx/geolibre-ai-proxy.conf", "w").write(config)
'
chmod 640 "$AI_PROXY_CONF"
echo "Authenticated AI proxy enabled at /ai."
else
cat > "$AI_PROXY_CONF" <<'EOF'
location ^~ /ai {
types {}
default_type application/json;
return 403 '{"detail":"AI disabled by deployment policy"}';
}
EOF
fi
python -c '
import base64
import json
import os
import re
import sys
# The env validators live next to the policy builder so GEOLIBRE_* variables and
# the matching deployment.json fields cannot disagree about what is valid.
sys.path.insert(0, "/usr/local/lib/geolibre")
from deployment_policy import (
builtin_services_hidden,
normalize_collab_url,
normalize_geolens_url,
normalize_share_url,
parse_capabilities_env,
parse_embed_origins,
read_services_file,
service_url,
)
deployment = {}
# Values published as bare globals rather than inside __GEOLIBRE_DEPLOYMENT_ENV__.
# That object is a GeoLibre convention (see deployment-env.ts); a plugin from
# outside this repo answers to its own contract, and some of them read a plain
# global. Keys here are literals from this file, never operator input, so they
# cannot inject anything into the generated script.
browser_globals = {}
if os.environ.get("GEOLIBRE_AI_URL"):
deployment["VITE_GEOLIBRE_AI_URL"] = os.environ["GEOLIBRE_AI_URL"]
deployment["VITE_GEOLIBRE_AI_MODEL"] = os.environ["GEOLIBRE_AI_MODEL"]
# Optional deployment service catalog. Read the mounted file on every startup;
# only the catalog fields are public, never the file path or other keys.
services_file = os.environ.get("GEOLIBRE_SERVICES_FILE", "")
if services_file:
deployment["VITE_GEOLIBRE_SERVICES"] = json.dumps(
{"services": read_services_file(services_file)}, separators=(",", ":"), allow_nan=False
)
# Optional hide of the built-in starter service library: with
# GEOLIBRE_BUILTIN_SERVICES=off the Browser and the service pickers show the
# configured catalog and personal entries only, so an entirely org-curated
# deployment never offers the built-in presets. Any other nonempty value fails
# the boot rather than silently keeping the built-ins (same discipline as
# GEOLIBRE_SERVICES_FILE).
if builtin_services_hidden(os.environ.get("GEOLIBRE_BUILTIN_SERVICES", "")):
deployment["VITE_GEOLIBRE_BUILTIN_SERVICES"] = "off"
# Optional Clerk sign-in gate. The publishable key is intentionally public and
# is all the browser needs; Clerk secrets never enter the image or runtime
# config. A publishable key is `pk_test_`/`pk_live_` + base64url of the Frontend
# API hostname with a trailing "$" delimiter, so check the whole shape now: the
# prefix rejects a secret key pasted into this variable (which would otherwise be
# published to every visitor in the runtime config), and decoding the hostname
# makes an invalid key fail at container startup instead of leaving a blank
# login page.
clerk_key = os.environ.get("GEOLIBRE_CLERK_PUBLISHABLE_KEY", "").strip()
if clerk_key:
if not clerk_key.startswith(("pk_test_", "pk_live_")):
raise SystemExit(
"ERROR: GEOLIBRE_CLERK_PUBLISHABLE_KEY must be a Clerk publishable key (pk_test_... or pk_live_...)."
)
try:
encoded = clerk_key.split("_", 2)[2]
encoded += "=" * (-len(encoded) % 4)
# validate=True so stray characters are an error rather than silently
# discarded, which would decode a malformed key into a plausible host.
clerk_fapi = base64.b64decode(encoded, altchars="-_", validate=True).decode()
except (IndexError, ValueError, UnicodeDecodeError) as error:
raise SystemExit("ERROR: GEOLIBRE_CLERK_PUBLISHABLE_KEY is invalid.") from error
if not clerk_fapi.endswith("$"):
raise SystemExit("ERROR: GEOLIBRE_CLERK_PUBLISHABLE_KEY is invalid.")
clerk_fapi = clerk_fapi[:-1]
if not re.fullmatch(r"[A-Za-z0-9.-]+", clerk_fapi) or "." not in clerk_fapi:
raise SystemExit("ERROR: GEOLIBRE_CLERK_PUBLISHABLE_KEY contains an invalid Frontend API host.")
deployment["VITE_GEOLIBRE_CLERK_PUBLISHABLE_KEY"] = clerk_key
# Optional waitlist screen, for a Clerk instance whose sign-up mode is
# "Waitlist": visitors request access and an admin approves each one from the
# Clerk Dashboard. Off by default, because on a "Restricted" (invite-only)
# instance the form would take submissions nobody can approve.
clerk_waitlist = os.environ.get("GEOLIBRE_CLERK_WAITLIST", "").strip().lower()
if clerk_waitlist in ("1", "true"):
# Refuse rather than ignore: an operator who set this expects visitors to be
# able to request access, and silently serving a public app instead would be
# the opposite of what they asked for.
if not clerk_key:
raise SystemExit(
"ERROR: GEOLIBRE_CLERK_WAITLIST needs GEOLIBRE_CLERK_PUBLISHABLE_KEY; the waitlist is part of the Clerk sign-in gate."
)
deployment["VITE_GEOLIBRE_CLERK_WAITLIST"] = "1"
elif clerk_waitlist not in ("", "0", "false"):
raise SystemExit("ERROR: GEOLIBRE_CLERK_WAITLIST must be 1/true or 0/false.")
# Optional Auth0 sign-in gate, the alternative to Clerk above. Both values are
# public by design (they end up in the runtime config every visitor downloads);
# an Auth0 client *secret* is not used by a single-page application and must
# never be passed here. The pair is validated together so a half configuration
# fails at container startup rather than silently serving an ungated app.
auth0_domain = os.environ.get("GEOLIBRE_AUTH0_DOMAIN", "").strip()
auth0_client_id = os.environ.get("GEOLIBRE_AUTH0_CLIENT_ID", "").strip()
if auth0_domain or auth0_client_id:
if clerk_key:
raise SystemExit(
"ERROR: configure either Clerk or Auth0, not both. Unset "
"GEOLIBRE_CLERK_PUBLISHABLE_KEY or the GEOLIBRE_AUTH0_* variables."
)
if not auth0_domain or not auth0_client_id:
raise SystemExit(
"ERROR: GEOLIBRE_AUTH0_DOMAIN and GEOLIBRE_AUTH0_CLIENT_ID must be set together."
)
# The dashboard shows the domain without a scheme, but "https://tenant..."
# is the natural thing to paste; the SDK builds its URLs by concatenation,
# so a scheme left in place yields https://https://... and a login that
# fails with no useful error. Normalize here and reject anything that is not
# a plain hostname (a port, credentials, a path).
auth0_host = re.sub(r"^https?://", "", auth0_domain, flags=re.IGNORECASE).split("/")[0].lower()
if not re.fullmatch(r"[a-z0-9.-]+", auth0_host) or "." not in auth0_host:
raise SystemExit(
"ERROR: GEOLIBRE_AUTH0_DOMAIN must be a tenant hostname such as example.us.auth0.com."
)
# Auth0 issues base62 client IDs, so anything else is a paste error.
if not re.fullmatch(r"[A-Za-z0-9_-]+", auth0_client_id):
raise SystemExit("ERROR: GEOLIBRE_AUTH0_CLIENT_ID is not a valid Auth0 client ID.")
deployment["VITE_GEOLIBRE_AUTH0_DOMAIN"] = auth0_host
deployment["VITE_GEOLIBRE_AUTH0_CLIENT_ID"] = auth0_client_id
# Origins allowed to drive a framed app over the embed postMessage API. Unset
# means the API stays off, so a public deployment can never be driven by the
# page that frames it. "*" allows any origin: private networks only.
origins = parse_embed_origins(os.environ.get("GEOLIBRE_EMBED_ORIGINS", ""))
if origins:
deployment["VITE_GEOLIBRE_EMBED_ORIGINS"] = ",".join(origins)
# The externally loaded NASA OPERA plugin can share the authenticated /ai route
# when the managed Worker exposes a search route. Operators using a separate
# news Worker can override this with its public HTTPS URL. Only the endpoint
# reaches the browser; the search keys remain Worker secrets.
#
# Either way the published value is the proxy *base* the plugin appends its own
# search path to -- /search for the GPT-native route, /tavily for the
# Tavily-only one -- which is why the same-origin fallback is "/ai" and not
# "/ai/search". That fallback needs no service_url() check: the shell block above
# already pins GEOLIBRE_AI_URL to exactly "/ai" and exits otherwise, and
# service_url() would in fact reject a bare path, having no scheme or netloc.
news_url = os.environ.get("GEOLIBRE_NASA_OPERA_NEWS_PROXY_ENDPOINT", "").strip()
if news_url:
# rstrip the trailing slash the way GEOLIBRE_AI_PROXY_URL is handled above. A
# base ending in "/" would become ".../search" with a doubled slash under a
# plain string join, and the plugin doing that join is out of this repo, so
# this is the only place that can rule it out. Stripping before service_url()
# keeps a slashes-only value an error rather than silently unsetting it.
news_endpoint = service_url(
"GEOLIBRE_NASA_OPERA_NEWS_PROXY_ENDPOINT",
news_url.rstrip("/"),
("https",),
("http",),
("localhost", "127.0.0.1", "::1"),
)
elif os.environ.get("GEOLIBRE_AI_URL"):
news_endpoint = os.environ["GEOLIBRE_AI_URL"]
else:
news_endpoint = ""
if news_endpoint:
# A TOP-LEVEL global, not a key in the deployment env object, because the
# reader is a plugin loaded from outside this repo and its contract is its
# own. geolibre-nasa-opera resolves this in src/lib/opera/news.ts as
# globalThis[GEOLIBRE_NASA_OPERA_NEWS_PROXY_ENDPOINT], falling back to its own
# build-time VITE_NEWS_PROXY_ENDPOINT; across the whole history of that repo it has
# never read window.__GEOLIBRE_DEPLOYMENT_ENV__ for this value. Publishing it
# only inside that object, as this block did before, therefore configured
# nothing: the plugin looked for a global that was not there, found nothing,
# and reported the news proxy as unconfigured.
#
# No apostrophes below: this whole program is one single-quoted `python -c`
# argument, and a literal apostrophe in a comment ends it just as surely as one
# in code (see the allowed_hosts note in service_url above).
browser_globals["GEOLIBRE_NASA_OPERA_NEWS_PROXY_ENDPOINT"] = news_endpoint
# Project sharing server. Unset means the public hosted service; "off" removes
# Share and the Project Gallery from the UI entirely.
share_url = os.environ.get("GEOLIBRE_SHARE_URL", "").strip()
if share_url:
deployment["VITE_GEOLIBRE_SHARE_URL"] = normalize_share_url(share_url)
# Display name for the app chrome (toolbar label, browser tab). Unset keeps
# "GeoLibre". It is rendered as text, never markup, and json.dump below escapes
# it for the generated script, so no further validation is needed here.
app_name = os.environ.get("GEOLIBRE_APP_NAME", "").strip()
if app_name:
deployment["VITE_GEOLIBRE_APP_NAME"] = app_name
# Live collaboration relay. Unset leaves collaboration dark.
collab_url = os.environ.get("GEOLIBRE_COLLAB_URL", "").strip()
if collab_url:
deployment["VITE_GEOLIBRE_COLLAB_URL"] = normalize_collab_url(collab_url)
# Default GeoLens catalog. Unset lets the plugin try the browser origin, which
# is the zero-config path when GeoLibre and GeoLens share a reverse proxy.
geolens_url = os.environ.get("GEOLIBRE_GEOLENS_URL", "").strip()
if geolens_url:
deployment["VITE_GEOLENS_DEFAULT_URL"] = normalize_geolens_url(geolens_url)
# Capabilities also ride in the runtime config, which loads synchronously, so an
# env-set grant survives a blocked or late deployment.json. "none" grants nothing.
capabilities_env = os.environ.get("GEOLIBRE_CAPABILITIES", "").strip()
if capabilities_env:
capabilities = parse_capabilities_env(capabilities_env)
deployment["VITE_GEOLIBRE_CAPABILITIES"] = ",".join(capabilities) or "none"
with open("/usr/share/nginx/html/geolibre-runtime-config.js", "w") as output:
output.write("window.__GEOLIBRE_DEPLOYMENT_ENV__ = ")
json.dump(deployment, output, separators=(",", ":"))
output.write(";\n")
for global_name, global_value in browser_globals.items():
output.write("window." + global_name + " = ")
json.dump(global_value, output)
output.write(";\n")
'
trim() {
printf '%s' "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'
}
if [ -n "$(trim "${GEOLIBRE_SHARE_URL:-}")" ]; then
SHARE_URL_LOG=$(trim "$GEOLIBRE_SHARE_URL")
case "$SHARE_URL_LOG" in
[oO][fF][fF]) echo "Project sharing disabled (GEOLIBRE_SHARE_URL=off)." ;;
*) echo "Project sharing server: $SHARE_URL_LOG" ;;
esac
fi
if [ -n "$(trim "${GEOLIBRE_COLLAB_URL:-}")" ]; then
echo "Collaboration relay: $(trim "$GEOLIBRE_COLLAB_URL")"
fi
if [ -n "$(trim "${GEOLIBRE_GEOLENS_URL:-}")" ]; then
GEOLENS_URL_LOG=$(trim "$GEOLIBRE_GEOLENS_URL")
case "$GEOLENS_URL_LOG" in
[oO][fF][fF]) echo "GeoLens disabled (GEOLIBRE_GEOLENS_URL=off)." ;;
*) echo "GeoLens server: $GEOLENS_URL_LOG" ;;
esac
fi
if [ -n "$(trim "${GEOLIBRE_NASA_OPERA_NEWS_PROXY_ENDPOINT:-}")" ]; then
echo "NASA OPERA news proxy: $(trim "$GEOLIBRE_NASA_OPERA_NEWS_PROXY_ENDPOINT")"
elif [ -n "${GEOLIBRE_AI_URL:-}" ]; then
echo "NASA OPERA news search routed through $GEOLIBRE_AI_URL (requires TAVILY_API_KEY on the Worker)."
fi
if [ -n "${GEOLIBRE_EMBED_ORIGINS:-}" ]; then
echo "Embed postMessage API enabled for: $GEOLIBRE_EMBED_ORIGINS"
fi
if [ "$(printf '%s' "$(trim "${GEOLIBRE_BUILTIN_SERVICES:-}")" | tr '[:upper:]' '[:lower:]')" = "off" ]; then
echo "Built-in starter services: hidden (GEOLIBRE_BUILTIN_SERVICES=off)"
fi
if [ -n "$(trim "${GEOLIBRE_CLERK_PUBLISHABLE_KEY:-}")" ]; then
case "$(trim "${GEOLIBRE_CLERK_WAITLIST:-}" | tr '[:upper:]' '[:lower:]')" in
1 | true) echo "Clerk sign-in gate enabled, with the waitlist screen." ;;
*) echo "Clerk sign-in gate enabled." ;;
esac
fi
if [ -n "$(trim "${GEOLIBRE_AUTH0_DOMAIN:-}")" ]; then
echo "Auth0 sign-in gate enabled for $(trim "$GEOLIBRE_AUTH0_DOMAIN" |
tr '[:upper:]' '[:lower:]' |
sed -e 's#^http://##' -e 's#^https://##' |
cut -d/ -f1)."
fi
python -c '
import json
import os
import re
import base64
from urllib.parse import urlsplit
token = os.environ["GEOLIBRE_SIDECAR_TOKEN"]
# A self-hosted relay has to be allowed in connect-src or the browser blocks its
# WebSocket: the directive has a bare "https:" (so any share host works) but no
# bare "wss:". Only the origin is inserted -- CSP source expressions do not take a
# path. The value comes from the deployment.json written (and validated) above, so
# a relay set only in a mounted GEOLIBRE_DEPLOYMENT_FILE is covered too.
with open("/usr/share/nginx/html/deployment.json", encoding="utf-8") as policy_file:
collab = json.load(policy_file).get("sharing", {}).get("collabUrl", "")
# Carries its own leading space so the header is byte-identical to the template
# when no relay is configured.
collab_src = ""
if collab:
parsed = urlsplit(collab)
origin = f"{parsed.scheme}://{parsed.netloc}"
# Re-checked here rather than trusting the validator: this string goes into a
# quoted nginx directive, so it must be a plain origin or nothing at all.
if not re.fullmatch(r"[A-Za-z]+://[A-Za-z0-9.\-:\[\]]+", origin):
raise SystemExit(f"ERROR: deployment.json sharing.collabUrl is not a plain origin: {collab!r}.")
collab_src = f" {origin}"
# Clerk loads its browser SDK from the Frontend API hostname encoded in the
# publishable key. Add only that exact hostname to script-src. The remaining
# documented Clerk requirements are fixed origins in the nginx template.
#
# The runtime-config block above already decoded and validated this same key
# (`set -e` means we never get here if it rejected one), but the decode is
# repeated with its own try/except rather than relying on that ordering: this is
# a separate `python -c` process, so an edit that reorders, extracts, or drops
# the earlier block would otherwise turn an invalid key into a raw traceback
# instead of the clean ERROR message.
clerk_src = ""
clerk_frame_src = ""
clerk_key = os.environ.get("GEOLIBRE_CLERK_PUBLISHABLE_KEY", "").strip()
if clerk_key:
if not clerk_key.startswith(("pk_test_", "pk_live_")):
raise SystemExit(
"ERROR: GEOLIBRE_CLERK_PUBLISHABLE_KEY must be a Clerk publishable key (pk_test_... or pk_live_...)."
)
try:
encoded = clerk_key.split("_", 2)[2]
encoded += "=" * (-len(encoded) % 4)
clerk_fapi = base64.b64decode(encoded, altchars="-_", validate=True).decode()
except (IndexError, ValueError, UnicodeDecodeError) as error:
raise SystemExit("ERROR: GEOLIBRE_CLERK_PUBLISHABLE_KEY is invalid.") from error
if not clerk_fapi.endswith("$"):
raise SystemExit("ERROR: GEOLIBRE_CLERK_PUBLISHABLE_KEY is invalid.")
clerk_fapi = clerk_fapi[:-1]
if not re.fullmatch(r"[A-Za-z0-9.-]+", clerk_fapi) or "." not in clerk_fapi:
raise SystemExit("ERROR: GEOLIBRE_CLERK_PUBLISHABLE_KEY contains an invalid Frontend API host.")
clerk_src = f" https://{clerk_fapi} https://challenges.cloudflare.com https://*.protect.clerk.com"
clerk_frame_src = " https://challenges.cloudflare.com https://*.protect.clerk.com"
# Auth0 needs no script-src entry -- its SDK is bundled into the app -- and its
# token endpoint is already covered by the bare `https:` in connect-src. What it
# does need is frame-src for the hidden silent-authentication iframe the SDK
# opens against the tenant to restore a session. Validated independently of the
# runtime-config block above for the same reason the Clerk decode is: this is a
# separate `python -c` process, so an edit that reorders or drops that block must
# not turn a bad value into a raw traceback here.
auth0_frame_src = ""
auth0_domain = os.environ.get("GEOLIBRE_AUTH0_DOMAIN", "").strip()
if auth0_domain:
auth0_host = re.sub(r"^https?://", "", auth0_domain, flags=re.IGNORECASE).split("/")[0].lower()
if not re.fullmatch(r"[a-z0-9.-]+", auth0_host) or "." not in auth0_host:
raise SystemExit(
"ERROR: GEOLIBRE_AUTH0_DOMAIN must be a tenant hostname such as example.us.auth0.com."
)
auth0_frame_src = f" https://{auth0_host}"
src = open("/etc/nginx/nginx.conf.template").read()
open("/etc/nginx/conf.d/default.conf", "w").write(
src.replace("__GEOLIBRE_SIDECAR_TOKEN__", token).replace(
"__GEOLIBRE_COLLAB_CONNECT_SRC__", collab_src
).replace(
"__GEOLIBRE_CLERK_SCRIPT_SRC__", clerk_src
).replace(
"__GEOLIBRE_CLERK_FRAME_SRC__", clerk_frame_src
).replace(
"__GEOLIBRE_AUTH0_FRAME_SRC__", auth0_frame_src
)
)
'
AUTH_CONF=/etc/nginx/geolibre-auth.conf
HTPASSWD=/etc/nginx/.htpasswd
if [ -n "${GEOLIBRE_AUTH_USER:-}" ] || [ -n "${GEOLIBRE_AUTH_PASSWORD:-}" ]; then
if [ -z "${GEOLIBRE_AUTH_USER:-}" ] || [ -z "${GEOLIBRE_AUTH_PASSWORD:-}" ]; then
echo "ERROR: GEOLIBRE_AUTH_USER and GEOLIBRE_AUTH_PASSWORD must be set together." >&2
exit 1
fi
case "$GEOLIBRE_AUTH_USER" in
*:*)
echo "ERROR: GEOLIBRE_AUTH_USER must not contain ':' (htpasswd field separator)." >&2
exit 1
;;
'#'*)
echo "ERROR: GEOLIBRE_AUTH_USER must not start with '#' (htpasswd treats such lines as comments)." >&2
exit 1
;;
esac
NL='
'
CR=$(printf '\r')
case "${GEOLIBRE_AUTH_USER}${GEOLIBRE_AUTH_PASSWORD}" in
*"$NL"*|*"$CR"*)
echo "ERROR: GEOLIBRE_AUTH_USER and GEOLIBRE_AUTH_PASSWORD must not contain newlines or carriage returns." >&2
exit 1
;;
esac
HASH=$(printf '%s\n' "$GEOLIBRE_AUTH_PASSWORD" | openssl passwd -6 -stdin)
printf '%s:%s\n' "$GEOLIBRE_AUTH_USER" "$HASH" > "$HTPASSWD"
chown root:www-data "$HTPASSWD"
chmod 640 "$HTPASSWD"
cat > "$AUTH_CONF" <<'EOF'
auth_basic "GeoLibre";
auth_basic_user_file /etc/nginx/.htpasswd;
EOF
echo "HTTP Basic Auth enabled for user '$GEOLIBRE_AUTH_USER'."
else
printf '# Basic Auth disabled (GEOLIBRE_AUTH_USER/GEOLIBRE_AUTH_PASSWORD not set).\n' > "$AUTH_CONF"
rm -f "$HTPASSWD"
fi
if [ "${GEOLIBRE_DISABLE_SIDECAR:-0}" = "1" ]; then
echo "Sidecar disabled: GEOLIBRE_DISABLE_SIDECAR=1."
elif [ "$SIDECAR_START" = "1" ]; then
echo "Sidecar starting: deployment capabilities permit sidecar routes."
python -m uvicorn geolibre_server.app.main:app \
--host 127.0.0.1 --port 8765 &
else
echo "Sidecar disabled: neither processing:run nor data:add is granted."
fi
exec nginx -g 'daemon off;'