fix(api): send session token on EPG, recordings and season-pass calls

The security hardening in 60d3f42 moved /api/epg, /api/recordings and
/api/season-passes behind authMiddleware, but the Flutter client still
called them through bare package:http. Those requests carry no
Authorization header, and on web BrowserClient sets withCredentials to
false so the session cookie is not sent either — every call came back
401. Symptoms: empty TV guide, empty recordings list, empty season
passes.

Adds AuthedHttp, a thin wrapper that injects the same token ApiClient
and XtreamService already use (localStorage['auth_token']), and routes
the 13 affected calls through it.

subtitle_service is left on plain http: it fetches third-party subtitle
URLs, not our API, and must not leak the session token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-10 21:54:01 +02:00
1 parent 1feeaae78a
commit ad3e970c8d
4 files changed
+73 -16

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
import 'dart:html' as html;
import 'package:http/http.dart' as http;
/// Client HTTP authentifié pour les routes `/api/*` protégées.
///
/// POURQUOI CE FICHIER EXISTE
/// Les routes `/api/epg`, `/api/recordings` et `/api/season-passes` sont
/// montées derrière `authMiddleware` côté serveur. Or plusieurs écrans les
/// appelaient avec `package:http` nu, qui n'envoie ni en-tête `Authorization`
/// ni cookie : sur le web, `BrowserClient` a `withCredentials = false`, donc
/// le cookie `session` reste à quai. Toutes ces requêtes repartaient en 401 —
/// guide TV vide, liste d'enregistrements vide, season passes vides.
///
/// Ce wrapper injecte le même jeton que `ApiClient` et `XtreamService`
/// (`localStorage['auth_token']`), de sorte qu'il n'existe qu'une seule
/// source de vérité pour la session.
class AuthedHttp {
AuthedHttp._();
/// Jeton de session posé par `ApiClient.setToken` à la connexion.
static String? get _token {
final t = html.window.localStorage['auth_token'];
return (t != null && t.isNotEmpty) ? t : null;
}
static Map<String, String> _headers([Map<String, String>? extra]) {
final token = _token;
return {
if (extra != null) ...extra,
if (token != null) 'Authorization': 'Bearer $token',
};
}
static Future<http.Response> get(Uri url, {Map<String, String>? headers}) =>
http.get(url, headers: _headers(headers));
static Future<http.Response> post(
Uri url, {
Map<String, String>? headers,
Object? body,
}) =>
http.post(url, headers: _headers(headers), body: body);
static Future<http.Response> put(
Uri url, {
Map<String, String>? headers,
Object? body,
}) =>
http.put(url, headers: _headers(headers), body: body);
static Future<http.Response> delete(
Uri url, {
Map<String, String>? headers,
Object? body,
}) =>
http.delete(url, headers: _headers(headers), body: body);
}
@@ -1,7 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:http/http.dart' as http; import '../../../core/api/authed_http.dart';
import '../../../core/models/iptv_models.dart'; import '../../../core/models/iptv_models.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/glass_container.dart'; import '../../../core/widgets/glass_container.dart';
@@ -45,7 +45,7 @@ class _RecordingModalState extends State<RecordingModal> {
try { try {
// Utilisation d'une URL relative en Web (ou d'une configuration pour autres plateformes) // Utilisation d'une URL relative en Web (ou d'une configuration pour autres plateformes)
final response = await http.post( final response = await AuthedHttp.post(
Uri.parse('/api/recordings'), Uri.parse('/api/recordings'),
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: json.encode({ body: json.encode({
+10 -10
View File
@@ -2,7 +2,7 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:http/http.dart' as http; import '../../../core/api/authed_http.dart';
import '../../../core/models/iptv_models.dart'; import '../../../core/models/iptv_models.dart';
import '../../../core/models/playlist_config.dart'; import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
@@ -111,7 +111,7 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
}); });
try { try {
final response = await http.get(Uri.parse('/api/epg/${ch.streamId}')); final response = await AuthedHttp.get(Uri.parse('/api/epg/${ch.streamId}'));
if (mounted) { if (mounted) {
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = json.decode(response.body) as Map<String, dynamic>; final data = json.decode(response.body) as Map<String, dynamic>;
@@ -589,7 +589,7 @@ class _ProgrammeCard extends StatelessWidget {
DateTime end, DateTime end,
) async { ) async {
try { try {
final response = await http.post( final response = await AuthedHttp.post(
Uri.parse('/api/recordings'), Uri.parse('/api/recordings'),
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: json.encode({ body: json.encode({
@@ -745,7 +745,7 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
_error = null; _error = null;
}); });
try { try {
final response = await http.get(Uri.parse('/api/recordings')); final response = await AuthedHttp.get(Uri.parse('/api/recordings'));
if (response.statusCode == 200) { if (response.statusCode == 200) {
setState(() { setState(() {
final decoded = json.decode(response.body); final decoded = json.decode(response.body);
@@ -767,7 +767,7 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
} }
Future<void> _stopRecording(String id, String title) async { Future<void> _stopRecording(String id, String title) async {
await http.post(Uri.parse('/api/recordings/stop/$id')); await AuthedHttp.post(Uri.parse('/api/recordings/stop/$id'));
_fetchRecordings(); _fetchRecordings();
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context)
@@ -776,7 +776,7 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
} }
Future<void> _deleteRecording(String id) async { Future<void> _deleteRecording(String id) async {
await http.delete(Uri.parse('/api/recordings/$id')); await AuthedHttp.delete(Uri.parse('/api/recordings/$id'));
_fetchRecordings(); _fetchRecordings();
} }
@@ -817,7 +817,7 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
Future<void> _showLogs(String id, String title) async { Future<void> _showLogs(String id, String title) async {
try { try {
final response = await http.get(Uri.parse('/api/recordings/logs/$id')); final response = await AuthedHttp.get(Uri.parse('/api/recordings/logs/$id'));
if (!mounted) return; if (!mounted) return;
final content = response.statusCode == 200 final content = response.statusCode == 200
? (json.decode(response.body)['logs'] as String? ?? 'Aucun log') ? (json.decode(response.body)['logs'] as String? ?? 'Aucun log')
@@ -1095,7 +1095,7 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> {
Future<void> _loadPasses() async { Future<void> _loadPasses() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
final r = await http.get(Uri.parse('/api/season-passes')); final r = await AuthedHttp.get(Uri.parse('/api/season-passes'));
if (r.statusCode == 200) { if (r.statusCode == 200) {
setState(() { setState(() {
_passes = json.decode(r.body); _passes = json.decode(r.body);
@@ -1110,7 +1110,7 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> {
} }
Future<void> _deletePass(String id, String title) async { Future<void> _deletePass(String id, String title) async {
await http.delete(Uri.parse('/api/season-passes/$id')); await AuthedHttp.delete(Uri.parse('/api/season-passes/$id'));
_loadPasses(); _loadPasses();
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -1177,7 +1177,7 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> {
? '/api/live/$c.ts' ? '/api/live/$c.ts'
: urlCtrl.text.trim(); : urlCtrl.text.trim();
Navigator.pop(ctx); Navigator.pop(ctx);
final r = await http.post( final r = await AuthedHttp.post(
Uri.parse('/api/season-passes'), Uri.parse('/api/season-passes'),
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: json.encode( body: json.encode(
@@ -1,6 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; import '../../../core/api/authed_http.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import '../../../core/models/iptv_models.dart'; import '../../../core/models/iptv_models.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
@@ -31,7 +31,7 @@ class _SimpleRecordingWidgetState extends State<SimpleRecordingWidget> {
setState(() => _status = 'Starting...'); setState(() => _status = 'Starting...');
try { try {
final response = await http.post( final response = await AuthedHttp.post(
Uri.parse('/api/record/now'), Uri.parse('/api/record/now'),
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: jsonEncode({ body: jsonEncode({
@@ -66,7 +66,7 @@ class _SimpleRecordingWidgetState extends State<SimpleRecordingWidget> {
final endTime = _startTime.add(Duration(minutes: _durationMinutes)); final endTime = _startTime.add(Duration(minutes: _durationMinutes));
try { try {
final response = await http.post( final response = await AuthedHttp.post(
Uri.parse('/api/record/schedule'), Uri.parse('/api/record/schedule'),
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: jsonEncode({ body: jsonEncode({
@@ -96,7 +96,7 @@ class _SimpleRecordingWidgetState extends State<SimpleRecordingWidget> {
/// 🔴 Stop recording /// 🔴 Stop recording
Future<void> _stopRecording() async { Future<void> _stopRecording() async {
try { try {
await http.post( await AuthedHttp.post(
Uri.parse('/api/record/stop/${widget.channel.streamId}'), Uri.parse('/api/record/stop/${widget.channel.streamId}'),
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
); );