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;