From ac250ea7794fe7de35bb8ff72c0521890cac04dc Mon Sep 17 00:00:00 2001 From: Tsanie Lily Date: Tue, 8 Aug 2023 17:15:43 +0800 Subject: [PATCH] . --- Server/Controller/BaseController.cs | 74 +++++++++++- Server/Controller/BaseController.sqlite.cs | 12 +- Server/Controller/EventApiController.cs | 8 +- Server/Controller/FlowerApiController.cs | 8 +- Server/Controller/ImageController.cs | 105 ++++++++++++++---- Server/Controller/UserApiController.cs | 81 ++++++++++---- .../Controller/UserApiController.structs.cs | 14 ++- Server/Dockerfile | 8 +- Server/Program.cs | 13 ++- Server/Server.csproj | 12 ++ Server/appsettings.json | 19 +++- Server/start | 10 ++ Server/wwwroot/image/avatar.jpg | Bin 0 -> 4363 bytes 13 files changed, 303 insertions(+), 61 deletions(-) create mode 100644 Server/start create mode 100644 Server/wwwroot/image/avatar.jpg diff --git a/Server/Controller/BaseController.cs b/Server/Controller/BaseController.cs index 087a324..403f6cc 100644 --- a/Server/Controller/BaseController.cs +++ b/Server/Controller/BaseController.cs @@ -1,7 +1,9 @@ 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; @@ -14,6 +16,8 @@ public abstract partial class BaseController : ControllerBase { private const string Salt = "Blah blah, o! Flower story, intimately help you record every bit of the garden."; + private const int ThumbWidth = 600; + /// /// 临时 id /// @@ -23,7 +27,7 @@ public abstract partial class BaseController : ControllerBase /// /// 文件上传路径 /// - protected static string UploadsDirectory => uploadsDirectory ??= Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "uploads"); + protected static string UploadsDirectory => uploadsDirectory ??= Path.Combine(Program.DataPath, "uploads"); /// /// 支持的图片文件签名 @@ -31,6 +35,7 @@ public abstract partial class BaseController : ControllerBase protected static readonly List PhotoSignatures = new() { // jpeg + new byte[] { 0xFF, 0xD8, 0xFF, 0xDB }, new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }, new byte[] { 0xFF, 0xD8, 0xFF, 0xE1 }, new byte[] { 0xFF, 0xD8, 0xFF, 0xE2 }, @@ -195,6 +200,29 @@ public abstract partial class BaseController : ControllerBase return count; } + /// + /// 获取嵌入流的数据 + /// + /// 文件名 + /// 命名空间,默认为程序集.wwwroot + /// + 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 Array.Empty(); + } + using var ms = new MemoryStream(); + stream.CopyTo(ms); + return ms.ToArray(); + } + /// /// 读取文件到 byte 数组 /// @@ -230,7 +258,7 @@ public abstract partial class BaseController : ControllerBase var ext = Path.GetExtension(name); var path = $"{WebUtility.UrlEncode(Path.GetFileNameWithoutExtension(name))}_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}{ext}"; - var image = SkiaSharp.SKImage.FromEncodedData(data); + var image = SKImage.FromEncodedData(data); return new FileResult { @@ -256,17 +284,57 @@ public abstract partial class BaseController : ControllerBase return directory; } + /// + /// 创建缩略图 + /// + /// 图片数据 + /// 最大宽度 + /// 缩放质量 + /// + protected static byte[] CreateThumbnail(byte[] data, int maxWidth = ThumbWidth, SKFilterQuality quality = SKFilterQuality.Medium) + { + if (maxWidth <= 0) + { + return data; + } + using var bitmap = SKBitmap.Decode(data); + if (maxWidth >= bitmap.Width) + { + using var enc = bitmap.Encode(SKEncodedImageFormat.Jpeg, 80); + return enc.ToArray(); + } + var height = maxWidth * bitmap.Height / bitmap.Width; + using var image = bitmap.Resize(new SKImageInfo(maxWidth, height), quality); + using var encode = image.Encode(SKEncodedImageFormat.Jpeg, 80); + return encode.ToArray(); + } + /// /// 写入文件到用户的花草目录中 /// /// 花草唯一 id /// 文件对象 + /// 创建缩略图 /// 取消令牌 - protected static async Task WriteToFile(int fid, FileResult file, CancellationToken token = default) + 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); + } + } } /// diff --git a/Server/Controller/BaseController.sqlite.cs b/Server/Controller/BaseController.sqlite.cs index fe9bb7c..9af40fd 100644 --- a/Server/Controller/BaseController.sqlite.cs +++ b/Server/Controller/BaseController.sqlite.cs @@ -52,7 +52,7 @@ partial class BaseController } /// - /// 获取用户头像数据 + /// 根据用户 uid 获取用户头像数据 /// /// 用户唯一 id /// @@ -61,6 +61,16 @@ partial class BaseController return database.Database.SqlQuery($"SELECT \"avatar\" AS \"Value\" FROM \"users\" WHERE \"uid\" = {uid} LIMIT 1").SingleOrDefault(); } + /// + /// 根据用户 id 获取用户头像数据 + /// + /// 用户 id + /// + protected byte[]? QueryUserAvatar(string id) + { + return database.Database.SqlQuery($"SELECT \"avatar\" AS \"Value\" FROM \"users\" WHERE \"id\" = {id} LIMIT 1").SingleOrDefault(); + } + /// /// 移除用户头像 /// diff --git a/Server/Controller/EventApiController.cs b/Server/Controller/EventApiController.cs index 566580e..a44d86c 100644 --- a/Server/Controller/EventApiController.cs +++ b/Server/Controller/EventApiController.cs @@ -316,7 +316,7 @@ public class EventApiController : BaseController }; AddPhotoItem(p); - await WriteToFile(@event.FlowerId, file, token); + await WriteToFile(@event.FlowerId, file, token: token); } }); } @@ -443,7 +443,7 @@ public class EventApiController : BaseController }; AddPhotoItem(cover); - await WriteToFile(update.FlowerId, file, token); + await WriteToFile(update.FlowerId, file, token: token); } }); } @@ -541,7 +541,7 @@ public class EventApiController : BaseController }; AddPhotoItem(p); - await WriteToFile(record.FlowerId, file, token); + await WriteToFile(record.FlowerId, file, token: token); }); } catch (Exception ex) @@ -639,7 +639,7 @@ public class EventApiController : BaseController }; AddPhotoItem(p); - await WriteToFile(record.FlowerId, file, token); + await WriteToFile(record.FlowerId, file, token: token); } } }); diff --git a/Server/Controller/FlowerApiController.cs b/Server/Controller/FlowerApiController.cs index 24b4edb..1ab3a52 100644 --- a/Server/Controller/FlowerApiController.cs +++ b/Server/Controller/FlowerApiController.cs @@ -235,7 +235,7 @@ public class FlowerApiController : BaseController f.Photos = database.Photos.Where(p => p.FlowerId == f.Id && p.RecordId == null).ToList(); foreach (var photo in f.Photos) { - photo.Url = $"photo/flower/{f.Id}/{photo.Path}"; + photo.Url = $"photo/flower/{f.Id}/{photo.Path}?thumb=1"; } } } @@ -532,7 +532,7 @@ public class FlowerApiController : BaseController }; AddPhotoItem(cover); - await WriteToFile(item.Id, file, token); + await WriteToFile(item.Id, file, token: token); }); } catch (Exception ex) @@ -753,7 +753,7 @@ public class FlowerApiController : BaseController }; AddPhotoItem(cover); - await WriteToFile(update.Id, file, token); + await WriteToFile(update.Id, file, token: token); }); } catch (Exception ex) @@ -854,7 +854,7 @@ public class FlowerApiController : BaseController }; AddPhotoItem(cover); - await WriteToFile(param.Id, file, token); + await WriteToFile(param.Id, file, token: token); }); } catch (Exception ex) diff --git a/Server/Controller/ImageController.cs b/Server/Controller/ImageController.cs index 5ce34a1..2f218ff 100644 --- a/Server/Controller/ImageController.cs +++ b/Server/Controller/ImageController.cs @@ -8,35 +8,36 @@ namespace Blahblah.FlowerStory.Server.Controller; /// /// 图片相关服务 /// -[Produces("image/png")] [Route("photo")] public class ImageController : BaseController { + static byte[]? emptyAvatar; + + static byte[] EmptyAvatar => emptyAvatar ??= GetEmbeddedData("image.avatar.jpg"); + /// public ImageController(FlowerDatabase database, ILogger? logger = null) : base(database, logger) { } /// - /// 请求用户头像 + /// 请求自己的头像 /// /// /// 请求示例: /// - /// GET /photo/avatar + /// GET /photo/my_avatar /// Authorization: authorization id /// /// - /// 认证通过则显示用户头像 + /// 认证通过则显示自己的头像 /// 返回头像 /// 认证失败 - /// 未找到头像 - [Route("avatar", Name = "getAvatar")] + [Route("my_avatar", Name = "getMyAvatar")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status404NotFound)] [HttpGet] - public ActionResult GetUserAvatar() + public ActionResult GetMyAvatar() { var (result, token) = CheckToken(); if (result != null) @@ -48,11 +49,47 @@ public class ImageController : BaseController return Unauthorized(); } var avatar = QueryUserAvatar(token.UserId); - if (avatar == null) + if (avatar?.Length > 0) { - return NotFound(); + return File(avatar, "image/jpeg"); } - return File(avatar, "image/png"); + return File(EmptyAvatar, "image/jpeg"); + } + + /// + /// 请求用户头像 + /// + /// + /// 请求示例: + /// + /// GET /photo/avatar/2.jpg + /// + /// + /// 用户唯一 id + /// 认证通过则显示用户头像 + /// 返回头像 + /// 认证失败 + [Route("avatar/{uid}.jpg", Name = "getAvatar")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [HttpGet] + public ActionResult GetAvatar([Required] int uid) + { + //var (result, token) = CheckToken(); + //if (result != null) + //{ + // return result; + //} + //if (token == null) + //{ + // return Unauthorized(); + //} + var avatar = QueryUserAvatar(uid); + if (avatar?.Length > 0) + { + return File(avatar, "image/jpeg"); + } + return File(EmptyAvatar, "image/jpeg"); } /// @@ -61,11 +98,12 @@ public class ImageController : BaseController /// /// 请求示例: /// - /// GET /photo/flower/{fid}/{name} + /// GET /photo/flower/1/test.jpg /// /// /// 花草唯一 id /// 照片名称 + /// 是否为缩略图 /// 认证通过则显示花草照片 /// 返回花草照片 /// 认证失败 @@ -75,7 +113,7 @@ public class ImageController : BaseController [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status404NotFound)] [HttpGet] - public async Task GetFlowerPhoto([Required] int fid, [Required] string name) + public async Task GetFlowerPhoto([Required] int fid, [Required] string name, [FromQuery] string? thumb = null) { //var (result, token) = CheckToken(); //if (result != null) @@ -87,7 +125,7 @@ public class ImageController : BaseController // return Unauthorized(); //} -#if !DEBUG +#if PRODUCTION var referrer = Request.Headers.Referer.ToString(); if (string.IsNullOrEmpty(referrer)) { @@ -98,12 +136,41 @@ public class ImageController : BaseController return Forbid(); } #endif - var path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "uploads", fid.ToString(), WebUtility.UrlEncode(name)); - if (System.IO.File.Exists(path)) + var filename = WebUtility.UrlEncode(name); + var directory = Path.Combine(Program.DataPath, "uploads", fid.ToString()); + var original = Path.Combine(directory, filename); + var thumbnail = Path.Combine(directory, $"{filename}.thumb"); + if (!string.IsNullOrEmpty(thumb)) { - var data = await System.IO.File.ReadAllBytesAsync(path); - var ext = Path.GetExtension(path).ToLower(); - return File(data, ext == ".png" ? "image/png" : "image/jpeg"); + if (System.IO.File.Exists(thumbnail)) + { + var thumbnailData = await System.IO.File.ReadAllBytesAsync(thumbnail); + return File(thumbnailData, "image/jpeg"); + } + else if (System.IO.File.Exists(original)) + { + try + { + var originalData = await System.IO.File.ReadAllBytesAsync(original); + var thumbnailData = CreateThumbnail(originalData); + await System.IO.File.WriteAllBytesAsync(thumbnail, thumbnailData); + return File(thumbnailData, "image/jpeg"); + } + catch (Exception ex) + { + logger?.LogWarning(ex, "failed to create thumbnail for flower: {fid}, name: {name}, error: {message}", fid, name, ex.Message); + } + } + return NotFound(); + } + if (System.IO.File.Exists(original)) + { + var data = await System.IO.File.ReadAllBytesAsync(original); + return Path.GetExtension(original).ToLower() switch + { + ".png" => File(data, "image/png"), + _ => File(data, "image/jpeg"), + }; } return NotFound(); } diff --git a/Server/Controller/UserApiController.cs b/Server/Controller/UserApiController.cs index 2136ec4..f531bd6 100644 --- a/Server/Controller/UserApiController.cs +++ b/Server/Controller/UserApiController.cs @@ -1,6 +1,7 @@ using Blahblah.FlowerStory.Server.Data; using Blahblah.FlowerStory.Server.Data.Model; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using System.ComponentModel.DataAnnotations; using System.Security.Cryptography; @@ -81,6 +82,9 @@ public partial class UserApiController : BaseController clientApp = "browser"; expires = 20 * 60; // 20 mins } + + database.Tokens.Where(t => t.UserId == user.Id && t.ClientApp == clientApp).ExecuteDelete(); + var now = DateTimeOffset.UtcNow; var token = new TokenItem { @@ -184,26 +188,31 @@ public partial class UserApiController : BaseController /// 请求示例: /// /// POST /api/user/register - /// { - /// "id": "blahblah", - /// "password": "pwd123", - /// "userName": "Blah blah", - /// "email": "blah@example.com", - /// "mobile": "18012345678" - /// } + /// + /// 参数: + /// + /// id: "blahblah" + /// password: "pwd123" + /// name: "Blah blah" + /// email: "blah@example.com" + /// mobile: "18012345678" + /// avatar: <avatar> /// /// /// 注册参数 /// 成功注册则返回已注册的用户对象 /// 返回已注册的用户对象 + /// 用户头像格式非法 /// 用户重复或其他服务器错误 [Route("register", Name = "register")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesErrorResponseType(typeof(ErrorResponse))] [HttpPost] - [Consumes("application/json")] - public ActionResult Register([FromBody] UserParameter user) + [Consumes("multipart/form-data")] + [RequestSizeLimit(15 * 1024 * 1024)] + public ActionResult Register([FromForm] UserParameter user) { #if DEBUG logger?.LogInformation("user register, {user}", user); @@ -214,6 +223,21 @@ public partial class UserApiController : BaseController return Problem("duplicateUser", "api/user/register"); } + byte[]? data; + if (user.Avatar != null) + { + var avatar = WrapFormFile(user.Avatar); + if (avatar == null) + { + return BadRequest(); + } + data = CreateThumbnail(avatar.Content); + } + else + { + data = null; + } + var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); var item = new UserItem { @@ -224,7 +248,8 @@ public partial class UserApiController : BaseController ActiveDateUnixTime = now, Name = user.UserName, Email = user.Email, - Mobile = user.Mobile + Mobile = user.Mobile, + Avatar = data }; database.Users.Add(item); SaveDatabase(); @@ -281,30 +306,35 @@ public partial class UserApiController : BaseController /// /// PUT /api/user/update /// Authorization: authorization id - /// { - /// "userName": "Blah blah", - /// "email": "blah@example.com", - /// "mobile": "18012345678" - /// } + /// + /// 参数: + /// + /// name": "Blah blah" + /// email": "blah@example.com" + /// mobile": "18012345678", + /// avatar: <avatar> /// /// /// 修改参数 /// 修改成功则返回已修改的用户对象 /// 返回已修改的用户对象 + /// 用户头像格式非法 /// 未找到登录会话或已过期 /// 用户已禁用 /// 未找到关联用户 /// 提交正文过大 [Route("update", Name = "updateProfile")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)] [ProducesErrorResponseType(typeof(ErrorResponse))] [HttpPut] - [Consumes("application/json")] - public ActionResult Update([FromBody] UpdateParameter update) + [Consumes("multipart/form-data")] + [RequestSizeLimit(15 * 1024 * 1024)] + public ActionResult Update([FromForm] UpdateParameter update) { #if DEBUG logger?.LogInformation("user update, {user}", update); @@ -319,6 +349,17 @@ public partial class UserApiController : BaseController return NotFound(); } + if (update.Avatar != null) + { + var avatar = WrapFormFile(update.Avatar); + if (avatar == null) + { + return BadRequest(); + } + + user.Avatar = CreateThumbnail(avatar.Content); + } + user.Name = update.UserName; user.Email = update.Email; user.Mobile = update.Mobile; @@ -382,7 +423,7 @@ public partial class UserApiController : BaseController { return BadRequest(); } - user.Avatar = file.Content; + user.Avatar = CreateThumbnail(file.Content); } SaveDatabase(); @@ -429,7 +470,7 @@ public partial class UserApiController : BaseController return Ok(count); } - //#if DEBUG +#if !PRODUCTION /// /// #DEBUG 获取所有用户 /// @@ -453,5 +494,5 @@ public partial class UserApiController : BaseController { return Ok(database.Tokens.ToArray()); } - //#endif +#endif } diff --git a/Server/Controller/UserApiController.structs.cs b/Server/Controller/UserApiController.structs.cs index f5cee42..3bec17a 100644 --- a/Server/Controller/UserApiController.structs.cs +++ b/Server/Controller/UserApiController.structs.cs @@ -1,4 +1,5 @@ -using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc; +using System.ComponentModel.DataAnnotations; namespace Blahblah.FlowerStory.Server.Controller; @@ -33,12 +34,14 @@ public record UserParameter : UpdateParameter /// 用户 id /// [Required] + [FromForm(Name = "id")] public required string Id { get; init; } /// /// 密码 /// [Required] + [FromForm(Name = "password")] public required string Password { get; init; } } @@ -51,15 +54,24 @@ public record UpdateParameter /// 用户名 /// [Required] + [FromForm(Name = "name")] public required string UserName { get; init; } /// /// 邮箱 /// + [FromForm(Name = "email")] public string? Email { get; init; } /// /// 联系电话 /// + [FromForm(Name = "mobile")] public string? Mobile { get; init; } + + /// + /// 用户头像 + /// + [FromForm(Name = "avatar")] + public IFormFile? Avatar { get; init; } } diff --git a/Server/Dockerfile b/Server/Dockerfile index a4907fc..aa3a84d 100644 --- a/Server/Dockerfile +++ b/Server/Dockerfile @@ -1,8 +1,14 @@ #See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. FROM mcr.microsoft.com/dotnet/aspnet:7.0 +RUN apt-get update && apt-get install -y libfontconfig1 COPY . /app WORKDIR /app -EXPOSE 80 +EXPOSE 5180 + +ARG UID=1026 +ARG GID=100 +RUN useradd -m -u ${UID} tsanie +USER ${UID}:${GID} ENTRYPOINT ["dotnet", "Server.dll"] \ No newline at end of file diff --git a/Server/Program.cs b/Server/Program.cs index 1f1dd24..6d69172 100644 --- a/Server/Program.cs +++ b/Server/Program.cs @@ -11,7 +11,14 @@ public class Program /// public const string ProjectName = "Flower Story"; /// - public const string Version = "1.0.807"; + public const string Version = "1.1.808"; + /// + public static string DataPath => +#if DEBUG + AppDomain.CurrentDomain.BaseDirectory; +#else + "/data"; +#endif /// public static void Main(string[] args) @@ -56,7 +63,9 @@ public class Program options.IncludeXmlComments(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Server.xml"), true); }); - builder.Services.AddDbContext(options => options.UseSqlite("DataSource=flower.db;Cache=Shared")); + var dbPath = Path.Combine(DataPath, "flower.db"); + builder.Services.AddDbContext(options => options.UseSqlite($"DataSource={dbPath};Cache=Shared")); + builder.Services.AddScoped(); var app = builder.Build(); diff --git a/Server/Server.csproj b/Server/Server.csproj index 577db82..3938a61 100644 --- a/Server/Server.csproj +++ b/Server/Server.csproj @@ -10,6 +10,14 @@ README.md + + + + + + + + @@ -18,6 +26,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -25,6 +34,9 @@ PreserveNewest + + PreserveNewest + Never diff --git a/Server/appsettings.json b/Server/appsettings.json index 10f68b8..c8f253b 100644 --- a/Server/appsettings.json +++ b/Server/appsettings.json @@ -1,9 +1,16 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://+:5180" + } + } } - }, - "AllowedHosts": "*" } diff --git a/Server/start b/Server/start new file mode 100644 index 0000000..eee66d9 --- /dev/null +++ b/Server/start @@ -0,0 +1,10 @@ +#!/bin/sh + +docker stop flower-server +docker rm flower-server +docker rmi flower-server-image + +docker build -t flower-server-image . +docker run -d -p 7080:5180 -v /volume2/docker/flower-data:/data --name flower-server flower-server-image:latest + +docker logs -f flower-server \ No newline at end of file diff --git a/Server/wwwroot/image/avatar.jpg b/Server/wwwroot/image/avatar.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5da644ccaee81a28f12613a9b00d01c06d4a07e2 GIT binary patch literal 4363 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