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:
justme committed 2026-10-16 06:46:15 +08:00
1 parent b1ac11e1d7
commit c858bd2a09
31 files changed
+194 -358

No files matched your search

@@ -32,11 +32,9 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
_writeConn = Open();
Exec(_writeConn, "PRAGMA journal_mode=WAL;");
InitializeSchema();
using (var columns = _writeConn.CreateCommand())
{
columns.CommandText = "SELECT COUNT(*) FROM pragma_table_info('seats') WHERE name='account_device_id'";
if ((long)columns.ExecuteScalar()! == 0) Exec(_writeConn, "ALTER TABLE seats ADD COLUMN account_device_id TEXT;");
}
// v8: 频道只接受登录账号,不再有访客席位;丢弃旧的席位表和针对终端的拒绝记录(幂等)
Exec(_writeConn, "DROP TABLE IF EXISTS seats;");
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;");
// Additive table: retain the running v2 database and its existing channel data.
@@ -131,15 +129,6 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
);
CREATE INDEX ix_channels_expires ON channels(expires_at);
CREATE TABLE seats (
channel_id BLOB NOT NULL,
endpoint_hash BLOB NOT NULL,
label BLOB,
account_name TEXT,
joined_at INTEGER NOT NULL,
PRIMARY KEY (channel_id, endpoint_hash)
) WITHOUT ROWID;
CREATE TABLE items (
channel_id BLOB NOT NULL,
item_id BLOB NOT NULL,
@@ -473,16 +462,14 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
WHERE k.username=@u AND c.expires_at>@now
""", r => new ChannelKeyPackage(IdAt(r, 0), r.GetString(1), r.GetString(2), r.GetString(3), r.GetInt64(4)),
("@u", username), ("@now", now));
public void DeleteAccountSeats(Id16 id, string username) => Run("DELETE FROM seats WHERE channel_id=@id AND account_name=@u", ("@id", id.ToArray()), ("@u", username));
private static ChannelMember MapMember(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), r.GetInt64(2));
public ChannelMember? GetChannelMember(Id16 id, string username) => One("SELECT username,role,joined_at FROM channel_members WHERE channel_id=@id AND username=@u", MapMember, ("@id", id.ToArray()), ("@u", username));
public IReadOnlyList<ChannelMember> ListChannelMembers(Id16 id) => Many("SELECT username,role,joined_at FROM channel_members WHERE channel_id=@id ORDER BY joined_at,username", MapMember, ("@id", id.ToArray()));
public void PutChannelMember(Id16 id, ChannelMember member) => Run("INSERT OR IGNORE INTO channel_members(channel_id,username,role,joined_at) VALUES(@id,@u,@r,@t)", ("@id", id.ToArray()), ("@u", member.Username), ("@r", member.Role), ("@t", member.JoinedAt));
public void DeleteChannelMember(Id16 id, string username) => Run("DELETE FROM channel_members WHERE channel_id=@id AND username=@u AND role<>'owner'", ("@id", id.ToArray()), ("@u", username));
public bool IsJoinDenied(Id16 id, string? username, byte[] endpointHash) => Scalar("SELECT 1 FROM channel_join_denials WHERE channel_id=@id AND (target=@a OR target=@e) LIMIT 1", ("@id", id.ToArray()), ("@a", username is null ? null : "account:" + username), ("@e", "endpoint:" + Codec.Encode(endpointHash))) is not null;
public bool IsJoinDenied(Id16 id, string username) => Scalar("SELECT 1 FROM channel_join_denials WHERE channel_id=@id AND target=@a LIMIT 1", ("@id", id.ToArray()), ("@a", "account:" + username)) is not null;
public void DenyAccount(Id16 id, string username) => Run("INSERT OR IGNORE INTO channel_join_denials VALUES(@id,@t)", ("@id", id.ToArray()), ("@t", "account:" + username));
public void AllowAccount(Id16 id, string username) => Run("DELETE FROM channel_join_denials WHERE channel_id=@id AND target=@t", ("@id", id.ToArray()), ("@t", "account:" + username));
public void DenyEndpoint(Id16 id, byte[] endpointHash) => Run("INSERT OR IGNORE INTO channel_join_denials VALUES(@id,@t)", ("@id", id.ToArray()), ("@t", "endpoint:" + Codec.Encode(endpointHash)));
public void AddChannelBytes(Id16 id, long delta) =>
Run("UPDATE channels SET bytes=bytes+@d WHERE id=@id", ("@d", delta), ("@id", id.ToArray()));
@@ -500,7 +487,6 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
Run("DELETE FROM frames WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM log WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM items WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM seats WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM channels WHERE id=@c", ("@c", key));
Run("DELETE FROM channel_invitations WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM channel_key_packages WHERE channel_id=@c", ("@c", key));
@@ -513,41 +499,10 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
public IReadOnlyList<Id16> ListExpiredChannels(long now, int limit) =>
Many("SELECT id FROM channels WHERE expires_at<=@now LIMIT @n", r => IdAt(r, 0), ("@now", now), ("@n", limit));
// ───────── 席位 ─────────
private static Seat MapSeat(SqliteDataReader r) => new(Blob(r, 0), r.GetInt64(1), r.IsDBNull(3) ? null : r.GetString(3), r.IsDBNull(4) ? null : r.GetString(4));
public Seat? GetSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash) => One(
"SELECT endpoint_hash, joined_at, label, account_name,account_device_id FROM seats WHERE channel_id=@c AND endpoint_hash=@e",
MapSeat, ("@c", channelId.ToArray()), ("@e", endpointHash.ToArray()));
public int CountSeats(Id16 channelId) =>
(int)(long)Scalar("SELECT COUNT(*) FROM seats WHERE channel_id=@c", ("@c", channelId.ToArray()))!;
// ───────── 成员 ─────────
public int CountMembers(Id16 channelId) =>
(int)(long)Scalar("SELECT (SELECT COUNT(*) FROM channel_members WHERE channel_id=@c) + (SELECT COUNT(*) FROM seats WHERE channel_id=@c AND account_name IS NULL)", ("@c", channelId.ToArray()))!;
public void InsertSeat(Id16 channelId, Seat seat) => Run(
"INSERT INTO seats (channel_id, endpoint_hash, joined_at,account_name,account_device_id) VALUES (@c,@e,@j,@a,@d)",
("@c", channelId.ToArray()), ("@e", seat.EndpointHash), ("@j", seat.JoinedAt), ("@a", seat.AccountName), ("@d", seat.AccountDeviceId));
public bool DeleteSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash) => Run(
"DELETE FROM seats WHERE channel_id=@c AND endpoint_hash=@e",
("@c", channelId.ToArray()), ("@e", endpointHash.ToArray())) > 0;
public IReadOnlyList<Seat> ListSeats(Id16 channelId) => Many(
"SELECT endpoint_hash, joined_at, label, account_name,account_device_id FROM seats WHERE channel_id=@c ORDER BY joined_at, endpoint_hash",
MapSeat, ("@c", channelId.ToArray()));
public IReadOnlyList<Id16> DeleteDeviceSeats(string username, string deviceId)
{
var ids = Many("SELECT DISTINCT channel_id FROM seats WHERE account_name=@u AND account_device_id=@d", r => IdAt(r, 0), ("@u", username), ("@d", deviceId));
Run("DELETE FROM seats WHERE account_name=@u AND account_device_id=@d", ("@u", username), ("@d", deviceId));
return ids;
}
public void BindSeatDevice(Id16 id, byte[] endpointHash, string deviceId) => Run(
"UPDATE seats SET account_device_id=@d WHERE channel_id=@c AND endpoint_hash=@e",
("@d", deviceId), ("@c", id.ToArray()), ("@e", endpointHash));
(int)(long)Scalar("SELECT COUNT(*) FROM channel_members WHERE channel_id=@c", ("@c", channelId.ToArray()))!;
// ───────── 条目 ─────────