using Blahblah.FlowerStory.Server.Data; using Blahblah.FlowerStory.Server.Data.Model; using Microsoft.AspNetCore.Mvc; using SkiaSharp; using System.Net; using System.Reflection; using System.Security.Cryptography; using System.Text; namespace Blahblah.FlowerStory.Server.Controller; /// <summary> /// 基础服务抽象类 /// </summary> /// <remarks> /// 构造基础服务类 /// </remarks> /// <param name="database">数据库对象</param> /// <param name="logger">日志对象</param> public abstract partial class BaseController<T>(FlowerDatabase database, ILogger<T>? logger = null) : ControllerBase { private const string Salt = "Blah blah, o! Flower story, intimately help you record every bit of the garden."; private const int ThumbWidth = 600; /// <summary> /// 临时 id /// </summary> protected const int TempId = -1; private static string? uploadsDirectory; /// <summary> /// 文件上传路径 /// </summary> protected static string UploadsDirectory => uploadsDirectory ??= Path.Combine(Program.DataPath, "uploads"); /// <summary> /// 支持的图片文件签名 /// </summary> protected static readonly List<byte[]> PhotoSignatures = [ // jpeg [0xFF, 0xD8, 0xFF, 0xDB], [0xFF, 0xD8, 0xFF, 0xE0], [0xFF, 0xD8, 0xFF, 0xE1], [0xFF, 0xD8, 0xFF, 0xE2], [0xFF, 0xD8, 0xFF, 0xE3], // png [0x89, 0x50, 0x4E, 0x47] ]; /// <summary> /// 自定义认证头的关键字 /// </summary> protected const string AuthHeader = "Authorization"; /// <summary> /// 禁用用户 /// </summary> protected const int UserDisabled = -1; /// <summary> /// 普通用户 /// </summary> protected const int UserCommon = 0; /// <summary> /// 管理员用户 /// </summary> protected const int UserAdmin = 99; /// <summary> /// 数据库对象 /// </summary> protected readonly FlowerDatabase database = database; /// <summary> /// 日志对象 /// </summary> protected readonly ILogger? logger = logger; /// <summary> /// 计算密码的 hash /// </summary> /// <param name="password">密码原文</param> /// <param name="id">用户 id</param> /// <returns>密码 hash,值为 SHA256(password+id+salt)</returns> /// <exception cref="ArgumentNullException"></exception> public string HashPassword(string password, string id) { ArgumentException.ThrowIfNullOrEmpty(password); ArgumentException.ThrowIfNullOrEmpty(id); var data = Encoding.UTF8.GetBytes($"{password}{id}{Salt}"); var hash = SHA256.HashData(data); return Convert.ToHexString(hash); } /// <summary> /// 检出当前会话 /// </summary> /// <returns>若检查失败,第一个参数返回异常 ActionResult。第二个参数返回会话令牌对象</returns> protected (ActionResult? Result, TokenItem? Token) CheckToken() { if (!Request.Headers.TryGetValue(AuthHeader, out var h)) { logger?.LogWarning("request with no {auth} header", AuthHeader); return (Unauthorized(), null); } string hash = h.ToString(); var token = database.Tokens.Find(hash); if (token == null) { logger?.LogWarning("token \"{hash}\" not found", hash); return (Unauthorized(), null); } return (null, token); } /// <summary> /// 检查当前会话权限 /// </summary> /// <param name="level">需要大于等于该权限,默认为 0 - UserCommon</param> /// <returns>若检查失败,第一个参数返回异常 ActionResult。第二个参数返回会话对应的用户对象</returns> protected (ActionResult? Result, UserItem? User) CheckPermission(int? level = 0) { var (result, token) = CheckToken(); if (result != null) { return (result, null); } if (token == null) { return (Unauthorized(), null); } if (token.ExpireDate < DateTimeOffset.UtcNow) { logger?.LogWarning("token \"{hash}\" has expired after {date}", token.Id, token.ExpireDate); return (Unauthorized(), null); } var user = QueryUserItem(token.UserId); //var user = database.Users.Where(u => u.Id == token.UserId).Select(u => new UserItem //{ // Id = u.Id, // UserId = u.UserId, // Level = u.Level, // RegisterDateUnixTime = u.RegisterDateUnixTime, // ActiveDateUnixTime = u.ActiveDateUnixTime, // Name = u.Name, // Email = u.Email, // Mobile = u.Mobile //}).SingleOrDefault(); if (user == null) { logger?.LogWarning("user not found with id {id}", token.UserId); } else if (user.Level < level) { logger?.LogWarning("user \"{id}\" level ({level}) lower than required ({required})", user.UserId, user.Level, level); return (Forbid(), user); } else { var now = DateTimeOffset.UtcNow; token.ActiveDateUnixTime = now.ToUnixTimeMilliseconds(); var expires = now.AddSeconds(token.ExpireSeconds).ToUnixTimeMilliseconds(); if (expires > token.ExpireDateUnixTime) { token.ExpireDateUnixTime = expires; } user.ActiveDateUnixTime = now.ToUnixTimeMilliseconds(); } return (null, user); } /// <summary> /// 保存数据库变动并输出日志 /// </summary> /// <returns>操作的数据库行数</returns> protected int SaveDatabase() { var count = database.SaveChanges(); if (count > 0) { logger?.LogInformation("{number} of entries written to database.", count); } else { logger?.LogWarning("no data written to database."); } return count; } /// <summary> /// 获取嵌入流的数据 /// </summary> /// <param name="filename">文件名</param> /// <param name="namespace">命名空间,默认为程序集.wwwroot</param> /// <returns></returns> protected static byte[] GetEmbeddedData(string filename, string? @namespace = null) { Assembly asm = Assembly.GetExecutingAssembly(); if (string.IsNullOrEmpty(@namespace)) { @namespace = typeof(Program).Namespace + ".wwwroot"; } using Stream? stream = asm.GetManifestResourceStream($"{@namespace}.{filename}"); if (stream == null) { return []; } using var ms = new MemoryStream(); stream.CopyTo(ms); return ms.ToArray(); } /// <summary> /// 读取文件到 byte 数组 /// </summary> /// <param name="file">来自请求的文件</param> /// <returns>文件结果对象</returns> protected static FileResult? WrapFormFile(IFormFile file) { if (file?.Length > 0) { using var stream = file.OpenReadStream(); // check header var headers = new byte[PhotoSignatures.Max(s => s.Length)]; int count = stream.Read(headers, 0, headers.Length); if (PhotoSignatures.Any(s => headers.Take(s.Length).SequenceEqual(s))) { using var ms = new MemoryStream(); ms.Write(headers, 0, count); #if __OLD_READER__ // reading const int size = 16384; var buffer = new byte[size]; while ((count = stream.Read(buffer, 0, size)) > 0) { ms.Write(buffer, 0, count); } #else stream.CopyTo(ms); #endif var data = ms.ToArray(); var name = file.FileName; var ext = Path.GetExtension(name); var path = $"{WebUtility.UrlEncode(Path.GetFileNameWithoutExtension(name))}_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}{ext}"; var image = SKImage.FromEncodedData(data); return new FileResult { Filename = name, FileType = ext, Path = path, Content = data, Width = image.Width, Height = image.Height }; } } return null; } private static string GetFlowerDirectory(int fid, bool create = false) { var directory = Path.Combine(UploadsDirectory, fid.ToString()); if (create && !Directory.Exists(directory)) { Directory.CreateDirectory(directory); } return directory; } /// <summary> /// 创建缩略图 /// </summary> /// <param name="data">图片数据</param> /// <param name="maxWidth">最大宽度</param> /// <param name="quality">缩放质量</param> /// <returns></returns> protected static byte[] CreateThumbnail(byte[] data, int maxWidth = ThumbWidth, SKFilterQuality quality = SKFilterQuality.Medium) { if (maxWidth <= 0) { return data; } using var image = SKImage.FromEncodedData(data); if (maxWidth >= image.Width) { using var enc = image.Encode(SKEncodedImageFormat.Jpeg, 80); return enc.ToArray(); } var height = maxWidth * image.Height / image.Width; using var bitmap = SKBitmap.FromImage(image); using var resized = bitmap.Resize(new SKImageInfo(maxWidth, height), quality); using var encode = resized.Encode(SKEncodedImageFormat.Jpeg, 80); return encode.ToArray(); } /// <summary> /// 写入文件到用户的花草目录中 /// </summary> /// <param name="fid">花草唯一 id</param> /// <param name="file">文件对象</param> /// <param name="thumbnail">创建缩略图</param> /// <param name="token">取消令牌</param> protected async Task WriteToFile(int fid, FileResult file, bool thumbnail = true, CancellationToken token = default) { var directory = GetFlowerDirectory(fid, true); var path = Path.Combine(directory, file.Path); await System.IO.File.WriteAllBytesAsync(path, file.Content, token); if (thumbnail) { try { var thumb = CreateThumbnail(file.Content); path = Path.Combine(directory, $"{file.Path}.thumb"); await System.IO.File.WriteAllBytesAsync(path, thumb, token); } catch (Exception ex) { logger?.LogError(ex, "failed to create thumbnail for flower: {fid}, file: {file}, error: {message}", fid, file.Filename, ex.Message); } } } /// <summary> /// 删除花草下的文件 /// </summary> /// <param name="fid">花草唯一 id</param> /// <param name="path">文件路径</param> /// <returns>返回是否已删除</returns> protected static bool DeleteFile(int fid, string path) { var directory = GetFlowerDirectory(fid); if (Directory.Exists(directory)) { path = Path.Combine(directory, path); if (System.IO.File.Exists(path)) { System.IO.File.Delete(path); return true; } } return false; } /// <summary> /// 移动临时照片到花草目录 /// </summary> /// <param name="fid">花草唯一 id</param> /// <param name="path">文件路径</param> protected static void MoveTempFileToFlower(int fid, string path) { var directory = GetFlowerDirectory(TempId); if (Directory.Exists(directory)) { var file = Path.Combine(directory, path); if (System.IO.File.Exists(file)) { directory = GetFlowerDirectory(fid, true); var to = Path.Combine(directory, path); if (System.IO.File.Exists(to)) { System.IO.File.Move(to, $"{to}.bak"); } System.IO.File.Move(file, to); } } } private const double EarthRadius = 6378137; private static double Radius(double degree) { return degree * Math.PI / 180.0; } /// <summary> /// 获取两个经纬度之间的距离 /// </summary> /// <param name="lat1">纬度1</param> /// <param name="lon1">经度1</param> /// <param name="lat2">纬度2</param> /// <param name="lon2">经度2</param> /// <returns></returns> protected static double GetDistance(double lat1, double lon1, double lat2, double lon2) { double rlat1 = Radius(lat1); double rlat2 = Radius(lat2); return EarthRadius * Math.Acos( Math.Cos(rlat1) * Math.Cos(rlat2) * Math.Cos(Radius(lon1) - Radius(lon2)) + Math.Sin(rlat1) * Math.Sin(rlat2)); } /// <inheritdoc/> protected static double GetDistance2(double lat1, double lon1, double lat2, double lon2) { double rlat1 = Radius(lat1); double rlat2 = Radius(lat2); double a = rlat1 - rlat2; double b = Radius(lon1) - Radius(lon2); double s = 2 * Math.Asin(Math.Sqrt(Math.Pow(Math.Sin(a / 2), 2) + Math.Cos(rlat1) * Math.Cos(rlat2) * Math.Pow(Math.Sin(b / 2), 2))); return s * EarthRadius; } } /// <summary> /// 文件结果 /// </summary> public record FileResult { /// <summary> /// 文件名 /// </summary> public required string Filename { get; init; } /// <summary> /// 文件类型 /// </summary> public required string FileType { get; init; } /// <summary> /// 储存路径 /// </summary> public required string Path { get; init; } /// <summary> /// 文件内容 /// </summary> public required byte[] Content { get; init; } /// <summary> /// 照片宽度 /// </summary> public required int Width { get; init; } /// <summary> /// 照片高度 /// </summary> public required int Height { get; init; } }