Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d66c93fe98 | ||
|
|
283196938e | ||
|
|
31ae3bc39e |
No files matched your search
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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.
Generated
+42
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}` } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -55,6 +55,8 @@ export interface ChannelSettingsPatch {
|
||||
name?: string;
|
||||
/** 空字符串表示移除头像 */
|
||||
avatar?: string;
|
||||
/** 有损的小图,随头像一起上传 */
|
||||
avatarThumb?: string;
|
||||
maxMembers?: number;
|
||||
membersCanUpload?: boolean;
|
||||
ttlSeconds?: number;
|
||||
|
||||
@@ -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'));
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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 } : {}) };
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 读完后关闭:这里立刻关掉会让还没送达的最后几条消息丢失
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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></>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('头像缩略图生成失败');
|
||||
}
|
||||
@@ -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)} />;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 = ''; });
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user