diff --git a/contracts/api.schemas.json b/contracts/api.schemas.json
index ddab721..d49c953 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": "340876061d7b324e4fd16972e276ffb0f238eeac66b214a043b3b4b98d560fcf",
+ "x-backend-contract-sha256": "e0ae14a47dfbb3c22017cdd7b5e12465b3e273842db2a35f36b7335c20b51cac",
"$defs": {
"AccountChannelBody": {
"type": "object",
@@ -312,7 +312,7 @@
"ChannelInfo": {
"type": "object",
"properties": {
- "avatar": {
+ "avatar_version": {
"anyOf": [
{
"type": "string"
diff --git a/server/src/WebDrop.Api/Endpoints/Endpoints.Accounts.cs b/server/src/WebDrop.Api/Endpoints/Endpoints.Accounts.cs
index bae5618..9507e5c 100644
--- a/server/src/WebDrop.Api/Endpoints/Endpoints.Accounts.cs
+++ b/server/src/WebDrop.Api/Endpoints/Endpoints.Accounts.cs
@@ -19,9 +19,7 @@ public static partial class Endpoints
if (!TryAccount(ctx, accounts, out var viewer)) throw new WebDropException(ErrorCode.AccountRequired);
// 只有和对方同在一个空间(或就是自己)才能看头像,不能靠猜用户名批量抓取
if (!svc.SharesSpace(viewer, NormalizeUsername(username))) throw new WebDropException(ErrorCode.NotFound);
- 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")}");
+ return AvatarResult(ctx, accounts.ProfileAvatar(NormalizeUsername(username), ctx.Request.Query["size"] == "full"));
});
api.MapPatch("/accounts/profile", async (HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
diff --git a/server/src/WebDrop.Api/Endpoints/Endpoints.cs b/server/src/WebDrop.Api/Endpoints/Endpoints.cs
index 76dc3d4..1db1137 100644
--- a/server/src/WebDrop.Api/Endpoints/Endpoints.cs
+++ b/server/src/WebDrop.Api/Endpoints/Endpoints.cs
@@ -82,11 +82,11 @@ public static partial class Endpoints
return Json(info, ApiJson.Default.ChannelInfo);
});
- // 空间头像原图(信息里只带缩略图):预览时才下载。带 ETag(内容哈希),没变时回 304
+ // 空间头像(信息里只带版本):默认缩略图,size=full 原图。磁盘文件直接发送,带 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 ?? ""))));
+ bool thumb = ctx.Request.Query["size"] != "full";
+ return AvatarResult(ctx, svc.GetChannelAvatarBlob(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), thumb));
});
api.MapDelete("/channels/{id}", (string id, HttpContext ctx, ChannelService svc) =>
@@ -486,16 +486,15 @@ public static partial class Endpoints
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)
+ /// 头像文件(带 ETag = 内容哈希,没变时 304)。直接按路径发送,不读进内存
+ private static IResult AvatarResult(HttpContext ctx, AvatarBlob? blob)
{
- if (data is null) return Results.NotFound();
- var etag = $"\"{version}\"";
+ if (blob is null) return Results.NotFound();
+ var etag = $"\"{blob.Hash}\"";
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);
+ return Results.File(blob.Path, blob.Mime);
}
private static async Task ReadJson(HttpRequest req, JsonTypeInfo info, CancellationToken ct) where T : class
@@ -515,7 +514,21 @@ public static partial class Endpoints
{
if (req.ContentLength is { } declared && declared > max) throw new WebDropException(ErrorCode.TooLarge);
- using var ms = new MemoryStream(req.ContentLength is { } len ? (int)len : 1024);
+ // 长度已知:直接读进刚好大小的数组,少一份拷贝(帧最大几 MB,多一份就是一次大对象分配)
+ if (req.ContentLength is { } known)
+ {
+ var exact = new byte[(int)known];
+ int filled = 0;
+ while (filled < exact.Length)
+ {
+ int got = await req.Body.ReadAsync(exact.AsMemory(filled), ct);
+ if (got == 0) throw new WebDropException(ErrorCode.BadRequest);
+ filled += got;
+ }
+ return exact;
+ }
+
+ using var ms = new MemoryStream(1024);
byte[] buffer = ArrayPool.Shared.Rent(16 * 1024);
try
{
diff --git a/server/src/WebDrop.Api/Hosting/BackupWorker.cs b/server/src/WebDrop.Api/Hosting/BackupWorker.cs
index 9c3b9ce..e912468 100644
--- a/server/src/WebDrop.Api/Hosting/BackupWorker.cs
+++ b/server/src/WebDrop.Api/Hosting/BackupWorker.cs
@@ -50,6 +50,20 @@ public sealed class BackupWorker(WebDropOptions options, SqliteChannelStore stor
foreach (var path in new[] { db, gz }) try { File.Delete(path); } catch (IOException) { }
}
+ // 头像是按内容哈希命名的磁盘文件(不在数据库里):内容不会变,已经上传过的跳过
+ foreach (var kind in new[] { "space", "account" })
+ {
+ string directory = Path.Combine(options.DataDir, "v2", "avatars", kind);
+ if (!Directory.Exists(directory)) continue;
+ var uploaded = (await s3.ListAsync($"chat/avatars/{kind}", ct).ConfigureAwait(false)).ToHashSet();
+ foreach (var file in Directory.EnumerateFiles(directory))
+ {
+ if (file.EndsWith(".tmp", StringComparison.Ordinal) || uploaded.Contains(Path.GetFileName(file))) continue;
+ await using var content = File.OpenRead(file);
+ await s3.PutAsync($"chat/avatars/{kind}/{Path.GetFileName(file)}", content, ct).ConfigureAwait(false);
+ }
+ }
+
// 保留期:按文件名里的日期清理更早的
int purged = 0;
var cutoff = now.Date.AddDays(-Math.Max(0, backup.RetainDays));
diff --git a/server/src/WebDrop.Core/Abstractions/Abstractions.cs b/server/src/WebDrop.Core/Abstractions/Abstractions.cs
index bd81528..2bd2490 100644
--- a/server/src/WebDrop.Core/Abstractions/Abstractions.cs
+++ b/server/src/WebDrop.Core/Abstractions/Abstractions.cs
@@ -120,7 +120,9 @@ public interface IResourceTx
void DeleteChannelRows(Id16 id);
/// 空间头像(data URL)单独存放,不放进每次请求都要读的设置 JSON
/// thumb 为 true 取缩略图(没有就用原图),否则取原图
- string? GetChannelAvatar(Id16 id, bool thumb);
+ AvatarBlob? GetChannelAvatar(Id16 id, bool thumb);
+ /// 头像的版本标记(头像文件名,内容变了才会变);没有头像为 null
+ string? GetChannelAvatarVersion(Id16 id);
void SetChannelAvatar(Id16 id, string? avatar, string? thumb);
IReadOnlyList ListExpiredChannels(long now, int limit);
diff --git a/server/src/WebDrop.Core/Accounts/AccountProfile.cs b/server/src/WebDrop.Core/Accounts/AccountProfile.cs
index 5999ac9..3c67274 100644
--- a/server/src/WebDrop.Core/Accounts/AccountProfile.cs
+++ b/server/src/WebDrop.Core/Accounts/AccountProfile.cs
@@ -6,7 +6,7 @@ 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 取
+ /// 头像文件名(服务端存的是磁盘文件)。资料列表、空间成员等接口不再内联头像(动图可能有几 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; }
diff --git a/server/src/WebDrop.Core/Accounts/AvatarData.cs b/server/src/WebDrop.Core/Accounts/AvatarData.cs
index 143d99c..9dfe700 100644
--- a/server/src/WebDrop.Core/Accounts/AvatarData.cs
+++ b/server/src/WebDrop.Core/Accounts/AvatarData.cs
@@ -30,3 +30,6 @@ public static class AvatarData
if (gif && !(bytes.Length > 6 && bytes[0] == (byte)'G' && bytes[1] == (byte)'I' && bytes[2] == (byte)'F')) throw new WebDropException(ErrorCode.BadRequest);
}
}
+
+/// 落在磁盘上的头像文件:下载时直接按路径发送,不读进内存。Hash 是内容的 SHA-256,也用作 ETag
+public sealed record AvatarBlob(string Path, string Mime, string Hash);
diff --git a/server/src/WebDrop.Core/Channels/ChannelService.cs b/server/src/WebDrop.Core/Channels/ChannelService.cs
index 0897994..c639705 100644
--- a/server/src/WebDrop.Core/Channels/ChannelService.cs
+++ b/server/src/WebDrop.Core/Channels/ChannelService.cs
@@ -79,14 +79,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, thumb: true), tx.GetWorkspace(id));
+ return ToInfo(channel, tx.CountMembers(id), principal, tx.GetChannelAvatarVersion(id), tx.GetWorkspace(id));
});
- /// 空间头像原图(预览时才下载)。需要能读这个空间
- public string? GetChannelAvatarFull(Id16 id, Credentials creds) => store.Read(tx =>
+ /// 空间头像(thumb 为缩略图,否则原图)。需要能读这个空间
+ public AvatarBlob? GetChannelAvatarBlob(Id16 id, Credentials creds, bool thumb) => store.Read(tx =>
{
_ = Authenticate(tx, id, creds, Membership.None);
- return tx.GetChannelAvatar(id, thumb: false);
+ return tx.GetChannelAvatar(id, thumb);
});
public ChannelInfo UpdateSettings(Id16 id, Credentials creds, ChannelSettingsPatch patch)
@@ -112,7 +112,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, thumb: true));
+ return ToInfo(channel with { Settings = updated, ExpiresAt = expiresAt }, tx.CountMembers(id), avatarVersion: tx.GetChannelAvatarVersion(id));
});
events.SettingsChanged(id);
return info;
@@ -842,13 +842,13 @@ 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, string? avatar = null, WorkspaceState? workspace = null)
+ private static ChannelInfo ToInfo(Channel channel, int membersUsed, Principal? principal = null, string? avatarVersion = null, WorkspaceState? workspace = null)
{
// 没有调用者(创建、改设置时)就是所有者本人
var who = principal ?? new Principal(Role.Owner, channel.CreatorAccount);
return new()
{
- Avatar = avatar,
+ AvatarVersion = avatarVersion,
IsOwner = ChannelPolicy.IsOwner(who),
CanUpload = ChannelPolicy.CanWrite(channel, who),
CanManageStructure = ChannelPolicy.CanManageStructure(workspace, who),
diff --git a/server/src/WebDrop.Core/Channels/Dtos.cs b/server/src/WebDrop.Core/Channels/Dtos.cs
index 384f73b..10bea35 100644
--- a/server/src/WebDrop.Core/Channels/Dtos.cs
+++ b/server/src/WebDrop.Core/Channels/Dtos.cs
@@ -71,8 +71,8 @@ public sealed class ChannelInfo
[JsonPropertyName("can_delete_own")] public bool CanDeleteOwn { 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; }
+ /// 头像版本标记,变了就要重新取头像(GET /channels/{id}/avatar);没有头像为 null。头像本体不随空间信息下发。
+ [JsonPropertyName("avatar_version")] public string? AvatarVersion { 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/Spaces/ChannelWorkspace.cs b/server/src/WebDrop.Core/Spaces/ChannelWorkspace.cs
index b789e64..e63163b 100644
--- a/server/src/WebDrop.Core/Spaces/ChannelWorkspace.cs
+++ b/server/src/WebDrop.Core/Spaces/ChannelWorkspace.cs
@@ -14,7 +14,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, thumb: true), tx.GetWorkspace(id));
+ return ToInfo(channel, tx.CountMembers(id), new Principal(role, username), tx.GetChannelAvatarVersion(id), tx.GetWorkspace(id));
}).ToList());
private static WorkspaceState NewWorkspace(string username, long now)
{
diff --git a/server/src/WebDrop.Storage/AvatarFiles.cs b/server/src/WebDrop.Storage/AvatarFiles.cs
new file mode 100644
index 0000000..f3b322c
--- /dev/null
+++ b/server/src/WebDrop.Storage/AvatarFiles.cs
@@ -0,0 +1,70 @@
+using System.Security.Cryptography;
+using WebDrop.Core;
+
+namespace WebDrop.Storage;
+
+///
+/// 头像按内容哈希存成磁盘文件,数据库里只记文件名(引用,形如 3f…a1.webp)。
+/// 下载时直接把文件交给服务器发送,不用把几 MB 的 base64 读进托管内存再解码。
+///
+public sealed class AvatarFiles
+{
+ private static readonly Dictionary MimeByExtension = new()
+ {
+ ["png"] = "image/png", ["jpg"] = "image/jpeg", ["webp"] = "image/webp", ["gif"] = "image/gif",
+ };
+ private readonly string _dir;
+
+ public AvatarFiles(string dir)
+ {
+ _dir = Path.GetFullPath(dir);
+ Directory.CreateDirectory(_dir);
+ }
+
+ public string Dir => _dir;
+
+ /// 旧数据把整段 data URL 存在数据库里;新数据是文件名
+ public static bool IsLegacy(string? value) => value is not null && value.StartsWith("data:", StringComparison.Ordinal);
+
+ /// 把已校验的 data URL 解码写成文件(同样内容只写一份),返回引用
+ public string Save(string dataUrl)
+ {
+ var chars = dataUrl.AsSpan();
+ int semicolon = chars.IndexOf(';'), comma = chars.IndexOf(',');
+ if (!dataUrl.StartsWith("data:image/", StringComparison.Ordinal) || semicolon < 0 || comma < semicolon) throw new WebDropException(ErrorCode.BadRequest);
+ string extension = chars[11..semicolon].ToString() switch { "jpeg" => "jpg", var other => other };
+ if (!MimeByExtension.ContainsKey(extension)) throw new WebDropException(ErrorCode.BadRequest);
+ var payload = chars[(comma + 1)..];
+ var bytes = new byte[payload.Length / 4 * 3 + 3];
+ if (!Convert.TryFromBase64Chars(payload, bytes, out int written)) throw new WebDropException(ErrorCode.BadRequest);
+ string name = Convert.ToHexStringLower(SHA256.HashData(bytes.AsSpan(0, written))) + "." + extension;
+ string path = Path.Combine(_dir, name);
+ if (!File.Exists(path))
+ {
+ string temp = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
+ File.WriteAllBytes(temp, bytes.AsSpan(0, written));
+ File.Move(temp, path, overwrite: true);
+ }
+ return name;
+ }
+
+ public AvatarBlob? Resolve(string? reference)
+ {
+ if (reference is null || IsLegacy(reference)) return null;
+ int dot = reference.IndexOf('.');
+ if (dot != 64 || !MimeByExtension.TryGetValue(reference[(dot + 1)..], out var mime)) return null;
+ foreach (char c in reference.AsSpan(0, 64)) if (!char.IsAsciiHexDigitLower(c)) return null;
+ string path = Path.Combine(_dir, reference);
+ return File.Exists(path) ? new AvatarBlob(path, mime, reference[..64]) : null;
+ }
+
+ /// 启动时调用:删掉没有任何记录引用的文件(替换、删除头像后留下的)
+ public void Sweep(IReadOnlySet referenced)
+ {
+ foreach (var path in System.IO.Directory.EnumerateFiles(_dir))
+ {
+ if (referenced.Contains(Path.GetFileName(path))) continue;
+ try { File.Delete(path); } catch (IOException) { }
+ }
+ }
+}
diff --git a/server/src/WebDrop.Storage/Sqlite/SqliteAccountProfiles.cs b/server/src/WebDrop.Storage/Sqlite/SqliteAccountProfiles.cs
index f574277..2f2d65e 100644
--- a/server/src/WebDrop.Storage/Sqlite/SqliteAccountProfiles.cs
+++ b/server/src/WebDrop.Storage/Sqlite/SqliteAccountProfiles.cs
@@ -4,6 +4,14 @@ namespace WebDrop.Storage;
public sealed partial class SqliteAccountStore
{
+ /// 头像文件(full 为原图,否则缩略图,没有缩略图就用原图)。没有头像为 null
+ public AvatarBlob? ProfileAvatar(string username, bool full)
+ {
+ var profile = Profile(username);
+ return _avatars.Resolve(full ? profile.Avatar : profile.AvatarThumb ?? profile.Avatar);
+ }
+
+ /// Avatar / AvatarThumb 是磁盘上的头像文件名,不是图片内容
public AccountProfile Profile(string username)
{
lock (_gate)
@@ -22,6 +30,7 @@ public sealed partial class SqliteAccountStore
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); }
+ if (!keepAvatar) { avatar = avatar is null ? null : _avatars.Save(avatar); avatarThumb = avatarThumb is null ? null : _avatars.Save(avatarThumb); }
lock (_gate)
{
using var cmd = _connection.CreateCommand();
diff --git a/server/src/WebDrop.Storage/Sqlite/SqliteAccountStore.cs b/server/src/WebDrop.Storage/Sqlite/SqliteAccountStore.cs
index df63abb..b2c6e3f 100644
--- a/server/src/WebDrop.Storage/Sqlite/SqliteAccountStore.cs
+++ b/server/src/WebDrop.Storage/Sqlite/SqliteAccountStore.cs
@@ -8,10 +8,13 @@ public sealed partial class SqliteAccountStore : IDisposable
{
private readonly SqliteConnection _connection;
private readonly object _gate = new();
+ private readonly AvatarFiles _avatars;
- public SqliteAccountStore(string path)
+ public SqliteAccountStore(string path, AvatarFiles? avatars = null)
{
- Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
+ string directory = Path.GetDirectoryName(Path.GetFullPath(path))!;
+ Directory.CreateDirectory(directory);
+ _avatars = avatars ?? new AvatarFiles(Path.Combine(directory, "avatars", "account"));
_connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = true }.ToString());
_connection.Open();
using var cmd = _connection.CreateCommand();
@@ -52,6 +55,45 @@ public sealed partial class SqliteAccountStore : IDisposable
alter.ExecuteNonQuery();
}
}
+ MigrateAvatars();
+ }
+
+ /// 旧版把头像的 data URL 整段存在数据库里:搬成磁盘文件(幂等,一个账号一个账号处理),再删掉没人引用的文件
+ private void MigrateAvatars()
+ {
+ var legacy = new List();
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "SELECT username FROM account_profiles WHERE avatar LIKE 'data:%' OR avatar_thumb LIKE 'data:%'";
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read()) legacy.Add(reader.GetString(0));
+ }
+ foreach (var username in legacy)
+ {
+ string? avatar, thumb;
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "SELECT avatar, avatar_thumb FROM account_profiles WHERE username=$u";
+ cmd.Parameters.AddWithValue("$u", username);
+ using var reader = cmd.ExecuteReader();
+ reader.Read();
+ avatar = reader.IsDBNull(0) ? null : reader.GetString(0); thumb = reader.IsDBNull(1) ? null : reader.GetString(1);
+ }
+ using var update = _connection.CreateCommand();
+ update.CommandText = "UPDATE account_profiles SET avatar=$a, avatar_thumb=$t WHERE username=$u";
+ update.Parameters.AddWithValue("$u", username);
+ update.Parameters.AddWithValue("$a", (object?)(AvatarFiles.IsLegacy(avatar) ? _avatars.Save(avatar!) : avatar) ?? DBNull.Value);
+ update.Parameters.AddWithValue("$t", (object?)(AvatarFiles.IsLegacy(thumb) ? _avatars.Save(thumb!) : thumb) ?? DBNull.Value);
+ update.ExecuteNonQuery();
+ }
+ var referenced = new HashSet();
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "SELECT avatar, avatar_thumb FROM account_profiles";
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read()) { if (!reader.IsDBNull(0)) referenced.Add(reader.GetString(0)); if (!reader.IsDBNull(1)) referenced.Add(reader.GetString(1)); }
+ }
+ _avatars.Sweep(referenced);
}
private static byte[] HashVerifier(byte[] verifier, byte[] salt) => Rfc2898DeriveBytes.Pbkdf2(verifier, salt, 210_000, HashAlgorithmName.SHA256, 32);
diff --git a/server/src/WebDrop.Storage/Sqlite/SqliteChannelStore.cs b/server/src/WebDrop.Storage/Sqlite/SqliteChannelStore.cs
index 5873376..c3b953c 100644
--- a/server/src/WebDrop.Storage/Sqlite/SqliteChannelStore.cs
+++ b/server/src/WebDrop.Storage/Sqlite/SqliteChannelStore.cs
@@ -15,11 +15,13 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
private readonly string _connectionString;
private readonly SqliteConnection _writeConn;
private readonly object _writeLock = new();
+ private readonly AvatarFiles _avatars;
- public SqliteChannelStore(string dbPath)
+ public SqliteChannelStore(string dbPath, AvatarFiles? avatars = null)
{
string? dir = Path.GetDirectoryName(Path.GetFullPath(dbPath));
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
+ _avatars = avatars ?? new AvatarFiles(Path.Combine(dir ?? ".", "avatars", "space"));
_connectionString = new SqliteConnectionStringBuilder
{
@@ -49,6 +51,45 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
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, thumb TEXT) WITHOUT ROWID;
""");
+ MigrateAvatars();
+ }
+
+ /// 旧版把头像的 data URL 整段存在数据库里:搬成磁盘文件(幂等,一行一行处理,不一次读入),再删掉没人引用的文件
+ private void MigrateAvatars()
+ {
+ var legacy = new List();
+ using (var cmd = _writeConn.CreateCommand())
+ {
+ cmd.CommandText = "SELECT space_id FROM channel_avatars WHERE avatar LIKE 'data:%' OR thumb LIKE 'data:%'";
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read()) legacy.Add((byte[])reader[0]);
+ }
+ foreach (var key in legacy)
+ {
+ string? avatar, thumb;
+ using (var cmd = _writeConn.CreateCommand())
+ {
+ cmd.CommandText = "SELECT avatar, thumb FROM channel_avatars WHERE space_id=@c";
+ cmd.Parameters.AddWithValue("@c", key);
+ using var reader = cmd.ExecuteReader();
+ reader.Read();
+ avatar = reader.GetString(0); thumb = reader.IsDBNull(1) ? null : reader.GetString(1);
+ }
+ using var update = _writeConn.CreateCommand();
+ update.CommandText = "UPDATE channel_avatars SET avatar=@a, thumb=@t WHERE space_id=@c";
+ update.Parameters.AddWithValue("@c", key);
+ update.Parameters.AddWithValue("@a", AvatarFiles.IsLegacy(avatar) ? _avatars.Save(avatar) : avatar);
+ update.Parameters.AddWithValue("@t", (object?)(AvatarFiles.IsLegacy(thumb) ? _avatars.Save(thumb!) : thumb) ?? DBNull.Value);
+ update.ExecuteNonQuery();
+ }
+ var referenced = new HashSet();
+ using (var cmd = _writeConn.CreateCommand())
+ {
+ cmd.CommandText = "SELECT avatar, thumb FROM channel_avatars";
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read()) { referenced.Add(reader.GetString(0)); if (!reader.IsDBNull(1)) referenced.Add(reader.GetString(1)); }
+ }
+ _avatars.Sweep(referenced);
}
/// 把整个数据库(含账号表,它们在同一个文件里)做一份一致的快照,不阻塞正在服务的请求
@@ -76,7 +117,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
SqliteConnection.ClearPool(conn);
continue;
}
- using (tx) return action(new SqliteTx(conn));
+ using (tx) return action(new SqliteTx(conn, _avatars));
}
}
@@ -87,7 +128,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
using var tx = _writeConn.BeginTransaction();
try
{
- T result = action(new SqliteTx(_writeConn));
+ T result = action(new SqliteTx(_writeConn, _avatars));
tx.Commit();
return result;
}
@@ -265,7 +306,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
CREATE TABLE channel_members(channel_id BLOB NOT NULL,username TEXT NOT NULL,role TEXT NOT NULL,joined_at INTEGER NOT NULL,PRIMARY KEY(channel_id,username));
""";
- private sealed class SqliteTx(SqliteConnection conn) : IResourceTx
+ private sealed class SqliteTx(SqliteConnection conn, AvatarFiles avatars) : IResourceTx
{
private const string ChannelCols = "id, mode, settings, read_hash, write_hash, owner_hash, created_at, expires_at, last_seq, compacted_through, bytes, creator_account, join_password_hash, join_password_salt";
public WorkspaceState? GetWorkspace(Id16 id) => One("SELECT state FROM workspace_structures WHERE space_id=@id",
@@ -519,13 +560,18 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
Run("DELETE FROM channel_join_denials WHERE channel_id=@c", ("@c", key));
}
- 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 AvatarBlob? GetChannelAvatar(Id16 id, bool thumb) => avatars.Resolve(
+ 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 string? GetChannelAvatarVersion(Id16 id) =>
+ Scalar("SELECT avatar || '|' || COALESCE(thumb, '') FROM channel_avatars WHERE space_id=@c", ("@c", id.ToArray())) as string;
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, 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));
+ if (avatar is null) { Run("DELETE FROM channel_avatars WHERE space_id=@c", ("@c", id.ToArray())); return; }
+ 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", avatars.Save(avatar)), ("@t", (object?)(thumb is null ? null : avatars.Save(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 0192acf..8f29c10 100644
Binary files a/server/tests/WebDrop.Api.Tests/WorkspaceApiTests.cs and b/server/tests/WebDrop.Api.Tests/WorkspaceApiTests.cs differ
diff --git a/server/tests/WebDrop.Core.Tests/AvatarFilesTests.cs b/server/tests/WebDrop.Core.Tests/AvatarFilesTests.cs
new file mode 100644
index 0000000..6d02b03
--- /dev/null
+++ b/server/tests/WebDrop.Core.Tests/AvatarFilesTests.cs
@@ -0,0 +1,51 @@
+using Microsoft.Data.Sqlite;
+using WebDrop.Core;
+using WebDrop.Storage;
+
+namespace WebDrop.Core.Tests;
+
+public sealed class AvatarFilesTests : IDisposable
+{
+ private readonly string _dir = Path.Combine(Path.GetTempPath(), "wd-avatar-" + Guid.NewGuid().ToString("N"));
+ public void Dispose() { SqliteConnection.ClearAllPools(); try { Directory.Delete(_dir, true); } catch (IOException) { } }
+
+ private static string Gif(byte fill) => "data:image/gif;base64," + Convert.ToBase64String([(byte)'G', (byte)'I', (byte)'F', fill, fill, fill, fill, fill]);
+
+ [Fact]
+ public void SavedAvatarsAreContentAddressedFilesAndResolveBack()
+ {
+ var files = new AvatarFiles(Path.Combine(_dir, "a"));
+ string first = files.Save(Gif(1)), again = files.Save(Gif(1)), other = files.Save(Gif(2));
+ Assert.Equal(first, again);
+ Assert.NotEqual(first, other);
+ var blob = files.Resolve(first)!;
+ Assert.Equal("image/gif", blob.Mime);
+ Assert.Equal(8, new FileInfo(blob.Path).Length);
+ Assert.Null(files.Resolve("../../etc/passwd"));
+ Assert.Null(files.Resolve(new string('0', 64) + ".gif"));
+ }
+
+ [Fact]
+ public void LegacyDataUrlRowsAreMovedToFilesOnOpenAndOrphansAreSwept()
+ {
+ string db = Path.Combine(_dir, "v2", "webdrop.db");
+ var id = Id16.FromBytes(Enumerable.Range(1, 16).Select(i => (byte)i).ToArray());
+ using (var store = new SqliteChannelStore(db)) { }
+ using (var conn = new SqliteConnection($"Data Source={db}"))
+ {
+ conn.Open();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "INSERT INTO channel_avatars(space_id, avatar, thumb) VALUES ($c, $a, $t)";
+ cmd.Parameters.AddWithValue("$c", id.ToArray()); cmd.Parameters.AddWithValue("$a", Gif(3)); cmd.Parameters.AddWithValue("$t", Gif(4));
+ cmd.ExecuteNonQuery();
+ }
+ string avatars = Path.Combine(_dir, "v2", "avatars", "space");
+ File.WriteAllBytes(Path.Combine(avatars, "orphan.gif"), [1]);
+ using var reopened = new SqliteChannelStore(db);
+ var (full, thumb) = reopened.Read(tx => (tx.GetChannelAvatar(id, thumb: false), tx.GetChannelAvatar(id, thumb: true)));
+ Assert.NotNull(full); Assert.NotNull(thumb);
+ Assert.NotEqual(full!.Hash, thumb!.Hash);
+ Assert.False(File.Exists(Path.Combine(avatars, "orphan.gif")));
+ Assert.Equal(2, Directory.GetFiles(avatars).Length);
+ }
+}
diff --git a/web/src/api/generated.ts b/web/src/api/generated.ts
index 83aef53..f20504f 100644
--- a/web/src/api/generated.ts
+++ b/web/src/api/generated.ts
@@ -1,5 +1,5 @@
// Generated from backend DTOs; do not edit.
-// backend-contract-sha256: 340876061d7b324e4fd16972e276ffb0f238eeac66b214a043b3b4b98d560fcf
+// backend-contract-sha256: e0ae14a47dfbb3c22017cdd7b5e12465b3e273842db2a35f36b7335c20b51cac
export interface AccountChannelBody {
channel_id: string;
@@ -75,7 +75,7 @@ export interface ChannelHistoryRequest {
filesHistory: boolean;
}
export interface ChannelInfo {
- avatar?: string | null;
+ avatar_version?: string | null;
can_create_channel: boolean;
can_delete_any: boolean;
can_delete_own: boolean;
diff --git a/web/src/worker/runtime/wire.ts b/web/src/worker/runtime/wire.ts
index 65b4ef9..5d31a5f 100644
--- a/web/src/worker/runtime/wire.ts
+++ b/web/src/worker/runtime/wire.ts
@@ -2,7 +2,7 @@ import type { SavedResource } from './database';
import type { ChannelInfo, Share, ShareSettings } from '@/domain/types';
import type { ChannelInfo as ChannelWire, ShareInfo as ShareWire, ShareItemInfo as ShareWireItem } from '@/api/generated';
export type { ChannelWire, ShareWire, ShareWireItem };
-export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelInfo {
+export function toChannelInfo(raw: ChannelWire, saved: SavedResource, avatar?: string): ChannelInfo {
const joinPolicy = raw.settings.join_policy;
if (joinPolicy !== 'open' && joinPolicy !== 'invite') throw new Error('空间加入规则无效');
return {
@@ -10,7 +10,7 @@ export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelIn
creatorAccount: raw.creator_account ?? undefined,
joinPolicy, passwordRequired: raw.password_required,
name: raw.settings.name ?? undefined,
- avatar: raw.avatar ?? undefined,
+ avatar,
lastSeq: raw.last_seq,
mode: raw.mode,
isOwner: raw.is_owner,
diff --git a/web/src/worker/space/channelEvents.ts b/web/src/worker/space/channelEvents.ts
index 364eb58..14501cc 100644
--- a/web/src/worker/space/channelEvents.ts
+++ b/web/src/worker/space/channelEvents.ts
@@ -11,6 +11,7 @@ import { previewKindOf } from '@/lib/files/fileKind';
import { blobs, readers } from '../runtime/sessions';
import { showRejected, publishWorkspace } from '../runtime/presentation';
import { type ChannelWire, toChannelInfo } from '../runtime/wire';
+import { channelInfo } from './spaceAvatars';
import { apiFetch, readJson } from '../runtime/http';
import { readResourceRecord } from './workspaceRecords';
import { clearFileCatalog, fileCatalogs, visibleFiles } from './fileCatalog';
@@ -139,7 +140,7 @@ export async function channelMetadata(resource: SavedResource, session: ChannelS
const info = await readJson(`/channels/${resource.id}`, session.authHeaders(tokenRole(session)));
session.verifyChannelInfo(info);
Object.assign(resource, await cacheResource(resource, channelCache(info)));
- publishWorkspace(resource.id, { t: 'channel', info: toChannelInfo(info, resource) });
+ publishWorkspace(resource.id, { t: 'channel', info: await channelInfo(info, resource) });
await loadWorkspace(resource.id);
}
diff --git a/web/src/worker/space/channelResources.ts b/web/src/worker/space/channelResources.ts
index 38355fc..213c03c 100644
--- a/web/src/worker/space/channelResources.ts
+++ b/web/src/worker/space/channelResources.ts
@@ -14,6 +14,7 @@ import type { ChannelSettingsPatch as SettingsRequest, JoinResult, MemberBody }
import { freshlyJoined, getSession, sessions } from '../runtime/sessions';
import { makeLink } from './links';
import { toChannelInfo, type ChannelWire } from '../runtime/wire';
+import { channelInfo } from './spaceAvatars';
import { stopChannel } from './channelTransport';
import { publishWorkspace } from '../runtime/presentation';
import { requireAccount, accountPublicKey } from '../account/accountSession';
@@ -62,7 +63,7 @@ export async function createChannel(options: CreateOptions, captchaToken?: strin
attachDataKeys(session, resource);
const workspace = await import('./workspaceStructure').then(module => module.loadWorkspace(session.id));
if (workspace.structure.defaultChannelId) await initializeChannelKeys(resource, session, workspace.structure.defaultChannelId);
- return toChannelInfo(info, resource);
+ return await channelInfo(info, resource);
} catch (error) {
session.dispose();
throw error;
@@ -94,7 +95,7 @@ export async function joinChannel(input: { link: ParsedChannelLink; password?: s
await saveResource(resource);
sessions.set(id, session);
freshlyJoined.set(id, Date.now());
- return toChannelInfo(info, resource);
+ return await channelInfo(info, resource);
} catch (error) {
session.dispose();
throw error;
@@ -106,7 +107,7 @@ export async function previewChannel(link: ParsedChannelLink): Promise(`/channels/${link.id}`, session.authHeaders('read'));
session.verifyChannelInfo(info);
- return toChannelInfo(info, { id: link.id, kind: 'channel', mode: session.mode, role: session.role,
+ return await channelInfo(info, { id: link.id, kind: 'channel', mode: session.mode, role: session.role,
endpointSecret: session.endpointSecret, link: session.link(self.location.origin), createdAt: info.created_at,
expiresAt: info.expires_at, maxMembers: info.settings.max_members });
} finally { session.dispose(); }
@@ -132,13 +133,13 @@ export async function listChannels(): Promise {
const { session } = await getSession(resource.id);
const raw = catalog?.get(resource.id) ?? await readJson(`/channels/${resource.id}`, session.authHeaders(tokenRole(session)));
session.verifyChannelInfo(raw);
- const info = toChannelInfo(raw, resource);
+ const info = await channelInfo(raw, resource);
await cacheResource(resource, channelCache(raw));
return { ...info, dataSource: 'server' };
} catch (error) {
if (hasCode(error, ErrorCode.NotFound, ErrorCode.Gone)) return null;
if (!isOfflineFailure(error)) throw error;
- if (resource.channelMetadata) return { ...toChannelInfo(resource.channelMetadata, resource), dataSource: 'cache' };
+ if (resource.channelMetadata) return { ...await channelInfo(resource.channelMetadata, resource), dataSource: 'cache' };
return {
id: resource.id, name: resource.name, mode: resource.mode, isOwner: false, canCreateChannel: false, canManageFolders: false, canInvite: false, canEditSettings: false, canManageRoles: false, canRemoveMembers: false, canDeleteAny: false, canDeleteOwn: false, maxMembers: resource.maxMembers, maxDownloads: resource.maxDownloads ?? undefined,
creatorAccount: resource.creatorAccount,
@@ -169,7 +170,7 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe
}).then((response) => response.json() as Promise);
session.verifyChannelInfo(info);
const updated = await cacheResource(resource, channelCache(info));
- publishWorkspace(resourceId, { t: 'channel', info: toChannelInfo(info, updated) });
+ publishWorkspace(resourceId, { t: 'channel', info: await channelInfo(info, updated) });
}
export async function forgetChannel(id: string): Promise {
diff --git a/web/src/worker/space/spaceAvatars.ts b/web/src/worker/space/spaceAvatars.ts
new file mode 100644
index 0000000..83ffebe
--- /dev/null
+++ b/web/src/worker/space/spaceAvatars.ts
@@ -0,0 +1,41 @@
+import type { ChannelInfo } from '@/domain/types';
+import type { SavedResource } from '../runtime/database';
+import { toChannelInfo, type ChannelWire } from '../runtime/wire';
+import { apiFetch } from '../runtime/http';
+import { getSession, tokenRole } from '../runtime/sessions';
+
+/**
+ * 空间头像不随空间信息下发(动图缩略图有几百 KB,每次刷新都带上会让服务端和浏览器反复分配大块内存)。
+ * 信息里只有头像版本,版本变了才取一次缩略图;同一个版本只在内存里留一份。
+ */
+interface Entry { version: string; value?: Promise }
+const cache = new Map();
+
+function toDataUrl(blob: Blob): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(String(reader.result));
+ reader.onerror = () => reject(reader.error);
+ reader.readAsDataURL(blob);
+ });
+}
+
+export function spaceAvatar(spaceId: string, version: string | null | undefined): Promise {
+ if (!version) { cache.delete(spaceId); return Promise.resolve(undefined); }
+ const entry = cache.get(spaceId);
+ if (entry && entry.version === version && entry.value) return entry.value;
+ const value = (async () => {
+ try {
+ const { session } = await getSession(spaceId);
+ const response = await apiFetch(`/channels/${spaceId}/avatar`, { headers: session.authHeaders(tokenRole(session)), cache: 'no-cache' });
+ return await toDataUrl(await response.blob());
+ } catch { cache.delete(spaceId); return undefined; }
+ })();
+ cache.set(spaceId, { version, value });
+ return value;
+}
+
+/** 带上头像的空间信息(头像按版本取,见上) */
+export async function channelInfo(raw: ChannelWire, saved: SavedResource): Promise {
+ return toChannelInfo(raw, saved, await spaceAvatar(raw.id, raw.avatar_version));
+}