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 0000000..5da644c Binary files /dev/null and b/Server/wwwroot/image/avatar.jpg differ