fix(profil): photo CDN avec Bearer OAuth et doc soutenance
Charge les avatars depuis cdn.intra.42.fr avec le même access token, parse image.versions, et documente le flux pour la correction. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../services/intra_api_exception.dart';
|
||||
import '../services/intra_auth_service.dart';
|
||||
import '../services/intra_profile_service.dart';
|
||||
import '../theme/swifty_ui.dart';
|
||||
|
||||
@@ -56,11 +60,22 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extrait l’URL photo depuis la fiche `GET /v2/users/{login}` (critère sujet : photo de profil).
|
||||
///
|
||||
/// Ordre : `image.link` → `image.versions` (large…micro) → champ legacy `image_url`.
|
||||
String? _imageUrl(Map<String, dynamic> user) {
|
||||
final image = user['image'];
|
||||
if (image is Map<String, dynamic>) {
|
||||
final link = image['link'];
|
||||
if (link is String && link.isNotEmpty) return link;
|
||||
|
||||
final versions = image['versions'];
|
||||
if (versions is Map<String, dynamic>) {
|
||||
for (final key in ['large', 'medium', 'small', 'micro']) {
|
||||
final v = versions[key];
|
||||
if (v is String && v.isNotEmpty) return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
final url = user['image_url'];
|
||||
if (url is String && url.isNotEmpty) return url;
|
||||
@@ -172,6 +187,71 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Avatar profil : charge l’image via HTTP avec le Bearer OAuth (`IntraAuthService.cdnImageHeaders`).
|
||||
///
|
||||
/// Un simple `NetworkImage(url)` sans token échoue souvent sur le CDN 42. Si le chargement
|
||||
/// échoue ou l’URL est absente, affiche l’initiale du login (conforme encadré confidentialité sujet).
|
||||
class _IntraProfileAvatar extends StatefulWidget {
|
||||
const _IntraProfileAvatar({required this.login, required this.imageUrl});
|
||||
|
||||
final String login;
|
||||
final String? imageUrl;
|
||||
|
||||
@override
|
||||
State<_IntraProfileAvatar> createState() => _IntraProfileAvatarState();
|
||||
}
|
||||
|
||||
class _IntraProfileAvatarState extends State<_IntraProfileAvatar> {
|
||||
Uint8List? _bytes;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _IntraProfileAvatar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.imageUrl != widget.imageUrl) {
|
||||
_bytes = null;
|
||||
_load();
|
||||
}
|
||||
}
|
||||
|
||||
/// GET binaire sur l’URL CDN ; même session que les appels `api.intra.42.fr`.
|
||||
Future<void> _load() async {
|
||||
final url = widget.imageUrl;
|
||||
if (url == null || url.isEmpty) return;
|
||||
|
||||
final headers = IntraAuthService.instance.cdnImageHeaders();
|
||||
try {
|
||||
final response = await http.get(Uri.parse(url), headers: headers);
|
||||
if (response.statusCode == 200 && mounted) {
|
||||
setState(() => _bytes = response.bodyBytes);
|
||||
}
|
||||
} catch (_) {
|
||||
// Initiale affichée en repli.
|
||||
}
|
||||
}
|
||||
|
||||
Widget _initial() {
|
||||
final letter =
|
||||
widget.login.isNotEmpty ? widget.login[0].toUpperCase() : '?';
|
||||
return Text(letter, style: const TextStyle(fontSize: 32));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CircleAvatar(
|
||||
radius: 56,
|
||||
backgroundImage:
|
||||
_bytes != null ? MemoryImage(_bytes!) : null,
|
||||
child: _bytes == null ? _initial() : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorBody extends StatelessWidget {
|
||||
const _ErrorBody({required this.message, required this.onRetry});
|
||||
|
||||
@@ -206,17 +286,7 @@ class _ProfileHeader extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 56,
|
||||
backgroundImage:
|
||||
imageUrl != null ? NetworkImage(imageUrl!) : null,
|
||||
child: imageUrl == null
|
||||
? Text(
|
||||
login.isNotEmpty ? login[0].toUpperCase() : '?',
|
||||
style: const TextStyle(fontSize: 32),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
_IntraProfileAvatar(login: login, imageUrl: imageUrl),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
login,
|
||||
|
||||
@@ -125,6 +125,16 @@ class IntraAuthService {
|
||||
};
|
||||
}
|
||||
|
||||
/// En-têtes pour télécharger la photo sur `cdn.intra.42.fr`.
|
||||
///
|
||||
/// L’URL vient du JSON user (`image.link` / `versions`). Le CDN refuse souvent
|
||||
/// les requêtes sans Bearer (403). On réutilise le **même** access token que pour
|
||||
/// l’API — pas un second OAuth. On n’envoie pas `Accept: application/json` ici.
|
||||
Map<String, String> cdnImageHeaders() {
|
||||
if (!isAuthenticated) return const {};
|
||||
return {'Authorization': 'Bearer $_accessToken'};
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> fetchMe() async {
|
||||
final response = await intraApiGet(
|
||||
Uri.parse('${IntraConfig.apiBaseUrl}/me'),
|
||||
|
||||
Reference in New Issue
Block a user