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

+3 -2
View File
@@ -3,7 +3,8 @@ namespace WebDrop.Api;
public static class ApiContract
{
// v5: 频道容量按账号成员计;v7: seats 改名 max_members/members_used/members_full,
// 去掉设备名与设备清单接口,访客改走 /guests。设备只是账号的属性。
// 去掉设备名与设备清单接口。设备只是账号的属性。
// v8: 频道强制登录,去掉未登录访客(allow_guests、/guests、终端席位);便捷分享不受影响。
// Keep this aligned with contracts/api-contract.json and docs/openapi.yaml.
public const string Version = "7.0.0";
public const string Version = "8.0.0";
}
-1
View File
@@ -235,7 +235,6 @@ public sealed class WorkspaceGroupCreatedBody
[JsonSerializable(typeof(CreateChannelRequest))]
[JsonSerializable(typeof(ChannelSettingsPatch))]
[JsonSerializable(typeof(JoinResult))]
[JsonSerializable(typeof(List<GuestInfo>))]
[JsonSerializable(typeof(AppendResult))]
[JsonSerializable(typeof(AccountRegisterRequest))]
[JsonSerializable(typeof(AccountDeviceSigningRequest))]
+2 -9
View File
@@ -12,7 +12,7 @@ public sealed class ChannelHub : IChannelEvents
private readonly object _gate = new();
private readonly Dictionary<Id16, Signal> _signals = [];
// 在线状态:频道 -> 账号 -> 账号设备 -> 活动订阅数。只记账号,访客没有可跨设备识别的身份,不参与。
// 在线状态:频道 -> 账号 -> 账号设备 -> 活动订阅数。只记账号。
private readonly Dictionary<Id16, Dictionary<string, Dictionary<string, int>>> _presence = [];
public Subscription Subscribe(Id16 id)
@@ -26,9 +26,8 @@ public sealed class ChannelHub : IChannelEvents
}
/// <summary>登记一个活动订阅,释放返回值即下线。只供成员接口读取,不向订阅流推送,以免改变流的线格式。</summary>
public IDisposable Connected(Id16 id, string? account, string deviceKey)
public IDisposable Connected(Id16 id, string account, string deviceKey)
{
if (account is null) return NoPresence.Instance;
string endpoint = deviceKey;
lock (_gate)
{
@@ -67,12 +66,6 @@ public sealed class ChannelHub : IChannelEvents
public void Dispose() => Interlocked.Exchange(ref _release, null)?.Invoke();
}
private sealed class NoPresence : IDisposable
{
public static readonly NoPresence Instance = new();
public void Dispose() { }
}
public void Advanced(Id16 channelId, long seq) => Publish(channelId, s => s.Seq = Math.Max(s.Seq, seq));
public void SettingsChanged(Id16 channelId) => Publish(channelId, s => s.SettingsVersion++);
public void MembersChanged(Id16 channelId) => Publish(channelId, s => s.MembersVersion++);
@@ -101,7 +101,6 @@ public static partial class Endpoints
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
accounts.RevokeDevice(username, id);
svc.ReleaseAccountDevice(username, id);
return Results.NoContent();
});
@@ -50,7 +50,7 @@ public static partial class Endpoints
api.MapGet("/channels/{id}/members", (string id, HttpContext ctx, ChannelService svc, ChannelHub hub) =>
{
var channelId = ParseId(id);
var members = svc.Members(channelId, RequestAuth.FromHeaders(ctx.Request, true)).Select(member =>
var members = svc.Members(channelId, RequestAuth.FromHeaders(ctx.Request, false)).Select(member =>
{
var (online, onlineDevices) = hub.Presence(channelId, member.Username);
// 设备是账号的属性:总数取账号的登录设备,在线数取该账号在本频道有订阅的设备
@@ -14,49 +14,49 @@ public static partial class Endpoints
.Select(u => accounts.Profile(u!)).ToList(),
};
api.MapGet("/spaces/{id}/structure", (string id, HttpContext ctx, ChannelService svc) =>
Json(View(svc.GetWorkspace(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true))), ApiJson.Default.WorkspaceBody));
Json(View(svc.GetWorkspace(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false))), ApiJson.Default.WorkspaceBody));
api.MapPost("/spaces/{id}/channels", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceChannelRequest, ct);
var created = svc.CreateWorkspaceChannelResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.Name, req.GroupId);
var created = svc.CreateWorkspaceChannelResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Name, req.GroupId);
return Json(new WorkspaceChannelCreatedBody { Workspace = View(created.State), Channel = created.Channel }, ApiJson.Default.WorkspaceChannelCreatedBody, 201);
});
api.MapPatch("/spaces/{id}/channels/{channelId}", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
return Json(View(svc.RenameWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), channelId, req.Name)), ApiJson.Default.WorkspaceBody);
return Json(View(svc.RenameWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.Name)), ApiJson.Default.WorkspaceBody);
});
api.MapPut("/spaces/{id}/channels/{channelId}/group", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceGroupRequest, ct);
return Json(View(svc.GroupWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), channelId, req.GroupId)), ApiJson.Default.WorkspaceBody);
return Json(View(svc.GroupWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.GroupId)), ApiJson.Default.WorkspaceBody);
});
api.MapPost("/spaces/{id}/groups", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
var created = svc.CreateWorkspaceGroupResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.Name);
var created = svc.CreateWorkspaceGroupResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Name);
return Json(new WorkspaceGroupCreatedBody { Workspace = View(created.State), Group = created.Group }, ApiJson.Default.WorkspaceGroupCreatedBody, 201);
});
api.MapPatch("/spaces/{id}/groups/{groupId}", async (string id, string groupId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
return Json(View(svc.RenameWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), groupId, req.Name)), ApiJson.Default.WorkspaceBody);
return Json(View(svc.RenameWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), groupId, req.Name)), ApiJson.Default.WorkspaceBody);
});
api.MapPost("/spaces/{id}/folders", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceFolderRequest, ct);
return Json(View(svc.CreateWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.ThreadId, req.ParentId, req.Name)), ApiJson.Default.WorkspaceBody, 201);
return Json(View(svc.CreateWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.ThreadId, req.ParentId, req.Name)), ApiJson.Default.WorkspaceBody, 201);
});
api.MapPatch("/spaces/{id}/folders/{folderId}", async (string id, string folderId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
return Json(View(svc.RenameWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), folderId, req.Name)), ApiJson.Default.WorkspaceBody);
return Json(View(svc.RenameWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), folderId, req.Name)), ApiJson.Default.WorkspaceBody);
});
api.MapPut("/spaces/{id}/locations", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceMoveRequest, ct);
if (req.ItemIds is null) throw new WebDropException(ErrorCode.BadRequest);
return Json(View(svc.MoveWorkspaceItems(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.ItemIds, req.ThreadId, req.FolderId)), ApiJson.Default.WorkspaceBody);
return Json(View(svc.MoveWorkspaceItems(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.ItemIds, req.ThreadId, req.FolderId)), ApiJson.Default.WorkspaceBody);
});
}
}
@@ -12,21 +12,21 @@ public static partial class Endpoints
{
private static void MapWorkspaceCrypto(RouteGroupBuilder api)
{
api.MapGet("/spaces/{id}/crypto", (string id, HttpContext ctx, ChannelService svc) => Json(svc.ReadCrypto(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true)), ApiJson.Default.CryptoState));
api.MapGet("/spaces/{id}/crypto", (string id, HttpContext ctx, ChannelService svc) => Json(svc.ReadCrypto(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false)), ApiJson.Default.CryptoState));
api.MapPut("/spaces/{id}/crypto/domains", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.DomainPublishRequest, ct);
svc.PublishDomain(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req); return Results.NoContent();
svc.PublishDomain(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req); return Results.NoContent();
});
api.MapPost("/spaces/{id}/crypto/objects", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.ObjectEnvelope, ct);
return Json(svc.DeclareObject(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req), ApiJson.Default.ObjectEnvelope, 201);
return Json(svc.DeclareObject(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req), ApiJson.Default.ObjectEnvelope, 201);
});
api.MapPatch("/spaces/{id}/channels/{channelId}/history", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.ChannelHistoryRequest, ct);
svc.SetChannelHistory(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), channelId, req.ChatHistory, req.FilesHistory); return Results.NoContent();
svc.SetChannelHistory(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.ChatHistory, req.FilesHistory); return Results.NoContent();
});
}
}
+7 -19
View File
@@ -63,10 +63,9 @@ public static partial class Endpoints
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var req = await ReadJson(ctx.Request, ApiJson.Default.CreateChannelRequest, ct);
var endpoint = RequestAuth.ParseEndpointHash(ctx.Request.Headers[RequestAuth.EndpointHeader]);
string token = ctx.Request.Headers[RequestAuth.CaptchaHeader].ToString();
await captcha.VerifyAsync(token.Length > 0 ? token : null, ct);
return Json(svc.CreateChannel(req, endpoint, username, accounts.SessionDevice(ctx.Request.Headers["X-WD-Account"].ToString())), ApiJson.Default.ChannelInfo, StatusCodes.Status201Created);
return Json(svc.CreateChannel(req, username), ApiJson.Default.ChannelInfo, StatusCodes.Status201Created);
}).RequireRateLimiting(CreatePolicy);
api.MapGet("/channels/{id}", (string id, HttpContext ctx, ChannelService svc) =>
@@ -90,28 +89,17 @@ public static partial class Endpoints
api.MapPost("/channels/{id}/join", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var request = ctx.Request.ContentType?.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) == true ? await ReadJson(ctx.Request, ApiJson.Default.JoinRequest, ct) : new JoinRequest();
return Json(svc.Join(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true), request.Password), ApiJson.Default.JoinResult);
return Json(svc.Join(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), request.Password), ApiJson.Default.JoinResult);
});
MapChannelAccess(api, accounts);
// 访客是未登录的终端;账号成员只按用户管理(members / invitations),设备不对外列出
api.MapGet("/channels/{id}/guests", (string id, HttpContext ctx, ChannelService svc) =>
Json([.. svc.ListGuests(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false))], ApiJson.Default.ListGuestInfo));
api.MapDelete("/channels/{id}/guests/{endpointHash}", (string id, string endpointHash, HttpContext ctx, ChannelService svc) =>
{
if (!Codec.TryDecodeExact(endpointHash, TokenHash.Size, out var hash)) throw new WebDropException(ErrorCode.BadRequest);
svc.ReleaseGuest(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), hash);
return Results.NoContent();
});
// ───────── 帧与日志 ─────────
api.MapPost("/channels/{id}/frames", async (string id, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore, ServerLimits limits, CancellationToken ct) =>
{
var channelId = ParseId(id);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false);
long? maxDownloads = ParseMaxDownloads(ctx.Request.Headers["X-WD-Max-Downloads"].ToString());
byte[] body = await ReadBody(ctx.Request, Envelope.HeaderSize + limits.MaxPayloadBytes, ct);
@@ -123,7 +111,7 @@ public static partial class Endpoints
api.MapGet("/channels/{id}/log", async (string id, HttpContext ctx, ChannelService svc, ChannelHub hub, CancellationToken ct) =>
{
var channelId = ParseId(id);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false);
long from = QueryLong(ctx.Request, "from", 0);
int limit = (int)Math.Clamp(QueryLong(ctx.Request, "limit", 200), 1, 1000);
var wait = TimeSpan.FromSeconds(Math.Clamp(QueryLong(ctx.Request, "wait", 0), 0, 25));
@@ -171,7 +159,7 @@ public static partial class Endpoints
api.MapGet("/channels/{id}/events", async (string id, HttpContext ctx, ChannelService svc, ChannelHub hub) =>
{
var channelId = ParseId(id);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false);
long from = QueryLong(ctx.Request, "from", -1);
if (from < 0)
from = long.TryParse(ctx.Request.Headers["Last-Event-ID"].ToString(), out long last) && last >= 0 ? last + 1 : 0;
@@ -192,7 +180,7 @@ public static partial class Endpoints
{
var channelId = ParseId(id);
var item = ParseId(itemId);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false);
long from = QueryLong(ctx.Request, "from", 0);
if (from is < 0 or > uint.MaxValue) throw new WebDropException(ErrorCode.BadRequest);
@@ -220,7 +208,7 @@ public static partial class Endpoints
api.MapDelete("/channels/{id}/items/{itemId}", (string id, string itemId, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore) =>
{
var channelId = ParseId(id); var parsedItemId = ParseId(itemId);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false);
var proof = ParseSignedDeleteEvent(ctx.Request, accountStore, svc, channelId, parsedItemId, creds);
svc.DeleteItem(channelId, creds, parsedItemId, proof);
return Results.NoContent();
+5 -5
View File
@@ -31,7 +31,7 @@ public static class LogStreamer
ILogSink? sink = null;
IDisposable? presence = null;
long cursor = from;
var (settingsV, seatsV, _) = sub.State;
var (settingsV, membersV, _) = sub.State;
try
{
@@ -42,7 +42,7 @@ public static class LogStreamer
var page = await svc.ReadLogAsync(id, creds, cursor, PageSize, ct).ConfigureAwait(false);
sink ??= await open().ConfigureAwait(false);
// 鉴权与首次读取都成功之后才算在线
presence ??= hub.Connected(id, creds.AccountName, creds.AccountDeviceId ?? Convert.ToHexString(creds.EndpointHash));
presence ??= hub.Connected(id, creds.AccountName!, creds.AccountDeviceId ?? "");
foreach (var record in page.Records)
{
@@ -50,15 +50,15 @@ public static class LogStreamer
cursor = record.Seq + 1;
}
var (settingsNow, seatsNow, deleted) = sub.State;
var (settingsNow, membersNow, deleted) = sub.State;
if (settingsNow != settingsV)
{
settingsV = settingsNow;
await sink.SendControlAsync("""{"t":"settings"}""", ct).ConfigureAwait(false);
}
if (seatsNow != seatsV)
if (membersNow != membersV)
{
seatsV = seatsNow;
membersV = membersNow;
await sink.SendControlAsync("""{"t":"members"}""", ct).ConfigureAwait(false);
}
if (deleted) throw new WebDropException(ErrorCode.NotFound);
+2 -2
View File
@@ -35,7 +35,7 @@ public static class RequestAuth
deviceId = accounts.SessionDevice(accountToken);
}
if (role == Role.Owner && accountName is null) throw new WebDropException(ErrorCode.AccountRequired);
// 账号以会话鉴权,终端密钥只对未登录访客是必需的
// 账号以会话鉴权;终端密钥只用于便捷分享(未登录的创建者与读者)
return new Credentials(role, token, ParseEndpoint(req.Headers[EndpointHeader].ToString(), requireEndpoint && accountName is null), accountName, deviceId, accountName is not null);
}
@@ -69,7 +69,7 @@ public static class RequestAuth
}
var parsedRole = ParseRole(role);
if (parsedRole == Role.Owner && accountName is null) throw new WebDropException(ErrorCode.AccountRequired);
return new Credentials(parsedRole, token, ParseEndpoint(endpoint, requireEndpoint: accountName is null), accountName, deviceId, accountName is not null);
return new Credentials(parsedRole, token, ParseEndpoint(endpoint, requireEndpoint: false), accountName, deviceId, accountName is not null);
}
public static byte[] ParseEndpointHash(string? header) => ParseEndpoint(header, requireEndpoint: true);