Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
a82176c
feat(docker): add entrypoint script with nginx proxy for custom Seerr…
v3DJG6GL Mar 15, 2026
4b5a161
feat(docker): add jq and custom headers env var in Dockerfile
v3DJG6GL Mar 15, 2026
e523035
feat(docker): add jq and custom headers env var in Dockerfile-rootless
v3DJG6GL Mar 15, 2026
aac4281
refactor(docker): remove old root-level docker-entrypoint.sh
v3DJG6GL Mar 15, 2026
ac9c31a
feat(docker): replace SEERR_HEADER with SEERR_CUSTOM_HEADERS in docke…
v3DJG6GL Mar 15, 2026
35b4cd1
feat(config): add seerrProxyPath field for nginx proxy routing
v3DJG6GL Mar 15, 2026
ffa0667
feat(config): add seerrProxyPath and remove stale seerrHeader
v3DJG6GL Mar 15, 2026
35adb92
feat(seerr): route requests through nginx proxy when seerrProxyPath i…
v3DJG6GL Mar 15, 2026
caecbc8
fix(docker): keep entrypoint at root and use existing SEERR_HEADER en…
v3DJG6GL Mar 15, 2026
e36ebb7
fix(docker): minimize Dockerfile diff - keep SEERR_HEADER and mkdir
v3DJG6GL Mar 15, 2026
07adf27
fix(docker): minimize Dockerfile-rootless diff - keep SEERR_HEADER an…
v3DJG6GL Mar 15, 2026
74da47d
fix(docker): revert docker-compose.yml to upstream (SEERR_HEADER alre…
v3DJG6GL Mar 15, 2026
0489d7a
fix(config): keep existing seerrHeader, only add seerrProxyPath
v3DJG6GL Mar 15, 2026
3d0e6b9
fix(docker): move apk add after USER root in Dockerfile-rootless
v3DJG6GL Mar 16, 2026
65e4efc
Merge branch 'DonutWare:develop' into feat/seerr-custom-headers
v3DJG6GL Mar 22, 2026
027c74a
Merge remote-tracking branch 'upstream/develop' into feat/seerr-custo…
v3DJG6GL Apr 22, 2026
1ac2287
chore(config): remove vestigial seerrHeader field from example config
v3DJG6GL Apr 22, 2026
98d403c
docs(compose): clarify SEERR_HEADER comment reflects nginx proxy inje…
v3DJG6GL Apr 22, 2026
b32f8cc
perf(seerr): skip redundant Uri.parse on web proxy path
v3DJG6GL Apr 22, 2026
07f20e5
refactor(entrypoint): reuse SEERR_PROXY_PATH as single proxy-enabled …
v3DJG6GL Apr 22, 2026
0de4db6
fix(entrypoint): strip trailing slash from SEERR_BASE_URL to avoid do…
v3DJG6GL Apr 22, 2026
0bb5a79
fix(entrypoint): preserve upstream's empty-string behavior for baseUr…
v3DJG6GL Apr 22, 2026
bfb1112
fix(entrypoint): harden SEERR_HEADER escaping, support $ in values
v3DJG6GL Apr 22, 2026
780331c
fix(entrypoint): namespace nginx literal-dollar variable to avoid col…
v3DJG6GL Apr 22, 2026
6355b7e
refactor(entrypoint): extract strip_trailing_slashes helper
v3DJG6GL Apr 22, 2026
03e4ff3
docs(entrypoint): clarify the two $-escape conventions in PROXY_BLOCK
v3DJG6GL Apr 22, 2026
382b203
docs(seerr): reword proxy comment
v3DJG6GL Apr 22, 2026
13f4aba
refactor(fladder_config): extract _nonEmpty helper for json string fi…
v3DJG6GL Apr 22, 2026
1f10aa3
fix(entrypoint): validate SEERR_HEADER shape before parsing
v3DJG6GL Apr 22, 2026
9523bb3
fix(entrypoint): validate SEERR_HEADER keys to prevent nginx directiv…
v3DJG6GL Apr 22, 2026
554f5cb
fix(entrypoint): quote SEERR_BASE_URL in proxy_pass to prevent direct…
v3DJG6GL Apr 22, 2026
b3f3d95
docs(compose): warn against using Seerr's X-Api-Key in SEERR_HEADER
v3DJG6GL Apr 22, 2026
52b3246
fix(seerr): reject non-same-origin seerrProxyPath to prevent config-t…
v3DJG6GL Apr 22, 2026
3b91d2e
Merge branch 'DonutWare:develop' into feat/seerr-custom-headers
v3DJG6GL Apr 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 3 additions & 1 deletion Dockerfile-rootless
Original file line number Diff line number Diff line change
Expand Up @@ -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 && \
Expand All @@ -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"]
CMD ["/docker-entrypoint.sh"]
2 changes: 1 addition & 1 deletion config/config.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"baseUrl": null,
"seerrBaseUrl": null,
"seerrHeader": null
"seerrProxyPath": null
}
6 changes: 5 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
100 changes: 85 additions & 15 deletions docker-entrypoint.sh
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,33 +1,99 @@
#!/bin/sh
set -e

# Generate config.json from environment variables
cat > /usr/share/nginx/html/assets/config/config.json <<EOF
CONFIG="/usr/share/nginx/html/assets/config/config.json"
NGINX_CONF="/etc/nginx/conf.d/default.conf"

strip_trailing_slashes() {
echo "$1" | sed 's|/*$||'
}

# Strip trailing slashes from SEERR_BASE_URL so proxy_pass doesn't emit a double slash.
SEERR_BASE_URL=$(strip_trailing_slashes "$SEERR_BASE_URL")

# --- Build config.json ---

# Determine seerrProxyPath: set when both SEERR_BASE_URL and SEERR_HEADER are provided
if [ -n "$SEERR_BASE_URL" ] && [ -n "$SEERR_HEADER" ] && [ "$SEERR_HEADER" != "null" ]; then
SEERR_PROXY_PATH="/seerr-proxy"
else
SEERR_PROXY_PATH=""
fi

SEERR_PROXY_JSON=$([ -n "$SEERR_PROXY_PATH" ] && echo "\"$SEERR_PROXY_PATH\"" || echo null)

cat > "$CONFIG" <<EOF
{
"baseUrl": "$BASE_URL",
"seerrBaseUrl": "$SEERR_BASE_URL"
"seerrBaseUrl": "$SEERR_BASE_URL",
"seerrProxyPath": $SEERR_PROXY_JSON
}
EOF

# Normalize FLADDER_WEBPATH (e.g. /fladder/)
# --- Normalize FLADDER_WEBPATH (e.g. /fladder/) ---
WEBPATH=$(echo "${FLADDER_WEBPATH:-/}" | sed 's|^/*|/|; s|/*$|/|')

# Update base href in index.html (always at root of build/web)
if [ -f "/usr/share/nginx/html/index.html" ]; then
sed -i "s|<base href=\"[^\"]*\">|<base href=\"$WEBPATH\">|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 <<EOF
server {
cat > "$NGINX_CONF" <<EOF
${GEO_BLOCK}server {
listen $PORT;
listen [::]:$PORT;
server_name localhost;
Expand All @@ -37,14 +103,15 @@ server {
index index.html;
try_files \$uri \$uri/ /index.html;
}
${PROXY_BLOCK}
}
EOF
else
echo "Configuring Fladder on subpath: $WEBPATH"
WEBPATH_NO_SLASH=$(echo "$WEBPATH" | sed 's|/*$||')
cat > /etc/nginx/conf.d/default.conf <<EOF
server {
WEBPATH_NO_SLASH=$(strip_trailing_slashes "$WEBPATH")

cat > "$NGINX_CONF" <<EOF
${GEO_BLOCK}server {
listen $PORT;
listen [::]:$PORT;
server_name localhost;
Expand All @@ -60,6 +127,7 @@ server {
location = $WEBPATH_NO_SLASH {
return 301 $WEBPATH;
}
${PROXY_BLOCK}

# Fallback for root or other paths
location / {
Expand All @@ -69,5 +137,7 @@ server {
EOF
fi

# --- Start nginx ---

exec nginx -g "daemon off;"

9 changes: 8 additions & 1 deletion lib/providers/seerr_api_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:developer';
import 'dart:io';

import 'package:chopper/chopper.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

Expand Down Expand Up @@ -62,7 +63,13 @@ class SeerrRequest implements Interceptor {
...?creds?.customHeaders,
};
final headers = {...authHeaders, ...customHeaders};
final apiBaseUri = Uri.parse(serverUrl);
// On web, route through the same-origin nginx proxy when seerrProxyPath is set.
// Nginx injects the headers server-side so the header values never reach the browser.
// Require a same-origin absolute path (starts with '/' but not '//') so a tampered
// config.json can't redirect Seerr traffic to a foreign host via Uri.base.resolve.
final proxyPath = FladderConfig.seerrProxyPath;
final isSafeProxyPath = proxyPath != null && proxyPath.startsWith('/') && !proxyPath.startsWith('//');
final apiBaseUri = (kIsWeb && isSafeProxyPath) ? Uri.base.resolve(proxyPath) : Uri.parse(serverUrl);

Uri resolvedRequestUri;
try {
Expand Down
15 changes: 9 additions & 6 deletions lib/util/fladder_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,19 @@ class FladderConfig {
static set seerrBaseUrl(String? value) => _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<String, dynamic> json) => _instance = FladderConfig._fromJson(json);

factory FladderConfig._fromJson(Map<String, dynamic> 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;
}
Loading