This commit is contained in:
Tsanie Lily 2023-08-08 17:15:43 +08:00
parent 697631c8d3
commit ac250ea779
13 changed files with 303 additions and 61 deletions

View File

@ -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;
/// <summary>
/// 临时 id
/// </summary>
@ -23,7 +27,7 @@ public abstract partial class BaseController : ControllerBase
/// <summary>
/// 文件上传路径
/// </summary>
protected static string UploadsDirectory => uploadsDirectory ??= Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "uploads");
protected static string UploadsDirectory => uploadsDirectory ??= Path.Combine(Program.DataPath, "uploads");
/// <summary>
/// 支持的图片文件签名
@ -31,6 +35,7 @@ public abstract partial class BaseController : ControllerBase
protected static readonly List<byte[]> 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;
}
/// <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 Array.Empty<byte>();
}
using var ms = new MemoryStream();
stream.CopyTo(ms);
return ms.ToArray();
}
/// <summary>
/// 读取文件到 byte 数组
/// </summary>
@ -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;
}
/// <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 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();
}
/// <summary>
/// 写入文件到用户的花草目录中
/// </summary>
/// <param name="fid">花草唯一 id</param>
/// <param name="file">文件对象</param>
/// <param name="thumbnail">创建缩略图</param>
/// <param name="token">取消令牌</param>
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);
}
}
}
/// <summary>

View File

@ -52,7 +52,7 @@ partial class BaseController
}
/// <summary>
/// 获取用户头像数据
/// 根据用户 uid 获取用户头像数据
/// </summary>
/// <param name="uid">用户唯一 id</param>
/// <returns></returns>
@ -61,6 +61,16 @@ partial class BaseController
return database.Database.SqlQuery<byte[]>($"SELECT \"avatar\" AS \"Value\" FROM \"users\" WHERE \"uid\" = {uid} LIMIT 1").SingleOrDefault();
}
/// <summary>
/// 根据用户 id 获取用户头像数据
/// </summary>
/// <param name="id">用户 id</param>
/// <returns></returns>
protected byte[]? QueryUserAvatar(string id)
{
return database.Database.SqlQuery<byte[]>($"SELECT \"avatar\" AS \"Value\" FROM \"users\" WHERE \"id\" = {id} LIMIT 1").SingleOrDefault();
}
/// <summary>
/// 移除用户头像
/// </summary>

View File

@ -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);
}
}
});

View File

@ -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)

View File

@ -8,35 +8,36 @@ namespace Blahblah.FlowerStory.Server.Controller;
/// <summary>
/// 图片相关服务
/// </summary>
[Produces("image/png")]
[Route("photo")]
public class ImageController : BaseController
{
static byte[]? emptyAvatar;
static byte[] EmptyAvatar => emptyAvatar ??= GetEmbeddedData("image.avatar.jpg");
/// <inheritdoc/>
public ImageController(FlowerDatabase database, ILogger<BaseController>? logger = null) : base(database, logger)
{
}
/// <summary>
/// 请求用户头像
/// 请求自己的头像
/// </summary>
/// <remarks>
/// 请求示例:
///
/// GET /photo/avatar
/// GET /photo/my_avatar
/// Authorization: authorization id
///
/// </remarks>
/// <returns>认证通过则显示用户头像</returns>
/// <returns>认证通过则显示自己的头像</returns>
/// <response code="200">返回头像</response>
/// <response code="401">认证失败</response>
/// <response code="404">未找到头像</response>
[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");
}
/// <summary>
/// 请求用户头像
/// </summary>
/// <remarks>
/// 请求示例:
///
/// GET /photo/avatar/2.jpg
///
/// </remarks>
/// <param name="uid">用户唯一 id</param>
/// <returns>认证通过则显示用户头像</returns>
/// <response code="200">返回头像</response>
/// <response code="401">认证失败</response>
[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");
}
/// <summary>
@ -61,11 +98,12 @@ public class ImageController : BaseController
/// <remarks>
/// 请求示例:
///
/// GET /photo/flower/{fid}/{name}
/// GET /photo/flower/1/test.jpg
///
/// </remarks>
/// <param name="fid">花草唯一 id</param>
/// <param name="name">照片名称</param>
/// <param name="thumb">是否为缩略图</param>
/// <returns>认证通过则显示花草照片</returns>
/// <response code="200">返回花草照片</response>
/// <response code="401">认证失败</response>
@ -75,7 +113,7 @@ public class ImageController : BaseController
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[HttpGet]
public async Task<ActionResult> GetFlowerPhoto([Required] int fid, [Required] string name)
public async Task<ActionResult> 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();
}

