fix(profil): photo CDN Bearer (aligné main, branche rendu)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-21 10:38:20 +02:00
parent 63886d2df5
commit eaacef0cb6
3 changed files with 94 additions and 12 deletions

View File

@@ -13,8 +13,8 @@ Application mobile Flutter : recherche d’un login 42 et affichage du profil (A
À la racine de **ce** dépôt Git (dossier qui contient `swifty_companion/`) :
```bash
cp .env.example swifty_companion/.env
cd swifty_companion
cp ../.env.example .env
```
Édite `swifty_companion/.env` : renseigne `CLIENT_ID`, `CLIENT_SECRET` et `REDIRECT_URI` (voir commentaires dans `.env.example` à la racine).
@@ -53,6 +53,8 @@ Documentation API : [api.intra.42.fr/apidoc](https://api.intra.42.fr/apidoc)
Certaines données peuvent s’afficher comme « — » : l’API peut masquer des champs selon la confidentialité.
**Photo :** l’URL est fournie par l’API (`image` sur la fiche user) ; le fichier JPEG est chargé depuis `cdn.intra.42.fr` avec le **même** access token OAuth (pas un second login).
## Vérifications avant soutenance
```bash

View File

@@ -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,

View File

@@ -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'),