Files
IM_NEW/Admin.WebApi/Services/SettingsService.cs
T

83 lines
6.6 KiB
C#

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<string, string[]> 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<string, JsonNode?>(k, all[k]?.DeepClone())));
}
public async Task<Policy> 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<Policy>(Json)!; policy.Version = rows.Sum(x => x.Version); return policy;
}
public async Task<object> 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<Policy>(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);