diff --git a/contracts/api.schemas.json b/contracts/api.schemas.json index a15e7af..3d07b2f 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": "a7ea3b6455a14ed68504e9683a38fae13e560bd48df12f3ecf3e82a88f0b83dd", + "x-backend-contract-sha256": "96cd0ffa3588a288af7bc720b2413cb56499dd862ac98d83c73ccc11a1147d25", "$defs": { "AccountChannelBody": { "type": "object", @@ -98,6 +98,9 @@ "displayName": { "type": "string" }, + "hasAvatar": { + "type": "boolean" + }, "modifiedAt": { "type": "integer" }, @@ -107,6 +110,7 @@ }, "required": [ "displayName", + "hasAvatar", "modifiedAt", "username" ] @@ -124,12 +128,26 @@ } ] }, + "avatarThumb": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "displayName": { "type": "string" + }, + "keepAvatar": { + "type": "boolean" } }, "required": [ - "displayName" + "displayName", + "keepAvatar" ] }, "AccountRegisterRequest": { @@ -431,6 +449,16 @@ } ] }, + "avatar_thumb": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "delete_policy": { "anyOf": [ { diff --git a/server/src/WebDrop.Api/ApiJson.cs b/server/src/WebDrop.Api/ApiJson.cs index 371ef12..801fd30 100644 --- a/server/src/WebDrop.Api/ApiJson.cs +++ b/server/src/WebDrop.Api/ApiJson.cs @@ -165,6 +165,10 @@ public sealed class AccountProfileRequest { [JsonPropertyName("displayName")] public required string DisplayName { get; init; } [JsonPropertyName("avatar")] public string? Avatar { get; init; } + /// 头像的有损小图(data URL)。列表、聊天默认只分发它,原图在预览时才下载 + [JsonPropertyName("avatarThumb")] public string? AvatarThumb { get; init; } + /// 只改名字、不动头像:保留服务端现有的头像(不带 avatar 的请求默认表示移除头像) + [JsonPropertyName("keepAvatar")] public bool KeepAvatar { get; init; } } public sealed class WorkspaceNameRequest { [JsonPropertyName("name")] public required string Name { get; init; } } public sealed class WorkspaceChannelRequest diff --git a/server/src/WebDrop.Api/Endpoints.Accounts.cs b/server/src/WebDrop.Api/Endpoints.Accounts.cs index fc2cd7e..299b23e 100644 --- a/server/src/WebDrop.Api/Endpoints.Accounts.cs +++ b/server/src/WebDrop.Api/Endpoints.Accounts.cs @@ -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() 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) => { diff --git a/server/src/WebDrop.Api/Endpoints.Workspace.cs b/server/src/WebDrop.Api/Endpoints.Workspace.cs index 9ee9660..902e984 100644 --- a/server/src/WebDrop.Api/Endpoints.Workspace.cs +++ b/server/src/WebDrop.Api/Endpoints.Workspace.cs @@ -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)); diff --git a/server/src/WebDrop.Api/Endpoints.cs b/server/src/WebDrop.Api/Endpoints.cs index 9390ee4..efcb5c3 100644 --- a/server/src/WebDrop.Api/Endpoints.cs +++ b/server/src/WebDrop.Api/Endpoints.cs @@ -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() 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); } + /// + /// 读 JSON 请求体,支持 Content-Encoding: gzip(头像这类带 base64 的大请求,压缩后能省下 base64 多出来的约四分之一)。 + /// 解压后的大小另外限制,防止小体积的压缩炸弹。 + /// + private static async Task ReadJsonCompressed(HttpRequest req, JsonTypeInfo 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); } + } + + /// data URL 头像 → 图片字节(带 ETag,没变时 304) + 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 ReadJson(HttpRequest req, JsonTypeInfo info, CancellationToken ct) where T : class { try diff --git a/server/src/WebDrop.Core/Abstractions.cs b/server/src/WebDrop.Core/Abstractions.cs index 9a96fcb..bd81528 100644 --- a/server/src/WebDrop.Core/Abstractions.cs +++ b/server/src/WebDrop.Core/Abstractions.cs @@ -119,8 +119,9 @@ public interface IResourceTx long NextSeq(Id16 id); void DeleteChannelRows(Id16 id); /// 空间头像(data URL)单独存放,不放进每次请求都要读的设置 JSON - string? GetChannelAvatar(Id16 id); - void SetChannelAvatar(Id16 id, string? avatar); + /// thumb 为 true 取缩略图(没有就用原图),否则取原图 + string? GetChannelAvatar(Id16 id, bool thumb); + void SetChannelAvatar(Id16 id, string? avatar, string? thumb); IReadOnlyList ListExpiredChannels(long now, int limit); // 成员 diff --git a/server/src/WebDrop.Core/AccountProfile.cs b/server/src/WebDrop.Core/AccountProfile.cs index 7318ebd..5999ac9 100644 --- a/server/src/WebDrop.Core/AccountProfile.cs +++ b/server/src/WebDrop.Core/AccountProfile.cs @@ -6,6 +6,13 @@ public sealed class AccountProfile { [JsonPropertyName("username")] public required string Username { get; init; } [JsonPropertyName("displayName")] public required string DisplayName { get; init; } + /// 头像(data URL)。资料列表、空间成员等接口不再内联头像(动图可能有几 MB),只告诉有没有,头像另用 /accounts/users/{用户名}/avatar 取 [JsonPropertyName("avatar")] public string? Avatar { get; init; } + [JsonPropertyName("hasAvatar")] public bool HasAvatar { get; init; } [JsonPropertyName("modifiedAt")] public required long ModifiedAt { get; init; } + + /// 有损的小图(列表、聊天里默认分发);没有单独上传时用原图。不随 JSON 输出 + [JsonIgnore] public string? AvatarThumb { get; init; } + + public AccountProfile WithoutAvatar() => new() { Username = Username, DisplayName = DisplayName, HasAvatar = HasAvatar, ModifiedAt = ModifiedAt }; } diff --git a/server/src/WebDrop.Core/AvatarData.cs b/server/src/WebDrop.Core/AvatarData.cs index 757f88d..d65d137 100644 --- a/server/src/WebDrop.Core/AvatarData.cs +++ b/server/src/WebDrop.Core/AvatarData.cs @@ -10,14 +10,20 @@ public static class AvatarData /// 空间头像会跟着空间信息发给所有成员和空间列表,动图上限比账号头像小,约 1.5 MB public const int MaxSpaceGifChars = 2_000_000; + /// 缩略图(列表、聊天里默认分发的有损小图)的上限:静态约 75 KB,动图约 370 KB + public const int MaxThumbStaticChars = 100_000; + public const int MaxThumbGifChars = 500_000; + private static readonly string[] Prefixes = ["data:image/png;base64,", "data:image/jpeg;base64,", "data:image/webp;base64,", "data:image/gif;base64,"]; + public static void ValidateThumb(string thumb) => Validate(thumb, MaxThumbGifChars, MaxThumbStaticChars); + /// 不合法时抛 BadRequest - public static void Validate(string avatar, int maxGifChars) + public static void Validate(string avatar, int maxGifChars, int maxStaticChars = MaxStaticChars) { 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); + 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); } diff --git a/server/src/WebDrop.Core/ChannelService.cs b/server/src/WebDrop.Core/ChannelService.cs index 5ae8085..df21a45 100644 --- a/server/src/WebDrop.Core/ChannelService.cs +++ b/server/src/WebDrop.Core/ChannelService.cs @@ -68,7 +68,14 @@ 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, tx.GetChannelAvatar(id)); + return ToInfo(channel, tx.CountMembers(id), principal, tx.GetChannelAvatar(id, thumb: true)); + }); + + /// 空间头像原图(预览时才下载)。需要能读这个空间 + public string? GetChannelAvatarFull(Id16 id, Credentials creds) => store.Read(tx => + { + _ = Authenticate(tx, id, creds, Membership.None); + return tx.GetChannelAvatar(id, thumb: false); }); public ChannelInfo UpdateSettings(Id16 id, Credentials creds, ChannelSettingsPatch patch) @@ -81,7 +88,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); + if (patch.AvatarThumb is { Length: > 0 } newThumb) { if (patch.Avatar is not { Length: > 0 }) throw new WebDropException(ErrorCode.BadRequest); AvatarData.ValidateThumb(newThumb); } + if (patch.Avatar is not null) tx.SetChannelAvatar(id, patch.Avatar.Length == 0 ? null : patch.Avatar, patch.Avatar.Length == 0 ? null : patch.AvatarThumb is { Length: > 0 } ? patch.AvatarThumb : null); long expiresAt = updated.TtlSeconds != channel.Settings.TtlSeconds ? clock.NowMs + updated.TtlSeconds * 1000L @@ -93,7 +101,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), avatar: tx.GetChannelAvatar(id)); + return ToInfo(channel with { Settings = updated, ExpiresAt = expiresAt }, tx.CountMembers(id), avatar: tx.GetChannelAvatar(id, thumb: true)); }); events.SettingsChanged(id); return info; diff --git a/server/src/WebDrop.Core/ChannelWorkspace.cs b/server/src/WebDrop.Core/ChannelWorkspace.cs index b6e1d2c..7129723 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), tx.GetChannelAvatar(id)); + return ToInfo(channel, tx.CountMembers(id), new Principal(role, username), tx.GetChannelAvatar(id, thumb: true)); }).ToList()); private static WorkspaceState NewWorkspace(string username, long now) { diff --git a/server/src/WebDrop.Core/Settings.cs b/server/src/WebDrop.Core/Settings.cs index c79f963..8e556af 100644 --- a/server/src/WebDrop.Core/Settings.cs +++ b/server/src/WebDrop.Core/Settings.cs @@ -66,6 +66,8 @@ public sealed class ChannelSettingsPatch [JsonPropertyName("ui_restrict_copy_download")] public bool? UiRestrictCopyDownload { get; init; } /// 空间头像 data URL;不传表示不改,空字符串表示移除。只有空间主能改。 [JsonPropertyName("avatar")] public string? Avatar { get; init; } + /// 空间头像的有损小图(data URL):空间列表、标题默认只分发它,原图在预览时才下载 + [JsonPropertyName("avatar_thumb")] public string? AvatarThumb { 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 0c09e0c..f574277 100644 --- a/server/src/WebDrop.Storage/SqliteAccountProfiles.cs +++ b/server/src/WebDrop.Storage/SqliteAccountProfiles.cs @@ -9,24 +9,27 @@ public sealed partial class SqliteAccountStore lock (_gate) { using var cmd = _connection.CreateCommand(); - cmd.CommandText = "SELECT display_name,avatar,modified_at FROM account_profiles WHERE username=$u"; + cmd.CommandText = "SELECT display_name,avatar,modified_at,avatar_thumb FROM account_profiles WHERE username=$u"; cmd.Parameters.AddWithValue("$u", username); using var reader = cmd.ExecuteReader(); - return reader.Read() ? new AccountProfile { Username = username, DisplayName = reader.GetString(0), Avatar = reader.IsDBNull(1) ? null : reader.GetString(1), ModifiedAt = reader.GetInt64(2) } + return reader.Read() ? new AccountProfile { Username = username, DisplayName = reader.GetString(0), Avatar = reader.IsDBNull(1) ? null : reader.GetString(1), HasAvatar = !reader.IsDBNull(1), ModifiedAt = reader.GetInt64(2), AvatarThumb = reader.IsDBNull(3) ? null : reader.GetString(3) } : new AccountProfile { Username = username, DisplayName = username, ModifiedAt = 0 }; } } - public AccountProfile UpdateProfile(string username, string displayName, string? avatar) + public AccountProfile UpdateProfile(string username, string displayName, string? avatar, string? avatarThumb = null, bool keepAvatar = false) { if (displayName is null || displayName.Trim().Length > 48 || displayName.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest); - if (avatar is not null) AvatarData.Validate(avatar, AvatarData.MaxAccountGifChars); + if (keepAvatar) { var current = Profile(username); avatar = current.Avatar; avatarThumb = current.AvatarThumb; } + else if (avatar is not null) AvatarData.Validate(avatar, AvatarData.MaxAccountGifChars); + if (!keepAvatar && avatarThumb is not null) { if (avatar is null) throw new WebDropException(ErrorCode.BadRequest); AvatarData.ValidateThumb(avatarThumb); } lock (_gate) { using var cmd = _connection.CreateCommand(); - cmd.CommandText = "INSERT INTO account_profiles(username,display_name,avatar,modified_at) VALUES($u,$n,$a,$t) ON CONFLICT(username) DO UPDATE SET display_name=excluded.display_name,avatar=excluded.avatar,modified_at=MAX(account_profiles.modified_at+1,excluded.modified_at)"; + cmd.CommandText = "INSERT INTO account_profiles(username,display_name,avatar,avatar_thumb,modified_at) VALUES($u,$n,$a,$b,$t) ON CONFLICT(username) DO UPDATE SET display_name=excluded.display_name,avatar=excluded.avatar,avatar_thumb=excluded.avatar_thumb,modified_at=MAX(account_profiles.modified_at+1,excluded.modified_at)"; cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$n", displayName.Trim()); cmd.Parameters.AddWithValue("$a", (object?)avatar ?? DBNull.Value); + cmd.Parameters.AddWithValue("$b", (object?)avatarThumb ?? DBNull.Value); cmd.Parameters.AddWithValue("$t", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); cmd.ExecuteNonQuery(); return Profile(username); diff --git a/server/src/WebDrop.Storage/SqliteAccountStore.cs b/server/src/WebDrop.Storage/SqliteAccountStore.cs index 0b3d3b8..df63abb 100644 --- a/server/src/WebDrop.Storage/SqliteAccountStore.cs +++ b/server/src/WebDrop.Storage/SqliteAccountStore.cs @@ -27,6 +27,19 @@ public sealed partial class SqliteAccountStore : IDisposable cmd.ExecuteNonQuery(); using (var columns = _connection.CreateCommand()) { + columns.CommandText = "PRAGMA table_info(account_profiles)"; + using (var profileColumns = columns.ExecuteReader()) + { + bool hasThumb = false; + while (profileColumns.Read()) if (profileColumns.GetString(1) == "avatar_thumb") hasThumb = true; + profileColumns.Close(); + if (!hasThumb) + { + using var alterProfiles = _connection.CreateCommand(); + alterProfiles.CommandText = "ALTER TABLE account_profiles ADD COLUMN avatar_thumb TEXT"; + alterProfiles.ExecuteNonQuery(); + } + } columns.CommandText = "PRAGMA table_info(accounts)"; using var reader = columns.ExecuteReader(); bool hasSigningKey = false; diff --git a/server/src/WebDrop.Storage/SqliteChannelStore.cs b/server/src/WebDrop.Storage/SqliteChannelStore.cs index b692476..c38fa01 100644 --- a/server/src/WebDrop.Storage/SqliteChannelStore.cs +++ b/server/src/WebDrop.Storage/SqliteChannelStore.cs @@ -37,6 +37,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable Exec(_writeConn, "DELETE FROM channel_join_denials WHERE target LIKE 'endpoint:%';"); // v7: 设置里的成员人数上限由 seats 改名为 max_members,迁移已有频道的设置 JSON(幂等) Exec(_writeConn, "UPDATE channels SET settings = json_remove(json_set(settings, '$.max_members', json_extract(settings, '$.seats')), '$.seats') WHERE json_extract(settings, '$.seats') IS NOT NULL AND json_extract(settings, '$.max_members') IS NULL;"); + try { Exec(_writeConn, "ALTER TABLE channel_avatars ADD COLUMN thumb TEXT;"); } catch (SqliteException) { /* 新建的表或已经迁移过 */ } // Additive table: retain the running v2 database and its existing channel data. Exec(_writeConn, """ CREATE TABLE IF NOT EXISTS channel_key_packages ( @@ -46,7 +47,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; + CREATE TABLE IF NOT EXISTS channel_avatars (space_id BLOB PRIMARY KEY, avatar TEXT NOT NULL, thumb TEXT) WITHOUT ROWID; """); } @@ -508,12 +509,13 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable 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 string? GetChannelAvatar(Id16 id, bool thumb) => + Scalar(thumb ? "SELECT COALESCE(thumb, avatar) FROM channel_avatars WHERE space_id=@c" : "SELECT avatar FROM channel_avatars WHERE space_id=@c", ("@c", id.ToArray())) as string; - public void SetChannelAvatar(Id16 id, string? avatar) + public void SetChannelAvatar(Id16 id, string? avatar, string? thumb) { 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)); + else Run("INSERT INTO channel_avatars (space_id, avatar, thumb) VALUES (@c,@a,@t) ON CONFLICT(space_id) DO UPDATE SET avatar=excluded.avatar, thumb=excluded.thumb", ("@c", id.ToArray()), ("@a", avatar), ("@t", (object?)thumb ?? DBNull.Value)); } public IReadOnlyList ListExpiredChannels(long now, int limit) => diff --git a/server/tests/WebDrop.Api.Tests/WorkspaceApiTests.cs b/server/tests/WebDrop.Api.Tests/WorkspaceApiTests.cs index 84a714b..0192acf 100644 Binary files a/server/tests/WebDrop.Api.Tests/WorkspaceApiTests.cs and b/server/tests/WebDrop.Api.Tests/WorkspaceApiTests.cs differ diff --git a/web/src/core-bridge/generated/api.ts b/web/src/core-bridge/generated/api.ts index e0add62..5e9356d 100644 --- a/web/src/core-bridge/generated/api.ts +++ b/web/src/core-bridge/generated/api.ts @@ -1,5 +1,5 @@ // Generated from backend DTOs; do not edit. -// backend-contract-sha256: a7ea3b6455a14ed68504e9683a38fae13e560bd48df12f3ecf3e82a88f0b83dd +// backend-contract-sha256: 96cd0ffa3588a288af7bc720b2413cb56499dd862ac98d83c73ccc11a1147d25 export interface AccountChannelBody { channel_id: string; @@ -25,12 +25,15 @@ export interface AccountDeviceSigningRequest { export interface AccountProfile { avatar?: string | null; displayName: string; + hasAvatar: boolean; modifiedAt: number; username: string; } export interface AccountProfileRequest { avatar?: string | null; + avatarThumb?: string | null; displayName: string; + keepAvatar: boolean; } export interface AccountRegisterRequest { public_key: string; @@ -101,6 +104,7 @@ export interface ChannelSettings { } export interface ChannelSettingsPatch { avatar?: string | null; + avatar_thumb?: string | null; delete_policy?: DeletePolicy | null; file_max_downloads?: number | null; join_password?: string | null;