3 Commits
Author SHA1 Message Date
justme d66c93fe98 feat(web): avatar thumbnails with original preview, registration avatar, pure-JS crypto fallback for http [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-17 14:35:42 +08:00
justme 283196938e feat(web): stream video and audio while decrypting, paste images, 16px inputs on touch 2026-10-17 14:35:41 +08:00
justme 31ae3bc39e feat(server): lossy avatar thumbnails, gzip upload and separate avatar endpoints 2026-10-17 14:35:41 +08:00
64 changed files with 1266 additions and 102 deletions

No files matched your search

+30 -2
View File
@@ -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": [
{
+4
View File
@@ -165,6 +165,10 @@ public sealed class AccountProfileRequest
{
[JsonPropertyName("displayName")] public required string DisplayName { get; init; }
[JsonPropertyName("avatar")] public string? Avatar { get; init; }
/// <summary>头像的有损小图(data URL)。列表、聊天默认只分发它,原图在预览时才下载</summary>
[JsonPropertyName("avatarThumb")] public string? AvatarThumb { get; init; }
/// <summary>只改名字、不动头像:保留服务端现有的头像(不带 avatar 的请求默认表示移除头像)</summary>
[JsonPropertyName("keepAvatar")] public bool KeepAvatar { get; init; }
}
public sealed class WorkspaceNameRequest { [JsonPropertyName("name")] public required string Name { get; init; } }
public sealed class WorkspaceChannelRequest
+12 -4
View File
@@ -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<IHttpMaxRequestBodySizeFeature>() 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) =>
{
@@ -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));
+44 -1
View File
@@ -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<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } size) size.MaxRequestBodySize = 4 * 1024 * 1024;
var patch = await ReadJson(ctx.Request, ApiJson.Default.ChannelSettingsPatch, ct);
var 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);
}
/// <summary>
/// 读 JSON 请求体,支持 Content-Encoding: gzip(头像这类带 base64 的大请求,压缩后能省下 base64 多出来的约四分之一)。
/// 解压后的大小另外限制,防止小体积的压缩炸弹。
/// </summary>
private static async Task<T> ReadJsonCompressed<T>(HttpRequest req, JsonTypeInfo<T> 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); }
}
/// <summary>data URL 头像 → 图片字节(带 ETag,没变时 304)</summary>
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<T> ReadJson<T>(HttpRequest req, JsonTypeInfo<T> info, CancellationToken ct) where T : class
{
try
+3 -2
View File
@@ -119,8 +119,9 @@ public interface IResourceTx
long NextSeq(Id16 id);
void DeleteChannelRows(Id16 id);
/// <summary>空间头像(data URL)单独存放,不放进每次请求都要读的设置 JSON</summary>
string? GetChannelAvatar(Id16 id);
void SetChannelAvatar(Id16 id, string? avatar);
/// <summary>thumb 为 true 取缩略图(没有就用原图),否则取原图</summary>
string? GetChannelAvatar(Id16 id, bool thumb);
void SetChannelAvatar(Id16 id, string? avatar, string? thumb);
IReadOnlyList<Id16> ListExpiredChannels(long now, int limit);
// 成员
@@ -6,6 +6,13 @@ public sealed class AccountProfile
{
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("displayName")] public required string DisplayName { get; init; }
/// <summary>头像(data URL)。资料列表、空间成员等接口不再内联头像(动图可能有几 MB),只告诉有没有,头像另用 /accounts/users/{用户名}/avatar 取</summary>
[JsonPropertyName("avatar")] public string? Avatar { get; init; }
[JsonPropertyName("hasAvatar")] public bool HasAvatar { get; init; }
[JsonPropertyName("modifiedAt")] public required long ModifiedAt { get; init; }
/// <summary>有损的小图(列表、聊天里默认分发);没有单独上传时用原图。不随 JSON 输出</summary>
[JsonIgnore] public string? AvatarThumb { get; init; }
public AccountProfile WithoutAvatar() => new() { Username = Username, DisplayName = DisplayName, HasAvatar = HasAvatar, ModifiedAt = ModifiedAt };
}
+8 -2
View File
@@ -10,14 +10,20 @@ public static class AvatarData
/// <summary>空间头像会跟着空间信息发给所有成员和空间列表,动图上限比账号头像小,约 1.5 MB</summary>
public const int MaxSpaceGifChars = 2_000_000;
/// <summary>缩略图(列表、聊天里默认分发的有损小图)的上限:静态约 75 KB,动图约 370 KB</summary>
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);
/// <summary>不合法时抛 BadRequest</summary>
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); }
+11 -3
View File
@@ -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));
});
/// <summary>空间头像原图(预览时才下载)。需要能读这个空间</summary>
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;
+1 -1
View File
@@ -10,7 +10,7 @@ public sealed partial class ChannelService
{
var channel = tx.GetChannel(id)!;
var role = tx.GetChannelMember(id, username)?.Role == "owner" ? Role.Owner : channel.WriteHash is not null ? Role.Write : Role.Read;
return ToInfo(channel, tx.CountMembers(id), new Principal(role, username), 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)
{
+2
View File
@@ -66,6 +66,8 @@ public sealed class ChannelSettingsPatch
[JsonPropertyName("ui_restrict_copy_download")] public bool? UiRestrictCopyDownload { get; init; }
/// <summary>空间头像 data URL;不传表示不改,空字符串表示移除。只有空间主能改。</summary>
[JsonPropertyName("avatar")] public string? Avatar { get; init; }
/// <summary>空间头像的有损小图(data URL):空间列表、标题默认只分发它,原图在预览时才下载</summary>
[JsonPropertyName("avatar_thumb")] public string? AvatarThumb { get; init; }
public ChannelSettings ApplyTo(ChannelSettings s) => new()
{
@@ -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);
@@ -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;
@@ -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<Id16> ListExpiredChannels(long now, int limit) =>
Binary file not shown.
+42
View File
@@ -9,6 +9,9 @@
"version": "0.0.0",
"dependencies": {
"@material/material-color-utilities": "^0.3.0",
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
"@noble/hashes": "^2.4.0",
"@webdrop/core": "file:../packages/core",
"cap-widget": "^0.1.58",
"gifenc": "^1.0.3",
@@ -311,6 +314,45 @@
"integrity": "sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==",
"license": "Apache-2.0"
},
"node_modules/@noble/ciphers": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
"integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz",
"integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.4.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+3
View File
@@ -13,6 +13,9 @@
},
"dependencies": {
"@material/material-color-utilities": "^0.3.0",
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
"@noble/hashes": "^2.4.0",
"@webdrop/core": "file:../packages/core",
"cap-widget": "^0.1.58",
"gifenc": "^1.0.3",
+79
View File
@@ -0,0 +1,79 @@
// 加密视频/音频的边下边播。
// 播放器请求 /__wd_stream/<id>(带 Range),这里不联网:把「要哪一段」告诉发起请求的页面,
// 页面把一个 MessagePort 交给加密线程,加密线程解密出这一段后一块一块从端口推回来,
// 这里再把它们作为 206 响应流式交给播放器。密钥始终只在加密线程里。
const PREFIX = '/__wd_stream/';
/** 一次最多让加密线程领先播放器多少块,避免整个文件都堆在内存里 */
const WINDOW = 6;
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (url.origin !== self.location.origin || !url.pathname.startsWith(PREFIX)) return;
event.respondWith(serve(event).catch((error) => new Response(String(error && error.message || error), { status: 500 })));
});
function parseRange(header) {
const m = /^bytes=(\d*)-(\d*)$/.exec(header || '');
if (!m) return { start: 0, end: undefined };
if (m[1] === '' && m[2] !== '') return { suffix: Number(m[2]) };
return { start: Number(m[1] || 0), end: m[2] === '' ? undefined : Number(m[2]) };
}
async function serve(event) {
const id = decodeURIComponent(new URL(event.request.url).pathname.slice(PREFIX.length));
const client = event.clientId ? await self.clients.get(event.clientId) : undefined;
const target = client ?? (await self.clients.matchAll({ type: 'window' }))[0];
if (!target) return new Response('no client', { status: 503 });
const range = parseRange(event.request.headers.get('Range'));
const channel = new MessageChannel();
const port = channel.port1;
// 第一条消息:{ total, mime, start, end } 或 { error };之后是 { data } / { done } / { error }
const first = new Promise((resolve) => {
port.onmessage = (message) => resolve(message.data);
});
target.postMessage({ type: 'wd-stream', id, range, port: channel.port2 }, [channel.port2]);
const head = await Promise.race([first, new Promise((resolve) => setTimeout(() => resolve({ error: 'timeout' }), 20000))]);
if (head.error) return new Response(head.error, { status: head.status || 502 });
const { total, mime, start, end } = head;
let pending = [];
let waiting;
let finished = false;
let failure;
port.onmessage = (message) => {
const m = message.data;
if (m.data) pending.push(m.data);
else if (m.done) finished = true;
else if (m.error) failure = m.error;
if (waiting) { const wake = waiting; waiting = undefined; wake(); }
};
port.postMessage({ credit: WINDOW });
const body = new ReadableStream({
async pull(controller) {
while (!pending.length && !finished && !failure) await new Promise((resolve) => { waiting = resolve; });
if (pending.length) {
controller.enqueue(new Uint8Array(pending.shift()));
port.postMessage({ credit: 1 });
} else if (failure) { controller.error(new Error(failure)); port.close(); }
else { controller.close(); port.close(); }
},
cancel() { port.postMessage({ cancel: true }); port.close(); },
});
const partial = !(start === 0 && end === total - 1 && !event.request.headers.get('Range'));
return new Response(body, {
status: partial ? 206 : 200,
headers: {
'Content-Type': mime || 'application/octet-stream',
'Content-Length': String(end - start + 1),
'Accept-Ranges': 'bytes',
'Cache-Control': 'no-store',
...(partial ? { 'Content-Range': `bytes ${start}-${end}/${total}` } : {}),
},
});
}
+4 -2
View File
@@ -1,6 +1,6 @@
import { callWorker } from './workerClient';
import type { AccountDeviceBody, InvitationBody } from './generated/api';
import type { PersonalProfile } from './protocol/account';
import type { AvatarChange, PersonalProfile } from './protocol/account';
const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1';
@@ -18,7 +18,9 @@ export const accountApi = {
sync: () => callWorker<AccountStatus>('syncAccount', { apiBase: API_BASE }),
invitations: () => callWorker<AccountInvitation[]>('accountInvitations', { apiBase: API_BASE }),
acceptInvitation: (id: string) => callWorker<void>('acceptInvitation', { id, apiBase: API_BASE }),
updatePersonal: (displayName: string, avatar?: string) => callWorker<AccountStatus>('updatePersonal', { displayName, avatar, apiBase: API_BASE }),
/** 默认是有损的缩略图;full 为 true 取原图(预览时) */
avatar: (username: string, version: number, full = false) => callWorker<string | undefined>('getAvatar', { username, version, full, apiBase: API_BASE }),
updatePersonal: (displayName: string, avatar?: AvatarChange) => callWorker<AccountStatus>('updatePersonal', { displayName, avatar, apiBase: API_BASE }),
devices: () => callWorker<AccountDevice[]>('accountDevices', { apiBase: API_BASE }),
renameDevice: (id: string, name: string) => callWorker<void>('renameAccountDevice', { id, name, apiBase: API_BASE }),
revokeDevice: (id: string) => callWorker<AccountStatus>('revokeAccountDevice', { id, apiBase: API_BASE }),
+2
View File
@@ -12,6 +12,8 @@ export interface WorkspaceApi {
createChannel(opts: CreateOptions, captchaToken?: string): Promise<ChannelInfo>;
/** 使用现有空间 settings 接口更新空间设置。 */
updateChannelSettings(patch: ChannelSettingsPatch): Promise<void>;
/** 空间头像原图(信息里只有缩略图),预览时才下载 */
spaceAvatar(): Promise<string | undefined>;
/** 通过链接加入:先 GET 空间信息预览,再 POST join */
joinChannel(link: ParsedChannelLink, password?: string): Promise<ChannelInfo>;
inviteUser(username: string): Promise<void>;
+5 -1
View File
@@ -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;
+5 -1
View File
@@ -2,7 +2,8 @@ import type { AccountProfile } from '../generated/api';
export interface InvitationKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey }
export interface SigningKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey }
/** Account vault v2: credentials and cached metadata, encrypted before transport. */
export type PersonalProfile = Pick<AccountProfile, 'displayName' | 'modifiedAt'> & { avatar?: string };
/** avatar 只在加密线程里有(发消息要带头像快照);界面这边通过 hasAvatar 判断,头像另取 */
export type PersonalProfile = Pick<AccountProfile, 'displayName' | 'modifiedAt'> & { avatar?: string; hasAvatar?: boolean };
export interface ResourceDelete { id: string; modifiedAt: number }
export interface VaultResource {
domainKeys?: Record<string, string>;
@@ -25,3 +26,6 @@ export interface VaultResource {
state?: 'draft' | 'sealed';
}
export interface VaultProfile { v: 3; keys: InvitationKeyPair & { signing: SigningKeyPair }; resources: VaultResource[]; deletes: ResourceDelete[] }
/** 资料里头像的修改:换成新的(原图 + 缩略图)、保持不变、或移除(不传) */
export type AvatarChange = { avatar: string; thumb: string } | 'keep' | undefined;
+1
View File
@@ -17,6 +17,7 @@ function reportChannelUnavailable(id: string, onEvent: (event: WorkspaceEvent) =
export const realApi: WorkspaceApi = {
createChannel: (options: CreateOptions, captchaToken?: string) => callWorker<ChannelInfo>('createChannel', { options, captchaToken, apiBase: API_BASE }),
updateChannelSettings: (patch: ChannelSettingsPatch) => callWorker<void>('updateChannelSettings', { id: currentChannelId(), patch, apiBase: API_BASE }),
spaceAvatar: () => callWorker<string | undefined>('getSpaceAvatar', { id: currentChannelId(), apiBase: API_BASE }),
joinChannel: (link: ParsedChannelLink, password?: string) => callWorker<ChannelInfo>('joinChannel', { link, password, apiBase: API_BASE }),
inviteUser: (username) => callWorker<void>('inviteChannelUser', { id: currentChannelId(), username, apiBase: API_BASE }),
listInvitations: () => callWorker<AccountInvitation[]>('listChannelInvitations', { id: currentChannelId(), apiBase: API_BASE }),
+48
View File
@@ -0,0 +1,48 @@
import { postToWorker } from './workerClient';
/**
* 页面这一侧的「边下边播」:把条目登记成一个 /__wd_stream/<id> 地址交给 <video>/<audio>,
* 播放器的每个 Range 请求由 Service Worker(public/stream-sw.js)转给这里,
* 这里把 SW 给的 MessagePort 直接交给加密线程,解密后的数据不再经过页面。
*/
interface Entry { resourceId: string; itemId: string; size: number; mime: string }
interface SwMessage { type: 'wd-stream'; id: string; range: { start?: number; end?: number; suffix?: number }; port: MessagePort }
const entries = new Map<string, Entry>();
let ready: Promise<boolean> | undefined;
function setup(): Promise<boolean> {
if (!('serviceWorker' in navigator)) return Promise.resolve(false);
return (async () => {
navigator.serviceWorker.addEventListener('message', (event: MessageEvent<SwMessage>) => {
const message = event.data;
if (message?.type !== 'wd-stream') return;
const entry = entries.get(message.id);
if (!entry) { message.port.postMessage({ error: 'gone', status: 404 }); message.port.close(); return; }
postToWorker({ operation: 'streamRange', payload: { ...entry, ...message.range } }, [message.port]);
});
await navigator.serviceWorker.register(`${import.meta.env.BASE_URL}stream-sw.js`);
await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) {
// 第一次安装:等 SW 接管当前页面(clients.claim)
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 2000);
navigator.serviceWorker.addEventListener('controllerchange', () => { clearTimeout(timer); resolve(); }, { once: true });
});
}
return navigator.serviceWorker.controller !== null;
})().catch(() => false);
}
/** 登记一个可以边下边播的条目,返回播放地址;浏览器或环境不支持时返回 undefined(调用方退回整个下载) */
export async function playbackUrl(resourceId: string, itemId: string, size: number, mime: string): Promise<string | undefined> {
ready ??= setup();
if (!(await ready)) return undefined;
const id = `${crypto.randomUUID()}`;
entries.set(id, { resourceId, itemId, size, mime });
return `${import.meta.env.BASE_URL}__wd_stream/${id}`;
}
export function releasePlayback(url: string): void {
entries.delete(url.slice(url.lastIndexOf('/') + 1));
}
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { fallbackSubtle as soft } from './subtleFallback';
const native = globalThis.crypto.subtle;
const text = (value: string) => new TextEncoder().encode(value);
const hex = (data: ArrayBuffer | Uint8Array) => Array.from(data instanceof Uint8Array ? data : new Uint8Array(data), (byte) => byte.toString(16).padStart(2, '0')).join('');
describe('crypto.subtle 的纯 JS 替代实现和原生逐字节一致', () => {
it('SHA-256、HMAC', async () => {
expect(hex(await soft.digest('SHA-256', text('hello')))).toBe(hex(await native.digest('SHA-256', text('hello'))));
const a = await soft.importKey('raw', text('k'), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const b = await native.importKey('raw', text('k'), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
expect(hex(await soft.sign('HMAC', a, text('m')))).toBe(hex(await native.sign('HMAC', b, text('m'))));
});
it('PBKDF2 和 HKDF', async () => {
const salt = text('salty');
const pa = await soft.importKey('raw', text('password'), 'PBKDF2', false, ['deriveBits']);
const pb = await native.importKey('raw', text('password'), 'PBKDF2', false, ['deriveBits']);
expect(hex(await soft.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 5000 }, pa, 256)))
.toBe(hex(await native.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 5000 }, pb, 256)));
const ha = await soft.importKey('raw', text('root'), 'HKDF', false, ['deriveBits']);
const hb = await native.importKey('raw', text('root'), 'HKDF', false, ['deriveBits']);
expect(hex(await soft.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info: text('i') }, ha, 256)))
.toBe(hex(await native.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info: text('i') }, hb, 256)));
});
it('AES-GCM 互相能解开,带附加数据;篡改会失败', async () => {
const raw = crypto.getRandomValues(new Uint8Array(32)); const iv = crypto.getRandomValues(new Uint8Array(12));
const a = await soft.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt']);
const b = await native.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt']);
const params = { name: 'AES-GCM', iv, additionalData: text('aad') };
const sealed = await soft.encrypt(params, a, text('秘密内容'));
expect(hex(sealed)).toBe(hex(await native.encrypt(params, b, text('秘密内容'))));
expect(new TextDecoder().decode(await native.decrypt(params, b, sealed))).toBe('秘密内容');
const tampered = new Uint8Array(sealed); tampered[0] = tampered[0]! ^ 1;
await expect(soft.decrypt(params, a, tampered)).rejects.toThrow();
});
it('ECDSA:原生签的软件能验,软件签的原生能验', async () => {
const pair = await soft.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']) as CryptoKeyPair;
const jwkPrivate = await soft.exportKey('jwk', pair.privateKey) as JsonWebKey;
const jwkPublic = await soft.exportKey('jwk', pair.publicKey) as JsonWebKey;
const nativePrivate = await native.importKey('jwk', jwkPrivate, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']);
const nativePublic = await native.importKey('jwk', jwkPublic, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
const algorithm = { name: 'ECDSA', hash: 'SHA-256' };
const fromSoft = await soft.sign(algorithm, pair.privateKey, text('消息'));
expect(fromSoft.byteLength).toBe(64);
expect(await native.verify(algorithm, nativePublic, fromSoft, text('消息'))).toBe(true);
const fromNative = await native.sign(algorithm, nativePrivate, text('消息'));
expect(await soft.verify(algorithm, pair.publicKey, fromNative, text('消息'))).toBe(true);
expect(await soft.verify(algorithm, pair.publicKey, fromNative, text('别的消息'))).toBe(false);
});
it('ECDH:两边算出同一个共享密钥,JWK 互通', async () => {
const ours = await soft.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']) as CryptoKeyPair;
const theirs = await native.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
const theirPublic = await native.exportKey('jwk', theirs.publicKey);
const ourPublic = await soft.exportKey('jwk', ours.publicKey) as JsonWebKey;
const soft1 = await soft.deriveBits({ name: 'ECDH', public: await soft.importKey('jwk', theirPublic, { name: 'ECDH', namedCurve: 'P-256' }, false, []) } as never, ours.privateKey, 256);
const nativePublic = await native.importKey('jwk', ourPublic, { name: 'ECDH', namedCurve: 'P-256' }, false, []);
const native1 = await native.deriveBits({ name: 'ECDH', public: nativePublic }, theirs.privateKey, 256);
expect(hex(soft1)).toBe(hex(native1));
});
it('不可导出的密钥导出会失败', async () => {
const key = await soft.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt']) as CryptoKey;
await expect(soft.exportKey('raw', key)).rejects.toThrow();
});
});
+161
View File
@@ -0,0 +1,161 @@
import { gcm } from '@noble/ciphers/aes.js';
import { p256 } from '@noble/curves/nist.js';
import { hkdf } from '@noble/hashes/hkdf.js';
import { hmac } from '@noble/hashes/hmac.js';
import { pbkdf2 } from '@noble/hashes/pbkdf2.js';
import { sha256 } from '@noble/hashes/sha2.js';
/**
* 浏览器只在 https 或 localhost 下提供 crypto.subtle。通过局域网 IP 用 http 访问时它不存在,
* 注册、登录(密码派生、保险库加解密、签名)全都会失败。这里用纯 JS 实现这个项目用到的那一小部分接口,
* 只在 crypto.subtle 不存在时才安装;输出和原生实现逐字节一致(见 subtleFallback.test.ts),所以同一个账号在 https 和 http 下可以互通。
*
* 只覆盖用到的算法:SHA-256、HMAC-SHA256、HKDF-SHA256、PBKDF2-SHA256、AES-256-GCM、ECDH P-256、ECDSA P-256(SHA-256)。
* 纯 JS 比原生慢(PBKDF2 的 60 万次迭代要一两秒),而且 http 页面本身可以被网络上的人替换,所以这只是开发和局域网试用的退路。
*/
type Bytes = Uint8Array;
interface FallbackKey {
readonly __fallback: true;
type: 'secret' | 'public' | 'private';
extractable: boolean;
algorithm: { name: string; [key: string]: unknown };
usages: string[];
raw?: Bytes;
/** EC:私钥 d(32 字节)和公钥(65 字节,未压缩) */
d?: Bytes;
point?: Bytes;
}
const toBytes = (data: BufferSource): Bytes => (data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
const fresh = (data: Bytes): ArrayBuffer => data.slice().buffer as ArrayBuffer;
const name = (algorithm: string | { name: string }): string => (typeof algorithm === 'string' ? algorithm : algorithm.name).toUpperCase();
const b64u = (data: Bytes): string => btoa(String.fromCharCode(...data)).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
const unb64u = (text: string): Bytes => Uint8Array.from(atob(text.replaceAll('-', '+').replaceAll('_', '/')), (char) => char.charCodeAt(0));
const fail = (message: string, kind = 'OperationError'): never => { throw new DOMException(message, kind); };
const asKey = (key: CryptoKey): FallbackKey => ((key as unknown as FallbackKey).__fallback ? (key as unknown as FallbackKey) : fail('密钥不是由这个环境创建的', 'InvalidAccessError'));
const wrap = (key: Omit<FallbackKey, '__fallback'>): CryptoKey => ({ __fallback: true, ...key }) as unknown as CryptoKey;
function ecKey(algorithmName: string, type: 'public' | 'private', point: Bytes, d: Bytes | undefined, extractable: boolean, usages: string[]): CryptoKey {
return wrap({ type, extractable, usages, algorithm: { name: algorithmName, namedCurve: 'P-256' }, point, ...(d ? { d } : {}) });
}
function ecPair(algorithmName: string, extractable: boolean, usages: string[]): CryptoKeyPair {
const d = p256.utils.randomSecretKey();
const point = p256.getPublicKey(d, false);
const publicUsages = algorithmName === 'ECDSA' ? usages.filter((usage) => usage === 'verify') : [];
const privateUsages = usages.filter((usage) => usage !== 'verify');
return { publicKey: ecKey(algorithmName, 'public', point, undefined, true, publicUsages), privateKey: ecKey(algorithmName, 'private', point, d, extractable, privateUsages) };
}
function importJwk(jwk: JsonWebKey, algorithm: { name: string; namedCurve?: string }, extractable: boolean, usages: string[]): CryptoKey {
if (jwk.kty !== 'EC' || jwk.crv !== 'P-256' || !jwk.x || !jwk.y) fail('只支持 P-256 的 EC 密钥', 'DataError');
const point = new Uint8Array(65);
point[0] = 4; point.set(unb64u(jwk.x!), 1); point.set(unb64u(jwk.y!), 33);
const d = jwk.d ? unb64u(jwk.d) : undefined;
return ecKey(algorithm.name, d ? 'private' : 'public', point, d, extractable, usages);
}
function exportJwk(key: FallbackKey): JsonWebKey {
if (!key.extractable) fail('这个密钥不允许导出', 'InvalidAccessError');
if (!key.point) fail('只能导出 EC 密钥', 'NotSupportedError');
const ops = key.type === 'private' ? key.usages : key.usages;
return { kty: 'EC', crv: 'P-256', ...(key.d ? { d: b64u(key.d) } : {}), x: b64u(key.point!.subarray(1, 33)), y: b64u(key.point!.subarray(33, 65)), ext: true, key_ops: ops };
}
const subtle = {
async digest(algorithm: string | { name: string }, data: BufferSource): Promise<ArrayBuffer> {
if (name(algorithm) !== 'SHA-256') fail('只支持 SHA-256', 'NotSupportedError');
return fresh(sha256(toBytes(data)));
},
async importKey(format: string, keyData: BufferSource | JsonWebKey, algorithm: string | { name: string; namedCurve?: string; hash?: string }, extractable: boolean, usages: string[]): Promise<CryptoKey> {
const algo = name(algorithm);
if (format === 'jwk') return importJwk(keyData as JsonWebKey, typeof algorithm === 'string' ? { name: algorithm } : algorithm, extractable, usages);
if (format !== 'raw') fail('只支持 raw 和 jwk', 'NotSupportedError');
const raw = toBytes(keyData as BufferSource).slice();
if (algo === 'AES-GCM' && ![16, 24, 32].includes(raw.length)) fail('AES 密钥长度不对', 'DataError');
return wrap({ type: 'secret', extractable, usages, algorithm: typeof algorithm === 'string' ? { name: algorithm } : { ...algorithm }, raw });
},
async exportKey(format: string, key: CryptoKey): Promise<JsonWebKey | ArrayBuffer> {
const own = asKey(key);
if (format === 'jwk') return exportJwk(own);
if (!own.extractable) fail('这个密钥不允许导出', 'InvalidAccessError');
return fresh(own.raw ?? own.point ?? new Uint8Array());
},
async generateKey(algorithm: { name: string; length?: number; namedCurve?: string }, extractable: boolean, usages: string[]): Promise<CryptoKey | CryptoKeyPair> {
const algo = name(algorithm);
if (algo === 'AES-GCM') return wrap({ type: 'secret', extractable, usages, algorithm: { name: 'AES-GCM', length: algorithm.length ?? 256 }, raw: crypto.getRandomValues(new Uint8Array((algorithm.length ?? 256) / 8)) });
if ((algo === 'ECDH' || algo === 'ECDSA') && algorithm.namedCurve === 'P-256') return ecPair(algo, extractable, usages);
return fail('不支持的算法', 'NotSupportedError');
},
async sign(algorithm: string | { name: string }, key: CryptoKey, data: BufferSource): Promise<ArrayBuffer> {
const own = asKey(key);
const algo = name(algorithm);
if (algo === 'HMAC') return fresh(hmac(sha256, own.raw!, toBytes(data)));
if (algo === 'ECDSA' && own.d) return fresh(p256.sign(toBytes(data), own.d, { prehash: true, format: 'compact' }));
return fail('不支持的签名', 'NotSupportedError');
},
async verify(algorithm: string | { name: string }, key: CryptoKey, signature: BufferSource, data: BufferSource): Promise<boolean> {
const own = asKey(key);
const algo = name(algorithm);
if (algo === 'HMAC') {
const expected = hmac(sha256, own.raw!, toBytes(data)); const got = toBytes(signature);
return expected.length === got.length && expected.every((byte, index) => byte === got[index]);
}
if (algo === 'ECDSA' && own.point) {
try { return p256.verify(toBytes(signature), toBytes(data), own.point, { prehash: true, format: 'compact', lowS: false }); } catch { return false; }
}
return fail('不支持的验证', 'NotSupportedError');
},
async encrypt(algorithm: { name: string; iv: BufferSource; additionalData?: BufferSource; tagLength?: number }, key: CryptoKey, data: BufferSource): Promise<ArrayBuffer> {
if (name(algorithm) !== 'AES-GCM' || (algorithm.tagLength ?? 128) !== 128) fail('只支持 AES-GCM(128 位标签)', 'NotSupportedError');
return fresh(gcm(asKey(key).raw!, toBytes(algorithm.iv), algorithm.additionalData ? toBytes(algorithm.additionalData) : undefined).encrypt(toBytes(data)));
},
async decrypt(algorithm: { name: string; iv: BufferSource; additionalData?: BufferSource; tagLength?: number }, key: CryptoKey, data: BufferSource): Promise<ArrayBuffer> {
if (name(algorithm) !== 'AES-GCM' || (algorithm.tagLength ?? 128) !== 128) fail('只支持 AES-GCM(128 位标签)', 'NotSupportedError');
try { return fresh(gcm(asKey(key).raw!, toBytes(algorithm.iv), algorithm.additionalData ? toBytes(algorithm.additionalData) : undefined).decrypt(toBytes(data))); }
catch { return fail('解密失败', 'OperationError'); }
},
async deriveBits(algorithm: { name: string; hash?: string; salt?: BufferSource; info?: BufferSource; iterations?: number; public?: CryptoKey }, key: CryptoKey, length: number): Promise<ArrayBuffer> {
const own = asKey(key);
const algo = name(algorithm);
if (algo === 'PBKDF2') return fresh(pbkdf2(sha256, own.raw!, toBytes(algorithm.salt!), { c: algorithm.iterations!, dkLen: length / 8 }));
if (algo === 'HKDF') return fresh(hkdf(sha256, own.raw!, toBytes(algorithm.salt!), toBytes(algorithm.info!), length / 8));
if (algo === 'ECDH' && own.d) return fresh(p256.getSharedSecret(own.d, asKey(algorithm.public!).point!, true).subarray(1, 33).slice(0, length / 8));
return fail('不支持的派生', 'NotSupportedError');
},
async deriveKey(algorithm: { name: string; salt?: BufferSource; info?: BufferSource }, key: CryptoKey, derived: { name: string; length?: number }, extractable: boolean, usages: string[]): Promise<CryptoKey> {
const length = derived.length ?? 256;
const raw = new Uint8Array(await subtle.deriveBits(algorithm as never, key, length));
return wrap({ type: 'secret', extractable, usages, algorithm: { name: derived.name, length }, raw });
},
};
/** randomUUID 同样只在安全上下文里有 */
function randomUuid(): `${string}-${string}-${string}-${string}-${string}` {
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6]! & 0x0f) | 0x40; bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
/** crypto.subtle、crypto.randomUUID 不存在时(http 的局域网访问)装上替代实现。已经有原生的就什么也不做 */
/** force:即使有原生实现也用纯 JS 的(排查用,localStorage 里设 webdrop.softcrypto=1) */
export function installSubtleFallback(force = false): boolean {
if (typeof crypto === 'undefined') return false;
if (force) { Object.defineProperty(crypto, 'subtle', { value: subtle, configurable: true }); return true; }
if (!crypto.randomUUID) Object.defineProperty(crypto, 'randomUUID', { value: randomUuid, configurable: true });
if (crypto.subtle) return false;
Object.defineProperty(crypto, 'subtle', { value: subtle, configurable: true });
return true;
}
export { subtle as fallbackSubtle };
+2
View File
@@ -55,6 +55,8 @@ export interface ChannelSettingsPatch {
name?: string;
/** 空字符串表示移除头像 */
avatar?: string;
/** 有损的小图,随头像一起上传 */
avatarThumb?: string;
maxMembers?: number;
membersCanUpload?: boolean;
ttlSeconds?: number;
+16 -2
View File
@@ -1,8 +1,9 @@
import { installSubtleFallback } from './subtleFallback';
import { setApiBase, getServerInfo } from './worker/http';
import type { WorkspaceRoleRequest } from './generated/api';
import { listPasskeys, removePasskey, beginPasskeyRegistration, finishPasskeyRegistration, beginPasskeyRecovery, finishPasskeyRecovery, beginPasskeyLogin, finishPasskeyLogin, unlockPasskeyLogin, clearPasskeyState } from './worker/passkeys';
import { connectChannelResource, getChannelBlob, listArchive, markChannelSeen, removeChannelItem, resolveTextConflict } from './worker/channelItems';
import { createChannel, forgetChannel, joinChannel, listChannels, updateChannelSettings, previewChannel, channelMembers } from './worker/channelResources';
import { createChannel, forgetChannel, getSpaceAvatar, joinChannel, listChannels, updateChannelSettings, previewChannel, channelMembers } from './worker/channelResources';
import { editChannelText, sendFiles, sendText, updateSnapshot } from './worker/channelSend';
import { cancelFileUpload } from './worker/uploadQueue';
import { stopChannel } from './worker/channelTransport';
@@ -15,6 +16,8 @@ import { serial } from './worker/serial';
import { errorMessage } from './errors';
import { accountStatus, loginAccount, logoutAccount, registerAccount, syncAccount, listAccountInvitations, acceptInvitation, inviteChannelUser, listChannelInvitations, revokeChannelInvitation } from './worker/account';
import { createFolder, createThread, createGroup, renameGroup, deleteGroup, createRole, updateRole, deleteRole, setRoleMembers, groupThread, moveItems, renameFolder, renameThread, setChannelHistory } from './worker/channelThreads';
import { fetchAvatar } from './worker/avatars';
import { servePlaybackRange } from './worker/playbackStream';
import { refreshLoadedWorkspaces } from './worker/workspaceStructure';
import { restoreAccount, updatePersonal, listAccountDevices, renameAccountDevice, revokeAccountDevice, getSavedLogin, clearSavedLogin } from './worker/account';
@@ -45,7 +48,9 @@ async function dispatch(operation: string, input: unknown): Promise<unknown> {
case 'beginPasskeyLogin': return beginPasskeyLogin(String(payload.username ?? ''));
case 'finishPasskeyLogin': return finishPasskeyLogin(String(payload.username ?? ''), id, String(payload.credential ?? ''), payload.prf as ArrayBuffer | undefined, payload.remember !== false);
case 'unlockPasskeyLogin': return unlockPasskeyLogin(String(payload.password ?? ''), payload.remember !== false);
case 'updatePersonal': await updatePersonal(String(payload.displayName ?? ''), typeof payload.avatar === 'string' ? payload.avatar : undefined); void refreshLoadedWorkspaces(); return accountStatus();
case 'getAvatar': return fetchAvatar(String(payload.username ?? ''), Number(payload.version ?? 0), payload.full === true);
case 'getSpaceAvatar': return getSpaceAvatar(id);
case 'updatePersonal': await updatePersonal(String(payload.displayName ?? ''), payload.avatar as Parameters<typeof updatePersonal>[1]); void refreshLoadedWorkspaces(); return accountStatus();
case 'accountDevices': return listAccountDevices();
case 'renameAccountDevice': await renameAccountDevice(id, String(payload.name ?? '')); return true;
case 'revokeAccountDevice': await revokeAccountDevice(id); return accountStatus();
@@ -127,6 +132,12 @@ function laneOf(operation: string, payload: Record<string, unknown>): string | u
scope.onmessage = (event) => {
const request = event.data;
// 边下边播的取数请求:数据通过随消息转移来的端口直接回给 Service Worker,不走请求/回复
if (request.operation === 'streamRange') {
const port = event.ports[0];
if (port) void servePlaybackRange(port, request.payload as Parameters<typeof servePlaybackRange>[1]);
return;
}
const lane = laneOf(request.operation, request.payload as Record<string, unknown>);
const run = () => dispatch(request.operation, request.payload);
const task = lane ? serial(lane, run) : run();
@@ -138,3 +149,6 @@ scope.onmessage = (event) => {
}),
);
};
// http 的局域网访问没有 crypto.subtle:装上纯 JS 的替代实现(原生存在时什么也不做)
installSubtleFallback(self.name.includes('softcrypto'));
+15 -5
View File
@@ -1,5 +1,5 @@
import { importResources, importResourceDeletes, listAllResources, listResourceDeletes, setResourceChangeListener, setResourceScope, type SavedResource } from '../database';
import { apiFetch } from './http';
import { apiFetch, jsonBody } from './http';
import { clearResourceSessions, getSession } from './sessions';
import { setAccountIdentity, requireAccount } from './accountSession';
import { b64, unb64, deriveAccountKeys, sealJson, openJson, generateInvitationKeys, generateAccountSigningKeys } from './accountCrypto';
@@ -11,6 +11,8 @@ import { forgetAccount, localAccountDevice, rememberedAccount, rememberAccount,
import type { AccountVaultBody, AccountSaltBody, AccountSessionBody, PublicKeyBody, InvitationBody, InvitationCryptoPlan, AccountDeviceBody, AccountRegisterRequest, AccountSessionRequest } from '../generated/api';
import { loadPersonal, personalProfile } from './accountProfile';
import { primeAvatar } from './avatars';
import type { AvatarChange } from '../protocol/account';
import type { AccountProfile, AccountProfileRequest } from '../generated/api';
import type { PersonalProfile } from '../protocol/account';
@@ -206,7 +208,8 @@ export async function unlockAuthenticatedAccount(identity: AccountSessionBody, k
export async function accountStatus(): Promise<{ username: string | null; revision: number; remembered: boolean; personal?: PersonalProfile; error?: string; unlockUsername?: string }> {
return { username: account?.username ?? null, revision: account?.revision ?? 0,
remembered: account?.remember ?? false, unlockUsername: passkeyUnlockUsername(),
...(account?.personal ? { personal: account.personal } : {}),
// 界面每隔几秒就读一次状态:不能每次都带着几 MB 的头像过 postMessage,头像由界面另外取
...(account?.personal ? { personal: { displayName: account.personal.displayName, modifiedAt: account.personal.modifiedAt, hasAvatar: account.personal.hasAvatar ?? Boolean(account.personal.avatar) } } : {}),
...(account?.error ? { error: '账号资源同步失败,请重试。' } : {}) };
}
export async function logoutAccount(): Promise<void> {
@@ -230,13 +233,20 @@ export async function clearSavedLogin(): Promise<void> {
}
await forgetAccount(); await forgetLoginFields();
}
export async function updatePersonal(displayName: string, avatar?: string): Promise<void> {
export async function updatePersonal(displayName: string, change?: AvatarChange): Promise<void> {
if (!account) throw new Error('请先登录账号');
const session = account;
const value = await apiFetch('/accounts/profile', { method: 'PATCH', headers: { ...headers(session), 'Content-Type': 'application/json' },
body: JSON.stringify({ displayName, avatar } satisfies AccountProfileRequest) }).then(r => r.json() as Promise<AccountProfile>);
const request: AccountProfileRequest = change === 'keep' ? { displayName, keepAvatar: true }
: change ? { displayName, keepAvatar: false, avatar: change.avatar, avatarThumb: change.thumb } : { displayName, keepAvatar: false };
const { body, headers: encoding } = await jsonBody(request);
const value = await apiFetch('/accounts/profile', { method: 'PATCH', headers: { ...headers(session), ...encoding }, body }).then(r => r.json() as Promise<AccountProfile>);
stillActive(session);
const previous = session.personal?.avatar;
session.personal = personalProfile(value);
// 本机刚设置的头像直接用,不用再下载;只改名字时沿用现有的
const shown = change && change !== 'keep' ? change.thumb : change === 'keep' ? previous : undefined;
if (shown) { session.personal.avatar = shown; session.personal.hasAvatar = true; }
primeAvatar(value.username, value.modifiedAt, shown);
setAccountIdentity(session, session.profile.keys.publicKey, session.personal);
await persistSession(session, { vault: await sealJson(session.profile, session.key), revision: session.revision });
}
+6 -2
View File
@@ -1,12 +1,16 @@
import { apiFetch } from './http';
import { fetchAvatar } from './avatars';
import type { AccountProfile } from '../generated/api';
import type { PersonalProfile } from '../protocol/account';
export function personalProfile(value: AccountProfile): PersonalProfile {
return { displayName: value.displayName, avatar: value.avatar ?? undefined, modifiedAt: value.modifiedAt };
return { displayName: value.displayName, hasAvatar: value.hasAvatar || Boolean(value.avatar), ...(value.avatar ? { avatar: value.avatar } : {}), modifiedAt: value.modifiedAt };
}
export async function loadPersonal(token: string): Promise<PersonalProfile> {
const headers = { 'X-WD-Account': token };
const profile = await apiFetch('/accounts/profile', { headers }).then(r => r.json() as Promise<AccountProfile>);
return personalProfile(profile);
const personal = personalProfile(profile);
// 头像后台取,不挡住登录;取到后补进来,之后发消息的头像快照就有了
if (personal.hasAvatar && !personal.avatar) void fetchAvatar(profile.username, profile.modifiedAt).then((avatar) => { if (avatar) personal.avatar = avatar; });
return personal;
}
+68
View File
@@ -0,0 +1,68 @@
import { apiFetch } from './http';
import { accountToken } from './accountSession';
/**
* 头像不再随成员资料、空间结构一起下发(动图头像有几 MB,每次刷新结构都带上会把打开空间拖得很慢)。
* 资料里只有「有没有头像」和修改时间,头像另取一次并缓存;修改时间变了才重新取,
* 服务端对头像带 ETag,浏览器重新打开时只回 304。
*/
interface Entry { version: number; value?: string; loading?: Promise<string | undefined> }
const cache = new Map<string, Entry>();
const waiting: (() => void)[] = [];
let running = 0;
const PARALLEL = 4;
/** 已经取到的头像(没取到或版本不对返回 undefined) */
export function cachedAvatar(username: string, version: number): string | undefined {
const entry = cache.get(username);
return entry && entry.version === version ? entry.value : undefined;
}
/** 本机刚设置的头像,直接放进缓存,不用再下载一遍 */
export function primeAvatar(username: string, version: number, value: string | undefined): void {
cache.set(username, { version, ...(value ? { value } : {}) });
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
async function slot<T>(task: () => Promise<T>): Promise<T> {
if (running >= PARALLEL) await new Promise<void>((resolve) => waiting.push(resolve));
running += 1;
try { return await task(); }
finally { running -= 1; waiting.shift()?.(); }
}
async function download(username: string, token: string, full: boolean): Promise<string> {
// no-cache:浏览器每次都带 ETag 去验证,没变化就是 304,不用重新下载
const response = await apiFetch(`/accounts/users/${encodeURIComponent(username)}/avatar${full ? '?size=full' : ''}`, { headers: { 'X-WD-Account': token }, cache: 'no-cache' });
return blobToDataUrl(await response.blob());
}
/** 默认取有损的缩略图(列表、聊天用,小,有内存缓存);full 取原图(预览时才用,不放进内存缓存) */
export function fetchAvatar(username: string, version: number, full = false): Promise<string | undefined> {
if (full) {
const token = accountToken();
return token ? download(username, token, true).catch(() => undefined) : Promise.resolve(undefined);
}
const entry = cache.get(username);
if (entry && entry.version === version) return entry.loading ?? Promise.resolve(entry.value);
const token = accountToken();
if (!token) return Promise.resolve(undefined);
const next: Entry = { version };
next.loading = slot(async () => {
try {
const value = await download(username, token, false);
next.value = value;
return value;
} catch { cache.delete(username); return undefined; }
});
cache.set(username, next);
return next.loading;
}
+3 -1
View File
@@ -3,6 +3,7 @@ import { canDecryptRecord, objectAuthorAccount, objectEventProjection, readDataK
import { getResource, cacheResource, type SavedResource } from '../database';
import { channelCache } from './resourceMetadata';
import { cachedPerson, loadWorkspace } from './workspaceStructure';
import { cachedAvatar } from './avatars';
import { displayAvatar } from './senderSnapshot';
import type { FileItem, Item, SenderInfo, Snapshot } from '../types';
import { previewKindOf } from '@/lib/fileKind';
@@ -37,7 +38,8 @@ async function authorOf(resourceId: string, itemId: string): Promise<SenderInfo
const username = objectAuthorAccount(resourceId, itemId);
if (!username) return undefined;
const person = cachedPerson(resourceId, username);
const avatar = person?.avatar ? await displayAvatar(person.avatar) : undefined;
const full = person?.hasAvatar ? cachedAvatar(person.username, person.modifiedAt) : undefined;
const avatar = full ? await displayAvatar(full) : undefined;
return { username, displayName: person?.displayName || username, ...(avatar ? { avatar } : {}) };
}
+15 -3
View File
@@ -3,7 +3,7 @@ import { cacheResource, getResource, listResources, removeResource, saveResource
import type { ParsedChannelLink } from '@/lib/channelLink';
import type { ChannelInfo, ChannelSettingsPatch, CreateOptions } from '../types';
import { apiFetch, readJson, getServerInfo } from './http';
import { jsonBody, apiFetch, readJson, getServerInfo } from './http';
import { channelCache, isOfflineFailure } from './resourceMetadata';
import { accountToken } from './accountSession';
import { attachDataKeys, initializeChannelKeys } from './channelDataKeys';
@@ -153,15 +153,17 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe
const body: SettingsRequest = {};
if (patch.name !== undefined) body.name = patch.name.trim();
if (patch.avatar !== undefined) body.avatar = patch.avatar;
if (patch.avatarThumb) body.avatar_thumb = patch.avatarThumb;
if (patch.maxMembers !== undefined) body.max_members = patch.maxMembers;
if (patch.membersCanUpload !== undefined) body.members_can_upload = patch.membersCanUpload;
if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds;
if (patch.fileMaxDownloads !== undefined) body.file_max_downloads = patch.fileMaxDownloads;
if (patch.joinPolicy !== undefined) body.join_policy = patch.joinPolicy;
if (patch.joinPassword !== undefined) body.join_password = patch.joinPassword;
const { body: payload, headers: encoding } = await jsonBody(body, Boolean(patch.avatar));
const info = await apiFetch(`/channels/${resourceId}/settings`, {
method: 'PATCH', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
method: 'PATCH', headers: { ...session.authHeaders('owner'), ...encoding },
body: payload,
}).then((response) => response.json() as Promise<ChannelWire>);
session.verifyChannelInfo(info);
const updated = await cacheResource(resource, channelCache(info));
@@ -175,3 +177,13 @@ export async function forgetChannel(id: string): Promise<void> {
await removeResource(id);
}
/** 空间头像原图:信息里只带缩略图,预览时才下载原图 */
export async function getSpaceAvatar(resourceId: string): Promise<string | undefined> {
const { session } = await getSession(resourceId);
try {
const response = await apiFetch(`/channels/${resourceId}/avatar`, { headers: session.authHeaders('read'), cache: 'no-cache' });
const blob = await response.blob();
return await new Promise<string>((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(reader.error); reader.readAsDataURL(blob); });
} catch { return undefined; }
}
+10 -1
View File
@@ -47,7 +47,8 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise<Re
const response = await fetch(apiPath(path), {
...init,
headers,
cache: 'no-store',
// 头像这类带 ETag 的资源可以让浏览器重新验证(304),其余一律不缓存
cache: init.cache ?? 'no-store',
credentials: 'omit',
referrerPolicy: 'no-referrer',
});
@@ -136,3 +137,11 @@ export async function readJson<T>(path: string, headers: Record<string, string>)
return apiFetch(path, { headers }).then((response) => response.json() as Promise<T>);
}
/** JSON 请求体,浏览器支持时用 gzip 压缩(头像是 base64,压缩后能省下约四分之一的上传量) */
export async function jsonBody(value: unknown, compress = true): Promise<{ body: BodyInit; headers: Record<string, string> }> {
const text = JSON.stringify(value);
if (!compress || typeof CompressionStream === 'undefined') return { body: text, headers: { 'Content-Type': 'application/json' } };
const zipped = await new Response(new Blob([text]).stream().pipeThrough(new CompressionStream('gzip'))).blob();
return { body: zipped, headers: { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' } };
}
@@ -0,0 +1,125 @@
import { fromBase64Url, IntegrityError } from '@webdrop/core';
import { apiFetch } from './http';
import { getSession } from './sessions';
/**
* 边下边播:按字节范围把加密条目解密后推给 Service Worker(stream-sw.js)。
*
* 条目的帧是定长的:第 0 帧内容 cap0 字节,之后每帧 maxData 字节,最后一帧不足。
* 读完第 0、1 帧就知道这两个数,于是任意字节位置都能算出在第几帧,从那一帧开始拉取(from 参数),
* 拖进度条不用从头下载。每一帧都有 AEAD 认证(绑定空间、条目和帧序号),被篡改的帧直接报错;
* 整个条目的清单校验需要读完全部帧,边播不做(下载保存时仍然会做)。
*/
interface Geometry { cap0: number; maxData: number; head: Uint8Array[] }
const geometries = new Map<string, Promise<Geometry>>();
interface PlaybackRequest { resourceId: string; itemId: string; size: number; mime: string; start?: number; end?: number; suffix?: number }
async function framesFrom(resourceId: string, itemId: string, from: number): Promise<{ next: () => Promise<Uint8Array | undefined>; close: () => Promise<void> }> {
const { session, resource } = await getSession(resourceId);
if (resource.kind !== 'channel') throw new Error('资源类型不匹配');
const role = session.can('read') ? 'read' : 'owner';
const response = await apiFetch(`/channels/${resourceId}/items/${itemId}/frames?from=${from}`, { headers: session.authHeaders(role) });
if (!response.body) throw new Error('服务端没有返回文件内容');
const expectedId = fromBase64Url(itemId, 16);
if (!expectedId) throw new IntegrityError('bad_meta');
// 每一帧解出来就是一块内容,块的边界就是帧的边界
const { content } = session.decryptItemStream(response.body, { itemId: expectedId, fromIndex: from });
const reader = content.getReader();
return {
next: async () => { const { done, value } = await reader.read(); return done ? undefined : value; },
close: () => reader.cancel().catch(() => undefined),
};
}
function geometryOf(resourceId: string, itemId: string, size: number): Promise<Geometry> {
const key = `${resourceId}:${itemId}`;
let known = geometries.get(key);
if (!known) {
if (geometries.size >= 8) geometries.clear();
known = (async () => {
const frames = await framesFrom(resourceId, itemId, 0);
try {
const first = await frames.next();
if (!first) throw new Error('文件内容为空');
if (first.length >= size) return { cap0: first.length, maxData: Math.max(1, first.length), head: [first] };
const second = await frames.next();
if (!second) throw new Error('文件不完整');
return { cap0: first.length, maxData: Math.max(1, second.length), head: [first, second] };
} finally { await frames.close(); }
})();
known.catch(() => geometries.delete(key));
geometries.set(key, known);
}
return known;
}
/** 字节位置 → 所在的帧序号和帧内偏移 */
function locate(g: Geometry, position: number): { index: number; offset: number } {
if (position < g.cap0) return { index: 0, offset: position };
const rest = position - g.cap0;
return { index: 1 + Math.floor(rest / g.maxData), offset: rest % g.maxData };
}
/** 流控:对方(Service Worker)每消费一块才给一个额度,避免把整个文件一口气解密进内存 */
function flow(port: MessagePort) {
let credit = 0; let cancelled = false; let wake: (() => void) | undefined;
port.onmessage = (message: MessageEvent<{ credit?: number; cancel?: boolean }>) => {
if (message.data.cancel) cancelled = true;
if (message.data.credit) credit += message.data.credit;
wake?.(); wake = undefined;
};
return {
get cancelled() { return cancelled; },
async take(): Promise<boolean> {
while (credit <= 0 && !cancelled) await new Promise<void>((resolve) => { wake = resolve; });
if (cancelled) return false;
credit -= 1; return true;
},
};
}
export async function servePlaybackRange(port: MessagePort, request: PlaybackRequest): Promise<void> {
const { resourceId, itemId, size, mime } = request;
const control = flow(port);
try {
if (request.suffix !== undefined && request.suffix <= 0) { port.postMessage({ error: 'bad range', status: 416 }); return; }
const start = request.suffix !== undefined ? Math.max(0, size - request.suffix) : request.start ?? 0;
const end = Math.min(size - 1, request.end ?? size - 1);
if (start >= size || start > end) { port.postMessage({ error: 'range not satisfiable', status: 416 }); return; }
const geometry = await geometryOf(resourceId, itemId, size);
port.postMessage({ total: size, mime, start, end });
let position = start;
const send = async (chunk: Uint8Array, offset: number): Promise<boolean> => {
if (!(await control.take())) return false;
const part = chunk.slice(offset, offset + (end + 1 - position));
const length = part.length; // 转移之后 part 就被分离了,长度会变成 0
port.postMessage({ data: part.buffer }, [part.buffer]);
position += length;
return true;
};
let { index, offset } = locate(geometry, position);
// 第 0、1 帧已经在手上
while (position <= end && index < geometry.head.length) {
if (!(await send(geometry.head[index]!, offset))) return;
index += 1; offset = 0;
}
if (position <= end) {
const frames = await framesFrom(resourceId, itemId, index);
try {
while (position <= end) {
const chunk = await frames.next();
if (!chunk) throw new Error('文件不完整');
if (!(await send(chunk, offset))) return;
offset = 0;
}
} finally { await frames.close(); }
}
port.postMessage({ done: true });
} catch (error) {
port.postMessage({ error: error instanceof Error ? error.message : '播放失败' });
}
// 端口由 Service Worker 读完后关闭:这里立刻关掉会让还没送达的最后几条消息丢失
}
+5 -1
View File
@@ -17,7 +17,11 @@ let clientHintsPromise: Promise<ClientHints> | undefined;
async function readClientHints(): Promise<ClientHints> {
const hints = (navigator as Navigator & { userAgentData?: ClientHints }).userAgentData;
if (!hints?.getHighEntropyValues) return hints ?? {};
clientHintsPromise ??= hints.getHighEntropyValues(['model', 'platform', 'platformVersion']).catch(() => hints);
// 个别手机浏览器的 getHighEntropyValues 会很久没有响应,不能因此卡住上传:最多等 1.5 秒,超时就用基础信息
clientHintsPromise ??= Promise.race([
hints.getHighEntropyValues(['model', 'platform', 'platformVersion']).catch(() => hints),
new Promise<ClientHints>((resolve) => setTimeout(() => resolve(hints), 1500)),
]);
return clientHintsPromise;
}
@@ -4,6 +4,7 @@ import { getResource, cacheResource } from '../database';
import { getSession } from './sessions';
import { apiFetch } from './http';
import { publishWorkspace } from './presentation';
import { cachedAvatar, fetchAvatar } from './avatars';
import { displayAvatar } from './senderSnapshot';
const current = new Map<string, WorkspaceBody>();
@@ -19,11 +20,31 @@ async function publish(id: string, body: WorkspaceBody): Promise<WorkspaceBody>
current.set(id, body);
const resource = await getResource(id);
if (resource) await cacheResource(resource, { workspaceMetadata: body });
const people = await Promise.all(body.people.map(async person => ({ ...person, avatar: person.avatar ? await displayAvatar(person.avatar) : undefined })));
await emit(id, body);
if (current.get(id) !== body) return current.get(id) ?? body;
publishWorkspace(id, { t: 'structure', state: body.structure, people });
void loadAvatars(id, body);
return body;
}
/** 先把成员列表发出去(没有头像时结构很小,打开空间立刻可用),头像稍后逐个补上 */
async function emit(id: string, body: WorkspaceBody): Promise<void> {
const people = await Promise.all(body.people.map(async person => {
const full = person.hasAvatar ? cachedAvatar(person.username, person.modifiedAt) : undefined;
return { ...person, avatar: full ? await displayAvatar(full) : undefined };
}));
if (current.get(id) !== body) return;
publishWorkspace(id, { t: 'structure', state: body.structure, people });
}
const republishing = new Set<string>();
async function loadAvatars(id: string, body: WorkspaceBody): Promise<void> {
const missing = body.people.filter(person => person.hasAvatar && cachedAvatar(person.username, person.modifiedAt) === undefined);
if (!missing.length) return;
await Promise.all(missing.map(person => fetchAvatar(person.username, person.modifiedAt)));
const latest = current.get(id);
if (!latest || republishing.has(id)) return;
republishing.add(id);
try { await emit(id, latest); } finally { republishing.delete(id); }
}
/** 结构由流里的 settings 通知保持最新;发消息等热路径直接用缓存,不再每次多等一个往返 */
export async function cachedWorkspace(id: string): Promise<WorkspaceBody> {
return current.get(id) ?? loadWorkspace(id);
+11 -1
View File
@@ -8,8 +8,13 @@ let nextId = 1;
const pending = new Map<number, Pending>();
const listeners = new Map<string, Set<(event: BridgeEvent, blob?: Blob) => void>>();
/** 排查用:localStorage 里设 webdrop.softcrypto=1,强制用纯 JS 的加密实现(模拟 http 局域网访问) */
export function forcedSoftCrypto(): boolean {
try { return localStorage.getItem('webdrop.softcrypto') === '1'; } catch { return false; }
}
function getWorker(): Worker {
worker ??= new Worker(new URL('./worker.ts', import.meta.url), { type: 'module', name: 'webdrop-crypto' });
worker ??= new Worker(new URL('./worker.ts', import.meta.url), { type: 'module', name: forcedSoftCrypto() ? 'webdrop-crypto softcrypto' : 'webdrop-crypto' });
worker.onmessage = (message: MessageEvent<WorkerReply>) => {
const reply = message.data;
if ('requestId' in reply) {
@@ -70,3 +75,8 @@ export function subscribeWorker<T extends BridgeEvent>(resourceId: string, liste
}
export type { BridgeEvent };
/** 直接给加密线程发消息(不等回复),可以带 MessagePort 之类要转移的对象 */
export function postToWorker(message: { operation: string; payload: unknown }, transfer: Transferable[]): void {
getWorker().postMessage(message, transfer);
}
@@ -180,3 +180,11 @@
display: flex;
gap: var(--space-2);
}
.avatarButton {
padding: 0;
border: 0;
border-radius: var(--radius-lg);
background: none;
cursor: zoom-in;
}
@@ -3,7 +3,9 @@ import type { ChannelInfo, ChannelSettingsPatch } from '@/core-bridge/types';
import { Button } from '@/ui/Button';
import { Icon, type IconName } from '@/ui/Icon';
import { Sheet } from '@/ui/Sheet';
import { AvatarEditor } from '../home/AvatarEditor';
import { api } from '@/core-bridge/instance';
import { AvatarPreview } from '../common/AvatarPreview';
import { AvatarEditor, type AvatarPick } from '../home/AvatarEditor';
import { checkAvatarSource } from '../home/avatarSource';
import { errorMessage } from '@/core-bridge/errors';
import { useToast } from '@/store/toast';
@@ -46,7 +48,11 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
const [joinPassword, setJoinPassword] = useState('');
const [clearPassword, setClearPassword] = useState(false);
const [tab, setTab] = useState<Tab>('basic');
const [avatar, setAvatar] = useState(channel.avatar);
// 新选的头像(原图 + 缩略图)、是否移除;没动就保持现有的。界面里默认只有有损缩略图,原图预览时才下载
const [pick, setPick] = useState<AvatarPick>();
const [removed, setRemoved] = useState(false);
const [previewing, setPreviewing] = useState(false);
const avatar = pick ? pick.thumb : removed ? undefined : channel.avatar;
const [editing, setEditing] = useState<File>();
const fileInput = useRef<HTMLInputElement>(null);
const spaceName = name.trim() || channel.name || '空间';
@@ -60,7 +66,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
setSaving(true);
setFailed(false);
try {
await onSave({ name: name.trim(), ...(avatar !== channel.avatar ? { avatar: avatar ?? '' } : {}), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy,
await onSave({ name: name.trim(), ...(pick ? { avatar: pick.avatar, avatarThumb: pick.thumb } : removed ? { avatar: '' } : {}), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy,
...(clearPassword ? { joinPassword: '' } : joinPassword ? { joinPassword } : {}) });
onClose();
} catch {
@@ -76,11 +82,12 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
catch (cause) { useToast.getState().push({ kind: 'error', text: errorMessage(cause) }); }
}
const dirty = avatar !== channel.avatar || name.trim() !== (channel.name ?? '') || maxMembers !== channel.maxMembers || membersCanUpload !== channel.membersCanUpload
const dirty = pick !== undefined || removed || name.trim() !== (channel.name ?? '') || maxMembers !== channel.maxMembers || membersCanUpload !== channel.membersCanUpload
|| ttlSeconds !== channel.ttlSeconds || fileMaxDownloads !== channel.maxDownloads || joinPolicy !== channel.joinPolicy || joinPassword !== '' || clearPassword;
const invalid = maxMembers < 1 || maxMembers > info.limits.max_members || ttlSeconds < info.limits.min_ttl_seconds || ttlSeconds > info.limits.max_ttl_seconds || Boolean(joinPassword && joinPassword.length < 8);
return (
<>{previewing && avatar ? <AvatarPreview title="空间头像" thumb={avatar} load={async () => pick?.avatar ?? api.spaceAvatar()} onClose={() => setPreviewing(false)} /> : null}
<Sheet title="空间设置" onClose={onClose} wide={tab === 'roles' ? 'xl' : true}>
<div className={`${styles.layout} ${tab === 'roles' ? styles.tall : ''}`}>
<div className={styles.tabs} role="tablist" aria-orientation="vertical" aria-label="空间设置分类">
@@ -88,16 +95,16 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
<Icon name={item.icon} size={18} /><span>{item.label}</span></button>)}
</div>
<div className={styles.content}>
{editing ? <AvatarEditor kind="space" file={editing} name={spaceName} onCancel={() => setEditing(undefined)} onDone={(url) => { setAvatar(url); setEditing(undefined); }} />
{editing ? <AvatarEditor kind="space" file={editing} name={spaceName} onCancel={() => setEditing(undefined)} onDone={(next) => { setPick(next); setRemoved(false); setEditing(undefined); }} />
: <section className={styles.pane} role="tabpanel" aria-label={TABS.find((item) => item.id === tab)?.label}>
{tab === 'basic' ? <>
<div className={styles.avatarRow}>
{avatar ? <img className={styles.avatar} src={avatar} alt="空间头像" /> : <span className={`${styles.avatar} ${styles.avatarFallback}`} aria-hidden="true">{Array.from(spaceName)[0]?.toLocaleUpperCase()}</span>}
{avatar ? <button type="button" className={styles.avatarButton} aria-label="查看空间头像原图" onClick={() => setPreviewing(true)}><img className={styles.avatar} src={avatar} alt="空间头像" /></button> : <span className={`${styles.avatar} ${styles.avatarFallback}`} aria-hidden="true">{Array.from(spaceName)[0]?.toLocaleUpperCase()}</span>}
<div className={styles.avatarSide}>
<input ref={fileInput} type="file" accept="image/png,image/jpeg,image/webp,image/gif" aria-label="选择空间头像文件" hidden onChange={(event) => { void chooseAvatar(event.target.files?.[0]); event.target.value = ''; }} />
<div className={styles.avatarButtons}>
<Button icon="image" disabled={!owner} onClick={() => fileInput.current?.click()}>更换空间头像</Button>
{avatar && owner ? <Button variant="ghost" onClick={() => setAvatar(undefined)}>移除</Button> : null}
{avatar && owner ? <Button variant="ghost" onClick={() => { setPick(undefined); setRemoved(true); }}>移除</Button> : null}
</div>
<span className={styles.note}>PNG、JPEG、WebP 或 GIF 动图,所有成员可见</span>
</div>
@@ -143,6 +150,6 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
) : <p className={styles.note}>只有所有者可以修改空间设置。</p>}
</div>
</div>
</Sheet>
</Sheet></>
);
}
+7 -6
View File
@@ -4,6 +4,7 @@ import { formatSize } from '@/lib/format';
import { Icon } from '@/ui/Icon';
import { ArchivePreview } from '../item/previews/ArchivePreview';
import { useBlobUrl } from '../item/useBlobUrl';
import { useMediaUrl } from '../item/useMediaUrl';
import styles from './ChatAttachment.module.css';
/** 聊天里自动展开的大小上限:再大的只显示文件卡片,点开预览 */
@@ -54,15 +55,15 @@ function Picture({ item, onOpen }: { item: FileItem; onOpen: () => void }) {
}
function Clip({ item }: { item: FileItem }) {
const blob = useBlobUrl(item.id);
if (blob.status === 'loading') return <div className={styles.placeholder}>加载中…</div>;
if (blob.status === 'error') return null;
return <video className={styles.video} src={blob.url} controls preload="metadata" playsInline />;
const { state, onError } = useMediaUrl(item.id);
if (state.status === 'loading') return <div className={styles.placeholder}>加载中…</div>;
if (state.status === 'error') return null;
return <video className={styles.video} src={state.url} controls preload="metadata" playsInline onError={onError} />;
}
function Sound({ item }: { item: FileItem }) {
const blob = useBlobUrl(item.id);
return blob.status === 'ready' ? <audio className={styles.audio} src={blob.url} controls /> : null;
const { state, onError } = useMediaUrl(item.id);
return state.status === 'ready' ? <audio className={styles.audio} src={state.url} controls preload="metadata" onError={onError} /> : null;
}
const LINES = 12;
+5 -1
View File
@@ -9,6 +9,8 @@ import { TransferProgress } from '@/features/item/TransferProgress';
import { Icon } from '@/ui/Icon';
import { Button } from '@/ui/Button';
import { ContextMenu, type MenuAction } from '@/ui/ContextMenu';
import { accountApi } from '@/core-bridge/account';
import { AvatarPreview } from '../common/AvatarPreview';
import { ChatAttachment, hasInlinePreview } from './ChatAttachment';
import { NameWithBadges } from './RoleBadges';
import styles from './ChatPane.module.css';
@@ -24,6 +26,7 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
const download = useDownload();
const [downloading, setDownloading] = useState(false);
const [menu, setMenu] = useState<{ x: number; y: number }>();
const [previewing, setPreviewing] = useState(false);
if (!item) return null;
const sender = item.author;
const name = senderLabel(item);
@@ -50,7 +53,7 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
return <article onClick={tap} className={`${styles.message} ${compact ? styles.compact : ''} ${sending ? styles.sending : ''}`}
aria-label={item.kind === 'file' ? `${name} 上传了 ${item.name}` : `${name} 的消息`} aria-busy={sending}>
{compact ? <time className={styles.gutterTime} dateTime={new Date(item.ts).toISOString()}>{formatTime(item.ts)}</time>
: sender?.avatar ? <img className={styles.avatar} src={sender.avatar} alt={`${name} 的头像`} />
: sender?.avatar ? <button type="button" className={styles.avatarButton} aria-label={`查看 ${name} 的头像原图`} onClick={() => setPreviewing(true)}><img className={styles.avatar} src={sender.avatar} alt={`${name} 的头像`} /></button>
: <span className={`${styles.avatar} ${styles.avatarFallback}`} role="img" aria-label={`${name} 的默认头像`}>{Array.from(name)[0] ?? '?'}</span>}
<div className={styles.body}>
{!compact ? <header><NameWithBadges username={sender?.username}><strong className={styles.senderName} title={sender?.username}>{name}</strong></NameWithBadges><time>{formatTime(item.ts)}</time></header> : null}
@@ -74,6 +77,7 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
: item.state.kind === 'incomplete' ? <span className={styles.delivery}>发送未完成,等待续传。</span> : null}
</div>
{owner || self ? <Button className={styles.deleteAction} variant="ghost" icon="trash" label={deleteLabel} onClick={() => onDelete(id)} /> : null}
{previewing && sender?.avatar ? <AvatarPreview title={`${name} 的头像`} thumb={sender.avatar} load={() => accountApi.avatar(sender.username, 0, true)} onClose={() => setPreviewing(false)} /> : null}
{menu ? <ContextMenu point={menu} actions={actions} onClose={() => setMenu(undefined)} /> : null}
</article>;
}
@@ -274,9 +274,17 @@
color: var(--text);
font: inherit;
font-size: 14px;
font-weight: 400;
letter-spacing: normal;
line-height: 20px;
}
.composer textarea::placeholder {
color: var(--text-dim);
font-weight: 400;
opacity: 1;
}
@media (width <= 899px) {
/* 手机顶栏已显示频道名 */
.head {
@@ -301,6 +309,12 @@
.composer {
margin: var(--space-2);
}
/* iOS 的输入框字号小于 16px 时,点进去页面会被放大,布局跟着错位 */
.composer textarea {
font-size: 16px;
line-height: 22px;
}
}
/* 触屏没有悬停:隐藏常驻按钮,点一下消息弹出菜单 */
@@ -332,3 +346,13 @@
flex: 1;
white-space: nowrap;
}
.avatarButton {
display: block;
flex: none;
padding: 0;
border: 0;
border-radius: var(--radius-avatar);
background: none;
cursor: zoom-in;
}
+17 -2
View File
@@ -1,4 +1,4 @@
import { Fragment, useEffect, useRef, useState, type KeyboardEvent } from 'react';
import { Fragment, useEffect, useRef, useState, type ClipboardEvent, type KeyboardEvent } from 'react';
import { Button } from '@/ui/Button';
import { useWorkspace } from '@/store/workspace';
import type { Item } from '@/core-bridge/types';
@@ -36,6 +36,21 @@ export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFil
void onSend(value);
}
// 粘贴图片(截图、复制的图片)直接当文件发送;只有文字的粘贴照常进输入框
function paste(event: ClipboardEvent<HTMLTextAreaElement>) {
if (!onFiles || !enabled) return;
const images = [...event.clipboardData.files].filter((file) => file.type.startsWith('image/'));
if (!images.length) return;
event.preventDefault();
const stamp = new Date().toLocaleString('sv').replace(/[: ]/g, '-');
onFiles(images.map((file, index) => {
// 截图的文件名通常就是 image.png,换成带时间的名字,不然连续发几张分不清
const generic = /^image\.\w+$/i.test(file.name) || !file.name;
const extension = file.type.split('/')[1]?.replace('jpeg', 'jpg') ?? 'png';
return generic ? new File([file], `粘贴的图片 ${stamp}${images.length > 1 ? `-${index + 1}` : ''}.${extension}`, { type: file.type, lastModified: file.lastModified }) : file;
}));
}
function keyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); submit(); }
}
@@ -67,7 +82,7 @@ export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFil
{onFiles ? <><input ref={fileInput} type="file" multiple hidden onChange={(event) => { if (event.target.files?.length) onFiles([...event.target.files]); event.target.value = ''; }} />
<Button variant="ghost" icon="plus" label="上传文件到当前频道" disabled={!enabled} onClick={() => fileInput.current?.click()} /></> : null}
<textarea ref={input} value={text} disabled={!enabled} rows={1} aria-label="消息内容" placeholder={enabled ? `给 #${threadName} 发消息` : '先创建或加入一个频道'}
onChange={(event) => { setText(event.target.value); fit(); }} onKeyDown={keyDown} />
onChange={(event) => { setText(event.target.value); fit(); }} onKeyDown={keyDown} onPaste={paste} />
<Button type="submit" variant="ghost" icon="send" label="发送消息" disabled={!enabled || !text.trim()} />
</form>
</section>
@@ -175,3 +175,13 @@
justify-content: flex-end;
gap: var(--space-2);
}
.avatarButton {
display: block;
flex: none;
padding: 0;
border: 0;
border-radius: var(--radius-avatar);
background: none;
cursor: zoom-in;
}
+8 -3
View File
@@ -7,14 +7,16 @@ import { useToast } from '@/store/toast';
import { useWorkspace } from '@/store/workspace';
import { permissionsOf } from '@/lib/roles';
import { useAccount } from '@/store/account';
import { accountApi } from '@/core-bridge/account';
import { Button } from '@/ui/Button';
import { AvatarPreview } from '../common/AvatarPreview';
import { NameWithBadges } from './RoleBadges';
import styles from './MembersPane.module.css';
interface Props { owner: boolean; /** 空间主,或所在身份组被授予「移除成员」 */ canRemove: boolean; refreshKey: number; onInvite: () => void }
function Avatar({ person, name }: { person?: SenderInfo; name: string }) {
return person?.avatar ? <img className={styles.avatar} src={person.avatar} alt="" />
function Avatar({ person, name, onOpen }: { person?: SenderInfo; name: string; onOpen: () => void }) {
return person?.avatar ? <button type="button" className={styles.avatarButton} aria-label={`查看 ${name} 的头像原图`} onClick={onOpen}><img className={styles.avatar} src={person.avatar} alt="" /></button>
: <span className={`${styles.avatar} ${styles.avatarFallback}`} aria-hidden="true">{Array.from(name)[0]?.toLocaleUpperCase() ?? '?'}</span>;
}
@@ -36,6 +38,7 @@ export function MembersPane({ owner, canRemove, refreshKey, onInvite }: Props) {
const [members, setMembers] = useState<ChannelMember[]>([]);
const [pending, setPending] = useState<AccountInvitation[]>([]);
const [error, setError] = useState('');
const [viewing, setViewing] = useState<string>();
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
@@ -70,7 +73,7 @@ export function MembersPane({ owner, canRemove, refreshKey, onInvite }: Props) {
const person = people[username];
const name = person?.displayName || username;
return <li className={`${styles.row} ${invited || (!isOnline && !isOwner) ? styles.dim : ''}`} key={`${invited ? 'i' : 'm'}:${username}`}>
<Avatar person={person} name={name} />
<Avatar person={person} name={name} onOpen={() => setViewing(username)} />
<span className={styles.who}><strong><NameWithBadges username={username}>{name}</NameWithBadges></strong>
{detail || name !== username ? <small>{[name !== username ? username : '', detail].filter(Boolean).join(' · ')}</small> : null}</span>
{isOnline ? <span className={styles.online} role="img" aria-label="在线" /> : null}
@@ -83,7 +86,9 @@ export function MembersPane({ owner, canRemove, refreshKey, onInvite }: Props) {
item.devices > 1 || item.online_devices > 1 ? `${item.online_devices}/${item.devices} 台设备在线` : undefined,
false, removable(item), item.role === 'owner', item.online);
const viewed = viewing ? people[viewing] : undefined;
return <div className={styles.pane}>
{viewing && viewed?.avatar ? <AvatarPreview title={`${viewed.displayName || viewing} 的头像`} thumb={viewed.avatar} load={() => accountApi.avatar(viewing, 0, true)} onClose={() => setViewing(undefined)} /> : null}
<div className={styles.head}>
<span>成员 {members.length}{maxMembers ? `/${maxMembers}` : ''}</span>
{owner ? <Button variant="primary" icon="plus" onClick={onInvite}>邀请成员</Button> : null}
@@ -0,0 +1,25 @@
.stage {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
}
.image {
display: block;
width: min(100%, 420px);
max-height: 60dvh;
border-radius: var(--radius-lg);
background: var(--surface-2);
object-fit: contain;
}
.blur {
image-rendering: auto;
}
.note {
margin: 0;
color: var(--text-dim);
font-size: 12px;
}
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useState } from 'react';
import { Sheet } from '@/ui/Sheet';
import styles from './AvatarPreview.module.css';
interface Props {
title: string;
/** 已经在界面上的有损缩略图:原图下载好之前先显示它 */
thumb: string;
/** 取原图;没有单独的原图(或取不到)时返回 undefined,继续显示缩略图 */
load: () => Promise<string | undefined>;
onClose: () => void;
}
/** 头像预览:默认分发的是有损缩略图,点开才下载无损的原图 */
export function AvatarPreview({ title, thumb, load, onClose }: Props) {
const [full, setFull] = useState<string>();
const [done, setDone] = useState(false);
useEffect(() => {
let active = true;
void load().then((value) => { if (active) { setFull(value); setDone(true); } }).catch(() => { if (active) setDone(true); });
return () => { active = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <Sheet title={title} onClose={onClose}>
<div className={styles.stage}>
<img className={`${styles.image} ${full ? '' : styles.blur}`} src={full ?? thumb} alt={title} />
{!done ? <p className={styles.note} role="status">正在加载原图…</p> : full ? null : <p className={styles.note}>没有更清晰的原图了</p>}
</div>
</Sheet>;
}
@@ -214,3 +214,49 @@
}
}
.avatarRow {
display: flex;
align-items: center;
gap: var(--space-3);
}
.avatarPick {
display: grid;
flex: 0 0 56px;
place-items: center;
width: 56px;
height: 56px;
padding: 0;
overflow: hidden;
border: 1px dashed var(--line-strong);
border-radius: var(--radius-avatar);
background: var(--surface-2);
color: var(--text-dim);
font-size: 20px;
cursor: pointer;
}
.avatarPick img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatarText {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--text-dim);
font-size: 13px;
}
.avatarClear {
padding: 0;
border: 0;
background: none;
color: var(--text-dim);
font: inherit;
text-decoration: underline;
cursor: pointer;
}
+33 -8
View File
@@ -7,6 +7,10 @@ import { Button } from '@/ui/Button';
import styles from './AccountPanel.module.css';
import { navigate, channelPath } from '@/app/navigation';
import { passkeysAvailable } from '@/core-bridge/passkeys';
import { useToast } from '@/store/toast';
import { errorMessage } from '@/core-bridge/errors';
import { AvatarEditor, type AvatarPick } from './AvatarEditor';
import { checkAvatarSource } from './avatarSource';
/** 未登录时的登录 / 注册 / 解锁。登录后的账号设置见 AccountSettings */
export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'auth' }) {
@@ -32,6 +36,12 @@ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'a
const [remember, setRemember] = useState(true);
const [saved, setSaved] = useState(false);
const edited = useRef(false);
// 浏览器只在 https 或 localhost 下开放 crypto.subtle;用局域网 IP 走 http 访问时改用纯 JS 的替代实现(见 subtleFallback)
const insecure = typeof window !== 'undefined' && !window.isSecureContext;
// 注册时可以顺便选个头像(可选):在这里裁剪好,账号建好后一起设置
const [pick, setPick] = useState<AvatarPick>();
const [editing, setEditing] = useState<File>();
const fileInput = useRef<HTMLInputElement>(null);
useEffect(() => {
if (account) return;
let active = true;
@@ -46,12 +56,17 @@ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'a
const mismatch = creating && confirm !== '' && password !== confirm;
// 与服务端一致:ID 3–32 位,仅字母、数字、_、-
const rules = [
{ text: 'ID 为 3–32 个字符', met: username.length >= 3 && username.length <= 32 },
{ text: 'ID 只能包含字母、数字、下划线 _ 和连字符 -', met: username !== '' && /^[A-Za-z0-9_-]+$/.test(username) },
{ text: '密码至少 12 个字符', met: password.length >= 12 },
{ text: '两次输入的密码一致', met: confirm !== '' && password === confirm },
{ text: 'ID 3–32 位', met: username.length >= 3 && username.length <= 32 },
{ text: 'ID 仅限字母、数字、_ 和 -', met: username !== '' && /^[A-Za-z0-9_-]+$/.test(username) },
{ text: '密码至少 12 位', met: password.length >= 12 },
{ text: '两次密码一致', met: confirm !== '' && password === confirm },
];
const unmet = creating && rules.some((rule) => !rule.met);
async function chooseAvatar(file?: File) {
if (!file) return;
try { await checkAvatarSource(file); setEditing(file); }
catch (cause) { useToast.getState().push({ kind: 'error', text: errorMessage(cause) }); }
}
async function clearSaved() {
edited.current = true;
try { await clearSavedLogin(); setSaved(false); setRemember(false); setUsername(''); setPassword(''); setMessage(account ? '本机保存已清除,当前页面仍可使用;刷新后需要重新登录。' : '已清除本机保存的账号和密码。'); }
@@ -64,8 +79,8 @@ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'a
if (creating && password !== confirm) { setMessage('两次输入的密码不一致'); return; }
setMessage('');
try {
if (unlockUsername) await unlockPasskey(password, remember); else await login(username, password, register, remember, displayName);
setPassword(''); setConfirm(''); setDisplayName('');
if (unlockUsername) await unlockPasskey(password, remember); else await login(username, password, register, remember, displayName, pick);
setPassword(''); setConfirm(''); setDisplayName(''); setPick(undefined);
await Promise.all([refreshChannels(), refreshShares()]);
setMessage(register ? '账号已创建,资源保险库已同步。' : '已登录,资源保险库已同步。');
const pending = useChannels.getState().pendingLink;
@@ -83,14 +98,24 @@ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'a
</div>
{account ? <span className={styles.user}>{account}</span> : null}
</div>
{account ? null : (
{account ? null : editing ? (
<AvatarEditor file={editing} name={displayName.trim() || username || '我'} onCancel={() => setEditing(undefined)} onDone={(next) => { setPick(next); setEditing(undefined); }} />
) : (
<form className={styles.form} onSubmit={(event) => { void submit(event); }}>
{variant === 'auth' && !unlockUsername ? <div className={styles.authMode} role="group" aria-label="选择登录或注册">
<button type="button" aria-pressed={!register} onClick={() => { edited.current = true; setRegister(false); setConfirm(''); setMessage(''); }}>登录</button>
<button type="button" aria-pressed={register} onClick={() => { edited.current = true; setRegister(true); setMessage(''); }}>创建账号</button>
</div> : null}
{insecure ? <p className={styles.note}>当前是 http 连接,使用纯 JS 加密(登录和同步会慢一点)。仅建议在局域网试用,正式使用请用 https。</p> : null}
{unlockUsername ? <p className={styles.note}>已用Passkey确认账号,请输入账号密码解锁资源。</p> : null}
<label>账号 ID<input name="username" autoComplete="username" minLength={3} maxLength={32} value={unlockUsername ?? username} disabled={Boolean(unlockUsername)} onChange={(event) => { edited.current = true; setUsername(event.target.value); }} required /></label>
{creating ? <div className={styles.avatarRow}>
<input ref={fileInput} type="file" accept="image/png,image/jpeg,image/webp,image/gif" aria-label="选择头像文件" hidden onChange={(event) => { void chooseAvatar(event.target.files?.[0]); event.target.value = ''; }} />
<button type="button" className={styles.avatarPick} aria-label={pick ? '更换头像' : '选择头像'} onClick={() => fileInput.current?.click()}>
{pick ? <img src={pick.thumb} alt="头像预览" /> : <span aria-hidden="true">+</span>}
</button>
<span className={styles.avatarText}>头像(可选){pick ? <button type="button" className={styles.avatarClear} onClick={() => setPick(undefined)}>移除</button> : null}</span>
</div> : null}
{creating ? <label>个人名称(可选)<input name="display-name" autoComplete="nickname" maxLength={48} value={displayName} placeholder={username || '显示给其他成员看的名字'} onChange={(event) => { edited.current = true; setDisplayName(event.target.value); }} /></label> : null}
<label>密码<input name="password" autoComplete={register ? 'new-password' : 'current-password'} type="password" minLength={register ? 12 : undefined} value={password} onChange={(event) => { edited.current = true; setPassword(event.target.value); }} required /></label>
{creating ? <label>确认密码<input name="confirm-password" autoComplete="new-password" type="password" minLength={12} value={confirm} aria-invalid={mismatch} onChange={(event) => { edited.current = true; setConfirm(event.target.value); }} required />
@@ -98,7 +123,7 @@ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'a
{creating ? <ul className={styles.rules} aria-label="注册要求">
{rules.map((rule) => <li key={rule.text} data-met={rule.met}><span aria-hidden="true">{rule.met ? '✓' : '○'}</span>{rule.text}<span className={styles.srOnly}>{rule.met ? ',已满足' : ',未满足'}</span></li>)}
</ul> : null}
{creating ? <p className={styles.note}>账号 ID 是登录用的唯一标识,注册后不能改;个人名称是其他成员看到的名字,留空就显示账号 ID,之后可在个人资料里修改。密码用于在本机加密你的资源保险库,<strong>忘记后无法找回</strong>,请妥善保管。</p> : null}
{creating ? <p className={styles.note}>账号 ID 注册后不能改,头像和个人名称之后都可以改。<strong>密码忘记后无法找回</strong>。</p> : null}
<label className={styles.remember}><input type="checkbox" checked={remember} onChange={(event) => setRemember(event.target.checked)} />记住账号和密码(本机)</label>
<div className={styles.actions}>
<Button type="submit" variant="primary" disabled={busy || !channelsReady || !sharesReady || unmet}>{busy ? '处理中…' : !channelsReady || !sharesReady ? '正在读取本机资源…' : unlockUsername ? '解锁资源' : register ? '创建账号' : '登录并同步'}</Button>
@@ -344,3 +344,11 @@
gap: var(--space-2);
}
}
.avatarButton {
padding: 0;
border: 0;
border-radius: var(--radius-avatar);
background: none;
cursor: zoom-in;
}
+11 -4
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
import { Button } from '@/ui/Button';
import { clampCrop, exportAvatar, paintCrop, type Crop } from './avatarCrop';
import { canDecodeGif, decodeGif, exportGifAvatar, type GifFrame } from './avatarGif';
import { clampCrop, exportAvatar, exportAvatarThumb, paintCrop, type Crop } from './avatarCrop';
import { canDecodeGif, decodeGif, exportGifAvatar, exportGifThumb, type GifFrame } from './avatarGif';
import styles from './AvatarEditor.module.css';
const VIEW = 260;
@@ -25,6 +25,8 @@ function Preview({ image, crop, size, rounded }: { image: ImageBitmap; crop: Cro
/** 空间头像的动图上限(data URL 字符数),和服务端 AvatarData.MaxSpaceGifChars 一致并留余量 */
export const SPACE_GIF_LIMIT = 1_900_000;
export interface AvatarPick { avatar: string; thumb: string }
interface Props {
/** 个人头像是圆的;空间头像是圆角方块 */
kind?: 'person' | 'space';
@@ -32,7 +34,8 @@ interface Props {
/** 预览里显示的名称 */
name: string;
onCancel: () => void;
onDone: (dataUrl: string) => void;
/** avatar 是原图(预览和下载用),thumb 是有损的小图(列表、聊天默认只分发它) */
onDone: (pick: AvatarPick) => void;
}
/** 在浏览器里裁剪头像:拖动、缩放、旋转,右侧按聊天消息和成员列表的样子实时预览 */
@@ -121,7 +124,11 @@ export function AvatarEditor({ file, name, onCancel, onDone, kind = 'person' }:
async function confirm() {
if (!image || busy) return;
setBusy(true);
try { onDone(frames.length ? await exportGifAvatar(frames, crop, VIEW, space ? SPACE_GIF_LIMIT : undefined) : await exportAvatar(image, crop, VIEW)); }
try {
onDone(frames.length
? { avatar: await exportGifAvatar(frames, crop, VIEW, space ? SPACE_GIF_LIMIT : undefined), thumb: await exportGifThumb(frames, crop, VIEW) }
: { avatar: await exportAvatar(image, crop, VIEW), thumb: await exportAvatarThumb(image, crop, VIEW) });
}
catch { setFailed(true); setBusy(false); }
}
+1
View File
@@ -53,6 +53,7 @@ function CreateForm({ onClose, onCreated, info }: Props & { info: ServerInfoBody
<label className={styles.check}><input type="checkbox" checked={opts.mode === 'enc'} onChange={(event) => set('mode', event.target.checked ? 'enc' : 'raw')} /><span>启用端到端加密</span></label>
<p className={opts.mode === 'enc' ? styles.secure : styles.warn}>{opts.mode === 'enc' ? '内容和文件名在设备端加密。' : '未加密的内容、文件名和频道信息对服务端可见。'}创建后不能改变加密方式。</p>
{opts.mode === 'enc' && !window.isSecureContext ? <p className={styles.warn}>当前是 http 连接:浏览器不提供加密接口,这里用的是纯 JS 实现(较慢),而且页面本身可能被网络上的人替换,端到端加密起不到防护作用。正式使用请用 https 访问。</p> : null}
<p className={styles.secure}>空间属于当前账号,并自动创建“常规”频道。默认通过账号邀请加入,成员在自己的设备上自动领取密钥包。</p>
<label className={styles.field}>
+17 -8
View File
@@ -3,7 +3,9 @@ import { errorMessage } from '@/core-bridge/errors';
import { useAccount } from '@/store/account';
import { useToast } from '@/store/toast';
import { Button } from '@/ui/Button';
import { AvatarEditor } from './AvatarEditor';
import { AvatarPreview } from '../common/AvatarPreview';
import { accountApi } from '@/core-bridge/account';
import { AvatarEditor, type AvatarPick } from './AvatarEditor';
import { checkAvatarSource } from './avatarSource';
import styles from './AccountSettings.module.css';
@@ -14,11 +16,15 @@ export function ProfileSettings() {
const update = useAccount((s) => s.updatePersonal);
const fileInput = useRef<HTMLInputElement>(null);
const [name, setName] = useState(personal?.displayName ?? '');
const [avatar, setAvatar] = useState(personal?.avatar);
// 新选的头像(原图 + 缩略图)、是否要移除;都没动就保持服务端现有的
const [pick, setPick] = useState<AvatarPick>();
const [removed, setRemoved] = useState(false);
const [previewing, setPreviewing] = useState(false);
const avatar = pick ? pick.thumb : removed ? undefined : personal?.avatar;
const [editing, setEditing] = useState<File>();
useEffect(() => { setName(personal?.displayName ?? ''); setAvatar(personal?.avatar); }, [personal?.displayName, personal?.avatar]);
useEffect(() => { setName(personal?.displayName ?? ''); setPick(undefined); setRemoved(false); }, [personal?.displayName, personal?.modifiedAt]);
const changed = name.trim() !== (personal?.displayName ?? '') || avatar !== personal?.avatar;
const changed = name.trim() !== (personal?.displayName ?? '') || pick !== undefined || removed;
const shown = name.trim() || username;
async function choose(file?: File) {
@@ -29,25 +35,28 @@ export function ProfileSettings() {
async function submit(event: FormEvent) {
event.preventDefault();
if (!changed || busy) return;
try { await update(name.trim(), avatar); useToast.getState().push({ kind: 'ok', text: '个人资料已保存并同步' }); }
try { await update(name.trim(), pick ?? (removed ? undefined : 'keep')); useToast.getState().push({ kind: 'ok', text: '个人资料已保存并同步' }); }
catch (cause) { useToast.getState().push({ kind: 'error', text: `保存失败:${errorMessage(cause)}` }); }
}
if (editing) return <div className={styles.page}>
<h2>编辑头像</h2>
<AvatarEditor file={editing} name={shown} onCancel={() => setEditing(undefined)} onDone={(url) => { setAvatar(url); setEditing(undefined); }} />
<AvatarEditor file={editing} name={shown} onCancel={() => setEditing(undefined)} onDone={(next) => { setPick(next); setRemoved(false); setEditing(undefined); }} />
</div>;
const preview = previewing && avatar ? <AvatarPreview title="我的头像" thumb={avatar} load={async () => pick?.avatar ?? accountApi.avatar(username, personal?.modifiedAt ?? 0, true)} onClose={() => setPreviewing(false)} /> : null;
return <form className={styles.page} onSubmit={(event) => { void submit(event); }} aria-label="个人资料">
<h2>个人资料</h2>
{preview}
<div className={styles.avatarRow}>
{avatar ? <img className={styles.avatarLarge} src={avatar} alt="个人头像" />
{avatar ? <button type="button" className={styles.avatarButton} aria-label="查看头像原图" onClick={() => setPreviewing(true)}><img className={styles.avatarLarge} src={avatar} alt="个人头像" /></button>
: <span className={`${styles.avatarLarge} ${styles.avatarFallback}`} aria-hidden="true">{Array.from(shown)[0]?.toLocaleUpperCase() ?? '?'}</span>}
<div className={styles.avatarSide}>
<input ref={fileInput} type="file" accept="image/png,image/jpeg,image/webp,image/gif" aria-label="选择头像文件" hidden onChange={(event) => { void choose(event.target.files?.[0]); event.target.value = ''; }} />
<div className={styles.row}>
<Button icon="image" onClick={() => fileInput.current?.click()}>更换头像</Button>
{avatar ? <Button variant="ghost" onClick={() => setAvatar(undefined)}>移除</Button> : null}
{avatar ? <Button variant="ghost" onClick={() => { setPick(undefined); setRemoved(true); }}>移除</Button> : null}
</div>
<span className={styles.hint}>PNG、JPEG 或 WebP,可在浏览器里裁剪</span>
</div>
+21
View File
@@ -71,3 +71,24 @@ export async function exportAvatar(image: Source, crop: Crop, from: number): Pro
}
throw new Error('头像压缩后仍然过大,请换一张图片');
}
export const THUMB_SIZE = 96;
/** 默认分发的有损缩略图:96×96 的 WebP(不支持时 PNG),压到 9 万字符以内。列表、聊天里都用它,原图预览时才下载 */
export async function exportAvatarThumb(image: Source, crop: Crop, from: number): Promise<string> {
for (const size of [THUMB_SIZE, 72, 48]) {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('当前浏览器不支持图片处理');
paintCrop(ctx, image, crop, from, size);
for (const quality of [0.7, 0.5, 0.3]) {
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/webp', quality));
if (!blob) continue;
const url = await blobToDataUrl(blob);
if (url.length <= 90_000) return url;
}
}
throw new Error('头像缩略图生成失败');
}
+12 -2
View File
@@ -54,7 +54,7 @@ function toDataUrl(bytes: Uint8Array): string {
return `data:image/gif;base64,${btoa(binary)}`;
}
function encode(frames: readonly GifFrame[], crop: Crop, from: number, size: number, step: number): string {
function encode(frames: readonly GifFrame[], crop: Crop, from: number, size: number, step: number, colors = 256): string {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
@@ -66,7 +66,7 @@ function encode(frames: readonly GifFrame[], crop: Crop, from: number, size: num
if (at % step !== 0 && at !== frames.length - 1) return;
paintCrop(ctx, frame.bitmap, crop, from, size);
const { data } = ctx.getImageData(0, 0, size, size);
const palette = quantize(data, 256, { format: 'rgba4444', oneBitAlpha: true });
const palette = quantize(data, colors, { format: 'rgba4444', oneBitAlpha: true });
const transparentIndex = palette.findIndex((color) => color.length > 3 && color[3] === 0);
encoder.writeFrame(applyPalette(data, palette, 'rgba4444'), size, size,
{ palette, delay: Math.round(delay), transparent: transparentIndex >= 0, transparentIndex: Math.max(0, transparentIndex) });
@@ -86,3 +86,13 @@ export async function exportGifAvatar(frames: readonly GifFrame[], crop: Crop, f
}
throw new Error('动图压缩后仍然过大,请换一张更短或更小的 GIF');
}
/** 动图的有损缩略图:帧数、颜色数和尺寸都压小(最多约 30 帧,64 色,80 像素),控制在 40 万字符以内。列表、聊天里默认用它 */
export async function exportGifThumb(frames: readonly GifFrame[], crop: Crop, from: number): Promise<string> {
for (const [size, maxFrames, colors] of [[80, 30, 64], [64, 24, 32], [48, 16, 32]] as const) {
await new Promise((resolve) => setTimeout(resolve, 0));
const url = encode(frames, crop, from, size, Math.max(1, Math.ceil(frames.length / maxFrames)), colors);
if (url.length <= 400_000) return url;
}
throw new Error('动图缩略图生成失败');
}
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useBlobUrl } from '../useBlobUrl';
import { useMediaUrl } from '../useMediaUrl';
import styles from './previews.module.css';
interface Props {
@@ -7,15 +8,27 @@ interface Props {
kind: 'image' | 'video' | 'audio';
}
/** 媒体用原生元素加载 blob URL,不执行任何内容里的脚本 */
/** 媒体用原生元素加载:视频、音频边下边播,图片取整个内容的 blob URL;都不执行内容里的脚本 */
export function MediaPreview({ id, kind }: Props) {
if (kind === 'image') return <ImagePreview id={id} />;
return <PlayerPreview id={id} kind={kind} />;
}
function PlayerPreview({ id, kind }: { id: string; kind: 'video' | 'audio' }) {
const { state, onError } = useMediaUrl(id);
if (state.status === 'loading') return <p className={styles.hint}>加载中…</p>;
if (state.status === 'error') return <p className={styles.hint}>无法预览此内容,可以直接下载。</p>;
return kind === 'video'
? <video className={styles.media} src={state.url} controls playsInline onError={onError} />
: <audio className={styles.audio} src={state.url} controls onError={onError} />;
}
function ImagePreview({ id }: { id: string }) {
const blob = useBlobUrl(id);
const [broken, setBroken] = useState(false);
if (blob.status === 'loading') return <p className={styles.hint}>加载中…</p>;
if (blob.status === 'error') return <p className={styles.hint}>无法预览此内容,可以直接下载。</p>;
if (broken) return <p className={styles.hint}>此浏览器无法显示该格式(例如 iPhone 的 HEIC 照片),可以直接下载。</p>;
if (kind === 'image') return <img className={styles.media} src={blob.url} alt="" onError={() => setBroken(true)} />;
if (kind === 'video') return <video className={styles.media} src={blob.url} controls playsInline onError={() => setBroken(true)} />;
return <audio className={styles.audio} src={blob.url} controls />;
return <img className={styles.media} src={blob.url} alt="" onError={() => setBroken(true)} />;
}
+9
View File
@@ -1,6 +1,7 @@
import { createContext, useContext } from 'react';
import type { ArchiveEntry, Item } from '@/core-bridge/types';
import { UNKNOWN_SENDER_LABEL } from '@/lib/sender';
import { playbackUrl } from '@/core-bridge/streamClient';
import { useShareSession } from '@/store/shareSession';
import { useWorkspace } from '@/store/workspace';
@@ -20,6 +21,8 @@ export interface ItemSource {
fallbackSender: string;
getBlob: (id: string, versionId?: string) => Promise<Blob>;
listArchive: (id: string) => Promise<ArchiveEntry[]>;
/** 视频、音频的边下边播地址;分享不支持,浏览器不支持时也返回 undefined(退回整个下载) */
streamUrl?: (id: string) => Promise<string | undefined>;
/** 只有空间有:分享是只读的 */
actions?: ItemActions;
}
@@ -29,6 +32,12 @@ export const channelSource: ItemSource = {
fallbackSender: UNKNOWN_SENDER_LABEL,
getBlob: (id, versionId) => useWorkspace.getState().getBlob(id, versionId),
listArchive: (id) => useWorkspace.getState().listArchive(id),
streamUrl: async (id) => {
const { channel, byId } = useWorkspace.getState();
const item = byId[id];
if (!channel || item?.kind !== 'file') return undefined;
return playbackUrl(channel.id, id, item.size, item.mime);
},
actions: {
markSeen: (id) => useWorkspace.getState().markSeen(id),
editText: (id, text) => useWorkspace.getState().editText(id, text),
+20
View File
@@ -0,0 +1,20 @@
import { useState } from 'react';
import { useBlobUrl } from './useBlobUrl';
import { useStreamUrl } from './useStreamUrl';
type MediaState = { status: 'loading' } | { status: 'ready'; url: string } | { status: 'error' };
/**
* 视频、音频的播放地址:优先边下边播(只拉播放器要的那一段,拖进度条也不用从头下载);
* 浏览器不支持或播放出错时,退回整个下载完再播放。
*/
export function useMediaUrl(id: string): { state: MediaState; onError: () => void } {
const stream = useStreamUrl(id);
const [failed, setFailed] = useState(false);
const fallback = stream.status === 'unavailable' || failed;
const blob = useBlobUrl(id, fallback);
const state: MediaState = fallback
? blob.status === 'ready' ? { status: 'ready', url: blob.url } : blob
: stream.status === 'ready' ? { status: 'ready', url: stream.url } : { status: 'loading' };
return { state, onError: () => setFailed(true) };
}
+22
View File
@@ -0,0 +1,22 @@
import { useEffect, useState } from 'react';
import { releasePlayback } from '@/core-bridge/streamClient';
import { useItemSource } from './source';
type StreamState = { status: 'loading' } | { status: 'ready'; url: string } | { status: 'unavailable' };
/** 视频、音频边下边播的地址。不支持时返回 unavailable,调用方退回整个下载再播放 */
export function useStreamUrl(id: string, enabled = true): StreamState {
const { streamUrl } = useItemSource();
const [state, setState] = useState<StreamState>({ status: 'loading' });
useEffect(() => {
if (!enabled) return;
if (!streamUrl) { setState({ status: 'unavailable' }); return; }
let url: string | undefined; let cancelled = false;
void streamUrl(id).then((value) => {
if (cancelled) { if (value) releasePlayback(value); return; }
url = value; setState(value ? { status: 'ready', url: value } : { status: 'unavailable' });
}).catch(() => { if (!cancelled) setState({ status: 'unavailable' }); });
return () => { cancelled = true; if (url) releasePlayback(url); };
}, [id, enabled, streamUrl]);
return state;
}
+4
View File
@@ -1,3 +1,5 @@
import { installSubtleFallback } from '@/core-bridge/subtleFallback';
import { forcedSoftCrypto } from '@/core-bridge/workerClient';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from '@/app/App';
@@ -19,6 +21,8 @@ if (link?.kind === 'share') useShares.getState().setPendingLink(link.link);
initTheme();
void useServer.getState().load().catch(() => {});
installSubtleFallback(forcedSoftCrypto());
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
+23 -5
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand';
import { accountApi } from '@/core-bridge/account';
import type { AccountStatus } from '@/core-bridge/account';
import type { AvatarChange } from '@/core-bridge/protocol/account';
import { useChannels } from './channels';
import { useShares } from './shares';
import { useWorkspace } from './workspace';
@@ -12,7 +13,7 @@ import { loginWithPasskey, passkeyApi } from '@/core-bridge/passkeys';
interface AccountState extends AccountStatus {
busy: boolean;
hydrated: boolean;
login: (username: string, password: string, create: boolean, remember?: boolean, displayName?: string) => Promise<void>;
login: (username: string, password: string, create: boolean, remember?: boolean, displayName?: string, avatar?: { avatar: string; thumb: string }) => Promise<void>;
passkeyLogin: (username: string, remember: boolean) => Promise<void>;
unlockPasskey: (password: string, remember: boolean) => Promise<void>;
cancelPasskey: () => Promise<void>;
@@ -20,7 +21,7 @@ interface AccountState extends AccountStatus {
logout: () => Promise<void>;
clearSavedLogin: () => Promise<void>;
sync: () => Promise<void>;
updatePersonal: (displayName: string, avatar?: string) => Promise<void>;
updatePersonal: (displayName: string, avatar?: AvatarChange) => Promise<void>;
}
let lastResourceRefresh = 0;
@@ -46,12 +47,12 @@ export const useAccount = create<AccountState>()((set, get) => ({
catch (error) { set({ busy: false, unlockUsername: undefined, error: error instanceof Error ? error.message : '账号解锁失败' }); throw error; }
},
cancelPasskey: async () => { await passkeyApi.cancel(); set({ unlockUsername: undefined, error: undefined }); },
login: async (username, password, create, remember = true, displayName = '') => {
login: async (username, password, create, remember = true, displayName = '', avatar) => {
set({ busy: true, error: undefined });
try {
let status = create ? await accountApi.register(username, password, remember) : await accountApi.login(username, password, remember);
// 注册时填的个人名称:账号已经建好,这一步失败不算注册失败,之后可在个人资料里补
if (create && displayName.trim()) status = await accountApi.updatePersonal(displayName.trim()).catch(() => status);
// 注册时填的个人名称和头像:账号已经建好,这一步失败不算注册失败,之后可在个人资料里补
if (create && (displayName.trim() || avatar)) status = await accountApi.updatePersonal(displayName.trim() || username, avatar).catch(() => status);
clearViews(); await refreshResources();
set({ ...status, busy: false, hydrated: true });
} catch (error) {
@@ -88,3 +89,20 @@ export const useAccount = create<AccountState>()((set, get) => ({
catch (error) { set({ error: error instanceof Error ? error.message : '资料保存失败', busy: false }); throw error; }
},
}));
// 头像不跟着账号状态一起传(动图有几 MB,状态每隔几秒就刷新一次):状态里只有 hasAvatar,头像在这里另取,
// 并且状态刷新后(personal 被换成没有头像的)按修改时间沿用已经取到的那张
let loadingAvatar = '';
useAccount.subscribe((state, previous) => {
const personal = state.personal;
if (!personal?.hasAvatar || personal.avatar || !state.username) return;
const known = previous.personal;
if (known?.avatar && known.modifiedAt === personal.modifiedAt) { useAccount.setState({ personal: { ...personal, avatar: known.avatar } }); return; }
const key = `${state.username}:${personal.modifiedAt}`;
if (loadingAvatar === key) return;
loadingAvatar = key;
void accountApi.avatar(state.username, personal.modifiedAt).then((avatar) => {
const now = useAccount.getState().personal;
if (avatar && now && now.modifiedAt === personal.modifiedAt && !now.avatar) useAccount.setState({ personal: { ...now, avatar } });
}).catch(() => undefined).finally(() => { if (loadingAvatar === key) loadingAvatar = ''; });
});
+1 -1
View File
@@ -13,7 +13,7 @@ describe('reduce', () => {
groups: [{ id: 'group-a', name: '项目', createdAt: 1 }],
folders: [{ id: 'folder-a', name: '目录', threadId: 'channel-a' }],
locations: [{ itemId: 'a', threadId: 'channel-a', folderId: 'folder-a', authorAccount: 'alice' }] },
people: [{ username: 'alice', displayName: '服务端名称', modifiedAt: 1 }] };
people: [{ username: 'alice', displayName: '服务端名称', hasAvatar: false, modifiedAt: 1 }] };
let state = reduce(initialData, stateEvent);
state = reduce(state, { t: 'item', item: { ...text('a', 1), threadId: 'old', author: { username: 'mallory', displayName: '伪造名称' } } });
expect(state.byId.a?.threadId).toBe('channel-a'); expect(state.byId.a?.folderId).toBe('folder-a');
+3 -3
View File
@@ -43,12 +43,12 @@ summary,
touch-action: manipulation;
}
/* iOS Safari 对小于 16px 的输入框聚焦时会自动放大页面 */
/* iOS Safari 对小于 16px 的输入框聚焦时会自动放大页面。各组件自己的样式(如登录页的 14px)优先级更高,所以这里用 !important 兜底 */
@media (pointer: coarse) {
input:not([type="checkbox"], [type="radio"]),
input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"]),
textarea,
select {
font-size: 16px;
font-size: 16px !important;
}
}