Merge master into feat/file-api
This commit is contained in:
commit
d3da3597cc
34 files changed
+551
-52
No files matched your search
@@ -14,9 +14,11 @@ public static partial class Endpoints
|
||||
return Json(accounts.Profile(username).WithoutAvatar(), ApiJson.Default.AccountProfile);
|
||||
});
|
||||
// 头像单独取:默认给有损的缩略图(列表、聊天用),size=full 才是原图(预览时)。带 ETag,没变化时只回 304。需要登录账号
|
||||
api.MapGet("/accounts/users/{username}/avatar", (string username, HttpContext ctx) =>
|
||||
api.MapGet("/accounts/users/{username}/avatar", (string username, HttpContext ctx, ChannelService svc) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out _)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
if (!TryAccount(ctx, accounts, out var viewer)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
// 只有和对方同在一个空间(或就是自己)才能看头像,不能靠猜用户名批量抓取
|
||||
if (!svc.SharesSpace(viewer, NormalizeUsername(username))) throw new WebDropException(ErrorCode.NotFound);
|
||||
var profile = accounts.Profile(NormalizeUsername(username));
|
||||
var full = ctx.Request.Query["size"] == "full";
|
||||
return AvatarResult(ctx, full ? profile.Avatar : profile.AvatarThumb ?? profile.Avatar, $"{profile.ModifiedAt}-{(full ? "f" : "t")}");
|
||||
|
||||
@@ -12,6 +12,44 @@ public static partial class Endpoints
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
return Json(svc.AccountSpaces(username).ToList(), ApiJson.Default.ListChannelInfo);
|
||||
});
|
||||
// 空间列表的实时提示:账号所在空间有新内容就推一条 {id,last_seq}。只含序号,不含内容
|
||||
api.MapGet("/accounts/spaces/events", async (HttpContext ctx, ChannelService svc, ChannelHub hub) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
var ct = ctx.RequestAborted;
|
||||
ctx.Response.ContentType = "text/event-stream";
|
||||
ctx.Response.Headers["X-Accel-Buffering"] = "no";
|
||||
await ctx.Response.StartAsync(ct);
|
||||
var sent = new Dictionary<Id16, long>();
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
// 每次唤醒都重新核对登录:退出登录、被移除设备后,这条流立刻结束
|
||||
if (!TryAccount(ctx, accounts, out var current) || current != username)
|
||||
{
|
||||
await ctx.Response.WriteAsync("event: error\ndata: {\"error\":\"account_required\"}\n\n", ct);
|
||||
break;
|
||||
}
|
||||
var changed = hub.AnyAdvanced;
|
||||
foreach (var id in svc.AccountSpaceIds(username))
|
||||
{
|
||||
var seq = hub.AdvancedSeq(id);
|
||||
// 第一次只记下当前值:连接建立前的进展客户端已经从列表里拿到了
|
||||
if (!sent.TryGetValue(id, out var known)) { sent[id] = seq; continue; }
|
||||
if (seq <= known) continue;
|
||||
sent[id] = seq;
|
||||
await ctx.Response.WriteAsync($"event: space\ndata: {{\"id\":\"{id.ToBase64Url()}\",\"last_seq\":{seq}}}\n\n", ct);
|
||||
}
|
||||
await ctx.Response.Body.FlushAsync(ct);
|
||||
var idle = Task.Delay(TimeSpan.FromSeconds(20), ct);
|
||||
// 久无变化就发心跳,客户端据此判断连接还活着
|
||||
if (await Task.WhenAny(changed, idle) == idle) { await ctx.Response.WriteAsync("event: ping\ndata: 1\n\n", ct); await ctx.Response.Body.FlushAsync(ct); }
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
return Results.Empty;
|
||||
});
|
||||
api.MapGet("/accounts/channels", (HttpContext ctx, ChannelService svc) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
@@ -54,8 +92,10 @@ public static partial class Endpoints
|
||||
{
|
||||
var (online, onlineDevices) = hub.Presence(channelId, member.Username);
|
||||
// 设备是账号的属性:总数取账号的登录设备,在线数取该账号在本频道有订阅的设备
|
||||
var devices = accounts.Devices(member.Username, null);
|
||||
return new MemberBody { Username = member.Username, Role = member.Role, JoinedAt = member.JoinedAt,
|
||||
Online = online, Devices = Math.Max(accounts.Devices(member.Username, null).Count, onlineDevices), OnlineDevices = onlineDevices };
|
||||
Online = online, Devices = Math.Max(devices.Count, onlineDevices), OnlineDevices = onlineDevices,
|
||||
LastSeen = devices.Count == 0 ? 0 : devices.Max(device => device.LastSeen) };
|
||||
}).ToList();
|
||||
return Json(members, ApiJson.Default.ListMemberBody);
|
||||
});
|
||||
|
||||
@@ -186,6 +186,9 @@ public sealed class MemberBody
|
||||
/// <summary>该账号在本频道登记过的设备(终端)数;设备只是账号的属性,不是独立成员。</summary>
|
||||
[JsonPropertyName("devices")] public required int Devices { get; init; }
|
||||
[JsonPropertyName("online_devices")] public required int OnlineDevices { get; init; }
|
||||
|
||||
/// <summary>该账号任一设备最近一次活跃的 Unix 时间戳(登录时是秒,活跃续期后是毫秒,客户端按量级区分);没有登录过设备时为 0。</summary>
|
||||
[JsonPropertyName("last_seen")] public required long LastSeen { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AccountProfileRequest
|
||||
|
||||
@@ -12,6 +12,10 @@ public sealed class ChannelHub : IChannelEvents
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<Id16, Signal> _signals = [];
|
||||
|
||||
// 账号级的「有频道前进了」信号:不管有没有人订阅这个频道都会记录,供空间列表上的未读提示用
|
||||
private readonly Dictionary<Id16, long> _advanced = [];
|
||||
private TaskCompletionSource _anyAdvanced = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
// 在线状态:频道 -> 账号 -> 账号设备 -> 活动订阅数。只记账号。
|
||||
private readonly Dictionary<Id16, Dictionary<string, Dictionary<string, int>>> _presence = [];
|
||||
|
||||
@@ -66,7 +70,24 @@ public sealed class ChannelHub : IChannelEvents
|
||||
public void Dispose() => Interlocked.Exchange(ref _release, null)?.Invoke();
|
||||
}
|
||||
|
||||
public void Advanced(Id16 channelId, long seq) => Publish(channelId, s => s.Seq = Math.Max(s.Seq, seq));
|
||||
public void Advanced(Id16 channelId, long seq)
|
||||
{
|
||||
TaskCompletionSource old;
|
||||
lock (_gate)
|
||||
{
|
||||
_advanced[channelId] = Math.Max(_advanced.GetValueOrDefault(channelId), seq);
|
||||
old = _anyAdvanced;
|
||||
_anyAdvanced = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
old.TrySetResult();
|
||||
Publish(channelId, s => s.Seq = Math.Max(s.Seq, seq));
|
||||
}
|
||||
|
||||
/// <summary>先取这个任务、再读 <see cref="AdvancedSeq"/>,最后等待任务:不会漏掉中间的前进。</summary>
|
||||
public Task AnyAdvanced { get { lock (_gate) return _anyAdvanced.Task; } }
|
||||
|
||||
/// <summary>本进程启动以来该频道见到的最大序号;没有见到过为 0。</summary>
|
||||
public long AdvancedSeq(Id16 channelId) { lock (_gate) return _advanced.GetValueOrDefault(channelId); }
|
||||
public void SettingsChanged(Id16 channelId) => Publish(channelId, s => s.SettingsVersion++);
|
||||
public void MembersChanged(Id16 channelId) => Publish(channelId, s => s.MembersVersion++);
|
||||
public void ChannelDeleted(Id16 channelId) => Publish(channelId, s => s.Deleted = true);
|
||||
|
||||
@@ -12,7 +12,7 @@ public static class AvatarData
|
||||
|
||||
/// <summary>缩略图(列表、聊天里默认分发的有损小图)的上限:静态约 75 KB,动图约 370 KB</summary>
|
||||
public const int MaxThumbStaticChars = 100_000;
|
||||
public const int MaxThumbGifChars = 500_000;
|
||||
public const int MaxThumbGifChars = 800_000;
|
||||
|
||||
private static readonly string[] Prefixes = ["data:image/png;base64,", "data:image/jpeg;base64,", "data:image/webp;base64,", "data:image/gif;base64,"];
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ public sealed partial class ChannelService
|
||||
{
|
||||
foreach (var id in store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs))) events.SettingsChanged(id);
|
||||
}
|
||||
/// <summary>两个账号是否在同一个空间里(自己总是可以)。成员资料只对有交集的账号开放</summary>
|
||||
public bool SharesSpace(string username, string other) => username == other
|
||||
|| store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs).Any(id => tx.GetChannelMember(id, other) is not null));
|
||||
public IReadOnlyList<Id16> AccountSpaceIds(string username) => store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs));
|
||||
public IReadOnlyList<ChannelInfo> AccountSpaces(string username) => store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs).Select(id =>
|
||||
{
|
||||
var channel = tx.GetChannel(id)!;
|
||||
@@ -35,9 +39,18 @@ public sealed partial class ChannelService
|
||||
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||
var state = tx.GetWorkspace(id) ?? new WorkspaceState();
|
||||
EnsureDefaultRole(state, clock.NowMs);
|
||||
return ViewFor(tx, space, principal, state);
|
||||
});
|
||||
/// <summary>
|
||||
/// 空间结构按调用者的授权裁剪:没授权的频道、目录和条目位置一律不下发。
|
||||
/// 读取和所有修改接口的返回值都必须经过它,否则任何一个成员的一次修改(比如发消息后登记条目位置)
|
||||
/// 都会把整份结构返回给他。
|
||||
/// </summary>
|
||||
private WorkspaceState ViewFor(IResourceTx tx, Channel space, Principal principal, WorkspaceState state)
|
||||
{
|
||||
if (principal.AccountName != space.CreatorAccount)
|
||||
{
|
||||
var crypto = tx.GetWorkspaceCrypto(id);
|
||||
var crypto = tx.GetWorkspaceCrypto(space.Id);
|
||||
var grants = crypto.Access.Where(a => a.Username == principal.AccountName).ToList();
|
||||
var ids = grants.Where(a => a.ChatRead || a.FilesRead).Select(a => a.ChannelId).ToHashSet();
|
||||
state.Channels.RemoveAll(c => !ids.Contains(c.Id));
|
||||
@@ -49,9 +62,9 @@ public sealed partial class ChannelService
|
||||
state.Folders.RemoveAll(f => !ids.Contains(f.ThreadId));
|
||||
state.Locations.RemoveAll(l => !Id16.TryParse(l.ItemId, out var itemId) || !CanReadObject(tx, space, principal, itemId));
|
||||
}
|
||||
state.Locations = state.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(id, itemId) is { } item && item.ExpiresAt > clock.NowMs).ToList();
|
||||
state.Locations = state.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(space.Id, itemId) is { } item && item.ExpiresAt > clock.NowMs).ToList();
|
||||
return state;
|
||||
});
|
||||
}
|
||||
private WorkspaceState ChangeWorkspace(Id16 id, Credentials creds, Action<IResourceTx, WorkspaceState, Principal> change, bool requireAccount = true, string? permission = null)
|
||||
{
|
||||
var state = store.Write(tx =>
|
||||
@@ -68,7 +81,8 @@ public sealed partial class ChannelService
|
||||
ValidateWorkspace(current);
|
||||
current.Revision++;
|
||||
tx.PutWorkspace(id, current);
|
||||
return current;
|
||||
// 先落库完整的结构,再按调用者裁剪后返回
|
||||
return ViewFor(tx, channel, principal, current);
|
||||
});
|
||||
events.SettingsChanged(id);
|
||||
return state;
|
||||
@@ -166,12 +180,24 @@ public sealed partial class ChannelService
|
||||
});
|
||||
private static WorkspaceRole FindRole(WorkspaceState state, string id) => state.Roles.Find(r => r.Id == id) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
public WorkspaceState CreateWorkspaceFolder(Id16 id, Credentials creds, string threadId, string? parentId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
|
||||
state.Folders.Add(new WorkspaceFolder { Id = Guid.NewGuid().ToString(), ThreadId = threadId, ParentId = parentId, Name = WorkspaceName(name) }));
|
||||
{
|
||||
FindChannel(state, threadId);
|
||||
state.Folders.Add(new WorkspaceFolder { Id = Guid.NewGuid().ToString(), ThreadId = threadId, ParentId = parentId, Name = WorkspaceName(name) });
|
||||
});
|
||||
public WorkspaceState RenameWorkspaceFolder(Id16 id, Credentials creds, string folderId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
|
||||
(state.Folders.Find(f => f.Id == folderId) ?? throw new WebDropException(ErrorCode.NotFound)).Name = WorkspaceName(name));
|
||||
public WorkspaceState MoveWorkspaceItems(Id16 id, Credentials creds, string[] itemIds, string threadId, string? folderId) => ChangeWorkspace(id, creds, (tx, state, principal) =>
|
||||
{
|
||||
if (itemIds.Length is < 1 or > 100 || itemIds.Distinct().Count() != itemIds.Length) throw new WebDropException(ErrorCode.BadRequest);
|
||||
// 目的频道必须存在,而且成员只能往自己有写入授权的频道放条目
|
||||
if (!state.Channels.Exists(c => c.Id == threadId)) throw new WebDropException(ErrorCode.NotFound);
|
||||
var space = tx.GetChannel(id)!; var crypto = tx.GetWorkspaceCrypto(id);
|
||||
// 与 CheckObjectWrite 同一口径:加密空间或已有授权记录时,必须有目的频道的写入授权
|
||||
if (principal.Role != Role.Owner && principal.AccountName != space.CreatorAccount && (space.Mode == ChannelMode.Enc ? crypto.Envelopes.Count > 0 : crypto.Access.Count > 0 || crypto.Objects.Count > 0))
|
||||
{
|
||||
var grant = crypto.Access.Find(a => a.ChannelId == threadId && a.Username == principal.AccountName);
|
||||
if (grant is null || !(grant.ChatWrite || grant.FilesUpload)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
}
|
||||
foreach (var itemId in itemIds)
|
||||
{
|
||||
if (!Id16.TryParse(itemId, out var parsed)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
|
||||
@@ -114,6 +114,33 @@ public sealed class ChannelCryptoTests
|
||||
Assert.Null(env.Service.ReadCrypto(space.Id, owner).Objects.Find(o => o.ItemId == file.ToBase64Url())?.Event);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MemberOnlySeesChannelsItWasGrantedAndNeverTheirKeysObjectsOrWrites()
|
||||
{
|
||||
using var env = new TestEnv(); var ep = TestEnv.Endpoint(); var memberEp = TestEnv.Endpoint(); var space = env.Create(ep); var owner = space.Owner(ep);
|
||||
var defaultId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
|
||||
Bootstrap(env, space, owner, defaultId);
|
||||
var secret = env.Service.CreateWorkspaceChannelResult(space.Id, owner, "secret", null).Channel.Id;
|
||||
Bootstrap(env, space, owner, secret);
|
||||
var hidden = TestEnv.NewId().ToBase64Url();
|
||||
env.Service.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = hidden, ChannelId = secret, Domain = CryptoDomain.Chat, Epoch = 1, KeyEnvelope = "k" });
|
||||
|
||||
var plan = env.Service.InvitationPlan(space.Id, owner, "member");
|
||||
var packages = plan.Batches.SelectMany(batch => batch.Recipients.Select(recipient => new DomainEnvelope
|
||||
{ ChannelId = plan.ChannelId, Domain = batch.Domain, Epoch = batch.Epoch, Username = recipient, Payload = $"wrapped-{batch.Domain}-{recipient}" })).ToList();
|
||||
env.Service.InviteUser(space.Id, owner, "member", "opaque-account-grant", packages);
|
||||
var member = space.Member(memberEp) with { AccountName = "member" }; env.Service.Join(space.Id, member);
|
||||
|
||||
// 结构里没有没授权的频道;密钥包、对象登记里也没有它的任何东西
|
||||
Assert.Equal(new[] { defaultId }, env.Service.GetWorkspace(space.Id, member).Channels.Select(c => c.Id));
|
||||
var crypto = env.Service.ReadCrypto(space.Id, member);
|
||||
Assert.DoesNotContain(crypto.Envelopes, e => e.ChannelId == secret);
|
||||
Assert.DoesNotContain(crypto.Objects, o => o.ChannelId == secret);
|
||||
Assert.DoesNotContain(crypto.Access, a => a.ChannelId == secret);
|
||||
// 也不能往没授权的频道登记对象
|
||||
Assert.Throws<WebDropException>(() => env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = TestEnv.NewId().ToBase64Url(), ChannelId = secret, Domain = CryptoDomain.Chat, Epoch = 1, KeyEnvelope = "x" }));
|
||||
}
|
||||
|
||||
private static void Bootstrap(TestEnv env, TestChannel space, Credentials owner, string channelId)
|
||||
{
|
||||
foreach (var domain in new[] { CryptoDomain.Chat, CryptoDomain.Files }) env.Service.PublishDomain(space.Id, owner, new DomainPublishRequest
|
||||
|
||||
Reference in new issue
Block a user