feat(server): space avatars and full member profiles in the structure response

This commit is contained in:
justme committed 2026-10-17 13:44:16 +08:00
1 parent 77c9017129
commit 1a4069576b
14 files changed
+99 -36

No files matched your search

+21 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"x-backend-contract-sha256": "4e07231f729947308afe7da4fda9441ef0de27552771d85ad0586f06cdad0200",
"x-backend-contract-sha256": "a7ea3b6455a14ed68504e9683a38fae13e560bd48df12f3ecf3e82a88f0b83dd",
"$defs": {
"AccountChannelBody": {
"type": "object",
@@ -294,6 +294,16 @@
"ChannelInfo": {
"type": "object",
"properties": {
"avatar": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"can_manage_structure": {
"type": "boolean"
},
@@ -411,6 +421,16 @@
"ChannelSettingsPatch": {
"type": "object",
"properties": {
"avatar": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"delete_policy": {
"anyOf": [
{
+19 -17
View File
@@ -7,76 +7,78 @@ public static partial class Endpoints
{
private static void MapWorkspace(RouteGroupBuilder api, SqliteAccountStore accounts)
{
WorkspaceBody View(WorkspaceState state) => new()
// 成员资料:发过内容的作者加上空间里所有成员(没发过内容的成员也要能显示头像和名字)
WorkspaceBody View(ChannelService service, Id16 space, WorkspaceState state) => new()
{
Structure = state,
People = state.Locations.Select(l => l.AuthorAccount).Where(u => u is not null).Distinct()
.Select(u => accounts.Profile(u!)).ToList(),
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(),
};
api.MapGet("/spaces/{id}/structure", (string id, HttpContext ctx, ChannelService svc) =>
Json(View(svc.GetWorkspace(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false))), ApiJson.Default.WorkspaceBody));
Json(View(svc, ParseId(id), svc.GetWorkspace(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false))), ApiJson.Default.WorkspaceBody));
api.MapPost("/spaces/{id}/channels", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceChannelRequest, ct);
var created = svc.CreateWorkspaceChannelResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Name, req.GroupId);
return Json(new WorkspaceChannelCreatedBody { Workspace = View(created.State), Channel = created.Channel }, ApiJson.Default.WorkspaceChannelCreatedBody, 201);
return Json(new WorkspaceChannelCreatedBody { Workspace = View(svc, ParseId(id), created.State), Channel = created.Channel }, ApiJson.Default.WorkspaceChannelCreatedBody, 201);
});
api.MapPatch("/spaces/{id}/channels/{channelId}", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
return Json(View(svc.RenameWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.Name)), ApiJson.Default.WorkspaceBody);
return Json(View(svc, ParseId(id), svc.RenameWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.Name)), ApiJson.Default.WorkspaceBody);
});
api.MapPut("/spaces/{id}/channels/{channelId}/group", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceGroupRequest, ct);
return Json(View(svc.GroupWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.GroupId)), ApiJson.Default.WorkspaceBody);
return Json(View(svc, ParseId(id), svc.GroupWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.GroupId)), ApiJson.Default.WorkspaceBody);
});
api.MapPost("/spaces/{id}/groups", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
var created = svc.CreateWorkspaceGroupResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Name);
return Json(new WorkspaceGroupCreatedBody { Workspace = View(created.State), Group = created.Group }, ApiJson.Default.WorkspaceGroupCreatedBody, 201);
return Json(new WorkspaceGroupCreatedBody { Workspace = View(svc, ParseId(id), created.State), Group = created.Group }, ApiJson.Default.WorkspaceGroupCreatedBody, 201);
});
api.MapPatch("/spaces/{id}/groups/{groupId}", async (string id, string groupId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
return Json(View(svc.RenameWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), groupId, req.Name)), ApiJson.Default.WorkspaceBody);
return Json(View(svc, ParseId(id), svc.RenameWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), groupId, req.Name)), ApiJson.Default.WorkspaceBody);
});
api.MapDelete("/spaces/{id}/groups/{groupId}", (string id, string groupId, HttpContext ctx, ChannelService svc) =>
Json(View(svc.DeleteWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), groupId)), ApiJson.Default.WorkspaceBody));
Json(View(svc, ParseId(id), svc.DeleteWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), groupId)), ApiJson.Default.WorkspaceBody));
api.MapPost("/spaces/{id}/roles", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceRoleRequest, ct);
var created = svc.CreateWorkspaceRoleResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Name, req.Prefix, req.Suffix, req.Color, req.Permissions);
return Json(new WorkspaceRoleCreatedBody { Workspace = View(created.State), Role = created.Role }, ApiJson.Default.WorkspaceRoleCreatedBody, 201);
return Json(new WorkspaceRoleCreatedBody { Workspace = View(svc, ParseId(id), created.State), Role = created.Role }, ApiJson.Default.WorkspaceRoleCreatedBody, 201);
});
api.MapPatch("/spaces/{id}/roles/{roleId}", async (string id, string roleId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceRoleRequest, ct);
return Json(View(svc.UpdateWorkspaceRole(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), roleId, req.Name, req.Prefix, req.Suffix, req.Color, req.Permissions)), ApiJson.Default.WorkspaceBody);
return Json(View(svc, ParseId(id), svc.UpdateWorkspaceRole(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), roleId, req.Name, req.Prefix, req.Suffix, req.Color, req.Permissions)), ApiJson.Default.WorkspaceBody);
});
api.MapDelete("/spaces/{id}/roles/{roleId}", (string id, string roleId, HttpContext ctx, ChannelService svc) =>
Json(View(svc.DeleteWorkspaceRole(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), roleId)), ApiJson.Default.WorkspaceBody));
Json(View(svc, ParseId(id), svc.DeleteWorkspaceRole(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), roleId)), ApiJson.Default.WorkspaceBody));
api.MapPut("/spaces/{id}/roles/{roleId}/members", async (string id, string roleId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceRoleMembersRequest, ct);
return Json(View(svc.SetWorkspaceRoleMembers(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), roleId, req.Members)), ApiJson.Default.WorkspaceBody);
return Json(View(svc, ParseId(id), svc.SetWorkspaceRoleMembers(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), roleId, req.Members)), ApiJson.Default.WorkspaceBody);
});
api.MapPost("/spaces/{id}/folders", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceFolderRequest, ct);
return Json(View(svc.CreateWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.ThreadId, req.ParentId, req.Name)), ApiJson.Default.WorkspaceBody, 201);
return Json(View(svc, ParseId(id), svc.CreateWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.ThreadId, req.ParentId, req.Name)), ApiJson.Default.WorkspaceBody, 201);
});
api.MapPatch("/spaces/{id}/folders/{folderId}", async (string id, string folderId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
return Json(View(svc.RenameWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), folderId, req.Name)), ApiJson.Default.WorkspaceBody);
return Json(View(svc, ParseId(id), svc.RenameWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), folderId, req.Name)), ApiJson.Default.WorkspaceBody);
});
api.MapPut("/spaces/{id}/locations", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceMoveRequest, ct);
if (req.ItemIds is null) throw new WebDropException(ErrorCode.BadRequest);
return Json(View(svc.MoveWorkspaceItems(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.ItemIds, req.ThreadId, req.FolderId)), ApiJson.Default.WorkspaceBody);
return Json(View(svc, ParseId(id), svc.MoveWorkspaceItems(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.ItemIds, req.ThreadId, req.FolderId)), ApiJson.Default.WorkspaceBody);
});
}
}
+2
View File
@@ -73,6 +73,8 @@ public static partial class Endpoints
api.MapPatch("/channels/{id}/settings", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
// 空间头像(动图)比其他设置大得多,单独放宽请求体上限
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 info = svc.UpdateSettings(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), patch);
return Json(info, ApiJson.Default.ChannelInfo);
+3
View File
@@ -118,6 +118,9 @@ public interface IResourceTx
void SetCompactedThrough(Id16 id, long seq);
long NextSeq(Id16 id);
void DeleteChannelRows(Id16 id);
/// <summary>空间头像(data URL)单独存放,不放进每次请求都要读的设置 JSON</summary>
string? GetChannelAvatar(Id16 id);
void SetChannelAvatar(Id16 id, string? avatar);
IReadOnlyList<Id16> ListExpiredChannels(long now, int limit);
// 成员
+26
View File
@@ -0,0 +1,26 @@
namespace WebDrop.Core;
/// <summary>头像(账号头像、空间头像)的 data URL 校验。浏览器里已经裁剪好并重新编码,服务端只做格式、大小和文件头检查</summary>
public static class AvatarData
{
/// <summary>静态头像(256×256 WebP/PNG/JPEG)的长度上限(字符数)</summary>
public const int MaxStaticChars = 350_000;
/// <summary>账号动图头像保留全部帧,约 4.5 MB</summary>
public const int MaxAccountGifChars = 6_000_000;
/// <summary>空间头像会跟着空间信息发给所有成员和空间列表,动图上限比账号头像小,约 1.5 MB</summary>
public const int MaxSpaceGifChars = 2_000_000;
private static readonly string[] Prefixes = ["data:image/png;base64,", "data:image/jpeg;base64,", "data:image/webp;base64,", "data:image/gif;base64,"];
/// <summary>不合法时抛 BadRequest</summary>
public static void Validate(string avatar, int maxGifChars)
{
var prefix = Prefixes.FirstOrDefault(p => avatar.StartsWith(p, StringComparison.Ordinal));
var gif = prefix?.Contains("gif", StringComparison.Ordinal) == true;
if (prefix is null || avatar.Length > (gif ? maxGifChars : MaxStaticChars) || avatar.Length <= prefix.Length) throw new WebDropException(ErrorCode.BadRequest);
byte[] bytes;
try { bytes = Convert.FromBase64String(avatar[prefix.Length..]); }
catch (FormatException) { throw new WebDropException(ErrorCode.BadRequest); }
if (gif && !(bytes.Length > 6 && bytes[0] == (byte)'G' && bytes[1] == (byte)'I' && bytes[2] == (byte)'F')) throw new WebDropException(ErrorCode.BadRequest);
}
}
@@ -70,6 +70,9 @@ public sealed partial class ChannelService
events.MembersChanged(id);
}
/// <summary>空间里所有成员的账号(不做鉴权,供已经通过鉴权的接口补全成员资料)</summary>
public IReadOnlyList<string> MemberAccounts(Id16 id) => store.Read(tx => tx.ListChannelMembers(id).Select(m => m.Username).ToList());
public IReadOnlyList<ChannelMember> Members(Id16 id, Credentials creds) => store.Read(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
+6 -3
View File
@@ -68,7 +68,7 @@ public sealed partial class ChannelService(
public ChannelInfo GetChannel(Id16 id, Credentials creds) => store.Read(tx =>
{
var (channel, principal) = Authenticate(tx, id, creds, Membership.None);
return ToInfo(channel, tx.CountMembers(id), principal);
return ToInfo(channel, tx.CountMembers(id), principal, tx.GetChannelAvatar(id));
});
public ChannelInfo UpdateSettings(Id16 id, Credentials creds, ChannelSettingsPatch patch)
@@ -80,6 +80,8 @@ public sealed partial class ChannelService(
var updated = patch.ApplyTo(channel.Settings);
updated.Validate(limits);
if (patch.Avatar is { Length: > 0 } newAvatar) AvatarData.Validate(newAvatar, AvatarData.MaxSpaceGifChars);
if (patch.Avatar is not null) tx.SetChannelAvatar(id, patch.Avatar.Length == 0 ? null : patch.Avatar);
long expiresAt = updated.TtlSeconds != channel.Settings.TtlSeconds
? clock.NowMs + updated.TtlSeconds * 1000L
@@ -91,7 +93,7 @@ public sealed partial class ChannelService(
tx.SetJoinPassword(id, hash, salt);
channel = channel with { JoinPasswordHash = hash, JoinPasswordSalt = salt };
}
return ToInfo(channel with { Settings = updated, ExpiresAt = expiresAt }, tx.CountMembers(id));
return ToInfo(channel with { Settings = updated, ExpiresAt = expiresAt }, tx.CountMembers(id), avatar: tx.GetChannelAvatar(id));
});
events.SettingsChanged(id);
return info;
@@ -677,8 +679,9 @@ public sealed partial class ChannelService(
if (!ChannelPolicy.IsOwner(principal)) throw new WebDropException(ErrorCode.Forbidden);
}
private static ChannelInfo ToInfo(Channel channel, int membersUsed, Principal? principal = null) => new()
private static ChannelInfo ToInfo(Channel channel, int membersUsed, Principal? principal = null, string? avatar = null) => new()
{
Avatar = avatar,
CanUpload = ChannelPolicy.CanWrite(channel, principal ?? new Principal(Role.Owner, channel.CreatorAccount)),
CanManageStructure = principal is null || principal.Role == Role.Owner,
Id = channel.Id.ToBase64Url(),
+1 -1
View File
@@ -10,7 +10,7 @@ public sealed partial class ChannelService
{
var channel = tx.GetChannel(id)!;
var role = tx.GetChannelMember(id, username)?.Role == "owner" ? Role.Owner : channel.WriteHash is not null ? Role.Write : Role.Read;
return ToInfo(channel, tx.CountMembers(id), new Principal(role, username));
return ToInfo(channel, tx.CountMembers(id), new Principal(role, username), tx.GetChannelAvatar(id));
}).ToList());
private static WorkspaceState NewWorkspace(string username, long now)
{
+2
View File
@@ -56,6 +56,8 @@ public sealed class ChannelInfo
[JsonPropertyName("can_manage_structure")] public bool CanManageStructure { get; init; }
[JsonPropertyName("password_required")] public bool PasswordRequired { get; init; }
[JsonPropertyName("creator_account")] public string? CreatorAccount { get; init; }
/// <summary>空间头像(data URL,PNG/JPEG/WebP/GIF),没有设置时为 null。</summary>
[JsonPropertyName("avatar")] public string? Avatar { get; init; }
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("mode")] public required ChannelMode Mode { get; init; }
[JsonPropertyName("settings")] public required ChannelSettings Settings { get; init; }
+2
View File
@@ -64,6 +64,8 @@ public sealed class ChannelSettingsPatch
[JsonPropertyName("max_items")] public int? MaxItems { get; init; }
[JsonPropertyName("file_max_downloads")] public int? FileMaxDownloads { get; init; }
[JsonPropertyName("ui_restrict_copy_download")] public bool? UiRestrictCopyDownload { get; init; }
/// <summary>空间头像 data URL;不传表示不改,空字符串表示移除。只有空间主能改。</summary>
[JsonPropertyName("avatar")] public string? Avatar { get; init; }
public ChannelSettings ApplyTo(ChannelSettings s) => new()
{
@@ -16,22 +16,10 @@ public sealed partial class SqliteAccountStore
: new AccountProfile { Username = username, DisplayName = username, ModifiedAt = 0 };
}
}
/// <summary>动图头像保留全部帧,允许比静态头像大得多(约 4.5 MB)</summary>
private const int MaxGifAvatarChars = 6_000_000;
public AccountProfile UpdateProfile(string username, string displayName, string? avatar)
{
if (displayName is null || displayName.Trim().Length > 48 || displayName.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
if (avatar is not null)
{
string? prefix = new[] { "data:image/png;base64,", "data:image/jpeg;base64,", "data:image/webp;base64,", "data:image/gif;base64," }.FirstOrDefault(p => avatar.StartsWith(p, StringComparison.Ordinal));
if (prefix is null || avatar.Length > (prefix.Contains("gif") ? MaxGifAvatarChars : 350_000) || avatar.Length <= prefix.Length) throw new WebDropException(ErrorCode.BadRequest);
try
{
var bytes = Convert.FromBase64String(avatar[prefix.Length..]);
if (prefix.Contains("gif") && !(bytes.Length > 6 && bytes[0] == (byte)'G' && bytes[1] == (byte)'I' && bytes[2] == (byte)'F')) throw new WebDropException(ErrorCode.BadRequest);
}
catch (FormatException) { throw new WebDropException(ErrorCode.BadRequest); }
}
if (avatar is not null) AvatarData.Validate(avatar, AvatarData.MaxAccountGifChars);
lock (_gate)
{
using var cmd = _connection.CreateCommand();
@@ -46,6 +46,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
CREATE INDEX IF NOT EXISTS ix_channel_keys_username ON channel_key_packages(username);
CREATE TABLE IF NOT EXISTS workspace_structures (space_id BLOB PRIMARY KEY, state TEXT NOT NULL) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS workspace_crypto (space_id BLOB PRIMARY KEY, state TEXT NOT NULL) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS channel_avatars (space_id BLOB PRIMARY KEY, avatar TEXT NOT NULL) WITHOUT ROWID;
""");
}
@@ -502,10 +503,19 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
Run("DELETE FROM channel_key_packages WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM workspace_structures WHERE space_id=@c", ("@c", key));
Run("DELETE FROM workspace_crypto WHERE space_id=@c", ("@c", key));
Run("DELETE FROM channel_avatars WHERE space_id=@c", ("@c", key));
Run("DELETE FROM channel_members WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM channel_join_denials WHERE channel_id=@c", ("@c", key));
}
public string? GetChannelAvatar(Id16 id) => Scalar("SELECT avatar FROM channel_avatars WHERE space_id=@c", ("@c", id.ToArray())) as string;
public void SetChannelAvatar(Id16 id, string? avatar)
{
if (avatar is null) Run("DELETE FROM channel_avatars WHERE space_id=@c", ("@c", id.ToArray()));
else Run("INSERT INTO channel_avatars (space_id, avatar) VALUES (@c,@a) ON CONFLICT(space_id) DO UPDATE SET avatar=excluded.avatar", ("@c", id.ToArray()), ("@a", avatar));
}
public IReadOnlyList<Id16> ListExpiredChannels(long now, int limit) =>
Many("SELECT id FROM channels WHERE expires_at<=@now LIMIT @n", r => IdAt(r, 0), ("@now", now), ("@n", limit));
Binary file not shown.
+3 -1
View File
@@ -1,5 +1,5 @@
// Generated from backend DTOs; do not edit.
// backend-contract-sha256: 4e07231f729947308afe7da4fda9441ef0de27552771d85ad0586f06cdad0200
// backend-contract-sha256: a7ea3b6455a14ed68504e9683a38fae13e560bd48df12f3ecf3e82a88f0b83dd
export interface AccountChannelBody {
channel_id: string;
@@ -72,6 +72,7 @@ export interface ChannelHistoryRequest {
filesHistory: boolean;
}
export interface ChannelInfo {
avatar?: string | null;
can_manage_structure: boolean;
can_upload: boolean;
created_at: number;
@@ -99,6 +100,7 @@ export interface ChannelSettings {
ui_restrict_copy_download: boolean;
}
export interface ChannelSettingsPatch {
avatar?: string | null;
delete_policy?: DeletePolicy | null;
file_max_downloads?: number | null;
join_password?: string | null;