using System.Text.Json; using System.Text.Json.Nodes; using IM.Admin.Api; using IM.Admin.Data; using IM.InitCommon.Management; using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; using System.Security.Cryptography; using System.Text; namespace IM.Admin.Services; public sealed class SettingsService(AdminDb db, IDataProtectionProvider protection, IConfiguration config) { public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); public static readonly Dictionary Fields = new() { ["platform"] = ["platformName", "description", "supportEmail"], ["account"] = ["registrationEnabled", "passwordMinLength"], ["social"] = ["friendLimit", "createdGroupLimit", "groupMemberLimit", "defaultJoinAuthority"], ["messaging"] = ["textLimit", "recallMinutes", "uploadMaxBytes", "allowedFileTypes"], ["reports"] = ["reportCategories", "reportsPerDay", "reportCooldownMinutes"], ["security"] = ["clientAccessMinutes", "clientRefreshDays", "adminSessionMinutes", "adminLockThreshold", "adminLockMinutes"], }; byte[] Key() { var key = Convert.FromBase64String(config["Management:CredentialKey"] ?? throw new InvalidOperationException("Management:CredentialKey is required")); if (key.Length != 32) throw new InvalidOperationException("CredentialKey must be 32 bytes"); return key; } public string Protect(string secret) { if (string.IsNullOrEmpty(secret)) return ""; var nonce = RandomNumberGenerator.GetBytes(12); var plain = Encoding.UTF8.GetBytes(secret); var cipher = new byte[plain.Length]; var tag = new byte[16]; using var aes = new AesGcm(Key(), 16); aes.Encrypt(nonce, plain, cipher, tag, Encoding.UTF8.GetBytes("IM.Admin.Config.v1")); return "v1:" + Convert.ToBase64String(nonce.Concat(tag).Concat(cipher).ToArray()); } public string Unprotect(string? secret) { if (string.IsNullOrEmpty(secret)) return ""; if (!secret.StartsWith("v1:")) throw new InvalidOperationException("Unsupported credential encryption version"); var data = Convert.FromBase64String(secret[3..]); var plain = new byte[data.Length - 28]; using var aes = new AesGcm(Key(), 16); aes.Decrypt(data.AsSpan(0,12), data.AsSpan(28), data.AsSpan(12,16), plain, Encoding.UTF8.GetBytes("IM.Admin.Config.v1")); return Encoding.UTF8.GetString(plain); } public static JsonObject Defaults(string id) { if (!Fields.TryGetValue(id, out var fields)) throw new ApiError(404, "配置分组不存在"); var all = JsonSerializer.SerializeToNode(new Policy(), Json)!.AsObject(); return new JsonObject(fields.Select(k => new KeyValuePair(k, all[k]?.DeepClone()))); } public async Task Policy() { var merged = JsonSerializer.SerializeToNode(new Policy(), Json)!.AsObject(); var rows = await db.Settings.AsNoTracking().Where(x => x.Id != "storage" && x.Id != "smtp").ToListAsync(); foreach (var row in rows) foreach (var p in JsonNode.Parse(row.Value)!.AsObject()) merged[p.Key] = p.Value?.DeepClone(); var policy = merged.Deserialize(Json)!; policy.Version = rows.Sum(x => x.Version); return policy; } public async Task List() { var rows = await db.Settings.AsNoTracking().ToListAsync(); return Fields.Keys.Concat(["storage", "smtp"]).Select(id => { var row = rows.SingleOrDefault(x => x.Id == id); return new { id, version = row?.Version ?? 0, value = row is null ? (Fields.ContainsKey(id) ? Defaults(id) : new JsonObject()) : JsonNode.Parse(row.Value), draft = row?.Draft is null ? null : JsonNode.Parse(row.Draft), secretSet = !string.IsNullOrEmpty(row?.Secret), draftSecretSet = !string.IsNullOrEmpty(row?.DraftSecret), tested = row?.TestedVersion == row?.Version && row is not null, updatedAt = row?.UpdatedAt }; }).ToArray(); } public async Task Save(string id, SettingInput input, HttpContext c) { ApiSupport.Reason(input.Reason); if (!Fields.ContainsKey(id)) throw new ApiError(400, "基础设施配置需走草稿发布流程"); Validate(id, input.Value); var row = await db.Settings.SingleOrDefaultAsync(x => x.Id == id); if ((row?.Version ?? 0) != input.Version) throw new ApiError(409, "配置版本已变化,请刷新后重试"); if (row is null) { row = new SystemSetting { Id = id }; db.Settings.Add(row); } var before = row.Value; row.Value = input.Value.ToJsonString(); row.Version++; row.UpdatedAt = DateTime.UtcNow; db.Audit(c, "修改系统配置", id, before, row.Value, input.Reason); await db.SaveChangesAsync(); } public static void Validate(string id, JsonObject value) { if (!Fields.TryGetValue(id, out var fields) || value.Count != fields.Length || fields.Any(x => !value.ContainsKey(x))) throw new ApiError(400, "配置字段不完整或包含未知字段"); try { var merged = JsonSerializer.SerializeToNode(new Policy(), Json)!.AsObject(); foreach (var p in value) merged[p.Key] = p.Value?.DeepClone(); var p1 = merged.Deserialize(Json)!; if (string.IsNullOrWhiteSpace(p1.PlatformName) || p1.PlatformName.Length > 60 || p1.Description.Length > 500 || p1.PasswordMinLength is < 6 or > 50 || p1.AdminSessionMinutes is < 5 or > 10080 || p1.AdminLockThreshold is < 1 or > 20 || p1.AdminLockMinutes is < 1 or > 1440 || p1.DefaultJoinAuthority is < 0 or > 2 || p1.ReportsPerDay is < 1 or > 1000 || p1.ReportCooldownMinutes is < 0 or > 1440) throw new Exception(); if (new[] { p1.FriendLimit, p1.CreatedGroupLimit, p1.GroupMemberLimit, p1.TextLimit, p1.RecallMinutes, p1.ClientAccessMinutes, p1.ClientRefreshDays }.Any(x => x < 0 || x > 1000000) || p1.UploadMaxBytes < 0 || p1.UploadMaxBytes > 1099511627776) throw new Exception(); if (p1.ReportCategories is null || p1.ReportCategories.Length is < 1 or > 30 || p1.ReportCategories.Any(x => string.IsNullOrWhiteSpace(x) || x.Length > 40) || p1.ReportCategories.Distinct().Count() != p1.ReportCategories.Length) throw new Exception(); if (p1.AllowedFileTypes is null || p1.AllowedFileTypes.Any(x => !System.Text.RegularExpressions.Regex.IsMatch(x, "^\\.[a-z0-9]{1,15}$"))) throw new Exception(); if (p1.SupportEmail.Length > 0 && !System.Net.Mail.MailAddress.TryCreate(p1.SupportEmail, out _)) throw new Exception(); } catch { throw new ApiError(400, "配置值无效,请检查类型、范围、邮箱和文件扩展名"); } } } public record SettingInput(long Version, JsonObject Value, string Reason, string? Secret = null);