diff --git a/Dockerfile b/Dockerfile index 1a1479f98..65a0863cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,14 @@ FROM nginx:alpine +RUN apk add --no-cache jq + EXPOSE 80 ENV BASE_URL="" ENV SEERR_BASE_URL="" ENV SEERR_HEADER="null" ENV FLADDER_WEBPATH="/" +ENV PORT=80 COPY build/web /usr/share/nginx/html COPY docker-entrypoint.sh /docker-entrypoint.sh diff --git a/Dockerfile-rootless b/Dockerfile-rootless index 1ed773351..c4800f9ed 100644 --- a/Dockerfile-rootless +++ b/Dockerfile-rootless @@ -6,8 +6,10 @@ ENV BASE_URL="" ENV SEERR_BASE_URL="" ENV SEERR_HEADER="null" ENV FLADDER_WEBPATH="/" +ENV PORT=8080 USER root +RUN apk add --no-cache jq COPY build/web /usr/share/nginx/html COPY docker-entrypoint.sh /docker-entrypoint.sh RUN mkdir -p /usr/share/nginx/html/assets/config && \ @@ -17,4 +19,4 @@ RUN mkdir -p /usr/share/nginx/html/assets/config && \ chown -R nginx:nginx /etc/nginx/conf.d USER nginx -CMD ["/docker-entrypoint.sh"] \ No newline at end of file +CMD ["/docker-entrypoint.sh"] diff --git a/config/config.json b/config/config.json index 697d6235c..a03ad8df0 100644 --- a/config/config.json +++ b/config/config.json @@ -1,5 +1,5 @@ { "baseUrl": null, "seerrBaseUrl": null, - "seerrHeader": null + "seerrProxyPath": null } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 8625a2cf9..240e56f6b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,5 +7,9 @@ services: environment: - BASE_URL=https://server-url #OPTIONAL: Locks the Fladder front-end to a certain jellyfin server - SEERR_BASE_URL=https://seerr-url #OPTIONAL: Presets Seerr base URL - - SEERR_HEADER={"key":"value"} #OPTIONAL: JSON object string of Seerr headers + # SEERR_HEADER is intended to e.g. bypass an OUTER auth wall which is in front of Seerr: + # Authelia, Authentik, forward-auth, Cloudflare Access, basic-auth on a reverse proxy, etc.) + # Do NOT put Seerr's own X-Api-Key here! + # /seerr-proxy/ is not authenticated by Fladder, so an X-Api-Key would let any HTTP caller act as a Seerr admin. + - SEERR_HEADER={"key":"value"} #OPTIONAL: JSON headers injected server-side via nginx proxy (requires SEERR_BASE_URL) - FLADDER_WEBPATH=/ #OPTIONAL: Configures a subpath to run Fladder at diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh old mode 100644 new mode 100755 index a2c39328c..3fea2aae9 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,15 +1,36 @@ #!/bin/sh set -e -# Generate config.json from environment variables -cat > /usr/share/nginx/html/assets/config/config.json < "$CONFIG" <||g" /usr/share/nginx/html/index.html fi -# Determine port (standard Nginx uses 80, rootless typically 8080) -if [ "$(id -u)" = "0" ]; then - PORT=80 -else - PORT=8080 +# --- Determine port --- +# Honor $PORT from env if set; otherwise default by uid (0 => 80, else 8080). +PORT="${PORT:-$([ "$(id -u)" = "0" ] && echo 80 || echo 8080)}" + +# --- Build Seerr proxy block --- +PROXY_BLOCK="" +GEO_BLOCK="" +if [ -n "$SEERR_PROXY_PATH" ]; then + # SEERR_HEADER must be a JSON object of strings. Validate up front so the jq + # iterations below don't abort with cryptic "cannot iterate over..." errors. + if ! printf '%s' "$SEERR_HEADER" | jq -e 'type == "object" and all(.[]; type == "string")' > /dev/null 2>&1; then + echo "Error: SEERR_HEADER must be a JSON object with string values, e.g. {\"Header-Name\":\"value\"}" >&2 + exit 1 + fi + # Keys are injected unquoted into the nginx directive — restrict to the common + # HTTP header-name form (alpha-start, then alphanumerics and hyphens) so a key + # containing whitespace or ';' can't escape the proxy_set_header directive. + if ! printf '%s' "$SEERR_HEADER" | jq -e 'all(keys_unsorted[]; test("^[A-Za-z][A-Za-z0-9-]*$"))' > /dev/null 2>&1; then + echo "Error: SEERR_HEADER keys must match ^[A-Za-z][A-Za-z0-9-]*\$ (standard HTTP header name format)" >&2 + exit 1 + fi + # CR/LF are forbidden in HTTP header values (RFC 9110 §5.5) — reject rather than escape. + if printf '%s' "$SEERR_HEADER" | jq -e '[.[] | test("[\r\n]")] | any' > /dev/null; then + echo "Error: SEERR_HEADER values must not contain newline or carriage return (invalid HTTP header values)" >&2 + exit 1 + fi + # nginx has no native escape for literal '$' in quoted strings; define a namespaced + # variable holding "$" at http level and substitute '$' -> '${seerr_literal_dollar}' + # in values. Namespace prefix avoids collision with user-mounted nginx configs. + # tojson handles " and \ escaping (nginx shares those conventions with JSON). + HEADER_DIRECTIVES=$(printf '%s' "$SEERR_HEADER" | jq -r ' + to_entries[] | + " proxy_set_header " + .key + " " + (.value | gsub("\\$"; "${seerr_literal_dollar}") | tojson) + ";" + ') + + GEO_BLOCK='geo $seerr_literal_dollar { + default "$"; +} +' + # Two '$' conventions coexist below: + # \$proxy_host — backslash escapes the shell; nginx sees $proxy_host (built-in variable). + # ${seerr_literal_dollar} — nginx-level reference to the geo-defined variable above, emits a literal '$'. + PROXY_BLOCK=" + location ${SEERR_PROXY_PATH}/ { + proxy_pass \"${SEERR_BASE_URL}/\"; + proxy_set_header Host \$proxy_host; + proxy_ssl_server_name on; +${HEADER_DIRECTIVES} + }" fi +# --- Emit nginx config --- if [ "$WEBPATH" = "/" ]; then echo "Configuring Fladder at root path" - cat > /etc/nginx/conf.d/default.conf < "$NGINX_CONF" < /etc/nginx/conf.d/default.conf < "$NGINX_CONF" < _instance._seerrBaseUrl = value; String? _seerrBaseUrl; + static String? get seerrProxyPath => _instance._seerrProxyPath; + static set seerrProxyPath(String? value) => _instance._seerrProxyPath = value; + String? _seerrProxyPath; + static void fromJson(Map json) => _instance = FladderConfig._fromJson(json); factory FladderConfig._fromJson(Map json) { final config = FladderConfig._(); - final newUrl = json['baseUrl'] as String?; - final newSeerrUrl = json['seerrBaseUrl'] as String?; - - config._baseUrl = newUrl?.isEmpty == true ? null : newUrl; - config._seerrBaseUrl = newSeerrUrl?.isEmpty == true ? null : newSeerrUrl; - + config._baseUrl = _nonEmpty(json['baseUrl'] as String?); + config._seerrBaseUrl = _nonEmpty(json['seerrBaseUrl'] as String?); + config._seerrProxyPath = _nonEmpty(json['seerrProxyPath'] as String?); return config; } + + static String? _nonEmpty(String? s) => s?.isEmpty == true ? null : s; }