## 后端安全加固 - 新增 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: 更新
132 lines
3.5 KiB
Dart
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,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|