From 1a4069576b43ac430fb329b18ec1b2c58b523baf Mon Sep 17 00:00:00 2001 From: JustMe Date: Sat, 17 Oct 2026 13:44:16 +0800 Subject: [PATCH] feat(server): space avatars and full member profiles in the structure response --- contracts/api.schemas.json | 22 ++++++++++- server/src/WebDrop.Api/Endpoints.Workspace.cs | 36 +++++++++--------- server/src/WebDrop.Api/Endpoints.cs | 2 + server/src/WebDrop.Core/Abstractions.cs | 3 ++ server/src/WebDrop.Core/AvatarData.cs | 26 +++++++++++++ server/src/WebDrop.Core/ChannelAdmission.cs | 3 ++ server/src/WebDrop.Core/ChannelService.cs | 9 +++-- server/src/WebDrop.Core/ChannelWorkspace.cs | 2 +- server/src/WebDrop.Core/Dtos.cs | 2 + server/src/WebDrop.Core/Settings.cs | 2 + .../WebDrop.Storage/SqliteAccountProfiles.cs | 14 +------ .../src/WebDrop.Storage/SqliteChannelStore.cs | 10 +++++ .../WebDrop.Api.Tests/WorkspaceApiTests.cs | Bin 7678 -> 9091 bytes web/src/core-bridge/generated/api.ts | 4 +- 14 files changed, 99 insertions(+), 36 deletions(-) create mode 100644 server/src/WebDrop.Core/AvatarData.cs diff --git a/contracts/api.schemas.json b/contracts/api.schemas.json index d305f95..a15e7af 100644 --- a/contracts/api.schemas.json +++ b/contracts/api.schemas.json @@ -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": [ { diff --git a/server/src/WebDrop.Api/Endpoints.Workspace.cs b/server/src/WebDrop.Api/Endpoints.Workspace.cs index ab839a1..9ee9660 100644 --- a/server/src/WebDrop.Api/Endpoints.Workspace.cs +++ b/server/src/WebDrop.Api/Endpoints.Workspace.cs @@ -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); }); } } diff --git a/server/src/WebDrop.Api/Endpoints.cs b/server/src/WebDrop.Api/Endpoints.cs index ed52404..9390ee4 100644 --- a/server/src/WebDrop.Api/Endpoints.cs +++ b/server/src/WebDrop.Api/Endpoints.cs @@ -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() 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); diff --git a/server/src/WebDrop.Core/Abstractions.cs b/server/src/WebDrop.Core/Abstractions.cs index d476b62..9a96fcb 100644 --- a/server/src/WebDrop.Core/Abstractions.cs +++ b/server/src/WebDrop.Core/Abstractions.cs @@ -118,6 +118,9 @@ public interface IResourceTx void SetCompactedThrough(Id16 id, long seq); long NextSeq(Id16 id); void DeleteChannelRows(Id16 id); + /// 空间头像(data URL)单独存放,不放进每次请求都要读的设置 JSON + string? GetChannelAvatar(Id16 id); + void SetChannelAvatar(Id16 id, string? avatar); IReadOnlyList ListExpiredChannels(long now, int limit); // 成员 diff --git a/server/src/WebDrop.Core/AvatarData.cs b/server/src/WebDrop.Core/AvatarData.cs new file mode 100644 index 0000000..757f88d --- /dev/null +++ b/server/src/WebDrop.Core/AvatarData.cs @@ -0,0 +1,26 @@ +namespace WebDrop.Core; + +/// 头像(账号头像、空间头像)的 data URL 校验。浏览器里已经裁剪好并重新编码,服务端只做格式、大小和文件头检查 +public static class AvatarData +{ + /// 静态头像(256×256 WebP/PNG/JPEG)的长度上限(字符数) + public const int MaxStaticChars = 350_000; + /// 账号动图头像保留全部帧,约 4.5 MB + public const int MaxAccountGifChars = 6_000_000; + /// 空间头像会跟着空间信息发给所有成员和空间列表,动图上限比账号头像小,约 1.5 MB + 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,"]; + + /// 不合法时抛 BadRequest + 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); + } +} diff --git a/server/src/WebDrop.Core/ChannelAdmission.cs b/server/src/WebDrop.Core/ChannelAdmission.cs index 7a0378d..c30ba63 100644 --- a/server/src/WebDrop.Core/ChannelAdmission.cs +++ b/server/src/WebDrop.Core/ChannelAdmission.cs @@ -70,6 +70,9 @@ public sealed partial class ChannelService events.MembersChanged(id); } + /// 空间里所有成员的账号(不做鉴权,供已经通过鉴权的接口补全成员资料) + public IReadOnlyList MemberAccounts(Id16 id) => store.Read(tx => tx.ListChannelMembers(id).Select(m => m.Username).ToList()); + public IReadOnlyList Members(Id16 id, Credentials creds) => store.Read(tx => { var (_, principal) = Authenticate(tx, id, creds, Membership.None); diff --git a/server/src/WebDrop.Core/ChannelService.cs b/server/src/WebDrop.Core/ChannelService.cs index d62c0fc..5ae8085 100644 --- a/server/src/WebDrop.Core/ChannelService.cs +++ b/server/src/WebDrop.Core/ChannelService.cs @@ -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(), diff --git a/server/src/WebDrop.Core/ChannelWorkspace.cs b/server/src/WebDrop.Core/ChannelWorkspace.cs index 9c1895b..b6e1d2c 100644 --- a/server/src/WebDrop.Core/ChannelWorkspace.cs +++ b/server/src/WebDrop.Core/ChannelWorkspace.cs @@ -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) { diff --git a/server/src/WebDrop.Core/Dtos.cs b/server/src/WebDrop.Core/Dtos.cs index e6f4875..f277eb8 100644 --- a/server/src/WebDrop.Core/Dtos.cs +++ b/server/src/WebDrop.Core/Dtos.cs @@ -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; } + /// 空间头像(data URL,PNG/JPEG/WebP/GIF),没有设置时为 null。 + [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; } diff --git a/server/src/WebDrop.Core/Settings.cs b/server/src/WebDrop.Core/Settings.cs index 6d1aff4..c79f963 100644 --- a/server/src/WebDrop.Core/Settings.cs +++ b/server/src/WebDrop.Core/Settings.cs @@ -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; } + /// 空间头像 data URL;不传表示不改,空字符串表示移除。只有空间主能改。 + [JsonPropertyName("avatar")] public string? Avatar { get; init; } public ChannelSettings ApplyTo(ChannelSettings s) => new() { diff --git a/server/src/WebDrop.Storage/SqliteAccountProfiles.cs b/server/src/WebDrop.Storage/SqliteAccountProfiles.cs index 0839b7e..0c09e0c 100644 --- a/server/src/WebDrop.Storage/SqliteAccountProfiles.cs +++ b/server/src/WebDrop.Storage/SqliteAccountProfiles.cs @@ -16,22 +16,10 @@ public sealed partial class SqliteAccountStore : new AccountProfile { Username = username, DisplayName = username, ModifiedAt = 0 }; } } - /// 动图头像保留全部帧,允许比静态头像大得多(约 4.5 MB) - 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(); diff --git a/server/src/WebDrop.Storage/SqliteChannelStore.cs b/server/src/WebDrop.Storage/SqliteChannelStore.cs index 956c70b..b692476 100644 --- a/server/src/WebDrop.Storage/SqliteChannelStore.cs +++ b/server/src/WebDrop.Storage/SqliteChannelStore.cs @@ -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 ListExpiredChannels(long now, int limit) => Many("SELECT id FROM channels WHERE expires_at<=@now LIMIT @n", r => IdAt(r, 0), ("@now", now), ("@n", limit)); diff --git a/server/tests/WebDrop.Api.Tests/WorkspaceApiTests.cs b/server/tests/WebDrop.Api.Tests/WorkspaceApiTests.cs index 2054030023d38bfa8812d92cf4424870d847ad72..84a714bd039d51a7dc85c1abc73fdef2b0c031a4 100644 GIT binary patch delta 839 zcmb`FO>5LZ7{`%CcFig+mfEgV7=tevC>a+#xxTOnUH9S^chRE`$#fH%X2Z;6%d+gL zf_M-q16HLVLRAFoYJH(p@C$hN6Lgb$^b3fSq>YPs@X$F-Cd~8v^Z);z{3F%5GQjJ;(PXdrIglT)^QEcz-<2FHO{>{$J}RETPC>-W-&J`jd5zb zO@L!l5OjghqW4IYiuYp$v7XDSr&NSJ8?}qbu(0bAhoP=v=~c6b7~xzth;PSFOeP|r z2VrSghKQesj)_`EnWFN7#u4R%>3`gmKu_GsKou`yo9{Z4nWRvfB=Mz*<&ks6zg?)) z5{4cFod&M&I1ug{(BdGegFGeJgb7T6GvgWg5m6kSC`daH>J`FgY0D#&cQiFgBl?9i z;>y&BIFdaKhwZ7B!DE`6>qY Sws!h2WSZdZrw