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
@@ -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";
|
||||
}
|
||||
@@ -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))]
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user