feat(server): lossy avatar thumbnails, gzip upload and separate avatar endpoints
This commit is contained in:
1 parent
ba0b6c258a
commit
31ae3bc39e
16 files changed
+155
-26
No files matched your search
@@ -165,6 +165,10 @@ public sealed class AccountProfileRequest
|
||||
{
|
||||
[JsonPropertyName("displayName")] public required string DisplayName { get; init; }
|
||||
[JsonPropertyName("avatar")] public string? Avatar { get; init; }
|
||||
/// <summary>头像的有损小图(data URL)。列表、聊天默认只分发它,原图在预览时才下载</summary>
|
||||
[JsonPropertyName("avatarThumb")] public string? AvatarThumb { get; init; }
|
||||
/// <summary>只改名字、不动头像:保留服务端现有的头像(不带 avatar 的请求默认表示移除头像)</summary>
|
||||
[JsonPropertyName("keepAvatar")] public bool KeepAvatar { get; init; }
|
||||
}
|
||||
public sealed class WorkspaceNameRequest { [JsonPropertyName("name")] public required string Name { get; init; } }
|
||||
public sealed class WorkspaceChannelRequest
|
||||
|
||||
@@ -11,17 +11,25 @@ public static partial class Endpoints
|
||||
api.MapGet("/accounts/profile", (HttpContext ctx) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
return Json(accounts.Profile(username), ApiJson.Default.AccountProfile);
|
||||
return Json(accounts.Profile(username).WithoutAvatar(), ApiJson.Default.AccountProfile);
|
||||
});
|
||||
// 头像单独取:默认给有损的缩略图(列表、聊天用),size=full 才是原图(预览时)。带 ETag,没变化时只回 304。需要登录账号
|
||||
api.MapGet("/accounts/users/{username}/avatar", (string username, HttpContext ctx) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out _)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
var profile = accounts.Profile(NormalizeUsername(username));
|
||||
var full = ctx.Request.Query["size"] == "full";
|
||||
return AvatarResult(ctx, full ? profile.Avatar : profile.AvatarThumb ?? profile.Avatar, $"{profile.ModifiedAt}-{(full ? "f" : "t")}");
|
||||
});
|
||||
api.MapPatch("/accounts/profile", async (HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
// 动图头像比其他请求大得多,单独放宽(全局上限 2 MB)
|
||||
if (ctx.Features.Get<IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } size) size.MaxRequestBodySize = 8 * 1024 * 1024;
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountProfileRequest, ct);
|
||||
var profile = accounts.UpdateProfile(username, req.DisplayName, req.Avatar);
|
||||
var req = await ReadJsonCompressed(ctx.Request, ApiJson.Default.AccountProfileRequest, 12 * 1024 * 1024, ct);
|
||||
var profile = accounts.UpdateProfile(username, req.DisplayName, req.Avatar, req.AvatarThumb, req.KeepAvatar);
|
||||
svc.AccountProfileChanged(username);
|
||||
return Json(profile, ApiJson.Default.AccountProfile);
|
||||
return Json(profile.WithoutAvatar(), ApiJson.Default.AccountProfile);
|
||||
});
|
||||
api.MapPost("/accounts", async (HttpContext ctx, CancellationToken ct) =>
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ public static partial class Endpoints
|
||||
Structure = state,
|
||||
People = state.Locations.Select(l => l.AuthorAccount).Where(u => u is not null).Select(u => u!)
|
||||
.Concat(service.MemberAccounts(space)).Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Select(u => accounts.Profile(u)).ToList(),
|
||||
.Select(u => accounts.Profile(u).WithoutAvatar()).ToList(),
|
||||
};
|
||||
api.MapGet("/spaces/{id}/structure", (string id, HttpContext ctx, ChannelService svc) =>
|
||||
Json(View(svc, ParseId(id), svc.GetWorkspace(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false))), ApiJson.Default.WorkspaceBody));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Buffers;
|
||||
using System.Diagnostics;
|
||||
using System.IO.Compression;
|
||||
using System.Net.WebSockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
@@ -75,11 +76,18 @@ public static partial class Endpoints
|
||||
{
|
||||
// 空间头像(动图)比其他设置大得多,单独放宽请求体上限
|
||||
if (ctx.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } size) size.MaxRequestBodySize = 4 * 1024 * 1024;
|
||||
var patch = await ReadJson(ctx.Request, ApiJson.Default.ChannelSettingsPatch, ct);
|
||||
var patch = await ReadJsonCompressed(ctx.Request, ApiJson.Default.ChannelSettingsPatch, 6 * 1024 * 1024, ct);
|
||||
var info = svc.UpdateSettings(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), patch);
|
||||
return Json(info, ApiJson.Default.ChannelInfo);
|
||||
});
|
||||
|
||||
// 空间头像原图(信息里只带缩略图):预览时才下载。带 ETag(内容哈希),没变时回 304
|
||||
api.MapGet("/channels/{id}/avatar", (string id, HttpContext ctx, ChannelService svc) =>
|
||||
{
|
||||
var data = svc.GetChannelAvatarFull(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false));
|
||||
return AvatarResult(ctx, data, Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(data ?? ""))));
|
||||
});
|
||||
|
||||
api.MapDelete("/channels/{id}", (string id, HttpContext ctx, ChannelService svc) =>
|
||||
{
|
||||
svc.DeleteChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false));
|
||||
@@ -454,6 +462,41 @@ public static partial class Endpoints
|
||||
return long.TryParse(header, out long n) && n >= 0 ? n : throw new WebDropException(ErrorCode.BadRequest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读 JSON 请求体,支持 Content-Encoding: gzip(头像这类带 base64 的大请求,压缩后能省下 base64 多出来的约四分之一)。
|
||||
/// 解压后的大小另外限制,防止小体积的压缩炸弹。
|
||||
/// </summary>
|
||||
private static async Task<T> ReadJsonCompressed<T>(HttpRequest req, JsonTypeInfo<T> info, int maxBytes, CancellationToken ct) where T : class
|
||||
{
|
||||
try
|
||||
{
|
||||
Stream body = req.Body;
|
||||
if (string.Equals(req.Headers.ContentEncoding.ToString(), "gzip", StringComparison.OrdinalIgnoreCase)) body = new GZipStream(req.Body, CompressionMode.Decompress);
|
||||
using var plain = new MemoryStream();
|
||||
var buffer = new byte[81920]; int read;
|
||||
while ((read = await body.ReadAsync(buffer, ct)) > 0)
|
||||
{
|
||||
if (plain.Length + read > maxBytes) throw new WebDropException(ErrorCode.BadRequest);
|
||||
plain.Write(buffer, 0, read);
|
||||
}
|
||||
plain.Position = 0;
|
||||
return await JsonSerializer.DeserializeAsync(plain, info, ct) ?? throw new WebDropException(ErrorCode.BadRequest);
|
||||
}
|
||||
catch (Exception error) when (error is JsonException or InvalidDataException) { throw new WebDropException(ErrorCode.BadRequest); }
|
||||
}
|
||||
|
||||
/// <summary>data URL 头像 → 图片字节(带 ETag,没变时 304)</summary>
|
||||
private static IResult AvatarResult(HttpContext ctx, string? data, string version)
|
||||
{
|
||||
if (data is null) return Results.NotFound();
|
||||
var etag = $"\"{version}\"";
|
||||
ctx.Response.Headers.ETag = etag;
|
||||
ctx.Response.Headers.CacheControl = "private, no-cache";
|
||||
if (ctx.Request.Headers.IfNoneMatch == etag) return Results.StatusCode(StatusCodes.Status304NotModified);
|
||||
var mime = data[5..data.IndexOf(';')];
|
||||
return Results.Bytes(Convert.FromBase64String(data[(data.IndexOf(',') + 1)..]), mime);
|
||||
}
|
||||
|
||||
private static async Task<T> ReadJson<T>(HttpRequest req, JsonTypeInfo<T> info, CancellationToken ct) where T : class
|
||||
{
|
||||
try
|
||||
|
||||
Reference in new issue
Block a user