## 后端安全加固 - 新增 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: 更新
127 lines
4.9 KiB
C#
127 lines
4.9 KiB
C#
using System.Text.Json;
|
||
using Health.Application.Users;
|
||
|
||
namespace Health.Infrastructure.Users;
|
||
|
||
public sealed class LocalAccountFileCleanup(string uploadsRoot) : IAccountFileCleanup
|
||
{
|
||
private readonly string _uploadsRoot = Path.GetFullPath(uploadsRoot);
|
||
|
||
public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct)
|
||
{
|
||
// 正式报告路径来自服务端生成的报告记录,只允许删除 reports 目录中的文件。
|
||
foreach (var fileUrl in references.FileUrls)
|
||
{
|
||
ct.ThrowIfCancellationRequested();
|
||
var localPath = ResolveReportPath(fileUrl);
|
||
if (localPath != null && File.Exists(localPath)) File.Delete(localPath);
|
||
}
|
||
|
||
// 对话元数据可能包含客户端传入的 URL,只允许解析当前账号自己的目录。
|
||
var attachmentUrls = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
foreach (var metadataJson in references.ConversationMetadataJson)
|
||
AddMetadataUrls(attachmentUrls, metadataJson);
|
||
|
||
foreach (var fileUrl in attachmentUrls)
|
||
{
|
||
ct.ThrowIfCancellationRequested();
|
||
var localPath = ResolveOwnedAttachmentPath(userId, fileUrl);
|
||
if (localPath != null && File.Exists(localPath)) File.Delete(localPath);
|
||
}
|
||
|
||
ct.ThrowIfCancellationRequested();
|
||
var userDirectory = Path.Combine(_uploadsRoot, "users", userId.ToString("N"));
|
||
if (Directory.Exists(userDirectory)) Directory.Delete(userDirectory, recursive: true);
|
||
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
private static void AddMetadataUrls(HashSet<string> fileUrls, string metadataJson)
|
||
{
|
||
try
|
||
{
|
||
using var document = JsonDocument.Parse(metadataJson);
|
||
AddStringProperty(document.RootElement, "imageUrl", fileUrls);
|
||
AddStringProperty(document.RootElement, "pdfUrl", fileUrls);
|
||
}
|
||
catch (JsonException)
|
||
{
|
||
// 旧消息元数据可能不是合法 JSON;不影响其余账号数据注销。
|
||
}
|
||
}
|
||
|
||
private static void AddStringProperty(JsonElement root, string propertyName, HashSet<string> fileUrls)
|
||
{
|
||
if (root.ValueKind != JsonValueKind.Object ||
|
||
!root.TryGetProperty(propertyName, out var property) ||
|
||
property.ValueKind != JsonValueKind.String)
|
||
return;
|
||
|
||
var value = property.GetString();
|
||
if (!string.IsNullOrWhiteSpace(value)) fileUrls.Add(value);
|
||
}
|
||
|
||
private string? ResolveReportPath(string fileUrl)
|
||
{
|
||
try
|
||
{
|
||
var path = Uri.TryCreate(fileUrl, UriKind.Absolute, out var absolute)
|
||
? absolute.AbsolutePath
|
||
: fileUrl.Split('?', 2)[0];
|
||
var prefixIndex = path.IndexOf("/uploads/reports/", StringComparison.OrdinalIgnoreCase);
|
||
if (prefixIndex < 0) return null;
|
||
var fileName = Uri.UnescapeDataString(path[(prefixIndex + "/uploads/reports/".Length)..]);
|
||
return ResolveInside(Path.Combine(_uploadsRoot, "reports"), fileName);
|
||
}
|
||
catch (ArgumentException)
|
||
{
|
||
return null;
|
||
}
|
||
catch (UriFormatException)
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private string? ResolveOwnedAttachmentPath(Guid userId, string fileUrl)
|
||
{
|
||
try
|
||
{
|
||
var path = Uri.TryCreate(fileUrl, UriKind.Absolute, out var absolute)
|
||
? absolute.AbsolutePath
|
||
: fileUrl.Split('?', 2)[0];
|
||
path = Uri.UnescapeDataString(path);
|
||
var protectedPrefix = "/api/files/content/";
|
||
var protectedIndex = path.IndexOf(protectedPrefix, StringComparison.OrdinalIgnoreCase);
|
||
var legacyPrefix = $"/uploads/users/{userId:N}/";
|
||
var legacyIndex = path.IndexOf(legacyPrefix, StringComparison.OrdinalIgnoreCase);
|
||
var fileName = protectedIndex >= 0
|
||
? path[(protectedIndex + protectedPrefix.Length)..]
|
||
: legacyIndex >= 0
|
||
? path[(legacyIndex + legacyPrefix.Length)..]
|
||
: null;
|
||
return fileName == null
|
||
? null
|
||
: ResolveInside(Path.Combine(_uploadsRoot, "users", userId.ToString("N")), fileName);
|
||
}
|
||
catch (ArgumentException)
|
||
{
|
||
return null;
|
||
}
|
||
catch (UriFormatException)
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private static string? ResolveInside(string root, string fileName)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(fileName) || fileName != Path.GetFileName(fileName)) return null;
|
||
var fullRoot = Path.GetFullPath(root);
|
||
var fullPath = Path.GetFullPath(Path.Combine(fullRoot, fileName));
|
||
var rootPrefix = fullRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||
+ Path.DirectorySeparatorChar;
|
||
return fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ? fullPath : null;
|
||
}
|
||
}
|