Files
AI-Health/health_app/lib/widgets/authenticated_network_image.dart
MingNian 9cea41705e feat: 文件存储安全加固 + 认证增强 + 媒体URL保护 + provider 重构
## 后端安全加固
- 新增 UserUploadPathResolver: 用户上传文件路径安全解析, 防目录穿越
- LocalReportFileStorage: 文件存储路径安全加固
- local_account_file_cleanup: 账号删除时文件清理逻辑增强
- AuthService: 认证逻辑增强
- file_endpoints / report_endpoints: 文件访问接口安全加固
- ai_chat_endpoints / doctor_endpoints: 接口安全调整
- Program.cs: 服务注册调整

## 前端认证与媒体
- 新增 authenticated_network_image.dart: 带认证的图片加载组件
- auth_provider: 认证状态管理大幅增强(+173)
- api_client: 网络客户端增强(+124)
- chat_provider: 聊天 provider 重构(+76)
- omron_device_provider: 蓝牙设备 provider 增强(+53)
- sse_handler: SSE 处理增强(+35)
- consultation_provider / data_providers / conversation_history_provider: 调整

## 页面调整
- remaining_pages: 健康档案/饮食记录等页面增强(+115)
- home_page / chat_messages_view: 主页微调
- doctor 端多页微调(consultations/dashboard/followups/patient_detail/profile/report_detail/reports)
- report_pages / settings_pages / notification_prefs_page: 微调
- device_scan_page / diet_capture_page / admin_home_page: 微调

## 测试
- 新增 file_path_security_tests: 文件路径安全测试
- 新增 protected_media_url_test: 媒体URL保护测试
- 新增 user_session_identity_test: 用户会话身份测试
- account_deletion_tests / application_service_tests / auth_tests: 更新
2026-07-20 10:19:01 +08:00

132 lines
3.5 KiB
Dart

import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/api_client.dart' show baseUrl;
import '../providers/auth_provider.dart';
String protectedMediaUrl(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) return trimmed;
final absolute = Uri.tryParse(trimmed);
final path = absolute?.path ?? trimmed.split('?').first;
final legacyIndex = path.toLowerCase().indexOf('/uploads/users/');
if (legacyIndex >= 0) {
final fileName = Uri.decodeComponent(path.split('/').last);
return '$baseUrl/api/files/content/${Uri.encodeComponent(fileName)}';
}
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return trimmed;
}
if (trimmed.startsWith('/')) return '$baseUrl$trimmed';
return '$baseUrl/$trimmed';
}
bool mediaRequiresAuthentication(String url) {
final normalizedBase = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/';
return url == baseUrl || url.startsWith(normalizedBase);
}
class AuthenticatedNetworkImage extends ConsumerStatefulWidget {
final String imageUrl;
final BoxFit? fit;
final double? width;
final double? height;
final ImageLoadingBuilder? loadingBuilder;
final ImageErrorWidgetBuilder? errorBuilder;
const AuthenticatedNetworkImage({
super.key,
required this.imageUrl,
this.fit,
this.width,
this.height,
this.loadingBuilder,
this.errorBuilder,
});
@override
ConsumerState<AuthenticatedNetworkImage> createState() =>
_AuthenticatedNetworkImageState();
}
class _AuthenticatedNetworkImageState
extends ConsumerState<AuthenticatedNetworkImage> {
late String _resolvedUrl;
Future<Uint8List>? _bytes;
@override
void initState() {
super.initState();
_configure();
}
@override
void didUpdateWidget(covariant AuthenticatedNetworkImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.imageUrl != widget.imageUrl) _configure();
}
void _configure() {
_resolvedUrl = protectedMediaUrl(widget.imageUrl);
_bytes = mediaRequiresAuthentication(_resolvedUrl)
? _loadProtectedBytes(_resolvedUrl)
: null;
}
Future<Uint8List> _loadProtectedBytes(String url) async {
final response = await ref
.read(apiClientProvider)
.dio
.get<List<int>>(
url,
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data ?? const []);
}
@override
Widget build(BuildContext context) {
final bytes = _bytes;
if (bytes == null) {
return Image.network(
_resolvedUrl,
fit: widget.fit,
width: widget.width,
height: widget.height,
loadingBuilder: widget.loadingBuilder,
errorBuilder: widget.errorBuilder,
);
}
return FutureBuilder<Uint8List>(
future: bytes,
builder: (context, snapshot) {
if (snapshot.hasError) {
return widget.errorBuilder?.call(
context,
snapshot.error!,
snapshot.stackTrace,
) ??
const Icon(Icons.broken_image_outlined);
}
final data = snapshot.data;
if (data == null) {
return const Center(child: CircularProgressIndicator(strokeWidth: 2));
}
return Image.memory(
data,
fit: widget.fit,
width: widget.width,
height: widget.height,
errorBuilder: widget.errorBuilder,
);
},
);
}
}