View File

@ -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: &lt;avatar&gt;
///
/// </remarks>
/// <param name="user">注册参数</param>
/// <returns>成功注册则返回已注册的用户对象</returns>
/// <response code="200">返回已注册的用户对象</response>
/// <response code="400">用户头像格式非法</response>
/// <response code="500">用户重复或其他服务器错误</response>
[Route("register", Name = "register")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesErrorResponseType(typeof(ErrorResponse))]
[HttpPost]
[Consumes("application/json")]
public ActionResult<UserItem> Register([FromBody] UserParameter user)
[Consumes("multipart/form-data")]
[RequestSizeLimit(15 * 1024 * 1024)]
public ActionResult<UserItem> 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: &lt;avatar&gt;
///
/// </remarks>
/// <param name="update">修改参数</param>
/// <returns>修改成功则返回已修改的用户对象</returns>
/// <response code="200">返回已修改的用户对象</response>
/// <response code="400">用户头像格式非法</response>
/// <response code="401">未找到登录会话或已过期</response>
/// <response code="403">用户已禁用</response>
/// <response code="404">未找到关联用户</response>
/// <response code="413">提交正文过大</response>
[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<UserItem> Update([FromBody] UpdateParameter update)
[Consumes("multipart/form-data")]
[RequestSizeLimit(15 * 1024 * 1024)]
public ActionResult<UserItem> 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
/// <summary>
/// #DEBUG 获取所有用户
/// </summary>
@ -453,5 +494,5 @@ public partial class UserApiController : BaseController
{
return Ok(database.Tokens.ToArray());
}
//#endif
#endif
}

View File

@ -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
/// </summary>
[Required]
[FromForm(Name = "id")]
public required string Id { get; init; }
/// <summary>
/// 密码
/// </summary>
[Required]
[FromForm(Name = "password")]
public required string Password { get; init; }
}
@ -51,15 +54,24 @@ public record UpdateParameter
/// 用户名
/// </summary>
[Required]
[FromForm(Name = "name")]
public required string UserName { get; init; }
/// <summary>
/// 邮箱
/// </summary>
[FromForm(Name = "email")]
public string? Email { get; init; }
/// <summary>
/// 联系电话
/// </summary>
[FromForm(Name = "mobile")]
public string? Mobile { get; init; }
/// <summary>
/// 用户头像
/// </summary>
[FromForm(Name = "avatar")]
public IFormFile? Avatar { get; init; }
}

View File

@ -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"]

View File

@ -11,7 +11,14 @@ public class Program
/// <inheritdoc/>
public const string ProjectName = "Flower Story";
/// <inheritdoc/>
public const string Version = "1.0.807";
public const string Version = "1.1.808";
/// <inheritdoc/>
public static string DataPath =>
#if DEBUG
AppDomain.CurrentDomain.BaseDirectory;
#else
"/data";
#endif
/// <inheritdoc/>
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<FlowerDatabase>(options => options.UseSqlite("DataSource=flower.db;Cache=Shared"));
var dbPath = Path.Combine(DataPath, "flower.db");
builder.Services.AddDbContext<FlowerDatabase>(options => options.UseSqlite($"DataSource={dbPath};Cache=Shared"));
builder.Services.AddScoped<SwaggerGenerator>();
var app = builder.Build();

View File

@ -10,6 +10,14 @@
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<ItemGroup>
<Content Remove="wwwroot\image\avatar.jpg" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="wwwroot\image\avatar.jpg" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.9" />
@ -18,6 +26,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="SkiaSharp" Version="2.88.3" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
@ -25,6 +34,9 @@
<None Update="Dockerfile">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="start">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="flower.db">
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
</None>

View File

@ -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": "*"
}

10
Server/start Normal file
View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB