diff --git a/seerr/CHANGELOG.md b/seerr/CHANGELOG.md index 79825e2987..81aae672d5 100644 --- a/seerr/CHANGELOG.md +++ b/seerr/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.3.0.1 (2026-07-28) + +- Fixed searches failing through ingress with `500 Internal Server Error` (#2906, #2646). Home Assistant's ingress proxy re-encodes the query string and passes a space as `+`, along with a bare `:` `/` `?` `@` `!` `$` `'` `(` `)` `*` `,` - all of which Seerr's OpenAPI validator rejects as reserved characters. Nginx now re-encodes them before proxying, so titles such as `Monsters, Inc.`, `Ocean's Eleven`, `Mission: Impossible` and `Who? What?` search correctly. Only ingress was affected; the directly published port 5055 always worked. + ## 3.3.0 (2026-06-05) - Update to latest version from seerr-team/seerr (changelog : https://github.com/seerr-team/seerr/releases) diff --git a/seerr/Dockerfile b/seerr/Dockerfile index d098b78e3d..859ac2cb64 100644 --- a/seerr/Dockerfile +++ b/seerr/Dockerfile @@ -56,7 +56,7 @@ COPY ha_automodules.sh /ha_automodules.sh RUN chmod 744 /ha_automodules.sh && /ha_automodules.sh "$MODULES" && rm /ha_automodules.sh # Manual apps -ENV PACKAGES="nginx" +ENV PACKAGES="nginx nginx-mod-http-js" # Automatic apps & bashio COPY ha_autoapps.sh /ha_autoapps.sh diff --git a/seerr/config.yaml b/seerr/config.yaml index 2299bd1e5c..b41b8401d0 100644 --- a/seerr/config.yaml +++ b/seerr/config.yaml @@ -96,4 +96,4 @@ schema: slug: seerr udev: true url: https://github.com/alexbelgium/hassio-addons/tree/master/seerr -version: "3.3.0" +version: "3.3.0.1" diff --git a/seerr/rootfs/etc/nginx/modules/10_http_js.conf b/seerr/rootfs/etc/nginx/modules/10_http_js.conf new file mode 100644 index 0000000000..1e77d2067c --- /dev/null +++ b/seerr/rootfs/etc/nginx/modules/10_http_js.conf @@ -0,0 +1,14 @@ +# Load the njs module used by njs/ingress.js. +# +# The nginx-mod-http-js package ships this same file, but it cannot be relied +# on: .templates/ha_automatic_packages.sh moves the rootfs /etc/nginx aside to +# /etc/nginx2 before installing nginx, then does `rm -r /etc/nginx` and restores +# the saved tree afterwards. That deletes anything the package placed under +# /etc/nginx, including its own load_module snippet, and nginx would then fail +# to start with "unknown directive js_import". +# +# Shipping it in the rootfs makes it survive that swap. The filename matches the +# package's on purpose: if the package's copy is ever kept instead, it overwrites +# this one rather than adding a second load_module for the same module, which +# nginx rejects as a duplicate. +load_module /usr/lib/nginx/modules/ngx_http_js_module.so; diff --git a/seerr/rootfs/etc/nginx/nginx.conf b/seerr/rootfs/etc/nginx/nginx.conf index 7e5bc6f7cb..1d703a701a 100644 --- a/seerr/rootfs/etc/nginx/nginx.conf +++ b/seerr/rootfs/etc/nginx/nginx.conf @@ -49,6 +49,11 @@ http { '' close; } + # Repair the query string mangled by Supervisor's ingress proxy before it + # reaches Seerr's OpenAPI validator. See njs/ingress.js for the full story. + js_import ingress from /etc/nginx/njs/ingress.js; + js_set $ingress_uri ingress.uri; + include /etc/nginx/includes/resolver.conf; include /etc/nginx/includes/upstream.conf; diff --git a/seerr/rootfs/etc/nginx/njs/ingress.js b/seerr/rootfs/etc/nginx/njs/ingress.js new file mode 100644 index 0000000000..70e5aefc51 --- /dev/null +++ b/seerr/rootfs/etc/nginx/njs/ingress.js @@ -0,0 +1,83 @@ +/* + * Repair the query string of a Home Assistant ingress request. + * + * Supervisor proxies ingress traffic with `params=request.query` + * (supervisor/api/ingress.py), so aiohttp/yarl re-encodes an already-decoded + * query string on the way to this add-on. yarl's "safe" set is much wider than + * the one Seerr's express-openapi-validator will accept: yarl emits a space as + * "+" and passes ":", "/", "?", "@", "!", "$", "'", "(", ")", "*" and "," + * through bare, while the validator checks the raw, still-encoded value against + * + * RESERVED_CHARS = /[\:\/\?#\[\]@!\$&\'()\*\+,;=]/ + * + * and answers 400 "Parameter '' must be url encoded". Every search for a + * title containing a space or punctuation therefore fails - "Monsters, Inc.", + * "Ocean's Eleven", "Mission: Impossible" - which Seerr's UI reports as a + * 500. The same requests succeed on the directly published port 5055, which + * does not pass through Supervisor. + * + * "?" deserves a note: the validator strips one with `qs.replace('?', '')` + * before testing, so a single bare "?" slips through by accident and only a + * second one ("Who? What?") produces the 400. It is encoded here regardless. + * + * Re-encoding those characters here is lossless, because yarl only ever emits + * them bare when they were literal characters of the value: anything the user + * actually typed that is ambiguous comes through already percent-encoded + * (a typed "+" arrives as "%2B", "&" as "%26", "=" as "%3D"). + * + * "&" and "=" are deliberately NOT re-encoded: they are the query string's own + * separators, so a bare one is always structural. + */ + +/* + * Every character of the validator's RESERVED_CHARS except "&" and "=", which + * are the query string's own separators and are handled above. Deriving the + * set from what the validator rejects - rather than from what yarl currently + * emits bare - keeps this correct if either side changes its safe set. + */ +var NEEDS_ENCODING = /[:\/?#\[\]@!$'()*,;]/g; + +function encodePart(part) { + return part + /* yarl encodes a space as "+"; a literal "+" arrives as "%2B". */ + .replace(/\+/g, "%20") + .replace(NEEDS_ENCODING, function (c) { + return "%" + c.charCodeAt(0).toString(16).toUpperCase(); + }); +} + +/* + * Returns the request URI with the path untouched byte-for-byte and only the + * query string repaired. Used as the proxy_pass target. + */ +function uri(r) { + var raw = r.variables.request_uri; + var split = raw.indexOf("?"); + + if (split < 0) { + return raw; + } + + var path = raw.substring(0, split); + var args = raw.substring(split + 1); + + /* A bare trailing "?" is forwarded as-is, so the URI stays byte-for-byte. */ + if (args === "") { + return raw; + } + + var repaired = args + .split("&") + .map(function (pair) { + var eq = pair.indexOf("="); + if (eq < 0) { + return encodePart(pair); + } + return encodePart(pair.substring(0, eq)) + "=" + encodePart(pair.substring(eq + 1)); + }) + .join("&"); + + return path + "?" + repaired; +} + +export default { uri }; diff --git a/seerr/rootfs/etc/nginx/servers/ingress.conf b/seerr/rootfs/etc/nginx/servers/ingress.conf index 91ebeb1826..4723e8934a 100644 --- a/seerr/rootfs/etc/nginx/servers/ingress.conf +++ b/seerr/rootfs/etc/nginx/servers/ingress.conf @@ -10,9 +10,11 @@ server { location ^~ / { set $app '%%ingress_entry%%'; - # Forward the raw request URI exactly as received by this nginx. - # This is the safest way to preserve query-string encoding. - proxy_pass http://127.0.0.1:5055$request_uri; + # Forward the request URI with the path byte-for-byte as received, and + # the query string re-encoded so the characters Supervisor's ingress + # proxy passes through bare (a space as "+", plus ":/@!$'()*,") do not + # trip Seerr's OpenAPI validator. See njs/ingress.js for the details. + proxy_pass http://127.0.0.1:5055$ingress_uri; proxy_http_version 1.1; proxy_set_header Referer $http_referer;