using System.IO.Compression; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; namespace Lskj.AgentPet.Host.Core.Attachments; public sealed record PendingAttachment( string AttachmentId, string FileName, string Type, string MimeType, long SizeBytes, string ContentSha256); public interface IAttachmentPicker { Task> PickAsync( int maximumCount, CancellationToken cancellationToken = default); } public interface IAstrBotAttachmentUploader { Task<(string AttachmentId, string Type)> UploadAsync( Stream content, string fileName, string mimeType, long sizeBytes, CancellationToken cancellationToken = default); } public interface IAttachmentSession { Task> PickAndUploadAsync( CancellationToken cancellationToken = default); IReadOnlyList Snapshot(); bool Remove(string attachmentId); void Consume(IEnumerable attachmentIds); } public sealed class AttachmentSession : IAttachmentSession { private static readonly Regex SafeAttachmentId = new( "^[A-Za-z0-9_-]{8,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private readonly IAttachmentPicker _picker; private readonly IAstrBotAttachmentUploader _uploader; private readonly int _maximumCount; private readonly long _maximumFileBytes; private readonly long _maximumTotalBytes; private readonly object _sync = new(); private readonly SemaphoreSlim _gate = new(1, 1); private readonly Dictionary _pending = new(StringComparer.Ordinal); public AttachmentSession( IAttachmentPicker picker, IAstrBotAttachmentUploader uploader, int maximumCount = 3, long maximumFileBytes = 12 * 1024 * 1024, long maximumTotalBytes = 36 * 1024 * 1024) { _picker = picker ?? throw new ArgumentNullException(nameof(picker)); _uploader = uploader ?? throw new ArgumentNullException(nameof(uploader)); if (maximumCount is < 1 or > 3 || maximumFileBytes is < 1024 or > 12 * 1024 * 1024 || maximumTotalBytes < maximumFileBytes || maximumTotalBytes > maximumCount * maximumFileBytes) throw new ArgumentOutOfRangeException(nameof(maximumCount)); _maximumCount = maximumCount; _maximumFileBytes = maximumFileBytes; _maximumTotalBytes = maximumTotalBytes; } public async Task> PickAndUploadAsync( CancellationToken cancellationToken = default) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { int available; long existingBytes; lock (_sync) { available = _maximumCount - _pending.Count; existingBytes = _pending.Values.Sum(item => item.SizeBytes); } if (available <= 0) throw new HostError( "attachment_count_exceeded", $"最多只能附加 {_maximumCount} 个文件。"); IReadOnlyList selected = await _picker.PickAsync( available, cancellationToken).ConfigureAwait(false); if (selected.Count > available) throw new HostError("attachment_count_exceeded", "选择的附件数量超过允许范围。"); foreach (string path in selected) { ValidatedFile file = ValidatePath(path); if (existingBytes + file.SizeBytes > _maximumTotalBytes) throw new HostError("attachment_total_size_exceeded", "待发送附件总大小超过限制。"); await using FileStream stream = new( file.FullPath, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan); if (stream.Length != file.SizeBytes) throw new HostError("attachment_changed", "附件在选择后发生变化,请重新选择。"); string mimeType = await DetectAndValidateAsync( stream, file.Extension, cancellationToken).ConfigureAwait(false); stream.Position = 0; byte[] contentDigest = await SHA256.HashDataAsync( stream, cancellationToken).ConfigureAwait(false); string contentSha256 = Convert.ToHexString(contentDigest) .ToLowerInvariant(); stream.Position = 0; (string attachmentId, string type) = await _uploader.UploadAsync( stream, file.FileName, mimeType, file.SizeBytes, cancellationToken).ConfigureAwait(false); if (!SafeAttachmentId.IsMatch(attachmentId) || (type != "image" && type != "file")) throw new HostError("astrbot_upload_protocol_error", "AstrBot 返回了无效附件标识。"); string expectedType = mimeType.StartsWith("image/", StringComparison.Ordinal) ? "image" : "file"; if (type != expectedType) throw new HostError("astrbot_upload_protocol_error", "AstrBot 返回的附件类型不匹配。"); PendingAttachment pending = new( attachmentId, file.FileName, type, mimeType, file.SizeBytes, contentSha256); lock (_sync) _pending[attachmentId] = pending; existingBytes += file.SizeBytes; } return Snapshot(); } finally { _gate.Release(); } } public IReadOnlyList Snapshot() { lock (_sync) return _pending.Values.OrderBy(item => item.FileName, StringComparer.Ordinal) .ToArray(); } public bool Remove(string attachmentId) { if (string.IsNullOrWhiteSpace(attachmentId) || !SafeAttachmentId.IsMatch(attachmentId)) return false; lock (_sync) return _pending.Remove(attachmentId); } public void Consume(IEnumerable attachmentIds) { if (attachmentIds is null) return; lock (_sync) { foreach (string id in attachmentIds) { if (!string.IsNullOrWhiteSpace(id)) _pending.Remove(id); } } } private ValidatedFile ValidatePath(string path) { try { string fullPath = Path.GetFullPath(path ?? string.Empty); FileInfo file = new(fullPath); string fileName = Path.GetFileName(fullPath); string extension = Path.GetExtension(fullPath).ToLowerInvariant(); if (!file.Exists || (file.Attributes & FileAttributes.ReparsePoint) != 0 || file.Length <= 0 || file.Length > _maximumFileBytes || string.IsNullOrWhiteSpace(fileName) || fileName.Length > 128 || fileName.Any(char.IsControl) || extension is not (".png" or ".jpg" or ".jpeg" or ".webp" or ".pdf" or ".xlsx" or ".csv")) throw new HostError("attachment_invalid", "附件不存在、格式不支持或大小超出限制。"); return new ValidatedFile(fullPath, fileName, extension, file.Length); } catch (HostError) { throw; } catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException) { throw new HostError("attachment_invalid", "无法安全读取所选附件。", error); } } private static async Task DetectAndValidateAsync( Stream stream, string extension, CancellationToken cancellationToken) { byte[] header = new byte[Math.Min(64 * 1024, checked((int)stream.Length))]; int total = 0; while (total < header.Length) { int read = await stream.ReadAsync( header.AsMemory(total, header.Length - total), cancellationToken).ConfigureAwait(false); if (read == 0) break; total += read; } switch (extension) { case ".png" when Starts(header, total, new byte[] { 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a }): return "image/png"; case ".jpg" or ".jpeg" when Starts(header, total, new byte[] { 0xff, 0xd8, 0xff }): return "image/jpeg"; case ".webp" when total >= 12 && Encoding.ASCII.GetString(header, 0, 4) == "RIFF" && Encoding.ASCII.GetString(header, 8, 4) == "WEBP": return "image/webp"; case ".pdf" when Starts(header, total, Encoding.ASCII.GetBytes("%PDF-")): return "application/pdf"; case ".csv": await ValidateUtf8CsvAsync(stream, cancellationToken).ConfigureAwait(false); return "text/csv"; case ".xlsx" when Starts(header, total, new byte[] { 0x50, 0x4b, 0x03, 0x04 }): ValidateXlsx(stream); return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; } throw new HostError("attachment_signature_invalid", "附件内容与扩展名不一致。"); } private static async Task ValidateUtf8CsvAsync( Stream stream, CancellationToken cancellationToken) { try { stream.Position = 0; using StreamReader reader = new( stream, new UTF8Encoding(false, true), detectEncodingFromByteOrderMarks: false, bufferSize: 64 * 1024, leaveOpen: true); char[] buffer = new char[32 * 1024]; while (true) { int read = await reader.ReadAsync( buffer.AsMemory(), cancellationToken).ConfigureAwait(false); if (read == 0) break; if (Array.IndexOf(buffer, '\0', 0, read) >= 0) throw new HostError( "attachment_signature_invalid", "CSV 附件包含二进制内容。"); } } catch (DecoderFallbackException error) { throw new HostError("attachment_encoding_invalid", "CSV 必须使用 UTF-8 编码。", error); } } private static void ValidateXlsx(Stream stream) { try { stream.Position = 0; using ZipArchive archive = new(stream, ZipArchiveMode.Read, leaveOpen: true); if (archive.Entries.Count > 10000 || !archive.Entries.Any(item => item.FullName == "[Content_Types].xml") || !archive.Entries.Any(item => item.FullName == "xl/workbook.xml") || archive.Entries.Sum(item => item.Length) > 100 * 1024 * 1024) throw new HostError("attachment_signature_invalid", "XLSX 文件结构无效或解压后过大。"); } catch (HostError) { throw; } catch (InvalidDataException error) { throw new HostError("attachment_signature_invalid", "XLSX 文件结构无效。", error); } } private static bool Starts(byte[] value, int length, byte[] prefix) { if (length < prefix.Length) return false; for (int index = 0; index < prefix.Length; index++) if (value[index] != prefix[index]) return false; return true; } private sealed record ValidatedFile( string FullPath, string FileName, string Extension, long SizeBytes); } public sealed class DisabledAttachmentSession : IAttachmentSession { public static DisabledAttachmentSession Instance { get; } = new(); private DisabledAttachmentSession() { } public Task> PickAndUploadAsync( CancellationToken cancellationToken = default) => Task.FromException>( new HostError("attachments_unavailable", "当前宿主未启用附件功能。")); public IReadOnlyList Snapshot() => Array.Empty(); public bool Remove(string attachmentId) => false; public void Consume(IEnumerable attachmentIds) { } }