feat(server)!: require a logged-in account for every channel request
Drop unauthenticated guests from channels: the allow_guests setting, the /guests endpoints and the guest seat table are gone, and requests without an account session get 401 account_required. Share links are unaffected. Contract version is now 8.0.0.
This commit is contained in:
1 parent
b1ac11e1d7
commit
c858bd2a09
31 files changed
+194
-358
No files matched your search
@@ -106,17 +106,13 @@ public interface IResourceTx
|
||||
void PutKeyPackage(Id16 id, string username, string payload, long createdAt);
|
||||
void DeleteKeyPackage(Id16 id, string username);
|
||||
IReadOnlyList<ChannelKeyPackage> ListUserKeyPackages(string username, long now);
|
||||
IReadOnlyList<Id16> DeleteDeviceSeats(string username, string deviceId);
|
||||
void BindSeatDevice(Id16 id, byte[] endpointHash, string deviceId);
|
||||
void DeleteAccountSeats(Id16 id, string username);
|
||||
ChannelMember? GetChannelMember(Id16 id, string username);
|
||||
IReadOnlyList<ChannelMember> ListChannelMembers(Id16 id);
|
||||
void PutChannelMember(Id16 id, ChannelMember member);
|
||||
void DeleteChannelMember(Id16 id, string username);
|
||||
bool IsJoinDenied(Id16 id, string? username, byte[] endpointHash);
|
||||
bool IsJoinDenied(Id16 id, string username);
|
||||
void DenyAccount(Id16 id, string username);
|
||||
void AllowAccount(Id16 id, string username);
|
||||
void DenyEndpoint(Id16 id, byte[] endpointHash);
|
||||
void TouchChannel(Id16 id, long expiresAt);
|
||||
void AddChannelBytes(Id16 id, long delta);
|
||||
void SetCompactedThrough(Id16 id, long seq);
|
||||
@@ -124,13 +120,8 @@ public interface IResourceTx
|
||||
void DeleteChannelRows(Id16 id);
|
||||
IReadOnlyList<Id16> ListExpiredChannels(long now, int limit);
|
||||
|
||||
// 席位
|
||||
Seat? GetSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash);
|
||||
int CountSeats(Id16 channelId);
|
||||
// 成员
|
||||
int CountMembers(Id16 channelId);
|
||||
void InsertSeat(Id16 channelId, Seat seat);
|
||||
bool DeleteSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash);
|
||||
IReadOnlyList<Seat> ListSeats(Id16 channelId);
|
||||
|
||||
// 条目
|
||||
Item? GetItem(Id16 channelId, Id16 itemId);
|
||||
|
||||
@@ -14,13 +14,12 @@ public sealed partial class ChannelService
|
||||
|
||||
private static void CheckAdmission(IResourceTx tx, Channel channel, Credentials creds, string? password)
|
||||
{
|
||||
if (creds.AccountName is not null && creds.AccountName == channel.CreatorAccount) return;
|
||||
if (tx.IsJoinDenied(channel.Id, creds.AccountName, creds.EndpointHash)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
bool invited = creds.AccountName is not null && tx.HasInvitation(channel.Id, creds.AccountName);
|
||||
if (invited) return;
|
||||
if (creds.AccountName is not null && tx.GetChannelMember(channel.Id, creds.AccountName) is not null) return;
|
||||
string account = creds.AccountName ?? throw new WebDropException(ErrorCode.AccountRequired);
|
||||
if (account == channel.CreatorAccount) return;
|
||||
if (tx.IsJoinDenied(channel.Id, account)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
if (tx.HasInvitation(channel.Id, account)) return;
|
||||
if (tx.GetChannelMember(channel.Id, account) is not null) return;
|
||||
if (channel.Settings.JoinPolicy == JoinPolicy.Invite) throw new WebDropException(ErrorCode.InvitationRequired);
|
||||
if (!channel.Settings.AllowGuests && creds.AccountName is null) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
if (channel.JoinPasswordHash is null) return;
|
||||
if (string.IsNullOrEmpty(password)) throw new WebDropException(ErrorCode.ChannelPasswordRequired);
|
||||
if (password.Length > 256) throw new WebDropException(ErrorCode.ChannelPasswordInvalid);
|
||||
@@ -30,7 +29,7 @@ public sealed partial class ChannelService
|
||||
|
||||
public IReadOnlyList<ChannelInvitation> Invitations(Id16 id, Credentials creds) => store.Read(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
|
||||
RequireOwner(principal);
|
||||
return tx.ListInvitations(id);
|
||||
});
|
||||
@@ -41,12 +40,11 @@ public sealed partial class ChannelService
|
||||
{
|
||||
store.Write(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
|
||||
RequireOwner(principal);
|
||||
if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden);
|
||||
tx.DeleteInvitation(id, username);
|
||||
tx.DeleteKeyPackage(id, username);
|
||||
tx.DeleteAccountSeats(id, username);
|
||||
tx.DeleteChannelMember(id, username);
|
||||
var crypto = tx.GetWorkspaceCrypto(id);
|
||||
crypto.Access.RemoveAll(a => a.Username == username);
|
||||
@@ -61,26 +59,20 @@ public sealed partial class ChannelService
|
||||
|
||||
public IReadOnlyList<ChannelMember> Members(Id16 id, Credentials creds) => store.Read(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
if (principal.Role != Role.Owner) _ = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
|
||||
if (principal.Role != Role.Owner) _ = Authenticate(tx, id, creds, Membership.Required);
|
||||
return tx.ListChannelMembers(id);
|
||||
});
|
||||
|
||||
public IReadOnlyList<ChannelKeyPackage> AccountChannels(string username) =>
|
||||
store.Read(tx => tx.ListUserKeyPackages(username, clock.NowMs));
|
||||
|
||||
public void ReleaseAccountDevice(string username, string deviceId)
|
||||
{
|
||||
var channels = store.Write(tx => tx.DeleteDeviceSeats(username, deviceId));
|
||||
foreach (var id in channels) events.MembersChanged(id);
|
||||
}
|
||||
|
||||
public void SaveOwnerKeyPackage(Id16 id, Credentials creds, string payload)
|
||||
{
|
||||
if (payload is null || payload.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest);
|
||||
store.Write(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
|
||||
RequireOwner(principal);
|
||||
tx.PutKeyPackage(id, principal.AccountName!, payload, clock.NowMs);
|
||||
return 0;
|
||||
|
||||
@@ -4,7 +4,7 @@ public sealed partial class ChannelService
|
||||
{
|
||||
public InvitationCryptoPlan InvitationPlan(Id16 id, Credentials creds, string username) => store.Read(tx =>
|
||||
{
|
||||
var (space, principal) = Authenticate(tx, id, creds, SeatRule.None); RequireOwner(principal);
|
||||
var (space, principal) = Authenticate(tx, id, creds, Membership.None); RequireOwner(principal);
|
||||
if (tx.GetChannelMember(id, username) is not null || tx.HasInvitation(id, username)) throw new WebDropException(ErrorCode.Conflict);
|
||||
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
var channelId = workspace.DefaultChannelId ?? throw new WebDropException(ErrorCode.Conflict);
|
||||
@@ -43,7 +43,7 @@ public sealed partial class ChannelService
|
||||
if (payload is null || payload.Length is < 1 or > 16_384 || envelopes is null) throw new WebDropException(ErrorCode.BadRequest);
|
||||
store.Write(tx =>
|
||||
{
|
||||
var (space, principal) = Authenticate(tx, id, creds, SeatRule.None); RequireOwner(principal);
|
||||
var (space, principal) = Authenticate(tx, id, creds, Membership.None); RequireOwner(principal);
|
||||
if (tx.GetChannelMember(id, username) is not null || tx.HasInvitation(id, username)) throw new WebDropException(ErrorCode.Conflict);
|
||||
if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden);
|
||||
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
@@ -136,7 +136,7 @@ public sealed partial class ChannelService
|
||||
}
|
||||
public CryptoState ReadCrypto(Id16 id, Credentials creds) => store.Read(tx =>
|
||||
{
|
||||
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||
var state = tx.GetWorkspaceCrypto(id);
|
||||
if (principal.Role == Role.Owner || principal.AccountName == space.CreatorAccount) return state;
|
||||
var access = state.Access.Where(a => a.Username == principal.AccountName).ToList();
|
||||
@@ -154,7 +154,7 @@ public sealed partial class ChannelService
|
||||
{
|
||||
store.Write(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required); RequireOwner(principal);
|
||||
var (_, principal) = Authenticate(tx, id, creds, Membership.Required); RequireOwner(principal);
|
||||
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
var channel = workspace.Channels.Find(c => c.Id == req.ChannelId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
if (!Enum.IsDefined(req.Domain) || req.Envelopes is null || req.Envelopes.Count != 1 || req.Epoch != 1) throw new WebDropException(ErrorCode.BadRequest);
|
||||
@@ -177,7 +177,7 @@ public sealed partial class ChannelService
|
||||
}
|
||||
public ObjectEnvelope DeclareObject(Id16 id, Credentials creds, ObjectEnvelope req) => store.Write(tx =>
|
||||
{
|
||||
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||
if (!ChannelPolicy.CanWrite(space, principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
if (!Id16.TryParse(req.ItemId, out _) || !Enum.IsDefined(req.Domain) || req.KeyEnvelope is null || req.KeyEnvelope.Length > 1024) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
@@ -202,7 +202,7 @@ public sealed partial class ChannelService
|
||||
{
|
||||
store.Write(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required); RequireOwner(principal);
|
||||
var (_, principal) = Authenticate(tx, id, creds, Membership.Required); RequireOwner(principal);
|
||||
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
var channel = workspace.Channels.Find(c => c.Id == channelId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
channel.ChatHistory = chat; channel.FilesHistory = files; workspace.Revision++; tx.PutWorkspace(id, workspace); return 0;
|
||||
|
||||
@@ -19,9 +19,7 @@ public static class ChannelPolicy
|
||||
return channel.Settings.DeletePolicy switch
|
||||
{
|
||||
DeletePolicy.Anyone => true,
|
||||
DeletePolicy.Own => item.AuthorAccount is not null
|
||||
? item.AuthorAccount == p.AccountName
|
||||
: item.AuthorHash.AsSpan().SequenceEqual(p.EndpointHash),
|
||||
DeletePolicy.Own => item.AuthorAccount == p.AccountName,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,11 +13,12 @@ public sealed partial class ChannelService(
|
||||
ServerLimits limits,
|
||||
IChannelEvents events)
|
||||
{
|
||||
private enum SeatRule { None, Required }
|
||||
/// <summary>Required:必须已是频道成员;None:只验证身份(加入、查看信息等)。</summary>
|
||||
private enum Membership { None, Required }
|
||||
|
||||
// ───────────────────────── 频道 ─────────────────────────
|
||||
|
||||
public ChannelInfo CreateChannel(CreateChannelRequest req, byte[] endpointHash, string creatorAccount, string? accountDeviceId = null)
|
||||
public ChannelInfo CreateChannel(CreateChannelRequest req, string creatorAccount)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(creatorAccount)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
if (req.KeyPackage is not null && req.KeyPackage.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest);
|
||||
@@ -66,7 +67,7 @@ public sealed partial class ChannelService(
|
||||
|
||||
public ChannelInfo GetChannel(Id16 id, Credentials creds) => store.Read(tx =>
|
||||
{
|
||||
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
var (channel, principal) = Authenticate(tx, id, creds, Membership.None);
|
||||
return ToInfo(channel, tx.CountMembers(id), principal);
|
||||
});
|
||||
|
||||
@@ -74,7 +75,7 @@ public sealed partial class ChannelService(
|
||||
{
|
||||
var info = store.Write(tx =>
|
||||
{
|
||||
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
var (channel, principal) = Authenticate(tx, id, creds, Membership.None);
|
||||
RequireOwner(principal);
|
||||
|
||||
var updated = patch.ApplyTo(channel.Settings);
|
||||
@@ -100,7 +101,7 @@ public sealed partial class ChannelService(
|
||||
{
|
||||
store.Write(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
|
||||
RequireOwner(principal);
|
||||
tx.DeleteChannelRows(id);
|
||||
return 0;
|
||||
@@ -111,62 +112,27 @@ public sealed partial class ChannelService(
|
||||
|
||||
// ───────────────────────── 席位 ─────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 加入频道。账号成员只登记成员关系(同一账号的多台设备不增加人数,也不登记终端);
|
||||
/// 未登录访客登记终端,每个终端按一个访客成员计入上限。
|
||||
/// </summary>
|
||||
/// <summary>加入频道。只有已登录的账号能加入:登记成员关系,同一账号的多台设备不增加人数。</summary>
|
||||
public JoinResult Join(Id16 id, Credentials creds, string? password = null)
|
||||
{
|
||||
var (joined, isNew) = store.Write(tx =>
|
||||
{
|
||||
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
var (channel, _) = Authenticate(tx, id, creds, Membership.None);
|
||||
string account = creds.AccountName!;
|
||||
int membersUsed = tx.CountMembers(id);
|
||||
JoinResult Result(long joinedAt, int used) => new() { JoinedAt = joinedAt, MembersUsed = used, MembersMax = channel.Settings.MaxMembers };
|
||||
|
||||
if (creds.AccountName is not null)
|
||||
{
|
||||
if (tx.GetChannelMember(id, creds.AccountName) is { } existingMember) return (Result(existingMember.JoinedAt, membersUsed), false);
|
||||
CheckAdmission(tx, channel, creds, password);
|
||||
if (membersUsed >= channel.Settings.MaxMembers) throw new WebDropException(ErrorCode.MembersFull);
|
||||
long memberAt = clock.NowMs;
|
||||
tx.PutChannelMember(id, new ChannelMember(creds.AccountName, "member", memberAt));
|
||||
return (Result(memberAt, membersUsed + 1), true);
|
||||
}
|
||||
|
||||
if (tx.GetSeat(id, principal.EndpointHash) is { } existing) return (Result(existing.JoinedAt, membersUsed), false);
|
||||
if (tx.GetChannelMember(id, account) is { } existingMember) return (Result(existingMember.JoinedAt, membersUsed), false);
|
||||
CheckAdmission(tx, channel, creds, password);
|
||||
if (membersUsed >= channel.Settings.MaxMembers) throw new WebDropException(ErrorCode.MembersFull);
|
||||
long now = clock.NowMs;
|
||||
tx.InsertSeat(id, new Seat(principal.EndpointHash, now));
|
||||
return (Result(now, membersUsed + 1), true);
|
||||
long memberAt = clock.NowMs;
|
||||
tx.PutChannelMember(id, new ChannelMember(account, "member", memberAt));
|
||||
return (Result(memberAt, membersUsed + 1), true);
|
||||
});
|
||||
if (isNew) events.MembersChanged(id);
|
||||
return joined;
|
||||
}
|
||||
|
||||
/// <summary>所有者查看访客(未登录终端)。账号成员按用户管理,其设备不对外列出。</summary>
|
||||
public IReadOnlyList<GuestInfo> ListGuests(Id16 id, Credentials creds) => store.Read(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
RequireOwner(principal);
|
||||
return (IReadOnlyList<GuestInfo>)tx.ListSeats(id).Where(s => s.AccountName is null)
|
||||
.Select(s => new GuestInfo { EndpointHash = Codec.Encode(s.EndpointHash), JoinedAt = s.JoinedAt }).ToList();
|
||||
});
|
||||
|
||||
/// <summary>所有者移除一个访客终端并禁止它再加入。账号成员改用撤销邀请(RevokeInvitation)。</summary>
|
||||
public void ReleaseGuest(Id16 id, Credentials creds, byte[] endpointHash)
|
||||
{
|
||||
store.Write(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
RequireOwner(principal);
|
||||
if (tx.GetSeat(id, endpointHash) is not { AccountName: null } || !tx.DeleteSeat(id, endpointHash)) throw new WebDropException(ErrorCode.NotFound);
|
||||
tx.DenyEndpoint(id, endpointHash);
|
||||
return 0;
|
||||
});
|
||||
events.MembersChanged(id);
|
||||
}
|
||||
|
||||
// ───────────────────────── 追加帧 ─────────────────────────
|
||||
|
||||
public async Task<AppendResult> AppendFrameAsync(
|
||||
@@ -182,7 +148,7 @@ public sealed partial class ChannelService(
|
||||
// 1. 预检:鉴权并处理幂等重发,避免为重复帧白写 blob
|
||||
var existing = store.Read(tx =>
|
||||
{
|
||||
var (channel, principal) = Authenticate(tx, channelId, creds, SeatRule.Required);
|
||||
var (channel, principal) = Authenticate(tx, channelId, creds, Membership.Required);
|
||||
CheckWritable(channel, principal, env);
|
||||
CheckObjectWrite(tx, channel, principal, env);
|
||||
return tx.GetFrame(channelId, env.ItemId, env.FrameIndex);
|
||||
@@ -228,7 +194,7 @@ public sealed partial class ChannelService(
|
||||
IResourceTx tx, Id16 channelId, Credentials creds, Envelope env, ReadOnlyMemory<byte> body,
|
||||
byte[] digest, string? blobKey, long? maxDownloads, SignedContentEventProof? signedEvent)
|
||||
{
|
||||
var (channel, principal) = Authenticate(tx, channelId, creds, SeatRule.Required);
|
||||
var (channel, principal) = Authenticate(tx, channelId, creds, Membership.Required);
|
||||
CheckWritable(channel, principal, env);
|
||||
CheckObjectWrite(tx, channel, principal, env);
|
||||
|
||||
@@ -291,7 +257,7 @@ public sealed partial class ChannelService(
|
||||
if (item is not null) throw new WebDropException(ErrorCode.BadRequest);
|
||||
long max = env.IsGated ? maxDownloads ?? channel.Settings.FileMaxDownloads : 0;
|
||||
if (env.IsGated && !ServerSettingChoices.IsFileMaxDownloadsAllowed(max, channel.Settings.FileMaxDownloads)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
item = new Item(channelId, env.ItemId, principal.EndpointHash, env.ItemFlags, max, 0, 0, -1, null, 0,
|
||||
item = new Item(channelId, env.ItemId, principal.GrantKey, env.ItemFlags, max, 0, 0, -1, null, 0,
|
||||
now, env.IsControl ? long.MaxValue : now + ttlMs, null, principal.AccountName);
|
||||
tx.InsertItem(item);
|
||||
}
|
||||
@@ -417,7 +383,7 @@ public sealed partial class ChannelService(
|
||||
limit = Math.Clamp(limit, 1, 1000);
|
||||
var (pending, lastSeq) = store.Read(tx =>
|
||||
{
|
||||
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
var (channel, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||
if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
|
||||
// 续传点早于已被清理的墓碑:客户端可能漏掉删除事件,必须从 0 全量同步
|
||||
@@ -463,7 +429,7 @@ public sealed partial class ChannelService(
|
||||
{
|
||||
store.Write(tx =>
|
||||
{
|
||||
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||
if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
if (!CanReadObject(tx, space, principal, itemId)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
@@ -498,7 +464,7 @@ public sealed partial class ChannelService(
|
||||
maxFrames = Math.Clamp(maxFrames, 1, 64);
|
||||
var (frames, next, complete) = store.Read(tx =>
|
||||
{
|
||||
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||
if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
if (!CanReadObject(tx, space, principal, itemId)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
@@ -535,7 +501,7 @@ public sealed partial class ChannelService(
|
||||
|
||||
public ObjectEnvelope? ObjectSigningContext(Id16 id, Credentials creds, Id16 itemId) => store.Read(tx =>
|
||||
{
|
||||
_ = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
_ = Authenticate(tx, id, creds, Membership.Required);
|
||||
return tx.GetWorkspaceCrypto(id).Objects.Find(o => o.ItemId == itemId.ToBase64Url());
|
||||
});
|
||||
|
||||
@@ -543,7 +509,7 @@ public sealed partial class ChannelService(
|
||||
{
|
||||
var (seq, prefixes) = store.Write(tx =>
|
||||
{
|
||||
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
var (channel, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
if (!ChannelPolicy.CanDeleteItem(channel, item, principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
|
||||
@@ -671,22 +637,18 @@ public sealed partial class ChannelService(
|
||||
|
||||
// ───────────────────────── 鉴权 ─────────────────────────
|
||||
|
||||
private (Channel Channel, Principal Principal) Authenticate(IResourceTx tx, Id16 id, Credentials creds, SeatRule seat)
|
||||
private (Channel Channel, Principal Principal) Authenticate(IResourceTx tx, Id16 id, Credentials creds, Membership seat)
|
||||
{
|
||||
// 频道强制登录:没有账号会话的请求一律拒绝(便捷分享不受影响)
|
||||
string account = creds.AccountName ?? throw new WebDropException(ErrorCode.AccountRequired);
|
||||
var channel = tx.GetChannel(id);
|
||||
if (channel is null || channel.ExpiresAt <= clock.NowMs) throw new WebDropException(ErrorCode.NotFound);
|
||||
|
||||
var role = VerifyRole(channel, creds);
|
||||
var member = creds.AccountName is null ? null : tx.GetChannelMember(id, creds.AccountName);
|
||||
var member = tx.GetChannelMember(id, account);
|
||||
if (role == Role.Owner && member?.Role != "owner") throw new WebDropException(ErrorCode.AccountRequired);
|
||||
if (seat == SeatRule.Required && member is null)
|
||||
{
|
||||
// 账号成员只凭账号会话鉴权,不再需要终端登记;未登录访客必须先有终端登记
|
||||
var endpoint = tx.GetSeat(id, creds.EndpointHash);
|
||||
if (endpoint is null) throw new WebDropException(ErrorCode.NotJoined);
|
||||
if (endpoint.AccountName is not null) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
}
|
||||
return (channel, new Principal(role, creds.EndpointHash, creds.AccountName));
|
||||
if (seat == Membership.Required && member is null) throw new WebDropException(ErrorCode.NotJoined);
|
||||
return (channel, new Principal(role, account));
|
||||
}
|
||||
|
||||
private static Role VerifyRole(Channel channel, Credentials creds)
|
||||
@@ -715,7 +677,7 @@ public sealed partial class ChannelService(
|
||||
|
||||
private static ChannelInfo ToInfo(Channel channel, int membersUsed, Principal? principal = null) => new()
|
||||
{
|
||||
CanUpload = ChannelPolicy.CanWrite(channel, principal ?? new Principal(Role.Owner, [], channel.CreatorAccount)),
|
||||
CanUpload = ChannelPolicy.CanWrite(channel, principal ?? new Principal(Role.Owner, channel.CreatorAccount)),
|
||||
CanManageStructure = principal is null || principal.Role == Role.Owner,
|
||||
Id = channel.Id.ToBase64Url(),
|
||||
CreatorAccount = channel.CreatorAccount,
|
||||
|
||||
@@ -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));
|
||||
return ToInfo(channel, tx.CountMembers(id), new Principal(role, username));
|
||||
}).ToList());
|
||||
private static WorkspaceState NewWorkspace(string username, long now)
|
||||
{
|
||||
@@ -19,7 +19,7 @@ public sealed partial class ChannelService
|
||||
}
|
||||
public WorkspaceState GetWorkspace(Id16 id, Credentials creds) => store.Read(tx =>
|
||||
{
|
||||
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||
var state = tx.GetWorkspace(id) ?? new WorkspaceState();
|
||||
if (principal.AccountName != space.CreatorAccount)
|
||||
{
|
||||
@@ -42,8 +42,7 @@ public sealed partial class ChannelService
|
||||
{
|
||||
var state = store.Write(tx =>
|
||||
{
|
||||
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
if (requireAccount && principal.AccountName is null) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
var (channel, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||
if (requireAccount && principal.Role != Role.Owner) throw new WebDropException(ErrorCode.Forbidden);
|
||||
if (!ChannelPolicy.CanWrite(channel, principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
var current = tx.GetWorkspace(id) ?? new WorkspaceState();
|
||||
@@ -97,7 +96,7 @@ public sealed partial class ChannelService
|
||||
if (!Id16.TryParse(itemId, out var parsed)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var item = tx.GetItem(id, parsed) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
if ((item.Flags & (FrameFlags.Control | FrameFlags.Snapshot)) != 0) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (principal.Role != Role.Owner && (item.AuthorAccount is not null ? item.AuthorAccount != principal.AccountName : !item.AuthorHash.AsSpan().SequenceEqual(principal.EndpointHash))) throw new WebDropException(ErrorCode.Forbidden);
|
||||
if (principal.Role != Role.Owner && item.AuthorAccount != principal.AccountName) throw new WebDropException(ErrorCode.Forbidden);
|
||||
state.Locations.RemoveAll(l => l.ItemId == itemId);
|
||||
state.Locations.Add(new WorkspaceLocation { ItemId = itemId, ThreadId = threadId, FolderId = folderId, AuthorAccount = item.AuthorAccount });
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ public sealed class ChannelInfo
|
||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||
[JsonPropertyName("mode")] public required ChannelMode Mode { get; init; }
|
||||
[JsonPropertyName("settings")] public required ChannelSettings Settings { get; init; }
|
||||
/// <summary>v5: 账号成员数加匿名访客终端数,不统计账号设备数。</summary>
|
||||
/// <summary>频道成员数(账号),不统计账号设备数。</summary>
|
||||
[JsonPropertyName("members_used")] public required int MembersUsed { get; init; }
|
||||
|
||||
/// <summary>从该 seq 起的续传有效;0 < from < oldest_seq 的续传会得到 410,需从 0 全量同步。</summary>
|
||||
@@ -78,13 +78,6 @@ public sealed class JoinResult
|
||||
[JsonPropertyName("members_max")] public required int MembersMax { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>未登录访客的一次加入(终端)。账号成员按用户管理,不在这里出现。</summary>
|
||||
public sealed class GuestInfo
|
||||
{
|
||||
[JsonPropertyName("endpoint_hash")] public required string EndpointHash { get; init; }
|
||||
[JsonPropertyName("joined_at")] public required long JoinedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AppendResult
|
||||
{
|
||||
[JsonPropertyName("seq")] public required long Seq { get; init; }
|
||||
|
||||
@@ -82,8 +82,6 @@ public sealed record Item(
|
||||
public bool IsComplete => FinalIndex is { } f && FrameCount == f + 1;
|
||||
}
|
||||
|
||||
/// <summary>频道内的终端登记(账号设备或访客终端)。设备只是账号的属性,不是成员;不再有单独的设备名。</summary>
|
||||
public sealed record Seat(byte[] EndpointHash, long JoinedAt, string? AccountName = null, string? AccountDeviceId = null);
|
||||
public sealed record ChannelInvitation(Id16 ChannelId, string Username, string Payload, long CreatedAt);
|
||||
public sealed record ChannelMember(string Username, string Role, long JoinedAt);
|
||||
public sealed record ChannelKeyPackage(Id16 ChannelId, string Username, string Role, string Payload, long CreatedAt);
|
||||
@@ -101,13 +99,11 @@ public sealed record StoredFrame(
|
||||
|
||||
public sealed record LogEntry(long Seq, LogKind Kind, Id16 ItemId, uint FrameIndex, byte Aux, long CreatedAt);
|
||||
|
||||
/// <summary>一次请求验证通过后的身份。</summary>
|
||||
public sealed record Principal(Role Role, byte[] EndpointHash, string? AccountName = null)
|
||||
/// <summary>一次请求验证通过后的身份。频道只接受已登录账号。</summary>
|
||||
public sealed record Principal(Role Role, string AccountName)
|
||||
{
|
||||
/// <summary>受限下载的计数单位:账号按用户计(设备只是账号的属性),访客按终端计。</summary>
|
||||
public byte[] GrantKey => AccountName is null
|
||||
? EndpointHash
|
||||
: System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes("webdrop/grant/v1/" + AccountName));
|
||||
/// <summary>受限下载的计数单位:按用户计(设备只是账号的属性)。</summary>
|
||||
public byte[] GrantKey => System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes("webdrop/grant/v1/" + AccountName));
|
||||
}
|
||||
|
||||
/// <summary>请求携带的凭据,尚未验证。</summary>
|
||||
|
||||
@@ -31,7 +31,6 @@ public static class ServerSettingChoices
|
||||
public sealed class ChannelSettings
|
||||
{
|
||||
[JsonPropertyName("join_policy")] public JoinPolicy JoinPolicy { get; set; } = JoinPolicy.Open;
|
||||
[JsonPropertyName("allow_guests")] public bool AllowGuests { get; set; } = true;
|
||||
[JsonPropertyName("name")] public string? Name { get; set; }
|
||||
/// <summary>成员人数上限;线字段名保留为 seats 以兼容已有频道设置。</summary>
|
||||
[JsonPropertyName("max_members")] public int MaxMembers { get; set; } = 2;
|
||||
@@ -56,7 +55,6 @@ public sealed class ChannelSettings
|
||||
public sealed class ChannelSettingsPatch
|
||||
{
|
||||
[JsonPropertyName("join_policy")] public JoinPolicy? JoinPolicy { get; init; }
|
||||
[JsonPropertyName("allow_guests")] public bool? AllowGuests { get; init; }
|
||||
[JsonPropertyName("join_password")] public string? JoinPassword { get; init; }
|
||||
[JsonPropertyName("name")] public string? Name { get; init; }
|
||||
[JsonPropertyName("max_members")] public int? MaxMembers { get; init; }
|
||||
@@ -70,7 +68,6 @@ public sealed class ChannelSettingsPatch
|
||||
public ChannelSettings ApplyTo(ChannelSettings s) => new()
|
||||
{
|
||||
JoinPolicy = JoinPolicy ?? s.JoinPolicy,
|
||||
AllowGuests = AllowGuests ?? s.AllowGuests,
|
||||
Name = Name is null ? s.Name : string.IsNullOrWhiteSpace(Name) ? null : Name.Trim(),
|
||||
MaxMembers = MaxMembers ?? s.MaxMembers,
|
||||
MembersCanUpload = MembersCanUpload ?? s.MembersCanUpload,
|
||||
@@ -118,7 +115,7 @@ public static class CreationDefaults
|
||||
|
||||
public static ChannelSettings Channel(ServerLimits limits) => new()
|
||||
{
|
||||
JoinPolicy = JoinPolicy.Invite, AllowGuests = false,
|
||||
JoinPolicy = JoinPolicy.Invite,
|
||||
MaxMembers = Math.Min(10, limits.MaxMembers),
|
||||
TtlSeconds = Math.Clamp(86_400, limits.MinTtlSeconds, limits.MaxTtlSeconds),
|
||||
MaxItems = Math.Min(1000, limits.MaxItemsLimit),
|
||||
|
||||
Reference in new issue
Block a user