feat(server): lossy avatar thumbnails, gzip upload and separate avatar endpoints

This commit is contained in:
justme committed 2026-10-17 14:35:41 +08:00
1 parent ba0b6c258a
commit 31ae3bc39e
16 files changed
+155 -26

No files matched your search

+4
View File
@@ -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
+12 -4
View File
@@ -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));
+44 -1
View File
@@ -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