Files
plezy/lib/services/seerr/seerr_http_client.dart
T
edde746 e46b3dab7c fix(seerr): diagnose an auth proxy in front of Seerr instead of misreporting it
A Seerr instance behind forward-auth (Authelia, Authentik, Cloudflare
Access) or HTTP Basic failed setup with "No Seerr instance at … (HTTP
200)": the probe followed the proxy's redirect to its login page and
decoded the HTML as "not JSON". A live session that later hit the same
wall took the proxy's 401 as Seerr's, re-authed through the wall, failed,
and unlinked a perfectly good stored session.

Seerr's API never redirects and always answers with JSON, so a 3xx or a
non-JSON 401/403 is the proxy talking. Seerr requests no longer follow
redirects, that shape maps to a SeerrProxyException with a message that
says what to do, the probe reports it in place of "no instance", and the
client neither re-auths nor unlinks on it.

close #1877
2026-09-03 21:42:47 +02:00

166 lines
6.6 KiB
Dart

import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../i18n/strings.g.dart';
import '../../utils/abortable_http_request.dart';
import '../../utils/app_logger.dart';
import '../../utils/platform_http_client_stub.dart'
if (dart.library.io) '../../utils/platform_http_client_io.dart'
as platform;
import '../../utils/url_utils.dart';
import '../trackers/tracker_http_client.dart';
import 'seerr_constants.dart';
import 'seerr_exceptions.dart';
/// HTTP response paired with its decoded JSON body. `data` is null for
/// no-content responses and non-JSON bodies.
class SeerrResponse {
final http.Response response;
final dynamic data;
const SeerrResponse(this.response, this.data);
int get statusCode => response.statusCode;
}
/// Thin wrapper around `package:http` for Seerr API calls.
///
/// Adds the two things the tracker HTTP layer doesn't cover:
/// 1. `connect.sid` cookie capture from `Set-Cookie` on login, replayed as
/// `Cookie:` on every subsequent request — Express session auth.
/// 2. Query encoding via [encodeQueryParameters] (`%20` for spaces): Seerr
/// proxies `/search` to TMDB, which rejects `+` in the query value.
class SeerrHttpClient {
final String baseUrl;
final http.Client _http;
String? _cookie;
SeerrHttpClient({required String baseUrl, http.Client? httpClient, String? cookie})
: baseUrl = normalizeBaseUrl(baseUrl),
_http = httpClient ?? platform.createPlatformClient(),
_cookie = (cookie?.isNotEmpty ?? false) ? cookie : null;
/// Current `connect.sid` value (no `name=` prefix); null until a login
/// response is captured or [cookie] was seeded.
String? get cookie => _cookie;
set cookie(String? value) => _cookie = (value?.isNotEmpty ?? false) ? value : null;
void dispose() => _http.close();
/// Parse `Set-Cookie` from [response] and keep the `connect.sid` value.
/// Returns true when a cookie was captured.
///
/// `package:http` joins multiple `Set-Cookie` headers into one
/// comma-delimited string. Cookie values are URL-encoded and can't contain
/// a literal comma, so splitting on `,` and scanning each chunk for the
/// `connect.sid=` prefix is safe.
bool captureSessionCookie(http.Response response) {
final raw = response.headers['set-cookie'];
if (raw == null || raw.isEmpty) return false;
const prefix = '${SeerrConstants.sessionCookieName}=';
for (final chunk in raw.split(',')) {
final trimmed = chunk.trimLeft();
if (!trimmed.startsWith(prefix)) continue;
final afterName = trimmed.substring(prefix.length);
final end = afterName.indexOf(';');
final value = (end == -1 ? afterName : afterName.substring(0, end)).trim();
if (value.isEmpty) continue;
_cookie = value;
return true;
}
return false;
}
/// Send a request under [SeerrConstants.apiPath], returning the decoded
/// JSON body. 401 is returned to the caller (never thrown) so the client
/// can run its silent re-auth path.
Future<SeerrResponse> send(
String method,
String path, {
Map<String, Object?>? query,
Map<String, Object?>? body,
Duration timeout = SeerrConstants.requestTimeout,
bool authenticated = true,
}) async {
if (!const {'GET', 'POST', 'PUT', 'DELETE'}.contains(method)) {
throw ArgumentError('Unsupported HTTP method: $method');
}
final uri = _uri(path, query);
final headers = <String, String>{
'Accept': 'application/json',
if (authenticated && _cookie != null) 'Cookie': '${SeerrConstants.sessionCookieName}=$_cookie',
if (body != null) 'Content-Type': 'application/json',
};
final sw = Stopwatch()..start();
// Abortable so a timeout tears the request down instead of letting it
// race on — a timed-out POST /request must not land server-side after
// the UI already reported failure. Redirects are not followed: Seerr's
// API never issues one, so a 3xx is an auth proxy in front of it, and
// following it would turn that into an HTML 200 nobody can diagnose.
final response = await sendAbortableHttpRequest(
_http,
method,
uri,
headers: headers,
body: body == null ? null : jsonEncode(body),
timeout: timeout,
operation: 'Seerr $method $path',
followRedirects: false,
);
appLogger.d('Seerr $method $path -> ${response.statusCode} (${sw.elapsedMilliseconds}ms)');
return SeerrResponse(response, TrackerHttpClient.decodeJson(response.body));
}
Uri _uri(String path, Map<String, Object?>? query) {
final base = Uri.parse('$baseUrl${SeerrConstants.apiPath}$path');
final encoded = encodeQueryParameters(query);
return encoded.isEmpty ? base : base.replace(query: encoded);
}
/// Whether [res] came from an auth wall in front of Seerr rather than Seerr.
///
/// Seerr's Express API never redirects and answers every status with JSON,
/// so a redirect (forward-auth bouncing to its login page) or a 401/403
/// without a JSON body (HTTP Basic challenge, Cloudflare Access, Authelia
/// answering a non-browser `Accept`) is the proxy talking. A 401/403 *with*
/// JSON stays Seerr's own — its login routes use both for bad credentials.
static bool isProxyInterception(SeerrResponse res) {
final code = res.statusCode;
if (code >= 300 && code < 400) return true;
return (code == 401 || code == 403) && res.data is! Map<String, dynamic>;
}
/// Throw [SeerrProxyException] when [isProxyInterception]; no-op otherwise.
static void throwIfProxied(SeerrResponse res) {
if (!isProxyInterception(res)) return;
throw SeerrProxyException(
'An auth proxy answered instead of Seerr (HTTP ${res.statusCode})',
display: t.seerr.behindAuthProxy,
statusCode: res.statusCode,
);
}
/// Throw the mapped exception for a 3xx/4xx/5xx response; no-op on success.
/// 401 is the caller's re-auth signal and also passes through — unless it
/// is the proxy's, which throws [SeerrProxyException] instead.
static void throwForStatus(SeerrResponse res) {
throwIfProxied(res);
final code = res.statusCode;
if (code >= 200 && code < 300 || code == 401) return;
final data = res.data;
final message = data is Map<String, dynamic> ? data['message'] as String? : null;
throw SeerrApiException((message?.isNotEmpty ?? false) ? message! : 'HTTP $code', statusCode: code);
}
/// Trim whitespace and trailing slashes so cookie/session identity and
/// request URLs agree on one canonical instance URL.
static String normalizeBaseUrl(String input) {
var v = input.trim();
while (v.endsWith('/')) {
v = v.substring(0, v.length - 1);
}
return v;
}
}