388 lines
12 KiB
C#
388 lines
12 KiB
C#
using Blahblah.FlowerStory.Server.Data;
|
||
using Blahblah.FlowerStory.Server.Data.Model;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using System.Net;
|
||
using System.Security.Cryptography;
|
||
using System.Text;
|
||
|
||
namespace Blahblah.FlowerStory.Server.Controller;
|
||
|
||
/// <summary>
|
||
/// 基础服务抽象类
|
||
/// </summary>
|
||
public abstract partial class BaseController : ControllerBase
|
||
{
|
||
private const string Salt = "Blah blah, o! Flower story, intimately help you record every bit of the garden.";
|
||
|
||
/// <summary>
|
||
/// 临时 id
|
||
/// </summary>
|
||
protected const int TempId = -1;
|
||
|
||
private static string? uploadsDirectory;
|
||
/// <summary>
|
||
/// 文件上传路径
|
||
/// </summary>
|
||
protected static string UploadsDirectory => uploadsDirectory ??= Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "uploads");
|
||
|
||
/// <summary>
|
||
/// 支持的图片文件签名
|
||
/// </summary>
|
||
protected static readonly List<byte[]> PhotoSignatures = new()
|
||
{
|
||
// jpeg
|
||
new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 },
|
||
new byte[] { 0xFF, 0xD8, 0xFF, 0xE1 },
|
||
new byte[] { 0xFF, 0xD8, 0xFF, 0xE2 },
|
||
new byte[] { 0xFF, 0xD8, 0xFF, 0xE3 },
|
||
// png
|
||
new byte[] { 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;
|
||
/// <summary>
|
||
/// 日志对象
|
||
/// </summary>
|
||
protected readonly ILogger<BaseController>? logger;
|
||
|
||
/// <summary>
|
||
/// 构造基础服务类
|
||
/// </summary>
|
||
/// <param name="database">数据库对象</param>
|
||
/// <param name="logger">日志对象</param>
|
||
protected BaseController(FlowerDatabase database, ILogger<BaseController>? logger = null)
|
||
{
|
||
this.database = database;
|
||
this.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>
|
||
protected string HashPassword(string password, string id)
|
||
{
|
||
if (string.IsNullOrEmpty(password))
|
||
{
|
||
throw new ArgumentNullException(nameof(password));
|
||
}
|
||
if (string.IsNullOrEmpty(id))
|
||
{
|
||
throw new ArgumentNullException(nameof(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>
|
||
/// 读取文件到 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 = SkiaSharp.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="fid">花草唯一 id</param>
|
||
/// <param name="file">文件对象</param>
|
||
/// <param name="token">取消令牌</param>
|
||
protected static async Task WriteToFile(int fid, FileResult file, CancellationToken token = default)
|
||
{
|
||
var directory = GetFlowerDirectory(fid, true);
|
||
var path = Path.Combine(directory, file.Path);
|
||
await System.IO.File.WriteAllBytesAsync(path, file.Content, token);
|
||
}
|
||
|
||
/// <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; }
|
||
}
|