From 42b93ae2d83ec4bc79311b046907f7147d27864e Mon Sep 17 00:00:00 2001 From: JustMe Date: Fri, 2 Oct 2026 00:59:56 +0800 Subject: [PATCH] feat(server): implement sealed share lifecycle Signed-off-by: JustMe --- server/Directory.Build.props | 15 + server/README.md | 59 ++ server/WebDrop.slnx | 11 + server/scripts/smoke.ps1 | 119 ++++ server/scripts/smoke.py | 124 ++++ server/src/WebDrop.Api/ApiJson.cs | 65 ++ server/src/WebDrop.Api/CapCaptchaVerifier.cs | 70 ++ server/src/WebDrop.Api/ChannelHub.cs | 104 +++ server/src/WebDrop.Api/Endpoints.cs | 409 +++++++++++ server/src/WebDrop.Api/LogStreamer.cs | 119 ++++ server/src/WebDrop.Api/Program.cs | 4 + .../Properties/launchSettings.json | 23 + server/src/WebDrop.Api/RequestAuth.cs | 80 +++ server/src/WebDrop.Api/SweepWorker.cs | 42 ++ server/src/WebDrop.Api/WebDrop.Api.csproj | 17 + server/src/WebDrop.Api/WebDropApp.cs | 173 +++++ server/src/WebDrop.Api/WebDropOptions.cs | 51 ++ server/src/WebDrop.Api/appsettings.json | 26 + server/src/WebDrop.Core/Abstractions.cs | 128 ++++ server/src/WebDrop.Core/Auth.cs | 19 + server/src/WebDrop.Core/ChannelPolicy.cs | 26 + server/src/WebDrop.Core/ChannelService.cs | 571 +++++++++++++++ server/src/WebDrop.Core/Dtos.cs | 125 ++++ server/src/WebDrop.Core/Enums.cs | 53 ++ server/src/WebDrop.Core/Envelope.cs | 50 ++ server/src/WebDrop.Core/Errors.cs | 75 ++ server/src/WebDrop.Core/Id16.cs | 91 +++ server/src/WebDrop.Core/Models.cs | 100 +++ server/src/WebDrop.Core/Settings.cs | 90 +++ server/src/WebDrop.Core/ShareService.cs | 359 ++++++++++ server/src/WebDrop.Core/WebDrop.Core.csproj | 9 + server/src/WebDrop.Storage/DiskBlobStore.cs | 90 +++ .../src/WebDrop.Storage/SqliteChannelStore.cs | 545 +++++++++++++++ .../WebDrop.Storage/WebDrop.Storage.csproj | 17 + server/tests/WebDrop.Api.Tests/CapTests.cs | 189 +++++ server/tests/WebDrop.Api.Tests/RestTests.cs | 432 ++++++++++++ .../tests/WebDrop.Api.Tests/ServerFixture.cs | 199 ++++++ server/tests/WebDrop.Api.Tests/ShareTests.cs | 139 ++++ .../tests/WebDrop.Api.Tests/StreamingTests.cs | 242 +++++++ .../WebDrop.Api.Tests.csproj | 31 + .../WebDrop.Core.Tests/ChannelServiceTests.cs | 654 ++++++++++++++++++ .../WebDrop.Core.Tests/PrimitivesTests.cs | 167 +++++ server/tests/WebDrop.Core.Tests/TestEnv.cs | 105 +++ .../tests/WebDrop.Core.Tests/VectorTests.cs | 279 ++++++++ .../WebDrop.Core.Tests.csproj | 31 + 45 files changed, 6327 insertions(+) create mode 100644 server/Directory.Build.props create mode 100644 server/README.md create mode 100644 server/WebDrop.slnx create mode 100644 server/scripts/smoke.ps1 create mode 100644 server/scripts/smoke.py create mode 100644 server/src/WebDrop.Api/ApiJson.cs create mode 100644 server/src/WebDrop.Api/CapCaptchaVerifier.cs create mode 100644 server/src/WebDrop.Api/ChannelHub.cs create mode 100644 server/src/WebDrop.Api/Endpoints.cs create mode 100644 server/src/WebDrop.Api/LogStreamer.cs create mode 100644 server/src/WebDrop.Api/Program.cs create mode 100644 server/src/WebDrop.Api/Properties/launchSettings.json create mode 100644 server/src/WebDrop.Api/RequestAuth.cs create mode 100644 server/src/WebDrop.Api/SweepWorker.cs create mode 100644 server/src/WebDrop.Api/WebDrop.Api.csproj create mode 100644 server/src/WebDrop.Api/WebDropApp.cs create mode 100644 server/src/WebDrop.Api/WebDropOptions.cs create mode 100644 server/src/WebDrop.Api/appsettings.json create mode 100644 server/src/WebDrop.Core/Abstractions.cs create mode 100644 server/src/WebDrop.Core/Auth.cs create mode 100644 server/src/WebDrop.Core/ChannelPolicy.cs create mode 100644 server/src/WebDrop.Core/ChannelService.cs create mode 100644 server/src/WebDrop.Core/Dtos.cs create mode 100644 server/src/WebDrop.Core/Enums.cs create mode 100644 server/src/WebDrop.Core/Envelope.cs create mode 100644 server/src/WebDrop.Core/Errors.cs create mode 100644 server/src/WebDrop.Core/Id16.cs create mode 100644 server/src/WebDrop.Core/Models.cs create mode 100644 server/src/WebDrop.Core/Settings.cs create mode 100644 server/src/WebDrop.Core/ShareService.cs create mode 100644 server/src/WebDrop.Core/WebDrop.Core.csproj create mode 100644 server/src/WebDrop.Storage/DiskBlobStore.cs create mode 100644 server/src/WebDrop.Storage/SqliteChannelStore.cs create mode 100644 server/src/WebDrop.Storage/WebDrop.Storage.csproj create mode 100644 server/tests/WebDrop.Api.Tests/CapTests.cs create mode 100644 server/tests/WebDrop.Api.Tests/RestTests.cs create mode 100644 server/tests/WebDrop.Api.Tests/ServerFixture.cs create mode 100644 server/tests/WebDrop.Api.Tests/ShareTests.cs create mode 100644 server/tests/WebDrop.Api.Tests/StreamingTests.cs create mode 100644 server/tests/WebDrop.Api.Tests/WebDrop.Api.Tests.csproj create mode 100644 server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs create mode 100644 server/tests/WebDrop.Core.Tests/PrimitivesTests.cs create mode 100644 server/tests/WebDrop.Core.Tests/TestEnv.cs create mode 100644 server/tests/WebDrop.Core.Tests/VectorTests.cs create mode 100644 server/tests/WebDrop.Core.Tests/WebDrop.Core.Tests.csproj diff --git a/server/Directory.Build.props b/server/Directory.Build.props new file mode 100644 index 0000000..ab30bea --- /dev/null +++ b/server/Directory.Build.props @@ -0,0 +1,15 @@ + + + net10.0 + latest + enable + enable + true + true + + + + + true + + diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..47d8548 --- /dev/null +++ b/server/README.md @@ -0,0 +1,59 @@ +# WebDrop 服务端 + +.NET 10,NativeAOT。设计见 [../docs](../docs),接口契约见 [../docs/openapi.yaml](../docs/openapi.yaml)。 + +## 结构 + +| 项目 | 职责 | +|---|---| +| `src/WebDrop.Core` | 领域类型、规则和业务逻辑(`ChannelService`、`ShareService`),不做 IO,只依赖抽象 | +| `src/WebDrop.Storage` | SQLite 存储(`SqliteChannelStore`)和磁盘 blob(`DiskBlobStore`) | +| `src/WebDrop.Api` | Minimal API、WebSocket / SSE / 长轮询、订阅枢纽、后台清理、限流、安全头 | +| `tests/` | `Core.Tests`(单元与服务层,真实 SQLite)、`Api.Tests`(真实 Kestrel 上的集成测试) | + +## 常用命令 + +```powershell +dotnet test # 全部测试 +dotnet run --project src/WebDrop.Api # 开发运行,默认 http://localhost:5000 + +# NativeAOT 发布(Windows 需要 vswhere.exe 在 PATH 里) +$env:PATH += ";${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer" +dotnet publish src/WebDrop.Api -c Release -r win-x64 -o out/win-x64 +pwsh scripts/smoke.ps1 -Exe out/win-x64/webdrop.exe # 对原生产物做端到端冒烟(含 WebSocket) +python3 scripts/smoke.py out/linux-x64/webdrop # 跨平台版本,只用标准库,不含 WebSocket +``` + +## 配置 + +配置节 `WebDrop`,环境变量写法 `WebDrop__DataDir`、`WebDrop__Captcha__Provider`。完整默认值见 `src/WebDrop.Api/appsettings.json`。 + +| 键 | 默认 | 说明 | +|---|---|---| +| `DataDir` | `data` | SQLite 数据库和 blob 目录 | +| `CorsOrigins` | 空 | 前端所在的源;前端托管在独立域名,必须列出,否则不发 CORS 头 | +| `TrustForwardedHeaders` | false | 在反向代理之后开启,限流按真实 IP 分区 | +| `EnableHttp3` | false | 仅在 `/info` 里通告,服务进程不内置 QUIC | +| `RequestsPerMinute` / `ChannelCreatesPerMinute` | 600 / 30 | 按 IP 的限流,只在内存里 | +| `ShareDraftTtlSeconds` | 86400 | 未完成分享草稿的保留时间 | +| `Captcha:Provider` | `none` | 创建频道的验证码:`none` 或 `cap`。选 `cap` 时下面三项必填,缺失会在启动时报错 | +| `Captcha:ApiEndpoint` | 空 | 浏览器里 Cap widget 连接的地址,形如 `https://cap.example.com//`,经 `/info` 下发 | +| `Captcha:SiteverifyUrl` | 空 | 本服务调用 Cap 校验 token 的地址,形如 `https://cap.example.com//siteverify`,可以是内网地址 | +| `Captcha:Secret` | 空 | Cap 站点密钥的 secret,只在服务端使用 | +| `Limits:*` | 见 `ServerLimits` | 负载、帧数、频道字节数、席位、TTL 上限等 | + +## 约束 + +- 单实例部署:订阅枢纽在进程内,多实例需要换成外部发布订阅。 +- 日志不记录请求头、请求体、完整 URL;Hosting 诊断日志已关闭,避免频道 id 进入日志。 +- NativeAOT:不用 SignalR、EF Core、MVC、反射式 JSON;所有 AOT / 裁剪警告视为错误。 + +## 验证码(Cap) + +工作量证明不由本项目实现,交给 [Cap](https://github.com/tiagozip/cap)。默认 `Provider=none`,适合开发和小型自托管。需要防刷时: + +1. 部署 Cap Standalone(官方是 Docker 容器,依赖 Redis/Valkey),建一个站点密钥。 +2. 配置 `Captcha:Provider=cap`、`ApiEndpoint`、`SiteverifyUrl`、`Secret`。 +3. 前端读取 `/info` 的 `captcha.api_endpoint`,用 Cap widget 求解后把 token 放进 `X-WD-Cap` 创建频道。 + +**测试状态**:服务端对 Cap 的调用只用一个假 Cap 服务(按其文档的 `POST //siteverify`、`{secret, response}` → `{success}` 实现)测过,**尚未与真实的 Cap 联调**。部署前请用真实实例跑一遍创建流程。Cap 不可用时创建频道会返回 `503 captcha_unavailable`(fail closed),不会放行。 diff --git a/server/WebDrop.slnx b/server/WebDrop.slnx new file mode 100644 index 0000000..527c2e2 --- /dev/null +++ b/server/WebDrop.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/server/scripts/smoke.ps1 b/server/scripts/smoke.ps1 new file mode 100644 index 0000000..2465a9e --- /dev/null +++ b/server/scripts/smoke.ps1 @@ -0,0 +1,119 @@ +# 对 NativeAOT 发布产物做端到端冒烟:创建频道、加入、追加帧、读日志、WebSocket 推送、GATED 下载、删除。 +# 用法: pwsh scripts/smoke.ps1 -Exe out/win-x64/webdrop.exe +param( + [Parameter(Mandatory)][string]$Exe, + [int]$Port = 5188 +) + +$ErrorActionPreference = 'Stop' +$base = "http://127.0.0.1:$Port" +$dataDir = Join-Path ([IO.Path]::GetTempPath()) ("webdrop-smoke-" + [guid]::NewGuid().ToString('N')) + +function B64([byte[]]$b) { [Convert]::ToBase64String($b).TrimEnd('=').Replace('+', '-').Replace('/', '_') } +function Rand([int]$n) { $b = [byte[]]::new($n); [Security.Cryptography.RandomNumberGenerator]::Fill($b); $b } +function Sha([byte[]]$b) { [Security.Cryptography.SHA256]::HashData($b) } +function Check($cond, $msg) { if (-not $cond) { throw "冒烟失败: $msg" } else { Write-Host " ok $msg" } } + +function Frame([byte[]]$item, [uint32]$index, [byte]$flags, [int]$payload, [byte]$fill) { + $f = [byte[]]::new(26 + $payload) + $f[0] = 1; $f[1] = $flags + [Array]::Copy($item, 0, $f, 2, 16) + $idx = [BitConverter]::GetBytes($index); [Array]::Reverse($idx); [Array]::Copy($idx, 0, $f, 18, 4) + $len = [BitConverter]::GetBytes([uint32]$payload); [Array]::Reverse($len); [Array]::Copy($len, 0, $f, 22, 4) + for ($i = 26; $i -lt $f.Length; $i++) { $f[$i] = $fill } + , $f +} + +function Headers($role, $token, [byte[]]$secret) { + $h = @{ 'X-WD-Role' = $role; 'X-WD-Endpoint' = (B64 $secret) } + if ($token) { $h['Authorization'] = 'Bearer ' + (B64 $token) } + $h +} + +$env:WebDrop__DataDir = $dataDir +$proc = Start-Process -FilePath $Exe -ArgumentList '--urls', $base -PassThru -WindowStyle Hidden +try { + for ($i = 0; $i -lt 50; $i++) { + try { $null = Invoke-WebRequest "$base/api/v1/info" -UseBasicParsing; break } catch { Start-Sleep -Milliseconds 200 } + } + $info = Invoke-RestMethod "$base/api/v1/info" + Check ($info.protocol -eq 1 -and -not $info.captcha.enabled) 'info 可访问,默认不启用验证码' + + $id = Rand 16; $readTok = Rand 32; $writeTok = Rand 32; $ownerTok = Rand 32 + $ownerSecret = Rand 32; $memberSecret = Rand 32 + + $body = @{ id = (B64 $id); mode = 'enc'; read_hash = (B64 (Sha $readTok)); write_hash = (B64 (Sha $writeTok)); owner_hash = (B64 (Sha $ownerTok)) } | ConvertTo-Json + $ownerH = Headers 'owner' $ownerTok $ownerSecret + + $created = Invoke-RestMethod "$base/api/v1/channels" -Method Post -Body $body -ContentType 'application/json' -Headers $ownerH + Check ($created.id -eq (B64 $id) -and $created.seats_used -eq 1) '创建频道成功' + $cid = B64 $id + + $memberH = Headers 'write' $writeTok $memberSecret + $joined = Invoke-RestMethod "$base/api/v1/channels/$cid/seats" -Method Post -Headers $memberH + Check ($joined.seats_used -eq 2) '成员加入' + + # enc 帧:负载 = 1024 档位 + 16 标签 + $item = Rand 16 + $frame = Frame $item 0 9 1040 0x42 + $r1 = Invoke-WebRequest "$base/api/v1/channels/$cid/frames" -Method Post -Body $frame -ContentType 'application/octet-stream' -Headers $ownerH -UseBasicParsing + Check ($r1.StatusCode -eq 201) '追加帧返回 201' + $r2 = Invoke-WebRequest "$base/api/v1/channels/$cid/frames" -Method Post -Body $frame -ContentType 'application/octet-stream' -Headers $ownerH -UseBasicParsing + Check ($r2.StatusCode -eq 200) '幂等重发返回 200' + + $log = Invoke-WebRequest "$base/api/v1/channels/$cid/log" -Headers $memberH -UseBasicParsing + $bytes = [byte[]]$log.Content + Check ($bytes.Length -eq 9 + $frame.Length -and $bytes[8] -eq 1) '日志返回完整的帧记录' + Check ((Compare-Object $bytes[9..($bytes.Length - 1)] $frame) -eq $null) '帧字节与上传一致' + + # WebSocket:凭据走子协议;先收到历史,再收到实时追加 + $ws = [Net.WebSockets.ClientWebSocket]::new() + foreach ($p in 'wd.v1', 'wd.role.write', ('wd.auth.' + (B64 $writeTok)), ('wd.ep.' + (B64 $memberSecret))) { $ws.Options.AddSubProtocol($p) } + $ws.ConnectAsync([Uri]("ws://127.0.0.1:$Port/api/v1/channels/$cid/ws?from=0"), [Threading.CancellationToken]::None).Wait() + $seg = [ArraySegment[byte]]::new([byte[]]::new(4096)) + $r = $ws.ReceiveAsync($seg, [Threading.CancellationToken]::None).Result + Check ($r.Count -eq 9 + $frame.Length) 'WebSocket 收到历史记录' + $item2 = Rand 16 + $null = Invoke-WebRequest "$base/api/v1/channels/$cid/frames" -Method Post -Body (Frame $item2 0 9 1040 0x43) -ContentType 'application/octet-stream' -Headers $ownerH -UseBasicParsing + $r = $ws.ReceiveAsync($seg, [Threading.CancellationToken]::None).Result + Check ($r.Count -eq 9 + 26 + 1040) 'WebSocket 实时收到新帧' + $ws.Dispose() + + # GATED 条目:三帧,下载上限 1 + $big = Rand 16 + foreach ($i in 0..2) { + $flags = if ($i -eq 2) { 13 } else { 12 } # Gated|Enc (+Final) + $h = $ownerH + @{ 'X-WD-Max-Downloads' = '1' } + $null = Invoke-WebRequest "$base/api/v1/channels/$cid/frames" -Method Post -Body (Frame $big ([uint32]$i) $flags 1040 ($i + 1)) -ContentType 'application/octet-stream' -Headers $h -UseBasicParsing + } + $big64 = B64 $big + $dl = Invoke-WebRequest "$base/api/v1/channels/$cid/items/$big64/frames" -Headers $memberH -UseBasicParsing + Check (([byte[]]$dl.Content).Length -eq 3 * (26 + 1040)) 'GATED 条目下载得到全部三帧' + $gone = try { Invoke-WebRequest "$base/api/v1/channels/$cid/items/$big64/frames" -Headers $memberH -UseBasicParsing; 0 } catch { [int]$_.Exception.Response.StatusCode } + Check ($gone -eq 410) '下载上限生效,第二次返回 410' + + $unauth = try { Invoke-WebRequest "$base/api/v1/channels/$cid" -Headers (Headers 'owner' (Rand 32) $ownerSecret) -UseBasicParsing; 0 } catch { [int]$_.Exception.Response.StatusCode } + Check ($unauth -eq 401) '错误令牌返回 401' + + $del = Invoke-WebRequest "$base/api/v1/channels/$cid" -Method Delete -Headers $ownerH -UseBasicParsing + Check ($del.StatusCode -eq 204) '删除频道' + + # 数据目录(含 SQLite 的 -wal 文件)里不应出现令牌或终端密钥的原值;服务仍在运行,用共享读取打开 + $found = $false + foreach ($file in (Get-ChildItem $dataDir -Recurse -File)) { + $fs = [IO.File]::Open($file.FullName, 'Open', 'Read', 'ReadWrite') + try { + $buf = [byte[]]::new($fs.Length); $null = $fs.Read($buf, 0, $buf.Length) + } finally { $fs.Dispose() } + $content = [Text.Encoding]::Latin1.GetString($buf) + if ($content.Contains((B64 $ownerTok)) -or $content.Contains((B64 $ownerSecret)) -or $content.Contains((B64 $writeTok))) { $found = $true } + } + Check (-not $found) '数据目录里没有令牌或终端密钥的原值' + + Write-Host "`n冒烟通过" -ForegroundColor Green +} +finally { + if (-not $proc.HasExited) { Stop-Process $proc -Force } + Start-Sleep -Milliseconds 300 + Remove-Item -Recurse -Force $dataDir -ErrorAction SilentlyContinue +} diff --git a/server/scripts/smoke.py b/server/scripts/smoke.py new file mode 100644 index 0000000..e233a19 --- /dev/null +++ b/server/scripts/smoke.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""对 NativeAOT 产物做跨平台冒烟(只用标准库,不含 WebSocket,那部分在 smoke.ps1 与集成测试里)。 + +用法: python3 scripts/smoke.py out/linux-x64/webdrop +""" +import base64, hashlib, json, os, shutil, struct, subprocess, sys, tempfile, time, urllib.error, urllib.request + +exe = sys.argv[1] +port = int(sys.argv[2]) if len(sys.argv) > 2 else 5199 +base = f"http://127.0.0.1:{port}" +data_dir = tempfile.mkdtemp(prefix="webdrop-smoke-") + + +def b64(b: bytes) -> str: + return base64.urlsafe_b64encode(b).rstrip(b"=").decode() + + +def sha(b: bytes) -> bytes: + return hashlib.sha256(b).digest() + + +def check(cond: bool, msg: str): + if not cond: + raise SystemExit(f"冒烟失败: {msg}") + print(f" ok {msg}") + + +def call(method, path, headers=None, body=None): + req = urllib.request.Request(base + path, data=body, method=method, headers=headers or {}) + try: + with urllib.request.urlopen(req, timeout=15) as r: + return r.status, r.read() + except urllib.error.HTTPError as e: + return e.code, e.read() + + +def hdr(role, token, secret): + h = {"X-WD-Role": role, "X-WD-Endpoint": b64(secret)} + if token: + h["Authorization"] = "Bearer " + b64(token) + return h + + +def frame(item: bytes, index: int, flags: int, payload: int, fill: int) -> bytes: + return bytes([1, flags]) + item + struct.pack(">II", index, payload) + bytes([fill]) * payload + + +env = dict(os.environ, WebDrop__DataDir=data_dir, WebDrop__Captcha__Provider="none") +proc = subprocess.Popen([exe, "--urls", base], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) +try: + for _ in range(50): + try: + call("GET", "/api/v1/info") + break + except Exception: + time.sleep(0.2) + + status, body = call("GET", "/api/v1/info") + check(status == 200 and json.loads(body)["protocol"] == 1, "info 可访问") + + cid = os.urandom(16) + read_tok, write_tok, owner_tok = os.urandom(32), os.urandom(32), os.urandom(32) + owner_secret, member_secret = os.urandom(32), os.urandom(32) + owner_h, member_h = hdr("owner", owner_tok, owner_secret), hdr("write", write_tok, member_secret) + + create = json.dumps({ + "id": b64(cid), "mode": "enc", + "read_hash": b64(sha(read_tok)), "write_hash": b64(sha(write_tok)), "owner_hash": b64(sha(owner_tok)), + }).encode() + status, body = call("POST", "/api/v1/channels", {**owner_h, "Content-Type": "application/json"}, create) + check(status == 201, "创建频道") + ch = b64(cid) + + status, body = call("POST", f"/api/v1/channels/{ch}/seats", member_h) + check(status == 200 and json.loads(body)["seats_used"] == 2, "成员加入") + + item = os.urandom(16) + f = frame(item, 0, 9, 1040, 0x42) + oct_h = {**owner_h, "Content-Type": "application/octet-stream"} + status, _ = call("POST", f"/api/v1/channels/{ch}/frames", oct_h, f) + check(status == 201, "追加帧 201") + status, _ = call("POST", f"/api/v1/channels/{ch}/frames", oct_h, f) + check(status == 200, "幂等重发 200") + status, body = call("POST", f"/api/v1/channels/{ch}/frames", oct_h, frame(item, 0, 9, 1040, 0x43)) + check(status == 409 and json.loads(body)["error"] == "conflict", "内容不同的重复帧 409") + + status, body = call("GET", f"/api/v1/channels/{ch}/log", member_h) + check(status == 200 and body[9:] == f and body[8] == 1, "日志返回完整帧记录") + + big = os.urandom(16) + for i in range(3): + flags = 13 if i == 2 else 12 + status, _ = call("POST", f"/api/v1/channels/{ch}/frames", {**oct_h, "X-WD-Max-Downloads": "1"}, + frame(big, i, flags, 1040, i + 1)) + check(status == 201, f"GATED 第 {i} 帧上传") + dl = f"/api/v1/channels/{ch}/items/{b64(big)}/frames" + status, body = call("GET", dl, member_h) + check(status == 200 and len(body) == 3 * (26 + 1040), "GATED 下载得到三帧") + status, body = call("GET", dl, member_h) + check(status == 410 and json.loads(body)["error"] == "gone", "下载上限生效 410") + + status, body = call("GET", f"/api/v1/channels/{ch}", hdr("owner", os.urandom(32), owner_secret)) + check(status == 401 and json.loads(body)["error"] == "unauthorized", "错误令牌 401") + + status, _ = call("DELETE", f"/api/v1/channels/{ch}", owner_h) + check(status == 204, "删除频道") + + # 数据目录里不应出现令牌或终端密钥的原值 + leaked = False + for root, _, files in os.walk(data_dir): + for name in files: + content = open(os.path.join(root, name), "rb").read() + for secret in (owner_tok, write_tok, owner_secret, member_secret): + if b64(secret).encode() in content or secret in content: + leaked = True + check(not leaked, "数据目录里没有令牌或终端密钥的原值") + print("\n冒烟通过") +finally: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + shutil.rmtree(data_dir, ignore_errors=True) diff --git a/server/src/WebDrop.Api/ApiJson.cs b/server/src/WebDrop.Api/ApiJson.cs new file mode 100644 index 0000000..f92a96d --- /dev/null +++ b/server/src/WebDrop.Api/ApiJson.cs @@ -0,0 +1,65 @@ +using System.Text.Json.Serialization; +using WebDrop.Core; + +namespace WebDrop.Api; + +public sealed class ErrorBody +{ + [JsonPropertyName("error")] public required string Error { get; init; } +} + +public sealed class GoneLogBody +{ + [JsonPropertyName("error")] public string Error { get; init; } = "gone"; + [JsonPropertyName("oldest_seq")] public required long OldestSeq { get; init; } +} + +public sealed class ServerInfoBody +{ + [JsonPropertyName("protocol")] public int Protocol { get; init; } = 1; + [JsonPropertyName("captcha")] public required CaptchaBody Captcha { get; init; } + [JsonPropertyName("transports")] public required TransportsBody Transports { get; init; } + [JsonPropertyName("limits")] public required LimitsBody Limits { get; init; } +} + +/// 告诉前端要不要出示验证码、widget 该连哪里(Cap 的 data-cap-api-endpoint)。 +public sealed class CaptchaBody +{ + [JsonPropertyName("provider")] public required string Provider { get; init; } + [JsonPropertyName("enabled")] public required bool Enabled { get; init; } + [JsonPropertyName("api_endpoint")] public string? ApiEndpoint { get; init; } +} + +public sealed class TransportsBody +{ + [JsonPropertyName("websocket")] public bool WebSocket { get; init; } = true; + [JsonPropertyName("sse")] public bool Sse { get; init; } = true; + [JsonPropertyName("http3")] public required bool Http3 { get; init; } +} + +public sealed class LimitsBody +{ + [JsonPropertyName("max_payload_bytes")] public required int MaxPayloadBytes { get; init; } + [JsonPropertyName("max_frames_per_item")] public required int MaxFramesPerItem { get; init; } + [JsonPropertyName("max_channel_bytes")] public required long MaxChannelBytes { get; init; } + [JsonPropertyName("max_seats")] public required int MaxSeats { get; init; } + [JsonPropertyName("max_ttl_seconds")] public required int MaxTtlSeconds { get; init; } +} + +[JsonSerializable(typeof(ErrorBody))] +[JsonSerializable(typeof(GoneLogBody))] +[JsonSerializable(typeof(ServerInfoBody))] +[JsonSerializable(typeof(ChannelInfo))] +[JsonSerializable(typeof(ShareInfo))] +[JsonSerializable(typeof(CreateShareRequest))] +[JsonSerializable(typeof(ShareSettingsPatch))] +[JsonSerializable(typeof(FinishShareRequest))] +[JsonSerializable(typeof(CreateChannelRequest))] +[JsonSerializable(typeof(ChannelSettingsPatch))] +[JsonSerializable(typeof(SeatJoined))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(AppendResult))] +[JsonSerializable(typeof(SiteverifyRequest))] +[JsonSerializable(typeof(SiteverifyResponse))] +[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +public sealed partial class ApiJson : JsonSerializerContext; diff --git a/server/src/WebDrop.Api/CapCaptchaVerifier.cs b/server/src/WebDrop.Api/CapCaptchaVerifier.cs new file mode 100644 index 0000000..c8e8f71 --- /dev/null +++ b/server/src/WebDrop.Api/CapCaptchaVerifier.cs @@ -0,0 +1,70 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using WebDrop.Core; + +namespace WebDrop.Api; + +public sealed class SiteverifyRequest +{ + [JsonPropertyName("secret")] public required string Secret { get; init; } + [JsonPropertyName("response")] public required string Response { get; init; } +} + +public sealed class SiteverifyResponse +{ + [JsonPropertyName("success")] public bool Success { get; init; } +} + +/// +/// 用 Cap(https://github.com/tiagozip/cap)校验创建频道的验证码。 +/// 工作量证明的出题、求解、验证全部由 Cap 完成,本项目不实现任何 PoW。 +/// +/// 流程:浏览器上的 Cap widget 直接向 Cap 取挑战、求解、换得 token,随创建请求放进 X-WD-Cap; +/// 这里只做一件事:把 token 交给 Cap 的 siteverify(与 reCAPTCHA / hCaptcha 兼容的接口)确认。 +/// +public sealed class CapCaptchaVerifier(HttpClient http, CaptchaOptions options, ILogger logger) : ICaptchaVerifier +{ + // token 来自客户端,先限制长度,避免把任意大的内容转发给第三方 + private const int MaxTokenLength = 2048; + + public bool Enabled => true; + + public async Task VerifyAsync(string? token, CancellationToken ct) + { + if (string.IsNullOrEmpty(token) || token.Length > MaxTokenLength) + throw new WebDropException(ErrorCode.CaptchaRequired); + + var body = JsonSerializer.Serialize( + new SiteverifyRequest { Secret = options.Secret, Response = token }, ApiJson.Default.SiteverifyRequest); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, options.SiteverifyUrl) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; + using var response = await http.SendAsync(request, ct).ConfigureAwait(false); + + // 非 2xx(站点密钥写错、Cap 出错等)是我们这边的配置或依赖问题,不是用户没通过验证 + if (!response.IsSuccessStatusCode) + { + logger.LogError("Cap siteverify 返回了 {Status}", (int)response.StatusCode); + throw new WebDropException(ErrorCode.CaptchaUnavailable); + } + + var result = await JsonSerializer.DeserializeAsync( + await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), + ApiJson.Default.SiteverifyResponse, ct).ConfigureAwait(false); + + if (result is not { Success: true }) throw new WebDropException(ErrorCode.CaptchaRequired); + } + catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException + && !ct.IsCancellationRequested) + { + // 只记录异常类型:不记录 token、密钥或响应内容 + logger.LogError("Cap siteverify 调用失败:{Type}", e.GetType().Name); + throw new WebDropException(ErrorCode.CaptchaUnavailable); + } + } +} diff --git a/server/src/WebDrop.Api/ChannelHub.cs b/server/src/WebDrop.Api/ChannelHub.cs new file mode 100644 index 0000000..5bb5985 --- /dev/null +++ b/server/src/WebDrop.Api/ChannelHub.cs @@ -0,0 +1,104 @@ +using WebDrop.Core; + +namespace WebDrop.Api; + +/// +/// 进程内的订阅枢纽。只传递「有变化」的信号,订阅者被唤醒后自己从日志里按游标读取, +/// 所以不存在回放与实时之间漏帧的竞态,也不需要为慢客户端缓冲数据。 +/// 因此只支持单实例部署(见 architecture.md)。 +/// +public sealed class ChannelHub : IChannelEvents +{ + private readonly object _gate = new(); + private readonly Dictionary _signals = []; + + public Subscription Subscribe(Id16 id) + { + lock (_gate) + { + if (!_signals.TryGetValue(id, out var signal)) _signals[id] = signal = new Signal(); + signal.Refs++; + return new Subscription(this, id, signal); + } + } + + 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 SeatsChanged(Id16 channelId) => Publish(channelId, s => s.SeatsVersion++); + public void ChannelDeleted(Id16 channelId) => Publish(channelId, s => s.Deleted = true); + + private void Publish(Id16 id, Action mutate) + { + TaskCompletionSource? toComplete; + lock (_gate) + { + if (!_signals.TryGetValue(id, out var signal)) return; // 没有订阅者,无需通知 + mutate(signal); + toComplete = signal.Replace(); + } + toComplete.TrySetResult(); // 在锁外完成,避免续体在锁内运行 + } + + private void Release(Id16 id, Signal signal) + { + lock (_gate) + { + if (--signal.Refs == 0) _signals.Remove(id); + } + } + + internal sealed class Signal + { + private TaskCompletionSource _tcs = New(); + public int Refs; + public long Seq; + public int SettingsVersion; + public int SeatsVersion; + public bool Deleted; + + public Task Changed => _tcs.Task; + + public TaskCompletionSource Replace() + { + var old = _tcs; + _tcs = New(); + return old; + } + + private static TaskCompletionSource New() => new(TaskCreationOptions.RunContinuationsAsynchronously); + } + + /// + /// 用法:先取 ,再读状态,最后等待取到的那个任务。 + /// 取任务之后发生的任何发布都会让它完成,不会丢通知。 + /// + public sealed class Subscription : IDisposable + { + private readonly ChannelHub _hub; + private readonly Id16 _id; + private readonly Signal _signal; + private int _disposed; + + internal Subscription(ChannelHub hub, Id16 id, Signal signal) + { + _hub = hub; + _id = id; + _signal = signal; + } + + public Task Changed + { + get { lock (_hub._gate) return _signal.Changed; } + } + + public (int Settings, int Seats, bool Deleted) State + { + get { lock (_hub._gate) return (_signal.SettingsVersion, _signal.SeatsVersion, _signal.Deleted); } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) _hub.Release(_id, _signal); + } + } +} diff --git a/server/src/WebDrop.Api/Endpoints.cs b/server/src/WebDrop.Api/Endpoints.cs new file mode 100644 index 0000000..eec6968 --- /dev/null +++ b/server/src/WebDrop.Api/Endpoints.cs @@ -0,0 +1,409 @@ +using System.Buffers; +using System.Diagnostics; +using System.Net.WebSockets; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using WebDrop.Core; + +namespace WebDrop.Api; + +public static class Endpoints +{ + public const string CreatePolicy = "create"; + private const string LastSeqHeader = "X-WD-Last-Seq"; + private const string OctetStream = "application/octet-stream"; + private static readonly TimeSpan MaxLongPoll = TimeSpan.FromSeconds(25); + + public static void MapApi(this IEndpointRouteBuilder app, WebDropOptions options, ICaptchaVerifier captcha) + { + var api = app.MapGroup("/api/v1"); + + api.MapGet("/info", (ServerLimits limits) => Json(new ServerInfoBody + { + Captcha = new CaptchaBody + { + Provider = options.Captcha.Provider, + Enabled = captcha.Enabled, + ApiEndpoint = captcha.Enabled ? options.Captcha.ApiEndpoint : null, + }, + Transports = new TransportsBody { Http3 = options.EnableHttp3 }, + Limits = new LimitsBody + { + MaxPayloadBytes = limits.MaxPayloadBytes, + MaxFramesPerItem = limits.MaxFramesPerItem, + MaxChannelBytes = limits.MaxChannelBytes, + MaxSeats = limits.MaxSeats, + MaxTtlSeconds = limits.MaxTtlSeconds, + }, + }, ApiJson.Default.ServerInfoBody)); + + // ───────── 频道 ───────── + + api.MapPost("/channels", async (HttpContext ctx, ChannelService svc, CancellationToken ct) => + { + 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), ApiJson.Default.ChannelInfo, StatusCodes.Status201Created); + }).RequireRateLimiting(CreatePolicy); + + api.MapGet("/channels/{id}", (string id, HttpContext ctx, ChannelService svc) => + Json(svc.GetChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false)), ApiJson.Default.ChannelInfo)); + + api.MapPatch("/channels/{id}/settings", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) => + { + var patch = await ReadJson(ctx.Request, ApiJson.Default.ChannelSettingsPatch, ct); + var info = svc.UpdateSettings(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), patch); + return Json(info, ApiJson.Default.ChannelInfo); + }); + + api.MapDelete("/channels/{id}", (string id, HttpContext ctx, ChannelService svc) => + { + svc.DeleteChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false)); + return Results.NoContent(); + }); + + // ───────── 席位 ───────── + + api.MapPost("/channels/{id}/seats", (string id, HttpContext ctx, ChannelService svc) => + Json(svc.Join(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true)), ApiJson.Default.SeatJoined)); + + api.MapGet("/channels/{id}/seats", (string id, HttpContext ctx, ChannelService svc) => + { + var seats = svc.ListSeats(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false)); + return Json([.. seats], ApiJson.Default.ListSeatInfo); + }); + + api.MapPut("/channels/{id}/seats/me", async (string id, HttpContext ctx, ChannelService svc, ServerLimits limits, CancellationToken ct) => + { + var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true); + byte[] label = await ReadBody(ctx.Request, limits.MaxSeatLabelBytes, ct); + svc.SetSeatLabel(ParseId(id), creds, label); + return Results.NoContent(); + }); + + api.MapDelete("/channels/{id}/seats/{endpointHash}", (string id, string endpointHash, HttpContext ctx, ChannelService svc) => + { + if (!Codec.TryDecodeExact(endpointHash, TokenHash.Size, out var hash)) throw new WebDropException(ErrorCode.BadRequest); + svc.ReleaseSeat(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), hash); + return Results.NoContent(); + }); + + // ───────── 帧与日志 ───────── + + api.MapPost("/channels/{id}/frames", async (string id, HttpContext ctx, ChannelService svc, ServerLimits limits, CancellationToken ct) => + { + var channelId = ParseId(id); + var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true); + long? maxDownloads = ParseMaxDownloads(ctx.Request.Headers["X-WD-Max-Downloads"].ToString()); + string label = ctx.Request.Headers["X-WD-Label"].ToString(); + + byte[] body = await ReadBody(ctx.Request, Envelope.HeaderSize + limits.MaxPayloadBytes, ct); + var result = await svc.AppendFrameAsync(channelId, creds, body, maxDownloads, label.Length > 0 ? label : null, ct); + return Json(result, ApiJson.Default.AppendResult, result.Created ? StatusCodes.Status201Created : StatusCodes.Status200OK); + }); + + 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); + 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)); + + var page = await ReadLogWithWait(svc, hub, channelId, creds, from, limit, wait, ct); + + ctx.Response.Headers[LastSeqHeader] = page.LastSeq.ToString(); + var all = new byte[page.Records.Sum(r => r.Bytes.Length)]; + int offset = 0; + foreach (var r in page.Records) + { + r.Bytes.CopyTo(all, offset); + offset += r.Bytes.Length; + } + return Results.Bytes(all, OctetStream); + }); + + api.MapGet("/channels/{id}/ws", async (string id, HttpContext ctx, ChannelService svc, ChannelHub hub) => + { + if (!ctx.WebSockets.IsWebSocketRequest || !ctx.WebSockets.WebSocketRequestedProtocols.Contains("wd.v1")) + throw new WebDropException(ErrorCode.BadRequest); + + var channelId = ParseId(id); + var creds = RequestAuth.FromSubprotocols(ctx.WebSockets.WebSocketRequestedProtocols); + long from = QueryLong(ctx.Request, "from", 0); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ctx.RequestAborted); + WebSocket? ws = null; + try + { + await LogStreamer.RunAsync(svc, hub, channelId, creds, from, async () => + { + ws = await ctx.WebSockets.AcceptWebSocketAsync("wd.v1"); + _ = WatchClose(ws, cts); + return new WebSocketSink(ws); + }, cts.Token); + } + finally + { + await CloseQuietly(ws); + } + return Results.Empty; + }); + + 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); + 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; + + await LogStreamer.RunAsync(svc, hub, channelId, creds, from, async () => + { + ctx.Response.ContentType = "text/event-stream"; + ctx.Response.Headers["X-Accel-Buffering"] = "no"; + await ctx.Response.StartAsync(); + return new SseSink(ctx.Response); + }, ctx.RequestAborted); + return Results.Empty; + }); + + // ───────── 条目 ───────── + + api.MapGet("/channels/{id}/items/{itemId}/frames", async (string id, string itemId, HttpContext ctx, ChannelService svc, ChannelHub hub, CancellationToken ct) => + { + var channelId = ParseId(id); + var item = ParseId(itemId); + var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true); + long from = QueryLong(ctx.Request, "from", 0); + if (from is < 0 or > uint.MaxValue) throw new WebDropException(ErrorCode.BadRequest); + + svc.StartItemDownload(channelId, creds, item, (uint)from); + ctx.Response.ContentType = OctetStream; + + using var sub = hub.Subscribe(channelId); + uint next = (uint)from; + while (!ct.IsCancellationRequested) + { + var changed = sub.Changed; + var chunk = await svc.ReadItemFramesAsync(channelId, creds, item, next, 16, ct); + foreach (var frame in chunk.Frames) await ctx.Response.Body.WriteAsync(frame, ct); + next = chunk.NextIndex; + + if (chunk.Complete) break; + if (chunk.Frames.Count > 0) continue; + + // 条目还在上传:等新帧;长时间没有进展就结束本次响应,客户端用 from=next 续传(不重复计数) + if (!await WaitOrTimeout(changed, MaxLongPoll, ct)) break; + } + return Results.Empty; + }); + + api.MapDelete("/channels/{id}/items/{itemId}", (string id, string itemId, HttpContext ctx, ChannelService svc) => + { + svc.DeleteItem(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true), ParseId(itemId)); + return Results.NoContent(); + }); + + // ───────── 便捷分享(独立于频道,无席位和日志) ───────── + + api.MapPost("/shares", async (HttpContext ctx, ShareService svc, CancellationToken ct) => + { + var req = await ReadJson(ctx.Request, ApiJson.Default.CreateShareRequest, ct); + string token = ctx.Request.Headers[RequestAuth.CaptchaHeader].ToString(); + await captcha.VerifyAsync(token.Length > 0 ? token : null, ct); + byte[] endpoint = RequestAuth.ParseEndpointHashOptional(ctx.Request.Headers[RequestAuth.EndpointHeader]); + return Json(svc.Create(req, endpoint.Length == 0 ? null : endpoint), ApiJson.Default.ShareInfo, StatusCodes.Status201Created); + }).RequireRateLimiting(CreatePolicy); + + api.MapPatch("/shares/{id}/settings", async (string id, HttpContext ctx, ShareService svc, CancellationToken ct) => + { + var patch = await ReadJson(ctx.Request, ApiJson.Default.ShareSettingsPatch, ct); + return Json(svc.UpdateSettings(ParseId(id), RequestAuth.ShareFromHeaders(ctx.Request, requireEndpoint: false), patch), ApiJson.Default.ShareInfo); + }); + + api.MapPost("/shares/{id}/frames", async (string id, HttpContext ctx, ShareService svc, ServerLimits limits, CancellationToken ct) => + { + var creds = RequestAuth.ShareFromHeaders(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); + var result = await svc.AppendFrameAsync(ParseId(id), creds, body, maxDownloads, ct); + return Json(result, ApiJson.Default.AppendResult, result.Created ? StatusCodes.Status201Created : StatusCodes.Status200OK); + }); + + api.MapDelete("/shares/{id}/items/{itemId}", (string id, string itemId, HttpContext ctx, ShareService svc) => + { + svc.DeleteItem(ParseId(id), RequestAuth.ShareFromHeaders(ctx.Request, requireEndpoint: false), ParseId(itemId)); + return Results.NoContent(); + }); + + api.MapDelete("/shares/{id}", (string id, HttpContext ctx, ShareService svc) => + { + svc.DeleteDraft(ParseId(id), RequestAuth.ShareFromHeaders(ctx.Request, requireEndpoint: false)); + return Results.NoContent(); + }); + + api.MapPost("/shares/{id}/finish", async (string id, HttpContext ctx, ShareService svc, CancellationToken ct) => + { + var req = await ReadJson(ctx.Request, ApiJson.Default.FinishShareRequest, ct); + return Json(svc.Finish(ParseId(id), RequestAuth.ShareFromHeaders(ctx.Request, requireEndpoint: false), req.ManifestItemId), ApiJson.Default.ShareInfo); + }); + + api.MapGet("/shares/{id}", (string id, HttpContext ctx, ShareService svc) => + Json(svc.GetInfo(ParseId(id), RequestAuth.ShareFromHeaders(ctx.Request, requireEndpoint: false)), ApiJson.Default.ShareInfo)); + + api.MapGet("/shares/{id}/manifest", async (string id, HttpContext ctx, ShareService svc) => + { + byte[] manifest = await svc.ReadManifestAsync(ParseId(id), RequestAuth.ShareFromHeaders(ctx.Request, requireEndpoint: true)); + return Results.Bytes(manifest, OctetStream); + }); + + api.MapGet("/shares/{id}/items/{itemId}/frames", async (string id, string itemId, HttpContext ctx, ShareService svc, CancellationToken ct) => + { + var shareId = ParseId(id); + var parsedItemId = ParseId(itemId); + var creds = RequestAuth.ShareFromHeaders(ctx.Request, requireEndpoint: true); + long from = QueryLong(ctx.Request, "from", 0); + if (from is < 0 or > uint.MaxValue) throw new WebDropException(ErrorCode.BadRequest); + uint next = (uint)from; + svc.StartItemDownload(shareId, creds, parsedItemId, next); + ctx.Response.ContentType = OctetStream; + while (!ct.IsCancellationRequested) + { + var chunk = await svc.ReadItemFramesAsync(shareId, creds, parsedItemId, next, ct); + foreach (var frame in chunk.Frames) await ctx.Response.Body.WriteAsync(frame, ct); + next = chunk.NextIndex; + if (chunk.Complete) break; + if (chunk.Frames.Count == 0) throw new WebDropException(ErrorCode.Gone); + } + return Results.Empty; + }); + } + + // ───────── 辅助 ───────── + + private static IResult Json(T value, JsonTypeInfo info, int? status = null) => + Results.Json(value, info, statusCode: status); + + private static Id16 ParseId(string text) => + Id16.TryParse(text, out var id) ? id : throw new WebDropException(ErrorCode.BadRequest); + + private static long QueryLong(HttpRequest req, string name, long fallback) + { + string value = req.Query[name].ToString(); + if (value.Length == 0) return fallback; + return long.TryParse(value, out long n) ? n : throw new WebDropException(ErrorCode.BadRequest); + } + + private static long? ParseMaxDownloads(string header) + { + if (header.Length == 0) return null; + return long.TryParse(header, out long n) && n >= 0 ? n : throw new WebDropException(ErrorCode.BadRequest); + } + + private static async Task ReadJson(HttpRequest req, JsonTypeInfo info, CancellationToken ct) where T : class + { + try + { + return await JsonSerializer.DeserializeAsync(req.Body, info, ct) ?? throw new WebDropException(ErrorCode.BadRequest); + } + catch (JsonException) + { + throw new WebDropException(ErrorCode.BadRequest); + } + } + + /// 读取请求体,超过上限立即拒绝(先看 Content-Length,再按实际读取量)。 + private static async Task ReadBody(HttpRequest req, int max, CancellationToken ct) + { + if (req.ContentLength is { } declared && declared > max) throw new WebDropException(ErrorCode.TooLarge); + + using var ms = new MemoryStream(req.ContentLength is { } len ? (int)len : 1024); + byte[] buffer = ArrayPool.Shared.Rent(16 * 1024); + try + { + int n; + while ((n = await req.Body.ReadAsync(buffer, ct)) > 0) + { + if (ms.Length + n > max) throw new WebDropException(ErrorCode.TooLarge); + ms.Write(buffer, 0, n); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + return ms.ToArray(); + } + + private static async Task ReadLogWithWait( + ChannelService svc, ChannelHub hub, Id16 id, Credentials creds, long from, int limit, TimeSpan wait, CancellationToken ct) + { + if (wait <= TimeSpan.Zero) return await svc.ReadLogAsync(id, creds, from, limit, ct); + + using var sub = hub.Subscribe(id); + var clock = Stopwatch.StartNew(); + while (true) + { + var changed = sub.Changed; + var page = await svc.ReadLogAsync(id, creds, from, limit, ct); + var remaining = wait - clock.Elapsed; + if (page.Records.Count > 0 || remaining <= TimeSpan.Zero) return page; + await WaitOrTimeout(changed, remaining, ct); + } + } + + /// 等待信号或超时。返回 true 表示信号先到。 + private static async Task WaitOrTimeout(Task changed, TimeSpan timeout, CancellationToken ct) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + var delay = Task.Delay(timeout, cts.Token); + var winner = await Task.WhenAny(changed, delay); + await cts.CancelAsync(); + ct.ThrowIfCancellationRequested(); + return winner == changed; + } + + /// WebSocket 的下行由订阅循环负责,这里只负责读取以便察觉客户端关闭。 + private static async Task WatchClose(WebSocket ws, CancellationTokenSource cts) + { + var buffer = new byte[256]; + try + { + while (ws.State == WebSocketState.Open) + { + var result = await ws.ReceiveAsync(buffer.AsMemory(), cts.Token); + if (result.MessageType == WebSocketMessageType.Close) break; + } + } + catch (Exception) + { + // 连接异常中断等同于关闭 + } + finally + { + try { await cts.CancelAsync(); } + catch (ObjectDisposedException) { } + } + } + + private static async Task CloseQuietly(WebSocket? ws) + { + if (ws is null) return; + try + { + if (ws.State is WebSocketState.Open or WebSocketState.CloseReceived) + await ws.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None); + } + catch (Exception) + { + // 对端已断开 + } + finally + { + ws.Dispose(); + } + } +} diff --git a/server/src/WebDrop.Api/LogStreamer.cs b/server/src/WebDrop.Api/LogStreamer.cs new file mode 100644 index 0000000..ec60dff --- /dev/null +++ b/server/src/WebDrop.Api/LogStreamer.cs @@ -0,0 +1,119 @@ +using System.Net.WebSockets; +using System.Text; +using WebDrop.Core; + +namespace WebDrop.Api; + +/// 订阅的下行通道。WebSocket 与 SSE 各自实现,订阅循环共用。 +public interface ILogSink +{ + ValueTask SendRecordAsync(LogRecord record, CancellationToken ct); + ValueTask SendControlAsync(string json, CancellationToken ct); + + /// 流已建立之后出现的业务错误(如席位被释放),通知客户端并结束。 + ValueTask FailAsync(ErrorCode code, CancellationToken ct); +} + +public static class LogStreamer +{ + private const int PageSize = 200; + public static readonly TimeSpan Heartbeat = TimeSpan.FromSeconds(20); + + /// + /// 按游标持续读取日志并推送。 在首次鉴权和读取成功之后才被调用, + /// 因此鉴权失败仍可以返回普通的 HTTP 错误,而不是先升级再断开。 + /// + public static async Task RunAsync( + ChannelService svc, ChannelHub hub, Id16 id, Credentials creds, long from, + Func> open, CancellationToken ct) + { + using var sub = hub.Subscribe(id); + ILogSink? sink = null; + long cursor = from; + var (settingsV, seatsV, _) = sub.State; + + try + { + while (!ct.IsCancellationRequested) + { + var changed = sub.Changed; // 先取任务再读状态:读取期间的发布不会丢 + var page = await svc.ReadLogAsync(id, creds, cursor, PageSize, ct).ConfigureAwait(false); + sink ??= await open().ConfigureAwait(false); + + foreach (var record in page.Records) + { + await sink.SendRecordAsync(record, ct).ConfigureAwait(false); + cursor = record.Seq + 1; + } + + var (settingsNow, seatsNow, deleted) = sub.State; + if (settingsNow != settingsV) + { + settingsV = settingsNow; + await sink.SendControlAsync("""{"t":"settings"}""", ct).ConfigureAwait(false); + } + if (seatsNow != seatsV) + { + seatsV = seatsNow; + await sink.SendControlAsync("""{"t":"seats"}""", ct).ConfigureAwait(false); + } + if (deleted) throw new WebDropException(ErrorCode.NotFound); + + if (page.Records.Count >= PageSize) continue; // 还有积压,不用等 + + using var delayCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + var delay = Task.Delay(Heartbeat, delayCts.Token); + var winner = await Task.WhenAny(changed, delay).ConfigureAwait(false); + await delayCts.CancelAsync().ConfigureAwait(false); + ct.ThrowIfCancellationRequested(); + + if (winner == delay) + await sink.SendControlAsync($$"""{"t":"hb","seq":{{page.LastSeq}}}""", ct).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // 客户端断开,正常结束 + } + catch (WebDropException e) when (sink is not null) + { + await sink.FailAsync(e.Code, CancellationToken.None).ConfigureAwait(false); + } + } +} + +public sealed class WebSocketSink(WebSocket ws) : ILogSink +{ + public ValueTask SendRecordAsync(LogRecord record, CancellationToken ct) => + ws.SendAsync(record.Bytes.AsMemory(), WebSocketMessageType.Binary, true, ct); + + public ValueTask SendControlAsync(string json, CancellationToken ct) => + ws.SendAsync(Encoding.UTF8.GetBytes(json).AsMemory(), WebSocketMessageType.Text, true, ct); + + public async ValueTask FailAsync(ErrorCode code, CancellationToken ct) + { + if (ws.State is WebSocketState.Open or WebSocketState.CloseReceived) + await ws.CloseAsync(WebSocketCloseStatus.PolicyViolation, code.ToWire(), ct).ConfigureAwait(false); + } +} + +public sealed class SseSink(HttpResponse response) : ILogSink +{ + public async ValueTask SendRecordAsync(LogRecord record, CancellationToken ct) + { + await response.WriteAsync($"id: {record.Seq}\nevent: log\ndata: {Codec.Encode(record.Bytes)}\n\n", ct).ConfigureAwait(false); + await response.Body.FlushAsync(ct).ConfigureAwait(false); + } + + public async ValueTask SendControlAsync(string json, CancellationToken ct) + { + await response.WriteAsync($"event: control\ndata: {json}\n\n", ct).ConfigureAwait(false); + await response.Body.FlushAsync(ct).ConfigureAwait(false); + } + + public async ValueTask FailAsync(ErrorCode code, CancellationToken ct) + { + await response.WriteAsync($"event: error\ndata: {{\"error\":\"{code.ToWire()}\"}}\n\n", ct).ConfigureAwait(false); + await response.Body.FlushAsync(ct).ConfigureAwait(false); + } +} diff --git a/server/src/WebDrop.Api/Program.cs b/server/src/WebDrop.Api/Program.cs new file mode 100644 index 0000000..6f49a41 --- /dev/null +++ b/server/src/WebDrop.Api/Program.cs @@ -0,0 +1,4 @@ +using WebDrop.Api; + +var app = WebDropApp.Build(args); +app.Run(); diff --git a/server/src/WebDrop.Api/Properties/launchSettings.json b/server/src/WebDrop.Api/Properties/launchSettings.json new file mode 100644 index 0000000..11a8e27 --- /dev/null +++ b/server/src/WebDrop.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5024", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7212;http://localhost:5024", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/server/src/WebDrop.Api/RequestAuth.cs b/server/src/WebDrop.Api/RequestAuth.cs new file mode 100644 index 0000000..3c9942f --- /dev/null +++ b/server/src/WebDrop.Api/RequestAuth.cs @@ -0,0 +1,80 @@ +using WebDrop.Core; + +namespace WebDrop.Api; + +/// 从请求头(或 WebSocket 子协议)解析凭据。这里只做格式校验,令牌是否正确由服务判断。 +public static class RequestAuth +{ + public const string RoleHeader = "X-WD-Role"; + public const string EndpointHeader = "X-WD-Endpoint"; + public const string CaptchaHeader = "X-WD-Cap"; + + public static Credentials FromHeaders(HttpRequest req, bool requireEndpoint) + { + var role = ParseRole(req.Headers[RoleHeader].ToString()); + + byte[]? token = null; + string auth = req.Headers.Authorization.ToString(); + if (auth.Length > 0) + { + const string prefix = "Bearer "; + if (!auth.StartsWith(prefix, StringComparison.Ordinal) + || !Codec.TryDecodeExact(auth[prefix.Length..], TokenHash.Size, out token)) + throw new WebDropException(ErrorCode.BadRequest); + } + + return new Credentials(role, token, ParseEndpoint(req.Headers[EndpointHeader].ToString(), requireEndpoint)); + } + + /// + /// 浏览器的 WebSocket 不能设请求头,凭据放在子协议里: + /// wd.v1, wd.role.<role>, wd.auth.<b64url>, wd.ep.<b64url> + /// + public static Credentials FromSubprotocols(IEnumerable protocols) + { + string? role = null, auth = null, endpoint = null; + foreach (string p in protocols) + { + if (p.StartsWith("wd.role.", StringComparison.Ordinal)) role = p["wd.role.".Length..]; + else if (p.StartsWith("wd.auth.", StringComparison.Ordinal)) auth = p["wd.auth.".Length..]; + else if (p.StartsWith("wd.ep.", StringComparison.Ordinal)) endpoint = p["wd.ep.".Length..]; + } + + byte[]? token = null; + if (auth is not null && !Codec.TryDecodeExact(auth, TokenHash.Size, out token)) + throw new WebDropException(ErrorCode.BadRequest); + + return new Credentials(ParseRole(role), token, ParseEndpoint(endpoint, requireEndpoint: true)); + } + + public static byte[] ParseEndpointHash(string? header) => ParseEndpoint(header, requireEndpoint: true); + + public static byte[] ParseEndpointHashOptional(string? header) => ParseEndpoint(header, requireEndpoint: false); + + public static Credentials ShareFromHeaders(HttpRequest req, bool requireEndpoint) + { + var credentials = FromHeaders(req, requireEndpoint); + if (credentials.ClaimedRole == Role.Write) throw new WebDropException(ErrorCode.BadRequest); + return credentials; + } + + private static Role ParseRole(string? value) => value switch + { + "read" => Role.Read, + "write" => Role.Write, + "owner" => Role.Owner, + _ => throw new WebDropException(ErrorCode.BadRequest), + }; + + /// 终端密钥 S 为 32 字节,服务端只保存并使用 SHA-256(S)。缺省时为空,仅允许不需要席位的操作。 + private static byte[] ParseEndpoint(string? value, bool requireEndpoint) + { + if (string.IsNullOrEmpty(value)) + { + if (requireEndpoint) throw new WebDropException(ErrorCode.BadRequest); + return []; + } + if (!Codec.TryDecodeExact(value, 32, out var secret)) throw new WebDropException(ErrorCode.BadRequest); + return TokenHash.Compute(secret); + } +} diff --git a/server/src/WebDrop.Api/SweepWorker.cs b/server/src/WebDrop.Api/SweepWorker.cs new file mode 100644 index 0000000..b0c8afb --- /dev/null +++ b/server/src/WebDrop.Api/SweepWorker.cs @@ -0,0 +1,42 @@ +using WebDrop.Core; + +namespace WebDrop.Api; + +/// 定期清理过期条目、下载达上限的条目、过期频道,并低频清理孤立 blob。 +public sealed class SweepWorker(ChannelService service, ShareService shares, WebDropOptions options, ILogger logger) : BackgroundService +{ + private const int OrphanSweepEvery = 20; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var interval = TimeSpan.FromSeconds(Math.Max(1, options.SweepIntervalSeconds)); + using var timer = new PeriodicTimer(interval); + int round = 0; + + do + { + try + { + bool orphans = round++ % OrphanSweepEvery == 0; + int removed = await Task.Run(() => service.Sweep(orphans) + shares.Sweep(), stoppingToken).ConfigureAwait(false); + if (removed > 0) logger.LogInformation("清理了 {Count} 个过期条目或频道", removed); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception e) + { + // 只记录异常类型,不记录可能含频道 id 的细节 + logger.LogError("清理任务失败:{Type}", e.GetType().Name); + } + } + while (await WaitAsync(timer, stoppingToken).ConfigureAwait(false)); + } + + private static async Task WaitAsync(PeriodicTimer timer, CancellationToken ct) + { + try { return await timer.WaitForNextTickAsync(ct).ConfigureAwait(false); } + catch (OperationCanceledException) { return false; } + } +} diff --git a/server/src/WebDrop.Api/WebDrop.Api.csproj b/server/src/WebDrop.Api/WebDrop.Api.csproj new file mode 100644 index 0000000..53d91f6 --- /dev/null +++ b/server/src/WebDrop.Api/WebDrop.Api.csproj @@ -0,0 +1,17 @@ + + + + true + true + true + false + webdrop + WebDrop.Api + + + + + + + + diff --git a/server/src/WebDrop.Api/WebDropApp.cs b/server/src/WebDrop.Api/WebDropApp.cs new file mode 100644 index 0000000..5db4c16 --- /dev/null +++ b/server/src/WebDrop.Api/WebDropApp.cs @@ -0,0 +1,173 @@ +using System.Threading.RateLimiting; +using WebDrop.Core; +using WebDrop.Storage; + +namespace WebDrop.Api; + +public static class WebDropApp +{ + /// 构建应用。 在读取配置之前执行,测试用它覆盖数据目录和端口。 + public static WebApplication Build(string[] args, Action? configure = null) + { + var builder = WebApplication.CreateSlimBuilder(args); + configure?.Invoke(builder); + + var options = builder.Configuration.GetSection("WebDrop").Get() ?? new WebDropOptions(); + + builder.WebHost.ConfigureKestrel(k => + { + k.AddServerHeader = false; + k.Limits.MaxRequestBodySize = 2 * 1024 * 1024; + }); + + builder.Services.AddSingleton(options); + builder.Services.AddSingleton(options.Limits); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(_ => new DiskBlobStore(Path.Combine(options.DataDir, "blobs"))); + builder.Services.AddSingleton(_ => new SqliteChannelStore(Path.Combine(options.DataDir, "webdrop.db"))); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(sp => CreateCaptchaVerifier(options.Captcha, sp)); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => new ShareService( + sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), + options.Limits, options.ShareDraftTtlSeconds)); + builder.Services.AddHostedService(); + + if (options.CorsOrigins.Length > 0) + { + builder.Services.AddCors(c => c.AddDefaultPolicy(p => p + .WithOrigins(options.CorsOrigins) + .AllowAnyMethod() + .WithHeaders("Authorization", "Content-Type", "Last-Event-ID", + "X-WD-Role", "X-WD-Endpoint", "X-WD-Cap", "X-WD-Label", "X-WD-Max-Downloads") + .WithExposedHeaders("X-WD-Last-Seq") + .SetPreflightMaxAge(TimeSpan.FromHours(1)))); + } + + builder.Services.AddRateLimiter(r => + { + r.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + // 限流状态只在内存里,按 IP 分区,不落盘 + r.GlobalLimiter = PartitionedRateLimiter.Create(ctx => + RateLimitPartition.GetFixedWindowLimiter(ClientKey(ctx), _ => PerMinute(options.RequestsPerMinute))); + r.AddPolicy(Endpoints.CreatePolicy, ctx => + RateLimitPartition.GetFixedWindowLimiter(ClientKey(ctx), _ => PerMinute(options.ChannelCreatesPerMinute))); + r.OnRejected = async (context, ct) => + { + context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; + await context.HttpContext.Response.WriteAsJsonAsync( + new ErrorBody { Error = ErrorCode.RateLimited.ToWire() }, ApiJson.Default.ErrorBody, cancellationToken: ct); + }; + }); + + var app = builder.Build(); + + if (options.TrustForwardedHeaders) + { + app.UseForwardedHeaders(new ForwardedHeadersOptions + { + ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor, + KnownIPNetworks = { }, + KnownProxies = { }, + ForwardLimit = 1, + }); + } + + app.Use(SecurityHeaders); + app.Use(HandleErrors); + if (options.CorsOrigins.Length > 0) app.UseCors(); + app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = LogStreamer.Heartbeat }); + app.UseRateLimiter(); + app.MapApi(options, app.Services.GetRequiredService()); + return app; + } + + /// 配置不完整就在启动时失败,而不是等到第一个用户创建频道时才发现。 + private static ICaptchaVerifier CreateCaptchaVerifier(CaptchaOptions o, IServiceProvider sp) + { + switch (o.Provider.ToLowerInvariant()) + { + case "none": + return new NullCaptchaVerifier(); + case "cap": + if (!Uri.TryCreate(o.SiteverifyUrl, UriKind.Absolute, out var verifyUri) || verifyUri.Scheme is not ("http" or "https")) + throw new InvalidOperationException("WebDrop:Captcha:SiteverifyUrl 必须是 http(s) 绝对地址"); + if (!Uri.TryCreate(o.ApiEndpoint, UriKind.Absolute, out var apiUri) || apiUri.Scheme is not ("http" or "https")) + throw new InvalidOperationException("WebDrop:Captcha:ApiEndpoint 必须是 http(s) 绝对地址(前端 widget 要连它)"); + if (string.IsNullOrEmpty(o.Secret)) + throw new InvalidOperationException("WebDrop:Captcha:Secret 不能为空"); + var http = new HttpClient { Timeout = TimeSpan.FromSeconds(Math.Max(1, o.TimeoutSeconds)) }; + return new CapCaptchaVerifier(http, o, sp.GetRequiredService>()); + default: + throw new InvalidOperationException($"WebDrop:Captcha:Provider 只能是 none 或 cap,当前为 {o.Provider}"); + } + } + + private static string ClientKey(HttpContext ctx) => ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + + private static FixedWindowRateLimiterOptions PerMinute(int permits) => new() + { + PermitLimit = Math.Max(1, permits), + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + }; + + /// API 不提供任何页面。禁用缓存、禁止嗅探、不发送 Referer、不允许被嵌入。 + private static Task SecurityHeaders(HttpContext ctx, RequestDelegate next) + { + var h = ctx.Response.Headers; + h["Referrer-Policy"] = "no-referrer"; + h["X-Content-Type-Options"] = "nosniff"; + h["Cache-Control"] = "no-store"; + h["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'"; + return next(ctx); + } + + /// 统一的错误出口:业务错误映射为 {"error": code};未预期的错误只返回 500,不暴露细节。 + private static async Task HandleErrors(HttpContext ctx, RequestDelegate next) + { + try + { + await next(ctx); + } + catch (WebDropException e) + { + await WriteError(ctx, e.Code, e.OldestSeq); + } + catch (BadHttpRequestException e) + { + await WriteError(ctx, e.StatusCode == StatusCodes.Status413PayloadTooLarge ? ErrorCode.TooLarge : ErrorCode.BadRequest, null); + } + catch (OperationCanceledException) when (ctx.RequestAborted.IsCancellationRequested) + { + // 客户端已断开 + } + catch (Exception e) + { + ctx.RequestServices.GetRequiredService().CreateLogger("WebDrop") + .LogError("未处理的异常:{Type}", e.GetType().Name); + if (ctx.Response.HasStarted) { ctx.Abort(); return; } + ctx.Response.StatusCode = StatusCodes.Status500InternalServerError; + await ctx.Response.WriteAsJsonAsync(new ErrorBody { Error = "internal_error" }, ApiJson.Default.ErrorBody); + } + } + + private static async Task WriteError(HttpContext ctx, ErrorCode code, long? oldestSeq) + { + if (ctx.Response.HasStarted) + { + ctx.Abort(); // 流已经开始,无法再改状态码,直接断开让客户端续传 + return; + } + + // 不能调用 Response.Clear():它会把前面中间件设置的安全头和 CORS 头一并清掉, + // 浏览器端就读不到错误体了。响应尚未开始,直接设状态码并写 JSON 即可。 + ctx.Response.StatusCode = code.ToStatus(); + if (code == ErrorCode.Gone && oldestSeq is { } oldest) + await ctx.Response.WriteAsJsonAsync(new GoneLogBody { OldestSeq = oldest }, ApiJson.Default.GoneLogBody); + else + await ctx.Response.WriteAsJsonAsync(new ErrorBody { Error = code.ToWire() }, ApiJson.Default.ErrorBody); + } +} diff --git a/server/src/WebDrop.Api/WebDropOptions.cs b/server/src/WebDrop.Api/WebDropOptions.cs new file mode 100644 index 0000000..b649dfe --- /dev/null +++ b/server/src/WebDrop.Api/WebDropOptions.cs @@ -0,0 +1,51 @@ +using WebDrop.Core; + +namespace WebDrop.Api; + +/// 配置节 "WebDrop"。环境变量形式:WebDrop__DataDir、WebDrop__Captcha__Provider …… +public sealed class WebDropOptions +{ + public string DataDir { get; set; } = "data"; + + /// 允许跨域访问的前端源。前端托管在独立域名,必须在此列出;为空则不发 CORS 头。 + public string[] CorsOrigins { get; set; } = []; + + /// 在反向代理之后部署时开启,以便限流按真实客户端 IP 分区。注意代理本身不受信任。 + public bool TrustForwardedHeaders { get; set; } + + /// + /// 声明部署层已启用 HTTP/3(例如由反向代理终结 QUIC),仅用于 /info 的通告。 + /// 服务端进程本身不内置 QUIC:CreateSlimBuilder 不含 HTTP/3,需要时再接入。 + /// + public bool EnableHttp3 { get; set; } + + public int RequestsPerMinute { get; set; } = 600; + public int ChannelCreatesPerMinute { get; set; } = 30; + public int ShareDraftTtlSeconds { get; set; } = 86_400; + public int SweepIntervalSeconds { get; set; } = 30; + + public ServerLimits Limits { get; set; } = new(); + + public CaptchaOptions Captcha { get; set; } = new(); +} + +/// +/// 创建频道的验证码。工作量证明完全交给 Cap(https://github.com/tiagozip/cap),本项目不自己实现。 +/// 选 "cap" 时需要部署一个 Cap Standalone(Docker),并在其中建一个站点密钥。 +/// +public sealed class CaptchaOptions +{ + /// "none"(默认,不校验)或 "cap"。 + public string Provider { get; set; } = "none"; + + /// 浏览器里的 Cap widget 连接的地址,形如 https://cap.example.com/<site_key>/。会通过 /info 下发给前端。 + public string ApiEndpoint { get; set; } = ""; + + /// 本服务调用 siteverify 的地址,形如 https://cap.example.com/<site_key>/siteverify。可以是内网地址。 + public string SiteverifyUrl { get; set; } = ""; + + /// Cap 站点密钥对应的 secret,只在服务端使用,不得下发给前端。 + public string Secret { get; set; } = ""; + + public int TimeoutSeconds { get; set; } = 5; +} diff --git a/server/src/WebDrop.Api/appsettings.json b/server/src/WebDrop.Api/appsettings.json new file mode 100644 index 0000000..493d250 --- /dev/null +++ b/server/src/WebDrop.Api/appsettings.json @@ -0,0 +1,26 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.AspNetCore.Hosting.Diagnostics": "None" + } + }, + "WebDrop": { + "DataDir": "data", + "CorsOrigins": [], + "TrustForwardedHeaders": false, + "EnableHttp3": false, + "RequestsPerMinute": 600, + "ChannelCreatesPerMinute": 30, + "ShareDraftTtlSeconds": 86400, + "SweepIntervalSeconds": 30, + "Captcha": { + "Provider": "none", + "ApiEndpoint": "", + "SiteverifyUrl": "", + "Secret": "", + "TimeoutSeconds": 5 + } + } +} diff --git a/server/src/WebDrop.Core/Abstractions.cs b/server/src/WebDrop.Core/Abstractions.cs new file mode 100644 index 0000000..48376fc --- /dev/null +++ b/server/src/WebDrop.Core/Abstractions.cs @@ -0,0 +1,128 @@ +namespace WebDrop.Core; + +public interface IClock +{ + long NowMs { get; } +} + +public sealed class SystemClock : IClock +{ + public long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); +} + +/// 大帧的字节存储。键由服务生成,实现不得解析。 +public interface IBlobStore +{ + ValueTask PutAsync(string key, ReadOnlyMemory data, CancellationToken ct = default); + ValueTask ReadAsync(string key, CancellationToken ct = default); + void Delete(string key); + void DeletePrefix(string prefix); + IEnumerable<(string Key, DateTimeOffset LastWrite)> Enumerate(); +} + +/// +/// 创建频道前的人机/滥用校验(Cap 的 PoW 验证码)。验证码本身不属于本项目: +/// 校验失败抛 CaptchaRequired,验证服务不可用抛 CaptchaUnavailable(两者对用户的含义不同)。 +/// +public interface ICaptchaVerifier +{ + bool Enabled { get; } + Task VerifyAsync(string? token, CancellationToken ct); +} + +/// 不启用验证码(开发和小型自托管)。 +public sealed class NullCaptchaVerifier : ICaptchaVerifier +{ + public bool Enabled => false; + public Task VerifyAsync(string? token, CancellationToken ct) => Task.CompletedTask; +} + +/// 服务提交后发出的通知,传输层据此唤醒订阅者。 +public interface IChannelEvents +{ + void Advanced(Id16 channelId, long seq); + void SettingsChanged(Id16 channelId); + void SeatsChanged(Id16 channelId); + void ChannelDeleted(Id16 channelId); +} + +public sealed class NullChannelEvents : IChannelEvents +{ + public void Advanced(Id16 channelId, long seq) { } + public void SettingsChanged(Id16 channelId) { } + public void SeatsChanged(Id16 channelId) { } + public void ChannelDeleted(Id16 channelId) { } +} + +/// 频道和分享资源共用的读写事务入口。Write 串行执行,抛出异常即回滚。 +public interface IResourceStore +{ + T Read(Func action); + T Write(Func action); +} + +/// 单个数据库事务内的频道、分享与帧操作。 +public interface IResourceTx +{ + // 便捷分享 + Share? GetShare(Id16 id); + void InsertShare(Share share); + void UpdateShare(Share share); + IReadOnlyList ListExpiredShares(long now, int limit); + bool HasResourceId(Id16 id); + int CountShareVisitors(Id16 id); + bool HasShareVisitor(Id16 id, ReadOnlySpan endpointHash); + void InsertShareVisitor(Id16 id, ReadOnlySpan endpointHash, long joinedAt); + void DeleteShareRows(Id16 id); + IReadOnlyList ListShareItems(Id16 id); + int CountShareItems(Id16 id); + void AddShareBytes(Id16 id, long delta); + IReadOnlyList ListExpiredShareItems(long now, int limit); + ShareItem? GetShareItem(Id16 shareId, Id16 itemId); + void InsertShareItem(ShareItem item); + void UpdateShareItem(ShareItem item); + void DeleteShareItemRows(Id16 shareId, Id16 itemId); + ShareFrame? GetShareFrame(Id16 shareId, Id16 itemId, uint index); + void InsertShareFrame(ShareFrame frame); + IReadOnlyList ListShareFrames(Id16 shareId, Id16 itemId, uint fromIndex, int limit); + + // 频道 + Channel? GetChannel(Id16 id); + void InsertChannel(Channel channel); + void UpdateChannel(Id16 id, ChannelSettings settings, long expiresAt); + void TouchChannel(Id16 id, long expiresAt); + void AddChannelBytes(Id16 id, long delta); + void SetCompactedThrough(Id16 id, long seq); + long NextSeq(Id16 id); + void DeleteChannelRows(Id16 id); + IReadOnlyList ListExpiredChannels(long now, int limit); + + // 席位 + Seat? GetSeat(Id16 channelId, ReadOnlySpan endpointHash); + int CountSeats(Id16 channelId); + void InsertSeat(Id16 channelId, Seat seat); + bool DeleteSeat(Id16 channelId, ReadOnlySpan endpointHash); + void SetSeatLabel(Id16 channelId, ReadOnlySpan endpointHash, byte[] label); + IReadOnlyList ListSeats(Id16 channelId); + + // 条目 + Item? GetItem(Id16 channelId, Id16 itemId); + void InsertItem(Item item); + void UpdateItem(Item item); + void DeleteItemRows(Id16 channelId, Id16 itemId); + int CountItems(Id16 channelId); + IReadOnlyList ListOldestItems(Id16 channelId, int limit); + Item? FindSnapshotItem(Id16 channelId, Id16 excludeItemId); + IReadOnlyList ListExpiredItems(long now, int limit); + + // 帧 + StoredFrame? GetFrame(Id16 channelId, Id16 itemId, uint index); + void InsertFrame(StoredFrame frame); + IReadOnlyList ListFrames(Id16 channelId, Id16 itemId, uint fromIndex, int limit); + bool BlobKeyExists(string blobKey); + + // 日志 + void InsertLog(Id16 channelId, LogEntry entry); + IReadOnlyList ReadLog(Id16 channelId, long fromSeq, int limit); + long PruneTombstones(Id16 channelId, long olderThanMs); +} diff --git a/server/src/WebDrop.Core/Auth.cs b/server/src/WebDrop.Core/Auth.cs new file mode 100644 index 0000000..cad8ba1 --- /dev/null +++ b/server/src/WebDrop.Core/Auth.cs @@ -0,0 +1,19 @@ +using System.Security.Cryptography; + +namespace WebDrop.Core; + +/// 服务端只保存令牌的 SHA-256,验证时常量时间比较。 +public static class TokenHash +{ + public const int Size = 32; + + public static byte[] Compute(ReadOnlySpan token) => SHA256.HashData(token); + + public static bool Matches(ReadOnlySpan token, byte[]? expectedHash) + { + if (expectedHash is not { Length: Size }) return false; + Span actual = stackalloc byte[Size]; + SHA256.HashData(token, actual); + return CryptographicOperations.FixedTimeEquals(actual, expectedHash); + } +} diff --git a/server/src/WebDrop.Core/ChannelPolicy.cs b/server/src/WebDrop.Core/ChannelPolicy.cs new file mode 100644 index 0000000..401d14f --- /dev/null +++ b/server/src/WebDrop.Core/ChannelPolicy.cs @@ -0,0 +1,26 @@ +namespace WebDrop.Core; + +/// 谁能做什么。规则只写在这里,服务和测试都调用它。 +public static class ChannelPolicy +{ + public static bool IsOwner(Principal p) => p.Role == Role.Owner; + + public static bool CanRead(Principal p) => p.Role >= Role.Read; + + /// 成员写入需要频道允许上传,且服务端持有写令牌哈希。 + public static bool CanWrite(Channel channel, Principal p) => + p.Role == Role.Owner + || (p.Role == Role.Write && channel.Settings.MembersCanUpload && channel.WriteHash is not null); + + public static bool CanDeleteItem(Channel channel, Item item, Principal p) + { + if (p.Role == Role.Owner) return true; + if (p.Role < Role.Read) return false; + return channel.Settings.DeletePolicy switch + { + DeletePolicy.Anyone => true, + DeletePolicy.Own => item.AuthorHash.AsSpan().SequenceEqual(p.EndpointHash), + _ => false, + }; + } +} diff --git a/server/src/WebDrop.Core/ChannelService.cs b/server/src/WebDrop.Core/ChannelService.cs new file mode 100644 index 0000000..c6f2862 --- /dev/null +++ b/server/src/WebDrop.Core/ChannelService.cs @@ -0,0 +1,571 @@ +using System.Security.Cryptography; + +namespace WebDrop.Core; + +/// +/// 频道的全部业务逻辑。依赖抽象(存储、blob、时钟、事件),不含 HTTP、SQL、文件细节。 +/// 约定:所有失败以 表达;提交成功后才发事件;blob 先写后提交。 +/// +public sealed class ChannelService( + IResourceStore store, + IBlobStore blobs, + IClock clock, + ServerLimits limits, + IChannelEvents events) +{ + private enum SeatRule { None, Required } + + // ───────────────────────── 频道 ───────────────────────── + + public ChannelInfo CreateChannel(CreateChannelRequest req, byte[] endpointHash) + { + if (!Id16.TryParse(req.Id, out var id)) throw new WebDropException(ErrorCode.BadRequest); + if (req.Mode is not { } mode) throw new WebDropException(ErrorCode.BadRequest); + + var settings = req.Settings ?? new ChannelSettings(); + settings.Validate(limits); + + if (!Codec.TryDecodeExact(req.OwnerHash, TokenHash.Size, out var ownerHash)) + throw new WebDropException(ErrorCode.BadRequest); + + byte[]? readHash = null; + if (mode == ChannelMode.Enc) + { + if (!Codec.TryDecodeExact(req.ReadHash, TokenHash.Size, out readHash)) + throw new WebDropException(ErrorCode.BadRequest); + } + else if (req.ReadHash is not null) + { + throw new WebDropException(ErrorCode.BadRequest); // raw 频道的读权限是 id 本身 + } + + byte[]? writeHash = null; + if (req.WriteHash is not null && !Codec.TryDecodeExact(req.WriteHash, TokenHash.Size, out writeHash)) + throw new WebDropException(ErrorCode.BadRequest); + + long now = clock.NowMs; + var channel = new Channel(id, mode, settings, readHash, writeHash, ownerHash, + now, now + settings.TtlSeconds * 1000L, 0, 0, 0); + + store.Write(tx => + { + if (tx.HasResourceId(id)) throw new WebDropException(ErrorCode.Conflict); + tx.InsertChannel(channel); + tx.InsertSeat(id, new Seat(endpointHash, now, null)); + return 0; + }); + return ToInfo(channel, 1); + } + + public ChannelInfo GetChannel(Id16 id, Credentials creds) => store.Read(tx => + { + var (channel, _) = Authenticate(tx, id, creds, SeatRule.None); + return ToInfo(channel, tx.CountSeats(id)); + }); + + public ChannelInfo UpdateSettings(Id16 id, Credentials creds, ChannelSettingsPatch patch) + { + var info = store.Write(tx => + { + var (channel, principal) = Authenticate(tx, id, creds, SeatRule.None); + RequireOwner(principal); + + var updated = patch.ApplyTo(channel.Settings); + updated.Validate(limits); + + long expiresAt = updated.TtlSeconds != channel.Settings.TtlSeconds + ? clock.NowMs + updated.TtlSeconds * 1000L + : channel.ExpiresAt; + tx.UpdateChannel(id, updated, expiresAt); + return ToInfo(channel with { Settings = updated, ExpiresAt = expiresAt }, tx.CountSeats(id)); + }); + events.SettingsChanged(id); + return info; + } + + public void DeleteChannel(Id16 id, Credentials creds) + { + store.Write(tx => + { + var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); + RequireOwner(principal); + tx.DeleteChannelRows(id); + return 0; + }); + blobs.DeletePrefix(id.ToHex()); + events.ChannelDeleted(id); + } + + // ───────────────────────── 席位 ───────────────────────── + + public SeatJoined Join(Id16 id, Credentials creds) + { + var (joined, isNew) = store.Write(tx => + { + var (channel, principal) = Authenticate(tx, id, creds, SeatRule.None); + var existing = tx.GetSeat(id, principal.EndpointHash); + int used = tx.CountSeats(id); + if (existing is not null) + return (new SeatJoined { EndpointHash = Codec.Encode(existing.EndpointHash), JoinedAt = existing.JoinedAt, SeatsUsed = used, SeatsMax = channel.Settings.Seats }, false); + + if (used >= channel.Settings.Seats) throw new WebDropException(ErrorCode.SeatsFull); + long now = clock.NowMs; + tx.InsertSeat(id, new Seat(principal.EndpointHash, now, null)); + return (new SeatJoined { EndpointHash = Codec.Encode(principal.EndpointHash), JoinedAt = now, SeatsUsed = used + 1, SeatsMax = channel.Settings.Seats }, true); + }); + if (isNew) events.SeatsChanged(id); + return joined; + } + + public IReadOnlyList ListSeats(Id16 id, Credentials creds) => store.Read(tx => + { + var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); + RequireOwner(principal); + return (IReadOnlyList)tx.ListSeats(id).Select(s => new SeatInfo + { + EndpointHash = Codec.Encode(s.EndpointHash), + JoinedAt = s.JoinedAt, + Label = s.Label is null ? null : Codec.Encode(s.Label), + }).ToList(); + }); + + public void SetSeatLabel(Id16 id, Credentials creds, byte[] label) + { + if (label.Length == 0) throw new WebDropException(ErrorCode.BadRequest); + if (label.Length > limits.MaxSeatLabelBytes) throw new WebDropException(ErrorCode.TooLarge); + store.Write(tx => + { + var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required); + tx.SetSeatLabel(id, principal.EndpointHash, label); + return 0; + }); + events.SeatsChanged(id); + } + + public void ReleaseSeat(Id16 id, Credentials creds, byte[] endpointHash) + { + store.Write(tx => + { + var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); + RequireOwner(principal); + if (!tx.DeleteSeat(id, endpointHash)) throw new WebDropException(ErrorCode.NotFound); + return 0; + }); + events.SeatsChanged(id); + } + + // ───────────────────────── 追加帧 ───────────────────────── + + public async Task AppendFrameAsync( + Id16 channelId, Credentials creds, ReadOnlyMemory body, + long? maxDownloads, string? label, CancellationToken ct = default) + { + if (!Envelope.TryParse(body.Span, out var env)) throw new WebDropException(ErrorCode.BadRequest); + if (env.PayloadLength > limits.MaxPayloadBytes) throw new WebDropException(ErrorCode.TooLarge); + if (label is not null && System.Text.Encoding.UTF8.GetByteCount(label) > limits.MaxLabelBytes) + throw new WebDropException(ErrorCode.BadRequest); + ValidateShape(env); + + byte[] digest = SHA256.HashData(body.Span); + + // 1. 预检:鉴权并处理幂等重发,避免为重复帧白写 blob + var existing = store.Read(tx => + { + var (channel, principal) = Authenticate(tx, channelId, creds, SeatRule.Required); + CheckWritable(channel, principal, env); + return tx.GetFrame(channelId, env.ItemId, env.FrameIndex); + }); + if (existing is not null) return Resolve(existing, digest); + + // 2. 大帧先写 blob。键含摘要,内容不同的并发写不会互相覆盖 + string? blobKey = null; + if (body.Length >= limits.InlineThresholdBytes) + { + blobKey = $"{channelId.ToHex()}/{env.ItemId.ToHex()}/{env.FrameIndex}.{Convert.ToHexStringLower(digest.AsSpan(0, 16))}"; + await blobs.PutAsync(blobKey, body, ct).ConfigureAwait(false); + } + + // 3. 单事务提交 + AppendOutcome outcome; + try + { + outcome = store.Write(tx => AppendInTx(tx, channelId, creds, env, body, digest, blobKey, maxDownloads, label)); + } + catch + { + DeleteBlobIfUnreferenced(blobKey); + throw; + } + + if (!outcome.Result.Created) DeleteBlobIfUnreferenced(blobKey); + foreach (string prefix in outcome.BlobPrefixesToDelete) blobs.DeletePrefix(prefix); + events.Advanced(channelId, outcome.LastSeq); + return outcome.Result; + } + + private sealed class AppendOutcome(AppendResult result, long lastSeq, List prefixes) + { + public AppendResult Result { get; } = result; + public long LastSeq { get; } = lastSeq; + public List BlobPrefixesToDelete { get; } = prefixes; + } + + private AppendOutcome AppendInTx( + IResourceTx tx, Id16 channelId, Credentials creds, Envelope env, ReadOnlyMemory body, + byte[] digest, string? blobKey, long? maxDownloads, string? label) + { + var (channel, principal) = Authenticate(tx, channelId, creds, SeatRule.Required); + CheckWritable(channel, principal, env); + + // 并发下预检之后可能已有同一帧 + if (tx.GetFrame(channelId, env.ItemId, env.FrameIndex) is { } dup) + return new AppendOutcome(Resolve(dup, digest), dup.Seq, []); + + long now = clock.NowMs; + long ttlMs = channel.Settings.TtlSeconds * 1000L; + var item = tx.GetItem(channelId, env.ItemId); + + if (env.FrameIndex == 0) + { + if (item is not null) throw new WebDropException(ErrorCode.BadRequest); + long max = env.IsGated ? Math.Clamp(maxDownloads ?? channel.Settings.FileMaxDownloads, 0, 1_000_000) : 0; + item = new Item(channelId, env.ItemId, principal.EndpointHash, env.ItemFlags, max, 0, 0, -1, null, 0, + now, now + ttlMs, null, label); + tx.InsertItem(item); + } + else + { + if (item is null) throw new WebDropException(ErrorCode.BadRequest); + if (item.Flags != env.ItemFlags) throw new WebDropException(ErrorCode.BadRequest); + if (item.CloseDeadline is not null) throw new WebDropException(ErrorCode.Gone); + if (item.FinalIndex is { } fi && env.FrameIndex > fi) throw new WebDropException(ErrorCode.BadRequest); + } + + if (env.FrameIndex >= limits.MaxFramesPerItem) throw new WebDropException(ErrorCode.TooLarge); + if (env.IsFinal) + { + if (item.MaxIndex > env.FrameIndex) throw new WebDropException(ErrorCode.BadRequest); + if (item.FinalIndex is { } f && f != env.FrameIndex) throw new WebDropException(ErrorCode.BadRequest); + } + if (channel.Bytes + body.Length > limits.MaxChannelBytes) throw new WebDropException(ErrorCode.TooLarge); + + long seq = tx.NextSeq(channelId); + tx.InsertFrame(new StoredFrame(channelId, env.ItemId, env.FrameIndex, seq, body.Length, digest, + blobKey is null ? body.ToArray() : null, blobKey)); + + if (!env.IsGated) + tx.InsertLog(channelId, new LogEntry(seq, LogKind.Frame, env.ItemId, env.FrameIndex, 0, now)); + else if (env.FrameIndex == 0) + tx.InsertLog(channelId, new LogEntry(seq, LogKind.Announce, env.ItemId, 0, 0, now)); + + tx.UpdateItem(item with + { + FrameCount = item.FrameCount + 1, + MaxIndex = Math.Max(item.MaxIndex, env.FrameIndex), + FinalIndex = env.IsFinal ? env.FrameIndex : item.FinalIndex, + Bytes = item.Bytes + body.Length, + }); + tx.AddChannelBytes(channelId, body.Length); + tx.TouchChannel(channelId, Math.Max(channel.ExpiresAt, now + ttlMs)); + + long lastSeq = seq; + var prefixes = new List(); + + if (env.FrameIndex == 0) + { + // 新快照取代旧快照 + if (env.IsSnapshot && tx.FindSnapshotItem(channelId, env.ItemId) is { } old) + lastSeq = DeleteItemCore(tx, channelId, old, TombstoneReason.Superseded, prefixes); + + // 超出条数上限:淘汰最旧的条目 + int excess = tx.CountItems(channelId) - channel.Settings.MaxItems; + if (excess > 0) + { + foreach (var victim in tx.ListOldestItems(channelId, excess + 1)) + { + if (excess <= 0) break; + if (victim.ItemId == env.ItemId) continue; + lastSeq = DeleteItemCore(tx, channelId, victim, TombstoneReason.Evicted, prefixes); + excess--; + } + } + + // 清理过期的墓碑,并推进续传水位 + long pruned = tx.PruneTombstones(channelId, now - limits.TombstoneRetentionSeconds * 1000L); + if (pruned > channel.CompactedThrough) tx.SetCompactedThrough(channelId, pruned); + } + + return new AppendOutcome(new AppendResult { Seq = seq, Created = true }, lastSeq, prefixes); + } + + private static AppendResult Resolve(StoredFrame existing, byte[] digest) => + existing.Digest.AsSpan().SequenceEqual(digest) + ? new AppendResult { Seq = existing.Seq, Created = false } + : throw new WebDropException(ErrorCode.Conflict); + + private void ValidateShape(Envelope env) + { + // 非 GATED 条目只能是单帧且必须 FINAL;快照只能是非 GATED 的单帧 + if (!env.IsGated && (env.FrameIndex != 0 || !env.IsFinal)) throw new WebDropException(ErrorCode.BadRequest); + if (env.IsSnapshot && env.IsGated) throw new WebDropException(ErrorCode.BadRequest); + } + + private static void CheckWritable(Channel channel, Principal principal, Envelope env) + { + if (!ChannelPolicy.CanWrite(channel, principal)) throw new WebDropException(ErrorCode.Forbidden); + if (env.IsEnc != (channel.Mode == ChannelMode.Enc)) throw new WebDropException(ErrorCode.ModeMismatch); + if (env.IsEnc && !PayloadTiers.IsValidEncPayloadLength(env.PayloadLength)) + throw new WebDropException(ErrorCode.BadRequest); + } + + private void DeleteBlobIfUnreferenced(string? blobKey) + { + if (blobKey is null) return; + if (!store.Read(tx => tx.BlobKeyExists(blobKey))) blobs.Delete(blobKey); + } + + // ───────────────────────── 读取 ───────────────────────── + + public async Task ReadLogAsync(Id16 id, Credentials creds, long from, int limit, CancellationToken ct = default) + { + limit = Math.Clamp(limit, 1, 1000); + var (pending, lastSeq) = store.Read(tx => + { + var (channel, principal) = Authenticate(tx, id, creds, SeatRule.Required); + if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden); + + // 续传点早于已被清理的墓碑:客户端可能漏掉删除事件,必须从 0 全量同步 + if (from > 0 && from < channel.CompactedThrough + 1) + throw new WebDropException(ErrorCode.Gone, channel.CompactedThrough + 1); + + var list = new List<(long Seq, byte[]? Bytes, StoredFrame? Frame)>(); + foreach (var e in tx.ReadLog(id, from, limit)) + { + switch (e.Kind) + { + case LogKind.Frame: + if (tx.GetFrame(id, e.ItemId, e.FrameIndex) is { } f) list.Add((e.Seq, null, f)); + break; + case LogKind.Announce: + if (tx.GetItem(id, e.ItemId) is { } it) + list.Add((e.Seq, LogRecord.EncodeAnnounce(e.Seq, e.ItemId, it.Flags, it.CreatedAt), null)); + break; + case LogKind.Tombstone: + list.Add((e.Seq, LogRecord.EncodeTombstone(e.Seq, e.ItemId, (TombstoneReason)e.Aux), null)); + break; + } + } + return (list, channel.LastSeq); + }); + + var records = new List(pending.Count); + foreach (var (seq, bytes, frame) in pending) + { + if (bytes is not null) { records.Add(new LogRecord(seq, bytes)); continue; } + byte[]? env = await LoadFrameBytesAsync(frame!, ct).ConfigureAwait(false); + if (env is not null) records.Add(new LogRecord(seq, LogRecord.Encode(seq, LogKind.Frame, env))); + } + return new LogPage(records, lastSeq); + } + + /// 开始拉取条目:鉴权,并按规则计一次下载。 为 0 才计数,大于 0 视为续传。 + public void StartItemDownload(Id16 id, Credentials creds, Id16 itemId, uint fromIndex) + { + store.Write(tx => + { + var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required); + if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden); + var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound); + + long now = clock.NowMs; + if (item.CloseDeadline is { } deadline && (fromIndex == 0 || now > deadline)) + throw new WebDropException(ErrorCode.Gone); + + if (fromIndex == 0 && item.IsGated) + { + if (item.MaxDownloads > 0 && item.Downloads >= item.MaxDownloads) + throw new WebDropException(ErrorCode.Gone); + + long downloads = item.Downloads + 1; + long? closeAt = item.MaxDownloads > 0 && downloads >= item.MaxDownloads + ? now + limits.DownloadGraceSeconds * 1000L + : item.CloseDeadline; + tx.UpdateItem(item with { Downloads = downloads, CloseDeadline = closeAt }); + } + return 0; + }); + } + + /// 读取自 起连续的帧。条目未完成时可能返回空批次,调用方等待后重试。 + public async Task ReadItemFramesAsync( + Id16 id, Credentials creds, Id16 itemId, uint fromIndex, int maxFrames, CancellationToken ct = default) + { + maxFrames = Math.Clamp(maxFrames, 1, 64); + var (frames, next, complete) = store.Read(tx => + { + var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required); + if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden); + var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound); + if (item.CloseDeadline is { } d && clock.NowMs > d) throw new WebDropException(ErrorCode.Gone); + + var taken = new List(); + uint expected = fromIndex; + foreach (var f in tx.ListFrames(id, itemId, fromIndex, maxFrames)) + { + if (f.Index != expected) break; // 并行上传时可能有空洞 + taken.Add(f); + expected++; + } + return (taken, expected, item.FinalIndex is { } fi && expected > fi); + }); + + var result = new List(frames.Count); + foreach (var f in frames) + { + byte[]? bytes = await LoadFrameBytesAsync(f, ct).ConfigureAwait(false); + if (bytes is null) throw new WebDropException(ErrorCode.Gone); + result.Add(bytes); + } + return new ItemChunk(result, next, complete); + } + + private async ValueTask LoadFrameBytesAsync(StoredFrame frame, CancellationToken ct) => + frame.Inline ?? (frame.BlobKey is null ? null : await blobs.ReadAsync(frame.BlobKey, ct).ConfigureAwait(false)); + + // ───────────────────────── 删除 ───────────────────────── + + public void DeleteItem(Id16 id, Credentials creds, Id16 itemId) + { + var (seq, prefixes) = store.Write(tx => + { + var (channel, principal) = Authenticate(tx, id, creds, SeatRule.Required); + var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound); + if (!ChannelPolicy.CanDeleteItem(channel, item, principal)) throw new WebDropException(ErrorCode.Forbidden); + + var list = new List(); + long s = DeleteItemCore(tx, id, item, TombstoneReason.Deleted, list); + return (s, list); + }); + foreach (string prefix in prefixes) blobs.DeletePrefix(prefix); + events.Advanced(id, seq); + } + + private long DeleteItemCore(IResourceTx tx, Id16 channelId, Item item, TombstoneReason reason, List prefixes) + { + tx.DeleteItemRows(channelId, item.ItemId); + tx.AddChannelBytes(channelId, -item.Bytes); + long seq = tx.NextSeq(channelId); + tx.InsertLog(channelId, new LogEntry(seq, LogKind.Tombstone, item.ItemId, 0, (byte)reason, clock.NowMs)); + prefixes.Add($"{channelId.ToHex()}/{item.ItemId.ToHex()}"); + return seq; + } + + // ───────────────────────── 后台清理 ───────────────────────── + + /// 清理过期条目、下载达上限的条目、过期频道;可选清理孤立 blob。返回删除的条目和频道总数。 + public int Sweep(bool includeOrphanBlobs = false) + { + int removed = 0; + long now = clock.NowMs; + + foreach (var group in store.Read(tx => tx.ListExpiredItems(now, 500)).GroupBy(i => i.ChannelId)) + { + var (lastSeq, prefixes, count) = store.Write(tx => + { + long last = 0; + int n = 0; + var list = new List(); + foreach (var stale in group) + { + // 重新读取,期间条目可能已被删除或续期 + if (tx.GetItem(stale.ChannelId, stale.ItemId) is not { } item) continue; + bool closed = item.CloseDeadline is { } d && d <= now; + if (item.ExpiresAt > now && !closed) continue; + last = DeleteItemCore(tx, item.ChannelId, item, closed ? TombstoneReason.DownloadLimit : TombstoneReason.Expired, list); + n++; + } + return (last, list, n); + }); + foreach (string prefix in prefixes) blobs.DeletePrefix(prefix); + if (count > 0) events.Advanced(group.Key, lastSeq); + removed += count; + } + + foreach (var channelId in store.Read(tx => tx.ListExpiredChannels(now, 200))) + { + bool deleted = store.Write(tx => + { + if (tx.GetChannel(channelId) is not { } c || c.ExpiresAt > now) return false; + tx.DeleteChannelRows(channelId); + return true; + }); + if (!deleted) continue; + blobs.DeletePrefix(channelId.ToHex()); + events.ChannelDeleted(channelId); + removed++; + } + + if (includeOrphanBlobs) SweepOrphanBlobs(now); + return removed; + } + + /// 删除没有任何帧引用、且超过 10 分钟的 blob(崩溃发生在写 blob 与提交事务之间会留下它们)。 + private void SweepOrphanBlobs(long now) + { + var cutoff = DateTimeOffset.FromUnixTimeMilliseconds(now).AddMinutes(-10); + foreach (var (key, lastWrite) in blobs.Enumerate().ToList()) + { + if (lastWrite > cutoff) continue; + if (!store.Read(tx => tx.BlobKeyExists(key))) blobs.Delete(key); + } + } + + // ───────────────────────── 鉴权 ───────────────────────── + + private (Channel Channel, Principal Principal) Authenticate(IResourceTx tx, Id16 id, Credentials creds, SeatRule seat) + { + var channel = tx.GetChannel(id); + if (channel is null || channel.ExpiresAt <= clock.NowMs) throw new WebDropException(ErrorCode.NotFound); + + var role = VerifyRole(channel, creds); + if (seat == SeatRule.Required && tx.GetSeat(id, creds.EndpointHash) is null) + throw new WebDropException(ErrorCode.NotJoined); + return (channel, new Principal(role, creds.EndpointHash)); + } + + private static Role VerifyRole(Channel channel, Credentials creds) + { + switch (creds.ClaimedRole) + { + case Role.Read: + // raw 频道:知道 id 即可读;enc 频道必须出示读令牌 + if (channel.Mode == ChannelMode.Raw) return Role.Read; + if (creds.Token is not null && TokenHash.Matches(creds.Token, channel.ReadHash)) return Role.Read; + break; + case Role.Write: + if (creds.Token is not null && TokenHash.Matches(creds.Token, channel.WriteHash)) return Role.Write; + break; + case Role.Owner: + if (creds.Token is not null && TokenHash.Matches(creds.Token, channel.OwnerHash)) return Role.Owner; + break; + } + throw new WebDropException(ErrorCode.Unauthorized); + } + + private static void RequireOwner(Principal principal) + { + if (!ChannelPolicy.IsOwner(principal)) throw new WebDropException(ErrorCode.Forbidden); + } + + private static ChannelInfo ToInfo(Channel channel, int seatsUsed) => new() + { + Id = channel.Id.ToBase64Url(), + Mode = channel.Mode, + Settings = channel.Settings, + SeatsUsed = seatsUsed, + OldestSeq = channel.CompactedThrough + 1, + LastSeq = channel.LastSeq, + CreatedAt = channel.CreatedAt, + ExpiresAt = channel.ExpiresAt, + }; +} diff --git a/server/src/WebDrop.Core/Dtos.cs b/server/src/WebDrop.Core/Dtos.cs new file mode 100644 index 0000000..f441d54 --- /dev/null +++ b/server/src/WebDrop.Core/Dtos.cs @@ -0,0 +1,125 @@ +using System.Buffers.Binary; +using System.Text.Json.Serialization; + +namespace WebDrop.Core; + +public sealed class CreateChannelRequest +{ + [JsonPropertyName("id")] public string? Id { get; init; } + [JsonPropertyName("mode")] public ChannelMode? Mode { get; init; } + [JsonPropertyName("settings")] public ChannelSettings? Settings { get; init; } + [JsonPropertyName("read_hash")] public string? ReadHash { get; init; } + [JsonPropertyName("write_hash")] public string? WriteHash { get; init; } + [JsonPropertyName("owner_hash")] public string? OwnerHash { get; init; } +} + +public sealed class CreateShareRequest +{ + [JsonPropertyName("id")] public string? Id { get; init; } + [JsonPropertyName("read_hash")] public string? ReadHash { get; init; } + [JsonPropertyName("owner_hash")] public string? OwnerHash { get; init; } + [JsonPropertyName("settings")] public ShareSettings? Settings { get; init; } +} + +public sealed class FinishShareRequest +{ + [JsonPropertyName("manifest_item_id")] public string? ManifestItemId { get; init; } +} + +public sealed class ShareInfo +{ + [JsonPropertyName("id")] public required string Id { get; init; } + [JsonPropertyName("state")] public required string State { get; init; } + [JsonPropertyName("expires_at")] public required long ExpiresAt { get; init; } + [JsonPropertyName("created_at")] public required long CreatedAt { get; init; } + [JsonPropertyName("sealed_at")] public long? SealedAt { get; init; } + [JsonPropertyName("manifest_item_id")] public string? ManifestItemId { get; init; } + [JsonPropertyName("settings")] public required ShareSettings Settings { get; init; } + [JsonPropertyName("items")] public required IReadOnlyList Items { get; init; } +} + +public sealed class ShareItemInfo +{ + [JsonPropertyName("item_id")] public required string ItemId { get; init; } + [JsonPropertyName("bytes")] public required long Bytes { get; init; } + [JsonPropertyName("frames")] public required int Frames { get; init; } + [JsonPropertyName("max_downloads")] public required long MaxDownloads { get; init; } + [JsonPropertyName("downloads_left")] [JsonIgnore(Condition = JsonIgnoreCondition.Never)] public long? DownloadsLeft { get; init; } +} + +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; } + [JsonPropertyName("seats_used")] public required int SeatsUsed { get; init; } + + /// 从该 seq 起的续传有效;0 < from < oldest_seq 的续传会得到 410,需从 0 全量同步。 + [JsonPropertyName("oldest_seq")] public required long OldestSeq { get; init; } + + [JsonPropertyName("last_seq")] public required long LastSeq { get; init; } + [JsonPropertyName("created_at")] public required long CreatedAt { get; init; } + [JsonPropertyName("expires_at")] public required long ExpiresAt { get; init; } +} + +public sealed class SeatJoined +{ + [JsonPropertyName("endpoint_hash")] public required string EndpointHash { get; init; } + [JsonPropertyName("joined_at")] public required long JoinedAt { get; init; } + [JsonPropertyName("seats_used")] public required int SeatsUsed { get; init; } + [JsonPropertyName("seats_max")] public required int SeatsMax { get; init; } +} + +public sealed class SeatInfo +{ + [JsonPropertyName("endpoint_hash")] public required string EndpointHash { get; init; } + [JsonPropertyName("joined_at")] public required long JoinedAt { get; init; } + [JsonPropertyName("label")] public string? Label { get; init; } +} + +public sealed class AppendResult +{ + [JsonPropertyName("seq")] public required long Seq { get; init; } + + /// true 为新追加(201),false 为幂等重发(200)。 + [JsonIgnore] public bool Created { get; init; } +} + +/// 一条日志记录的线格式:seq(u64) ‖ kind(u8) ‖ body。 +public sealed record LogRecord(long Seq, byte[] Bytes) +{ + public static byte[] Encode(long seq, LogKind kind, ReadOnlySpan body) + { + var bytes = new byte[9 + body.Length]; + BinaryPrimitives.WriteInt64BigEndian(bytes, seq); + bytes[8] = (byte)kind; + body.CopyTo(bytes.AsSpan(9)); + return bytes; + } + + public static byte[] EncodeTombstone(long seq, Id16 itemId, TombstoneReason reason) + { + Span body = stackalloc byte[Id16.Size + 1]; + itemId.WriteTo(body); + body[Id16.Size] = (byte)reason; + return Encode(seq, LogKind.Tombstone, body); + } + + public static byte[] EncodeAnnounce(long seq, Id16 itemId, FrameFlags flags, long createdMs) + { + Span body = stackalloc byte[Id16.Size + 1 + 8]; + itemId.WriteTo(body); + body[Id16.Size] = (byte)flags; + BinaryPrimitives.WriteInt64BigEndian(body[(Id16.Size + 1)..], createdMs); + return Encode(seq, LogKind.Announce, body); + } +} + +public sealed record LogPage(IReadOnlyList Records, long LastSeq); + +/// GATED 条目拉取的一个批次:自 FromIndex 起连续的完整封装。 +public sealed record ItemChunk(IReadOnlyList Frames, uint NextIndex, bool Complete); + +[JsonSerializable(typeof(ChannelSettings))] +[JsonSerializable(typeof(ShareSettings))] +public sealed partial class CoreJsonContext : JsonSerializerContext; diff --git a/server/src/WebDrop.Core/Enums.cs b/server/src/WebDrop.Core/Enums.cs new file mode 100644 index 0000000..e380a3b --- /dev/null +++ b/server/src/WebDrop.Core/Enums.cs @@ -0,0 +1,53 @@ +using System.Text.Json.Serialization; + +namespace WebDrop.Core; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ChannelMode : byte +{ + [JsonStringEnumMemberName("enc")] Enc = 1, + [JsonStringEnumMemberName("raw")] Raw = 2, +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum DeletePolicy : byte +{ + [JsonStringEnumMemberName("owner_only")] OwnerOnly = 0, + [JsonStringEnumMemberName("own")] Own = 1, + [JsonStringEnumMemberName("anyone")] Anyone = 2, +} + +/// 请求声明并通过验证的角色,数值越大权限越高。 +public enum Role : byte +{ + None = 0, + Read = 1, + Write = 2, + Owner = 3, +} + +[Flags] +public enum FrameFlags : byte +{ + None = 0, + Final = 1, + Snapshot = 2, + Gated = 4, + Enc = 8, +} + +public enum LogKind : byte +{ + Frame = 1, + Tombstone = 2, + Announce = 3, +} + +public enum TombstoneReason : byte +{ + Deleted = 0, + Expired = 1, + Evicted = 2, + DownloadLimit = 3, + Superseded = 4, +} diff --git a/server/src/WebDrop.Core/Envelope.cs b/server/src/WebDrop.Core/Envelope.cs new file mode 100644 index 0000000..0214297 --- /dev/null +++ b/server/src/WebDrop.Core/Envelope.cs @@ -0,0 +1,50 @@ +using System.Buffers.Binary; + +namespace WebDrop.Core; + +/// 帧封装的 26 字节明文头部,见 protocol.md §4。负载由服务端原样保存,从不解析。 +public readonly record struct Envelope(FrameFlags Flags, Id16 ItemId, uint FrameIndex, int PayloadLength) +{ + public const int HeaderSize = 26; + public const byte Version = 1; + + public int TotalLength => HeaderSize + PayloadLength; + + /// 头部格式合法、保留位为 0、长度与 payload_len 严格一致。 + public static bool TryParse(ReadOnlySpan data, out Envelope envelope) + { + envelope = default; + if (data.Length < HeaderSize || data[0] != Version) return false; + + byte flags = data[1]; + if ((flags & 0xF0) != 0) return false; + + uint payloadLen = BinaryPrimitives.ReadUInt32BigEndian(data[22..]); + if (payloadLen > int.MaxValue || data.Length != HeaderSize + (int)payloadLen) return false; + + envelope = new Envelope( + (FrameFlags)flags, + Id16.FromBytes(data.Slice(2, Id16.Size)), + BinaryPrimitives.ReadUInt32BigEndian(data[18..]), + (int)payloadLen); + return true; + } + + public bool IsFinal => (Flags & FrameFlags.Final) != 0; + public bool IsGated => (Flags & FrameFlags.Gated) != 0; + public bool IsSnapshot => (Flags & FrameFlags.Snapshot) != 0; + public bool IsEnc => (Flags & FrameFlags.Enc) != 0; + + /// 条目级标志:同一条目的所有帧必须一致。 + public FrameFlags ItemFlags => Flags & (FrameFlags.Snapshot | FrameFlags.Gated); +} + +public static class PayloadTiers +{ + public const int TagSize = 16; + private static readonly int[] Tiers = [1024, 4096, 16384, 65536, 262144, 1048576]; + + /// enc 负载 = 档位明文 + 16 字节 GCM 标签,服务端据此强制 padding。 + public static bool IsValidEncPayloadLength(int payloadLength) => + payloadLength > TagSize && Array.IndexOf(Tiers, payloadLength - TagSize) >= 0; +} diff --git a/server/src/WebDrop.Core/Errors.cs b/server/src/WebDrop.Core/Errors.cs new file mode 100644 index 0000000..e138d35 --- /dev/null +++ b/server/src/WebDrop.Core/Errors.cs @@ -0,0 +1,75 @@ +namespace WebDrop.Core; + +public enum ErrorCode +{ + BadRequest, + Unauthorized, + Forbidden, + NotJoined, + SeatsFull, + ModeMismatch, + NotFound, + Conflict, + Gone, + TooLarge, + CaptchaRequired, + CaptchaUnavailable, + RateLimited, + VisitorsFull, + Sealed, +} + +/// 预期内的业务失败。消息只含错误码,绝不回显用户提交的内容。 +public sealed class WebDropException : Exception +{ + public ErrorCode Code { get; } + + /// 仅在 410 日志续传失败时有值,见 api.md。 + public long? OldestSeq { get; } + + public WebDropException(ErrorCode code, long? oldestSeq = null) : base(code.ToWire()) + { + Code = code; + OldestSeq = oldestSeq; + } +} + +public static class ErrorCodes +{ + public static string ToWire(this ErrorCode code) => code switch + { + ErrorCode.BadRequest => "bad_request", + ErrorCode.Unauthorized => "unauthorized", + ErrorCode.Forbidden => "forbidden", + ErrorCode.NotJoined => "not_joined", + ErrorCode.SeatsFull => "seats_full", + ErrorCode.ModeMismatch => "mode_mismatch", + ErrorCode.NotFound => "not_found", + ErrorCode.Conflict => "conflict", + ErrorCode.Gone => "gone", + ErrorCode.TooLarge => "too_large", + ErrorCode.CaptchaRequired => "captcha_required", + ErrorCode.CaptchaUnavailable => "captcha_unavailable", + ErrorCode.RateLimited => "rate_limited", + ErrorCode.VisitorsFull => "visitors_full", + ErrorCode.Sealed => "sealed", + _ => "bad_request", + }; + + public static int ToStatus(this ErrorCode code) => code switch + { + ErrorCode.BadRequest => 400, + ErrorCode.Unauthorized => 401, + ErrorCode.Forbidden or ErrorCode.NotJoined or ErrorCode.SeatsFull or ErrorCode.ModeMismatch => 403, + ErrorCode.NotFound => 404, + ErrorCode.Conflict => 409, + ErrorCode.Sealed => 409, + ErrorCode.Gone => 410, + ErrorCode.TooLarge => 413, + ErrorCode.CaptchaRequired => 428, + ErrorCode.CaptchaUnavailable => 503, + ErrorCode.RateLimited => 429, + ErrorCode.VisitorsFull => 403, + _ => 400, + }; +} diff --git a/server/src/WebDrop.Core/Id16.cs b/server/src/WebDrop.Core/Id16.cs new file mode 100644 index 0000000..ceb7350 --- /dev/null +++ b/server/src/WebDrop.Core/Id16.cs @@ -0,0 +1,91 @@ +using System.Buffers.Binary; +using System.Buffers.Text; + +namespace WebDrop.Core; + +/// 16 字节标识(频道 id、条目 id),可作字典键。 +public readonly struct Id16 : IEquatable +{ + public const int Size = 16; + private const int Base64Length = 22; + + private readonly ulong _hi; + private readonly ulong _lo; + + private Id16(ulong hi, ulong lo) + { + _hi = hi; + _lo = lo; + } + + public static Id16 FromBytes(ReadOnlySpan bytes) + { + if (bytes.Length != Size) throw new ArgumentException("Id16 需要 16 字节", nameof(bytes)); + return new Id16(BinaryPrimitives.ReadUInt64BigEndian(bytes), BinaryPrimitives.ReadUInt64BigEndian(bytes[8..])); + } + + /// 只接受规范的 22 字符 base64url,拒绝同一值的非规范编码。 + public static bool TryParse(ReadOnlySpan text, out Id16 id) + { + id = default; + if (text.Length != Base64Length) return false; + Span buf = stackalloc byte[Size]; + if (!Codec.TryDecodeExact(text, buf)) return false; + id = FromBytes(buf); + return true; + } + + public void WriteTo(Span destination) + { + BinaryPrimitives.WriteUInt64BigEndian(destination, _hi); + BinaryPrimitives.WriteUInt64BigEndian(destination[8..], _lo); + } + + public byte[] ToArray() + { + var bytes = new byte[Size]; + WriteTo(bytes); + return bytes; + } + + public string ToBase64Url() => Codec.Encode(ToArray()); + + /// 小写十六进制。用于文件路径,避免大小写不敏感的文件系统造成不同 id 冲突。 + public string ToHex() => Convert.ToHexStringLower(ToArray()); + + public bool Equals(Id16 other) => _hi == other._hi && _lo == other._lo; + public override bool Equals(object? obj) => obj is Id16 other && Equals(other); + public override int GetHashCode() => HashCode.Combine(_hi, _lo); + public static bool operator ==(Id16 a, Id16 b) => a.Equals(b); + public static bool operator !=(Id16 a, Id16 b) => !a.Equals(b); + public override string ToString() => ToBase64Url(); +} + +/// base64url(无填充)编解码,解码要求规范编码。 +public static class Codec +{ + public static string Encode(ReadOnlySpan bytes) => Base64Url.EncodeToString(bytes); + + /// 解码为恰好 destination.Length 字节,且重新编码后必须与输入逐字符一致。 + public static bool TryDecodeExact(ReadOnlySpan text, Span destination) + { + // 注意:Base64Url.TryDecodeFromChars 遇到非法输入会抛 FormatException,这里用返回状态码的版本 + var status = Base64Url.DecodeFromChars(text, destination, out int consumed, out int written, isFinalBlock: true); + if (status != System.Buffers.OperationStatus.Done || consumed != text.Length || written != destination.Length) + return false; + Span check = stackalloc char[Base64Url.GetEncodedLength(destination.Length)]; + return Base64Url.TryEncodeToChars(destination, check, out int n) + && n == text.Length + && check[..n].SequenceEqual(text); + } + + public static bool TryDecodeExact(string? text, int length, out byte[] bytes) + { + bytes = []; + if (string.IsNullOrEmpty(text) || text.Length != Base64Url.GetEncodedLength(length)) return false; + var buf = new byte[length]; + if (!TryDecodeExact(text.AsSpan(), buf)) return false; + bytes = buf; + return true; + } +} diff --git a/server/src/WebDrop.Core/Models.cs b/server/src/WebDrop.Core/Models.cs new file mode 100644 index 0000000..dd89701 --- /dev/null +++ b/server/src/WebDrop.Core/Models.cs @@ -0,0 +1,100 @@ +namespace WebDrop.Core; + +public sealed record Channel( + Id16 Id, + ChannelMode Mode, + ChannelSettings Settings, + byte[]? ReadHash, + byte[]? WriteHash, + byte[] OwnerHash, + long CreatedAt, + long ExpiresAt, + long LastSeq, + long CompactedThrough, + long Bytes); + +public sealed record Share( + Id16 Id, + ShareSettings Settings, + byte[] ReadHash, + byte[]? OwnerHash, + byte[]? CreatorEndpointHash, + long CreatedAt, + long ExpiresAt, + long? SealedAt, + Id16? ManifestItemId, + long Bytes) +{ + public bool IsSealed => SealedAt is not null; +} + +public sealed record ShareItem( + Id16 ShareId, + Id16 ItemId, + FrameFlags Flags, + long MaxDownloads, + long Downloads, + int FrameCount, + long MaxIndex, + long? FinalIndex, + long Bytes, + long CreatedAt, + long ExpiresAt, + long? CloseDeadline) +{ + public bool IsGated => (Flags & FrameFlags.Gated) != 0; + public bool IsSnapshot => (Flags & FrameFlags.Snapshot) != 0; + public bool IsComplete => FinalIndex is { } f && FrameCount == f + 1; +} + +public sealed record ShareFrame( + Id16 ShareId, + Id16 ItemId, + uint Index, + long AckSeq, + int Length, + byte[] Digest, + byte[]? Inline, + string? BlobKey); + +public sealed record Item( + Id16 ChannelId, + Id16 ItemId, + byte[] AuthorHash, + FrameFlags Flags, + long MaxDownloads, + long Downloads, + int FrameCount, + long MaxIndex, + long? FinalIndex, + long Bytes, + long CreatedAt, + long ExpiresAt, + long? CloseDeadline, + string? Label) +{ + public bool IsGated => (Flags & FrameFlags.Gated) != 0; + public bool IsSnapshot => (Flags & FrameFlags.Snapshot) != 0; + public bool IsComplete => FinalIndex is { } f && FrameCount == f + 1; +} + +public sealed record Seat(byte[] EndpointHash, long JoinedAt, byte[]? Label); + +/// 已存储的一帧(完整封装)。Inline 与 BlobKey 二选一。 +public sealed record StoredFrame( + Id16 ChannelId, + Id16 ItemId, + uint Index, + long Seq, + int Length, + byte[] Digest, + byte[]? Inline, + string? BlobKey); + +public sealed record LogEntry(long Seq, LogKind Kind, Id16 ItemId, uint FrameIndex, byte Aux, long CreatedAt); + +/// 一次请求验证通过后的身份。 +public sealed record Principal(Role Role, byte[] EndpointHash); + +/// 请求携带的凭据,尚未验证。 +public sealed record Credentials(Role ClaimedRole, byte[]? Token, byte[] EndpointHash); diff --git a/server/src/WebDrop.Core/Settings.cs b/server/src/WebDrop.Core/Settings.cs new file mode 100644 index 0000000..876cf3a --- /dev/null +++ b/server/src/WebDrop.Core/Settings.cs @@ -0,0 +1,90 @@ +using System.Text.Json.Serialization; + +namespace WebDrop.Core; + +/// 服务端限制,由配置绑定。 +public sealed class ServerLimits +{ + public int MaxPayloadBytes { get; set; } = (1 << 20) + 16; + public int MaxFramesPerItem { get; set; } = 65_536; + public long MaxChannelBytes { get; set; } = 1L << 30; + public int MaxSeats { get; set; } = 64; + public int MinTtlSeconds { get; set; } = 60; + public int MaxTtlSeconds { get; set; } = 604_800; + public int MaxItemsLimit { get; set; } = 10_000; + public int InlineThresholdBytes { get; set; } = 64 * 1024; + public int DownloadGraceSeconds { get; set; } = 300; + public int TombstoneRetentionSeconds { get; set; } = 604_800; + public int MaxLabelBytes { get; set; } = 255; + public int MaxSeatLabelBytes { get; set; } = 256; +} + +public sealed class ChannelSettings +{ + [JsonPropertyName("seats")] public int Seats { get; init; } = 2; + [JsonPropertyName("members_can_upload")] public bool MembersCanUpload { get; init; } = true; + [JsonPropertyName("delete_policy")] public DeletePolicy DeletePolicy { get; init; } = DeletePolicy.Own; + [JsonPropertyName("ttl_seconds")] public int TtlSeconds { get; init; } = 86_400; + [JsonPropertyName("max_items")] public int MaxItems { get; init; } = 1000; + [JsonPropertyName("file_max_downloads")] public int FileMaxDownloads { get; init; } = 10; + [JsonPropertyName("ui_restrict_copy_download")] public bool UiRestrictCopyDownload { get; init; } + + public void Validate(ServerLimits limits) + { + if (Seats < 1 || Seats > limits.MaxSeats) throw new WebDropException(ErrorCode.BadRequest); + if (TtlSeconds < limits.MinTtlSeconds || TtlSeconds > limits.MaxTtlSeconds) throw new WebDropException(ErrorCode.BadRequest); + if (MaxItems < 1 || MaxItems > limits.MaxItemsLimit) throw new WebDropException(ErrorCode.BadRequest); + if (FileMaxDownloads < 0) throw new WebDropException(ErrorCode.BadRequest); + } +} + +public sealed class ChannelSettingsPatch +{ + [JsonPropertyName("seats")] public int? Seats { get; init; } + [JsonPropertyName("members_can_upload")] public bool? MembersCanUpload { get; init; } + [JsonPropertyName("delete_policy")] public DeletePolicy? DeletePolicy { get; init; } + [JsonPropertyName("ttl_seconds")] public int? TtlSeconds { get; init; } + [JsonPropertyName("max_items")] public int? MaxItems { get; init; } + [JsonPropertyName("file_max_downloads")] public int? FileMaxDownloads { get; init; } + [JsonPropertyName("ui_restrict_copy_download")] public bool? UiRestrictCopyDownload { get; init; } + + public ChannelSettings ApplyTo(ChannelSettings s) => new() + { + Seats = Seats ?? s.Seats, + MembersCanUpload = MembersCanUpload ?? s.MembersCanUpload, + DeletePolicy = DeletePolicy ?? s.DeletePolicy, + TtlSeconds = TtlSeconds ?? s.TtlSeconds, + MaxItems = MaxItems ?? s.MaxItems, + FileMaxDownloads = FileMaxDownloads ?? s.FileMaxDownloads, + UiRestrictCopyDownload = UiRestrictCopyDownload ?? s.UiRestrictCopyDownload, + }; +} + +public sealed class ShareSettings +{ + [JsonPropertyName("ttl_seconds")] public int TtlSeconds { get; init; } = 86_400; + [JsonPropertyName("max_visitors")] public int MaxVisitors { get; init; } + [JsonPropertyName("file_max_downloads")] public int FileMaxDownloads { get; init; } = 10; + + public void Validate(ServerLimits limits) + { + if (TtlSeconds < limits.MinTtlSeconds || TtlSeconds > limits.MaxTtlSeconds + || MaxVisitors < 0 || MaxVisitors > 10_000 + || FileMaxDownloads < 0) + throw new WebDropException(ErrorCode.BadRequest); + } +} + +public sealed class ShareSettingsPatch +{ + [JsonPropertyName("ttl_seconds")] public int? TtlSeconds { get; init; } + [JsonPropertyName("max_visitors")] public int? MaxVisitors { get; init; } + [JsonPropertyName("file_max_downloads")] public int? FileMaxDownloads { get; init; } + + public ShareSettings ApplyTo(ShareSettings s) => new() + { + TtlSeconds = TtlSeconds ?? s.TtlSeconds, + MaxVisitors = MaxVisitors ?? s.MaxVisitors, + FileMaxDownloads = FileMaxDownloads ?? s.FileMaxDownloads, + }; +} diff --git a/server/src/WebDrop.Core/ShareService.cs b/server/src/WebDrop.Core/ShareService.cs new file mode 100644 index 0000000..fac33cb --- /dev/null +++ b/server/src/WebDrop.Core/ShareService.cs @@ -0,0 +1,359 @@ +using System.Security.Cryptography; + +namespace WebDrop.Core; + +/// 便捷分享的草稿、封存、访客和下载规则。与频道共享帧格式及 blob 存储,不共享席位或日志。 +public sealed class ShareService( + IResourceStore store, + IBlobStore blobs, + IClock clock, + ServerLimits limits, + int draftTtlSeconds) +{ + public ShareInfo Create(CreateShareRequest req, byte[]? creatorEndpointHash) + { + if (!Id16.TryParse(req.Id, out var id) + || !Codec.TryDecodeExact(req.ReadHash, TokenHash.Size, out var readHash) + || !Codec.TryDecodeExact(req.OwnerHash, TokenHash.Size, out var ownerHash)) + throw new WebDropException(ErrorCode.BadRequest); + + var settings = req.Settings ?? new ShareSettings(); + settings.Validate(limits); + long now = clock.NowMs; + var share = new Share(id, settings, readHash, ownerHash, creatorEndpointHash, now, + now + Math.Max(60, draftTtlSeconds) * 1000L, null, null, 0); + + store.Write(tx => + { + if (tx.HasResourceId(id)) throw new WebDropException(ErrorCode.Conflict); + tx.InsertShare(share); + return 0; + }); + return ToInfo(share, []); + } + + public ShareInfo GetInfo(Id16 id, Credentials creds) => store.Read(tx => + { + var share = Live(tx.GetShare(id)); + if (share.IsSealed) + { + if (creds.ClaimedRole != Role.Read || !TokenHash.Matches(creds.Token ?? [], share.ReadHash)) + throw new WebDropException(ErrorCode.Unauthorized); + } + else + { + if (creds.ClaimedRole == Role.Read) throw new WebDropException(ErrorCode.NotFound); + RequireOwner(share, creds); + } + return ToInfo(share, tx.ListShareItems(id)); + }); + + public ShareInfo UpdateSettings(Id16 id, Credentials creds, ShareSettingsPatch patch) => store.Write(tx => + { + var share = Draft(tx.GetShare(id), creds); + var settings = patch.ApplyTo(share.Settings); + settings.Validate(limits); + share = share with { Settings = settings }; + tx.UpdateShare(share); + return ToInfo(share, tx.ListShareItems(id)); + }); + + public async Task AppendFrameAsync( + Id16 id, Credentials creds, ReadOnlyMemory body, long? maxDownloads, CancellationToken ct = default) + { + if (!Envelope.TryParse(body.Span, out var env)) throw new WebDropException(ErrorCode.BadRequest); + if (env.PayloadLength > limits.MaxPayloadBytes || body.Length > limits.MaxPayloadBytes + Envelope.HeaderSize) + throw new WebDropException(ErrorCode.TooLarge); + if (!env.IsEnc) throw new WebDropException(ErrorCode.ModeMismatch); + if (!PayloadTiers.IsValidEncPayloadLength(env.PayloadLength)) throw new WebDropException(ErrorCode.BadRequest); + if (env.IsSnapshot) throw new WebDropException(ErrorCode.BadRequest); + if (!env.IsGated && (env.FrameIndex != 0 || !env.IsFinal)) throw new WebDropException(ErrorCode.BadRequest); + if (maxDownloads is < 0) throw new WebDropException(ErrorCode.BadRequest); + + var existing = store.Read(tx => + { + _ = Draft(tx.GetShare(id), creds); + return tx.GetShareFrame(id, env.ItemId, env.FrameIndex); + }); + byte[] digest = SHA256.HashData(body.Span); + if (existing is not null) return Resolve(existing, digest); + + string? blobKey = null; + if (body.Length >= limits.InlineThresholdBytes) + { + blobKey = $"{id.ToHex()}/{env.ItemId.ToHex()}/{env.FrameIndex}.{Convert.ToHexStringLower(digest.AsSpan(0, 16))}"; + await blobs.PutAsync(blobKey, body, ct).ConfigureAwait(false); + } + + try + { + var result = store.Write(tx => + { + var share = Draft(tx.GetShare(id), creds); + if (tx.GetShareFrame(id, env.ItemId, env.FrameIndex) is { } duplicate) + return Resolve(duplicate, digest); + + long now = clock.NowMs; + var item = tx.GetShareItem(id, env.ItemId); + if (env.FrameIndex == 0) + { + if (item is not null) throw new WebDropException(ErrorCode.BadRequest); + if (tx.CountShareItems(id) >= limits.MaxItemsLimit) throw new WebDropException(ErrorCode.TooLarge); + long cap = env.IsGated ? maxDownloads ?? share.Settings.FileMaxDownloads : 0; + item = new ShareItem(id, env.ItemId, env.ItemFlags, cap, 0, 0, -1, null, + 0, now, share.ExpiresAt, null); + tx.InsertShareItem(item); + } + else + { + if (item is null || item.Flags != env.ItemFlags || item.CloseDeadline is not null + || item.FinalIndex is { } final && env.FrameIndex > final) + throw new WebDropException(ErrorCode.BadRequest); + } + + if (env.FrameIndex >= limits.MaxFramesPerItem) throw new WebDropException(ErrorCode.TooLarge); + if (env.IsFinal && (item!.MaxIndex > env.FrameIndex || item.FinalIndex is { } f && f != env.FrameIndex)) + throw new WebDropException(ErrorCode.BadRequest); + if (share.Bytes + body.Length > limits.MaxChannelBytes) throw new WebDropException(ErrorCode.TooLarge); + + tx.InsertShareFrame(new ShareFrame(id, env.ItemId, env.FrameIndex, env.FrameIndex, body.Length, digest, + blobKey is null ? body.ToArray() : null, blobKey)); + tx.UpdateShareItem(item! with + { + FrameCount = item!.FrameCount + 1, + MaxIndex = Math.Max(item.MaxIndex, env.FrameIndex), + FinalIndex = env.IsFinal ? env.FrameIndex : item.FinalIndex, + Bytes = item.Bytes + body.Length, + }); + tx.AddShareBytes(id, body.Length); + return new AppendResult { Seq = env.FrameIndex, Created = true }; + }); + + if (!result.Created) DeleteBlobIfUnreferenced(blobKey); + return result; + } + catch + { + DeleteBlobIfUnreferenced(blobKey); + throw; + } + } + + public void DeleteItem(Id16 id, Credentials creds, Id16 itemId) + { + string prefix = $"{id.ToHex()}/{itemId.ToHex()}"; + store.Write(tx => + { + _ = Draft(tx.GetShare(id), creds); + var item = tx.GetShareItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound); + tx.DeleteShareItemRows(id, itemId); + tx.AddShareBytes(id, -item.Bytes); + return 0; + }); + blobs.DeletePrefix(prefix); + } + + public void DeleteDraft(Id16 id, Credentials creds) + { + store.Write(tx => + { + _ = Draft(tx.GetShare(id), creds); + tx.DeleteShareRows(id); + return 0; + }); + blobs.DeletePrefix(id.ToHex()); + } + + public ShareInfo Finish(Id16 id, Credentials creds, string? manifestItemId) + { + if (!Id16.TryParse(manifestItemId, out var manifestId)) throw new WebDropException(ErrorCode.BadRequest); + return store.Write(tx => + { + var share = Draft(tx.GetShare(id), creds); + var items = tx.ListShareItems(id); + var manifest = tx.GetShareItem(id, manifestId); + if (manifest is null || manifest.IsGated || manifest.IsSnapshot || !manifest.IsComplete + || manifest.FrameCount != 1 || manifest.FinalIndex != 0) + throw new WebDropException(ErrorCode.BadRequest); + if (items.Any(i => !i.IsComplete || i.ItemId != manifestId && !i.IsGated)) + throw new WebDropException(ErrorCode.BadRequest); + + long now = clock.NowMs; + long expiresAt = now + share.Settings.TtlSeconds * 1000L; + var sealedShare = share with + { + OwnerHash = null, + SealedAt = now, + ExpiresAt = expiresAt, + ManifestItemId = manifestId, + }; + tx.UpdateShare(sealedShare); + foreach (var item in items) tx.UpdateShareItem(item with { ExpiresAt = expiresAt }); + return ToInfo(sealedShare, tx.ListShareItems(id)); + }); + } + + public async Task ReadManifestAsync(Id16 id, Credentials creds) + { + var frame = store.Write(tx => + { + var share = SealedForRead(tx.GetShare(id), creds); + RegisterVisitor(tx, share, creds.EndpointHash); + var manifestId = share.ManifestItemId ?? throw new WebDropException(ErrorCode.NotFound); + return tx.GetShareFrame(id, manifestId, 0) ?? throw new WebDropException(ErrorCode.Gone); + }); + return await LoadFrameAsync(frame).ConfigureAwait(false); + } + + public void StartItemDownload(Id16 id, Credentials creds, Id16 itemId, uint fromIndex) + { + store.Write(tx => + { + var share = SealedForRead(tx.GetShare(id), creds); + RegisterVisitor(tx, share, creds.EndpointHash); + var item = tx.GetShareItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound); + if (!item.IsGated) throw new WebDropException(ErrorCode.BadRequest); + long now = clock.NowMs; + if (item.CloseDeadline is { } d && (fromIndex == 0 || now > d)) throw new WebDropException(ErrorCode.Gone); + if (fromIndex == 0) + { + if (item.MaxDownloads > 0 && item.Downloads >= item.MaxDownloads) throw new WebDropException(ErrorCode.Gone); + long count = item.Downloads + 1; + long? closeAt = item.MaxDownloads > 0 && count >= item.MaxDownloads + ? now + limits.DownloadGraceSeconds * 1000L + : item.CloseDeadline; + tx.UpdateShareItem(item with { Downloads = count, CloseDeadline = closeAt }); + } + return 0; + }); + } + + public async Task ReadItemFramesAsync(Id16 id, Credentials creds, Id16 itemId, uint fromIndex, CancellationToken ct = default) + { + var frames = store.Read(tx => + { + _ = SealedForRead(tx.GetShare(id), creds); + var item = tx.GetShareItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound); + var list = tx.ListShareFrames(id, itemId, fromIndex, 32); + uint expected = fromIndex; + foreach (var frame in list) + { + if (frame.Index != expected++) throw new WebDropException(ErrorCode.Gone); + } + return (Frames: list, Next: expected, Complete: item.FinalIndex is { } f && expected > f); + }); + var result = new List(frames.Frames.Count); + foreach (var frame in frames.Frames) result.Add(await LoadFrameAsync(frame).ConfigureAwait(false)); + return new ItemChunk(result, frames.Next, frames.Complete); + } + + public int Sweep() + { + int removed = 0; + long now = clock.NowMs; + var stale = store.Read(tx => tx.ListExpiredShareItems(now, 500)); + foreach (var group in stale.GroupBy(i => i.ShareId)) + { + foreach (var item in group) + { + bool didRemove = store.Write(tx => + { + if (tx.GetShareItem(item.ShareId, item.ItemId) is not { } current) return false; + if (current.ExpiresAt > now && (current.CloseDeadline is null || current.CloseDeadline > now)) + return false; + tx.DeleteShareItemRows(item.ShareId, item.ItemId); + tx.AddShareBytes(item.ShareId, -current.Bytes); + return true; + }); + if (didRemove) + { + blobs.DeletePrefix($"{group.Key.ToHex()}/{item.ItemId.ToHex()}"); + removed++; + } + } + } + + foreach (var id in store.Read(tx => tx.ListExpiredShares(now, 200))) + { + store.Write(tx => { tx.DeleteShareRows(id); return 0; }); + blobs.DeletePrefix(id.ToHex()); + removed++; + } + return removed; + } + + private Share SealedForRead(Share? candidate, Credentials creds) + { + var share = Live(candidate); + if (!share.IsSealed || creds.ClaimedRole != Role.Read || !TokenHash.Matches(creds.Token ?? [], share.ReadHash)) + throw new WebDropException(share.IsSealed ? ErrorCode.Unauthorized : ErrorCode.NotFound); + return share; + } + + private Share Draft(Share? candidate, Credentials creds) + { + var share = Live(candidate); + if (share.IsSealed) throw new WebDropException(ErrorCode.Sealed); + if (creds.ClaimedRole == Role.Read && TokenHash.Matches(creds.Token ?? [], share.ReadHash)) + throw new WebDropException(ErrorCode.Forbidden); + RequireOwner(share, creds); + return share; + } + + private void RequireOwner(Share share, Credentials creds) + { + if (creds.ClaimedRole != Role.Owner || !TokenHash.Matches(creds.Token ?? [], share.OwnerHash)) + throw new WebDropException(ErrorCode.Unauthorized); + } + + private Share Live(Share? candidate) + { + if (candidate is null || candidate.ExpiresAt <= clock.NowMs) throw new WebDropException(ErrorCode.NotFound); + return candidate; + } + + private void RegisterVisitor(IResourceTx tx, Share share, byte[] endpointHash) + { + if (share.CreatorEndpointHash is { } creator && CryptographicOperations.FixedTimeEquals(creator, endpointHash)) return; + if (tx.HasShareVisitor(share.Id, endpointHash)) return; + if (share.Settings.MaxVisitors > 0 && tx.CountShareVisitors(share.Id) >= share.Settings.MaxVisitors) + throw new WebDropException(ErrorCode.VisitorsFull); + tx.InsertShareVisitor(share.Id, endpointHash, clock.NowMs); + } + + private async ValueTask LoadFrameAsync(ShareFrame frame) + { + if (frame.Inline is { } bytes) return bytes; + if (frame.BlobKey is { } key && await blobs.ReadAsync(key).ConfigureAwait(false) is { } blob) return blob; + throw new WebDropException(ErrorCode.Gone); + } + + private void DeleteBlobIfUnreferenced(string? key) + { + if (key is not null && !store.Read(tx => tx.BlobKeyExists(key))) blobs.Delete(key); + } + + private static AppendResult Resolve(ShareFrame existing, byte[] digest) => + existing.Digest.AsSpan().SequenceEqual(digest) + ? new AppendResult { Seq = existing.AckSeq, Created = false } + : throw new WebDropException(ErrorCode.Conflict); + + private static ShareInfo ToInfo(Share share, IReadOnlyList items) => new() + { + Id = share.Id.ToBase64Url(), + State = share.IsSealed ? "sealed" : "draft", + ExpiresAt = share.ExpiresAt, + CreatedAt = share.CreatedAt, + SealedAt = share.SealedAt, + ManifestItemId = share.ManifestItemId?.ToBase64Url(), + Settings = share.Settings, + Items = items.Where(i => !share.IsSealed || i.ItemId != share.ManifestItemId).Select(i => new ShareItemInfo + { + ItemId = i.ItemId.ToBase64Url(), + Bytes = i.Bytes, + Frames = i.FrameCount, + MaxDownloads = i.MaxDownloads, + DownloadsLeft = i.MaxDownloads == 0 ? null : Math.Max(0, i.MaxDownloads - i.Downloads), + }).ToList(), + }; +} diff --git a/server/src/WebDrop.Core/WebDrop.Core.csproj b/server/src/WebDrop.Core/WebDrop.Core.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/server/src/WebDrop.Core/WebDrop.Core.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/server/src/WebDrop.Storage/DiskBlobStore.cs b/server/src/WebDrop.Storage/DiskBlobStore.cs new file mode 100644 index 0000000..a35ff20 --- /dev/null +++ b/server/src/WebDrop.Storage/DiskBlobStore.cs @@ -0,0 +1,90 @@ +using WebDrop.Core; + +namespace WebDrop.Storage; + +/// +/// 磁盘 blob 存储。键形如 "channelHex/itemHex/index.digest",直接映射为目录与文件。 +/// 键只能由服务生成(十六进制和数字),这里仍做路径逃逸检查作为纵深防御。 +/// +public sealed class DiskBlobStore : IBlobStore +{ + private readonly string _root; + + public DiskBlobStore(string root) + { + _root = Path.GetFullPath(root); + Directory.CreateDirectory(_root); + } + + public async ValueTask PutAsync(string key, ReadOnlyMemory data, CancellationToken ct = default) + { + string path = Resolve(key); + // 键里含内容摘要:文件已存在就意味着内容相同,无需重写(也避免并发覆盖在 Windows 上冲突) + if (File.Exists(path)) return; + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + + // 先写临时文件再重命名,读者永远看不到半个文件 + string tmp = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + await using (var fs = new FileStream(tmp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.Asynchronous)) + { + await fs.WriteAsync(data, ct).ConfigureAwait(false); + } + + try + { + File.Move(tmp, path, overwrite: false); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException && File.Exists(path)) + { + File.Delete(tmp); // 并发写入同一内容,对方先到了 + } + } + + public async ValueTask ReadAsync(string key, CancellationToken ct = default) + { + string path = Resolve(key); + try + { + return await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false); + } + catch (FileNotFoundException) + { + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + } + + public void Delete(string key) + { + try { File.Delete(Resolve(key)); } + catch (DirectoryNotFoundException) { } + } + + public void DeletePrefix(string prefix) + { + string dir = Resolve(prefix); + try { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); } + catch (DirectoryNotFoundException) { } + } + + public IEnumerable<(string Key, DateTimeOffset LastWrite)> Enumerate() + { + foreach (string file in Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories)) + { + if (file.EndsWith(".tmp", StringComparison.Ordinal)) continue; + string key = Path.GetRelativePath(_root, file).Replace('\\', '/'); + yield return (key, new DateTimeOffset(File.GetLastWriteTimeUtc(file), TimeSpan.Zero)); + } + } + + private string Resolve(string key) + { + string full = Path.GetFullPath(Path.Combine(_root, key)); + if (!full.StartsWith(_root + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + throw new ArgumentException("非法 blob 键", nameof(key)); + return full; + } +} diff --git a/server/src/WebDrop.Storage/SqliteChannelStore.cs b/server/src/WebDrop.Storage/SqliteChannelStore.cs new file mode 100644 index 0000000..55a447c --- /dev/null +++ b/server/src/WebDrop.Storage/SqliteChannelStore.cs @@ -0,0 +1,545 @@ +using System.Text.Json; +using Microsoft.Data.Sqlite; +using WebDrop.Core; + +namespace WebDrop.Storage; + +/// +/// SQLite 实现:WAL 模式,写入由单个连接加锁串行执行(保证 seq 严格连续),读取使用独立连接。 +/// 全部使用手写 SQL 与显式映射,不依赖反射,可 NativeAOT 发布。 +/// +public sealed class SqliteChannelStore : IResourceStore, IDisposable +{ + private const int SchemaVersion = 2; + + private readonly string _connectionString; + private readonly SqliteConnection _writeConn; + private readonly object _writeLock = new(); + + public SqliteChannelStore(string dbPath) + { + string? dir = Path.GetDirectoryName(Path.GetFullPath(dbPath)); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + + _connectionString = new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Mode = SqliteOpenMode.ReadWriteCreate, + Cache = SqliteCacheMode.Private, + Pooling = true, + }.ToString(); + + _writeConn = Open(); + Exec(_writeConn, "PRAGMA journal_mode=WAL;"); + Migrate(); + } + + public T Read(Func action) + { + using var conn = Open(); + // 显式事务让一次 Read 内的多条查询看到同一份快照 + using var tx = conn.BeginTransaction(); + return action(new SqliteTx(conn)); + } + + public T Write(Func action) + { + lock (_writeLock) + { + using var tx = _writeConn.BeginTransaction(); + try + { + T result = action(new SqliteTx(_writeConn)); + tx.Commit(); + return result; + } + catch + { + tx.Rollback(); + throw; + } + } + } + + public void Dispose() + { + _writeConn.Dispose(); + SqliteConnection.ClearAllPools(); + } + + private SqliteConnection Open() + { + var conn = new SqliteConnection(_connectionString); + conn.Open(); + Exec(conn, "PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=OFF;"); + return conn; + } + + private static void Exec(SqliteConnection conn, string sql) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } + + private void Migrate() + { + using var cmd = _writeConn.CreateCommand(); + cmd.CommandText = "PRAGMA user_version;"; + long version = (long)cmd.ExecuteScalar()!; + if (version > SchemaVersion) throw new InvalidOperationException("数据库版本高于本程序支持的版本"); + if (version == SchemaVersion) return; + + using var tx = _writeConn.BeginTransaction(); + if (version == 0) Exec(_writeConn, SchemaV1); + if (version < 2) Exec(_writeConn, SchemaV2); + Exec(_writeConn, $"PRAGMA user_version={SchemaVersion};"); + tx.Commit(); + } + + private const string SchemaV1 = """ + CREATE TABLE channels ( + id BLOB NOT NULL PRIMARY KEY, + mode INTEGER NOT NULL, + settings TEXT NOT NULL, + read_hash BLOB, + write_hash BLOB, + owner_hash BLOB NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + last_seq INTEGER NOT NULL DEFAULT 0, + compacted_through INTEGER NOT NULL DEFAULT 0, + bytes INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX ix_channels_expires ON channels(expires_at); + + CREATE TABLE seats ( + channel_id BLOB NOT NULL, + endpoint_hash BLOB NOT NULL, + label BLOB, + 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, + author_hash BLOB NOT NULL, + flags INTEGER NOT NULL, + max_downloads INTEGER NOT NULL, + downloads INTEGER NOT NULL, + frame_count INTEGER NOT NULL, + max_index INTEGER NOT NULL, + final_index INTEGER, + bytes INTEGER NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + close_deadline INTEGER, + label TEXT, + PRIMARY KEY (channel_id, item_id) + ); + CREATE INDEX ix_items_channel_created ON items(channel_id, created_at); + CREATE INDEX ix_items_expires ON items(expires_at); + CREATE INDEX ix_items_close ON items(close_deadline) WHERE close_deadline IS NOT NULL; + + CREATE TABLE frames ( + channel_id BLOB NOT NULL, + item_id BLOB NOT NULL, + frame_index INTEGER NOT NULL, + seq INTEGER NOT NULL, + length INTEGER NOT NULL, + digest BLOB NOT NULL, + inline_blob BLOB, + blob_key TEXT, + PRIMARY KEY (channel_id, item_id, frame_index) + ) WITHOUT ROWID; + CREATE INDEX ix_frames_blob ON frames(blob_key) WHERE blob_key IS NOT NULL; + + CREATE TABLE log ( + channel_id BLOB NOT NULL, + seq INTEGER NOT NULL, + kind INTEGER NOT NULL, + item_id BLOB NOT NULL, + frame_index INTEGER NOT NULL, + aux INTEGER NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (channel_id, seq) + ) WITHOUT ROWID; + CREATE INDEX ix_log_item ON log(channel_id, item_id); + """; + + private const string SchemaV2 = """ + CREATE TABLE shares ( + id BLOB NOT NULL PRIMARY KEY, + settings TEXT NOT NULL, + read_hash BLOB NOT NULL, + owner_hash BLOB, + creator_endpoint_hash BLOB, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + sealed_at INTEGER, + manifest_item_id BLOB, + bytes INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX ix_shares_expires ON shares(expires_at); + + CREATE TABLE share_visitors ( + share_id BLOB NOT NULL, + endpoint_hash BLOB NOT NULL, + joined_at INTEGER NOT NULL, + PRIMARY KEY (share_id, endpoint_hash) + ) WITHOUT ROWID; + + CREATE TABLE share_items ( + share_id BLOB NOT NULL, + item_id BLOB NOT NULL, + flags INTEGER NOT NULL, + max_downloads INTEGER NOT NULL, + downloads INTEGER NOT NULL, + frame_count INTEGER NOT NULL, + max_index INTEGER NOT NULL, + final_index INTEGER, + bytes INTEGER NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + close_deadline INTEGER, + PRIMARY KEY (share_id, item_id) + ) WITHOUT ROWID; + CREATE INDEX ix_share_items_expires ON share_items(expires_at); + CREATE INDEX ix_share_items_close ON share_items(close_deadline) WHERE close_deadline IS NOT NULL; + + CREATE TABLE share_frames ( + share_id BLOB NOT NULL, + item_id BLOB NOT NULL, + frame_index INTEGER NOT NULL, + seq INTEGER NOT NULL, + length INTEGER NOT NULL, + digest BLOB NOT NULL, + inline_blob BLOB, + blob_key TEXT, + PRIMARY KEY (share_id, item_id, frame_index) + ) WITHOUT ROWID; + CREATE INDEX ix_share_frames_blob ON share_frames(blob_key) WHERE blob_key IS NOT NULL; + """; + + private sealed class SqliteTx(SqliteConnection conn) : IResourceTx + { + private const string ChannelCols = "id, mode, settings, read_hash, write_hash, owner_hash, created_at, expires_at, last_seq, compacted_through, bytes"; + private const string ItemCols = "channel_id, item_id, author_hash, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline, label"; + private const string FrameCols = "channel_id, item_id, frame_index, seq, length, digest, inline_blob, blob_key"; + private const string ShareItemCols = "share_id, item_id, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline"; + private const string ShareFrameCols = "share_id, item_id, frame_index, seq, length, digest, inline_blob, blob_key"; + + // ───────── 通用辅助 ───────── + + private SqliteCommand Cmd(string sql, (string, object?)[] args) + { + var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + foreach (var (name, value) in args) cmd.Parameters.AddWithValue(name, value ?? DBNull.Value); + return cmd; + } + + private int Run(string sql, params (string, object?)[] args) + { + using var cmd = Cmd(sql, args); + return cmd.ExecuteNonQuery(); + } + + private object? Scalar(string sql, params (string, object?)[] args) + { + using var cmd = Cmd(sql, args); + return cmd.ExecuteScalar(); + } + + private T? One(string sql, Func map, params (string, object?)[] args) where T : class + { + using var cmd = Cmd(sql, args); + using var r = cmd.ExecuteReader(); + return r.Read() ? map(r) : null; + } + + private List Many(string sql, Func map, params (string, object?)[] args) + { + using var cmd = Cmd(sql, args); + using var r = cmd.ExecuteReader(); + var list = new List(); + while (r.Read()) list.Add(map(r)); + return list; + } + + private static byte[] Blob(SqliteDataReader r, int i) => (byte[])r.GetValue(i); + private static byte[]? BlobOrNull(SqliteDataReader r, int i) => r.IsDBNull(i) ? null : (byte[])r.GetValue(i); + private static long? LongOrNull(SqliteDataReader r, int i) => r.IsDBNull(i) ? null : r.GetInt64(i); + private static Id16 IdAt(SqliteDataReader r, int i) => Id16.FromBytes(Blob(r, i)); + + // ───────── 便捷分享 ───────── + + private static Share MapShare(SqliteDataReader r) => new( + IdAt(r, 0), + JsonSerializer.Deserialize(r.GetString(1), CoreJsonContext.Default.ShareSettings)!, + Blob(r, 2), BlobOrNull(r, 3), BlobOrNull(r, 4), r.GetInt64(5), r.GetInt64(6), + LongOrNull(r, 7), r.IsDBNull(8) ? null : IdAt(r, 8), r.GetInt64(9)); + + public Share? GetShare(Id16 id) => One( + "SELECT id,settings,read_hash,owner_hash,creator_endpoint_hash,created_at,expires_at,sealed_at,manifest_item_id,bytes FROM shares WHERE id=@id", + MapShare, ("@id", id.ToArray())); + + public bool HasResourceId(Id16 id) => Scalar( + "SELECT 1 FROM channels WHERE id=@id UNION ALL SELECT 1 FROM shares WHERE id=@id LIMIT 1", + ("@id", id.ToArray())) is not null; + + public void InsertShare(Share s) => Run( + "INSERT INTO shares (id,settings,read_hash,owner_hash,creator_endpoint_hash,created_at,expires_at,sealed_at,manifest_item_id,bytes) VALUES (@id,@settings,@read,@owner,@creator,@created,@expires,@sealed,@manifest,@bytes)", + ("@id", s.Id.ToArray()), ("@settings", JsonSerializer.Serialize(s.Settings, CoreJsonContext.Default.ShareSettings)), + ("@read", s.ReadHash), ("@owner", s.OwnerHash), ("@creator", s.CreatorEndpointHash), + ("@created", s.CreatedAt), ("@expires", s.ExpiresAt), ("@sealed", s.SealedAt), + ("@manifest", s.ManifestItemId?.ToArray()), ("@bytes", s.Bytes)); + + public void UpdateShare(Share s) => Run( + "UPDATE shares SET settings=@settings,owner_hash=@owner,expires_at=@expires,sealed_at=@sealed,manifest_item_id=@manifest,bytes=@bytes WHERE id=@id", + ("@settings", JsonSerializer.Serialize(s.Settings, CoreJsonContext.Default.ShareSettings)), ("@owner", s.OwnerHash), + ("@expires", s.ExpiresAt), ("@sealed", s.SealedAt), ("@manifest", s.ManifestItemId?.ToArray()), + ("@bytes", s.Bytes), ("@id", s.Id.ToArray())); + + public IReadOnlyList ListExpiredShares(long now, int limit) => Many( + "SELECT id FROM shares WHERE expires_at<=@now LIMIT @n", r => IdAt(r, 0), ("@now", now), ("@n", limit)); + + public int CountShareVisitors(Id16 id) => + (int)(long)Scalar("SELECT COUNT(*) FROM share_visitors WHERE share_id=@id", ("@id", id.ToArray()))!; + + public bool HasShareVisitor(Id16 id, ReadOnlySpan endpointHash) => Scalar( + "SELECT 1 FROM share_visitors WHERE share_id=@id AND endpoint_hash=@ep", + ("@id", id.ToArray()), ("@ep", endpointHash.ToArray())) is not null; + + public void InsertShareVisitor(Id16 id, ReadOnlySpan endpointHash, long joinedAt) => Run( + "INSERT INTO share_visitors (share_id,endpoint_hash,joined_at) VALUES (@id,@ep,@at)", + ("@id", id.ToArray()), ("@ep", endpointHash.ToArray()), ("@at", joinedAt)); + + public void DeleteShareRows(Id16 id) + { + byte[] key = id.ToArray(); + Run("DELETE FROM share_frames WHERE share_id=@id", ("@id", key)); + Run("DELETE FROM share_items WHERE share_id=@id", ("@id", key)); + Run("DELETE FROM share_visitors WHERE share_id=@id", ("@id", key)); + Run("DELETE FROM shares WHERE id=@id", ("@id", key)); + } + + private static ShareItem MapShareItem(SqliteDataReader r) => new( + IdAt(r, 0), IdAt(r, 1), (FrameFlags)r.GetInt32(2), r.GetInt64(3), r.GetInt64(4), r.GetInt32(5), + r.GetInt64(6), LongOrNull(r, 7), r.GetInt64(8), r.GetInt64(9), r.GetInt64(10), LongOrNull(r, 11)); + + public IReadOnlyList ListShareItems(Id16 id) => Many( + $"SELECT {ShareItemCols} FROM share_items WHERE share_id=@id ORDER BY created_at,item_id", MapShareItem, ("@id", id.ToArray())); + public int CountShareItems(Id16 id) => (int)(long)Scalar("SELECT COUNT(*) FROM share_items WHERE share_id=@id", ("@id", id.ToArray()))!; + public void AddShareBytes(Id16 id, long delta) => Run("UPDATE shares SET bytes=bytes+@d WHERE id=@id", ("@d", delta), ("@id", id.ToArray())); + public IReadOnlyList ListExpiredShareItems(long now, int limit) => Many( + $"SELECT {ShareItemCols} FROM share_items WHERE expires_at<=@now OR (close_deadline IS NOT NULL AND close_deadline<=@now) LIMIT @n", + MapShareItem, ("@now", now), ("@n", limit)); + + public ShareItem? GetShareItem(Id16 shareId, Id16 itemId) => One( + $"SELECT {ShareItemCols} FROM share_items WHERE share_id=@s AND item_id=@i", MapShareItem, + ("@s", shareId.ToArray()), ("@i", itemId.ToArray())); + public void InsertShareItem(ShareItem i) => Run( + "INSERT INTO share_items (share_id,item_id,flags,max_downloads,downloads,frame_count,max_index,final_index,bytes,created_at,expires_at,close_deadline) VALUES (@s,@i,@f,@md,@d,@fc,@mi,@fi,@b,@cr,@ex,@cd)", + ("@s", i.ShareId.ToArray()), ("@i", i.ItemId.ToArray()), ("@f", (int)i.Flags), + ("@md", i.MaxDownloads), ("@d", i.Downloads), ("@fc", i.FrameCount), ("@mi", i.MaxIndex), ("@fi", i.FinalIndex), + ("@b", i.Bytes), ("@cr", i.CreatedAt), ("@ex", i.ExpiresAt), ("@cd", i.CloseDeadline)); + public void UpdateShareItem(ShareItem i) => Run( + "UPDATE share_items SET downloads=@d,frame_count=@fc,max_index=@mi,final_index=@fi,bytes=@b,expires_at=@ex,close_deadline=@cd WHERE share_id=@s AND item_id=@i", + ("@d", i.Downloads), ("@fc", i.FrameCount), ("@mi", i.MaxIndex), ("@fi", i.FinalIndex), ("@b", i.Bytes), + ("@ex", i.ExpiresAt), ("@cd", i.CloseDeadline), ("@s", i.ShareId.ToArray()), ("@i", i.ItemId.ToArray())); + public void DeleteShareItemRows(Id16 shareId, Id16 itemId) + { + Run("DELETE FROM share_frames WHERE share_id=@s AND item_id=@i", ("@s", shareId.ToArray()), ("@i", itemId.ToArray())); + Run("DELETE FROM share_items WHERE share_id=@s AND item_id=@i", ("@s", shareId.ToArray()), ("@i", itemId.ToArray())); + } + + private static ShareFrame MapShareFrame(SqliteDataReader r) => new( + IdAt(r, 0), IdAt(r, 1), (uint)r.GetInt64(2), r.GetInt64(3), r.GetInt32(4), Blob(r, 5), BlobOrNull(r, 6), + r.IsDBNull(7) ? null : r.GetString(7)); + public ShareFrame? GetShareFrame(Id16 s, Id16 i, uint n) => One( + $"SELECT {ShareFrameCols} FROM share_frames WHERE share_id=@s AND item_id=@i AND frame_index=@n", MapShareFrame, + ("@s", s.ToArray()), ("@i", i.ToArray()), ("@n", (long)n)); + public void InsertShareFrame(ShareFrame f) => Run( + $"INSERT INTO share_frames ({ShareFrameCols}) VALUES (@s,@i,@n,@seq,@len,@digest,@inline,@key)", + ("@s", f.ShareId.ToArray()), ("@i", f.ItemId.ToArray()), ("@n", (long)f.Index), ("@seq", f.AckSeq), + ("@len", f.Length), ("@digest", f.Digest), ("@inline", f.Inline), ("@key", f.BlobKey)); + public IReadOnlyList ListShareFrames(Id16 s, Id16 i, uint from, int limit) => Many( + $"SELECT {ShareFrameCols} FROM share_frames WHERE share_id=@s AND item_id=@i AND frame_index>=@from ORDER BY frame_index LIMIT @n", + MapShareFrame, ("@s", s.ToArray()), ("@i", i.ToArray()), ("@from", (long)from), ("@n", limit)); + + // ───────── 频道 ───────── + + private static Channel MapChannel(SqliteDataReader r) => new( + IdAt(r, 0), + (ChannelMode)r.GetInt32(1), + JsonSerializer.Deserialize(r.GetString(2), CoreJsonContext.Default.ChannelSettings)!, + BlobOrNull(r, 3), + BlobOrNull(r, 4), + Blob(r, 5), + r.GetInt64(6), r.GetInt64(7), r.GetInt64(8), r.GetInt64(9), r.GetInt64(10)); + + public Channel? GetChannel(Id16 id) => + One($"SELECT {ChannelCols} FROM channels WHERE id=@id", MapChannel, ("@id", id.ToArray())); + + public void InsertChannel(Channel c) => Run( + $"INSERT INTO channels ({ChannelCols}) VALUES (@id,@mode,@settings,@rh,@wh,@oh,@created,@expires,@seq,@comp,@bytes)", + ("@id", c.Id.ToArray()), ("@mode", (int)c.Mode), + ("@settings", JsonSerializer.Serialize(c.Settings, CoreJsonContext.Default.ChannelSettings)), + ("@rh", c.ReadHash), ("@wh", c.WriteHash), ("@oh", c.OwnerHash), + ("@created", c.CreatedAt), ("@expires", c.ExpiresAt), + ("@seq", c.LastSeq), ("@comp", c.CompactedThrough), ("@bytes", c.Bytes)); + + public void UpdateChannel(Id16 id, ChannelSettings settings, long expiresAt) => Run( + "UPDATE channels SET settings=@s, expires_at=@e WHERE id=@id", + ("@s", JsonSerializer.Serialize(settings, CoreJsonContext.Default.ChannelSettings)), + ("@e", expiresAt), ("@id", id.ToArray())); + + public void TouchChannel(Id16 id, long expiresAt) => + Run("UPDATE channels SET expires_at=@e WHERE id=@id", ("@e", expiresAt), ("@id", id.ToArray())); + + public void AddChannelBytes(Id16 id, long delta) => + Run("UPDATE channels SET bytes=bytes+@d WHERE id=@id", ("@d", delta), ("@id", id.ToArray())); + + public void SetCompactedThrough(Id16 id, long seq) => + Run("UPDATE channels SET compacted_through=@s WHERE id=@id", ("@s", seq), ("@id", id.ToArray())); + + public long NextSeq(Id16 id) => + (long)Scalar("UPDATE channels SET last_seq=last_seq+1 WHERE id=@id RETURNING last_seq", ("@id", id.ToArray()))!; + + public void DeleteChannelRows(Id16 id) + { + byte[] key = id.ToArray(); + 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)); + } + + public IReadOnlyList 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), BlobOrNull(r, 2)); + + public Seat? GetSeat(Id16 channelId, ReadOnlySpan endpointHash) => One( + "SELECT endpoint_hash, joined_at, label 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 void InsertSeat(Id16 channelId, Seat seat) => Run( + "INSERT INTO seats (channel_id, endpoint_hash, label, joined_at) VALUES (@c,@e,@l,@j)", + ("@c", channelId.ToArray()), ("@e", seat.EndpointHash), ("@l", seat.Label), ("@j", seat.JoinedAt)); + + public bool DeleteSeat(Id16 channelId, ReadOnlySpan endpointHash) => Run( + "DELETE FROM seats WHERE channel_id=@c AND endpoint_hash=@e", + ("@c", channelId.ToArray()), ("@e", endpointHash.ToArray())) > 0; + + public void SetSeatLabel(Id16 channelId, ReadOnlySpan endpointHash, byte[] label) => Run( + "UPDATE seats SET label=@l WHERE channel_id=@c AND endpoint_hash=@e", + ("@l", label), ("@c", channelId.ToArray()), ("@e", endpointHash.ToArray())); + + public IReadOnlyList ListSeats(Id16 channelId) => Many( + "SELECT endpoint_hash, joined_at, label FROM seats WHERE channel_id=@c ORDER BY joined_at, endpoint_hash", + MapSeat, ("@c", channelId.ToArray())); + + // ───────── 条目 ───────── + + private static Item MapItem(SqliteDataReader r) => new( + IdAt(r, 0), IdAt(r, 1), Blob(r, 2), (FrameFlags)r.GetInt32(3), + r.GetInt64(4), r.GetInt64(5), r.GetInt32(6), r.GetInt64(7), LongOrNull(r, 8), + r.GetInt64(9), r.GetInt64(10), r.GetInt64(11), LongOrNull(r, 12), + r.IsDBNull(13) ? null : r.GetString(13)); + + public Item? GetItem(Id16 channelId, Id16 itemId) => One( + $"SELECT {ItemCols} FROM items WHERE channel_id=@c AND item_id=@i", + MapItem, ("@c", channelId.ToArray()), ("@i", itemId.ToArray())); + + public void InsertItem(Item i) => Run( + $"INSERT INTO items ({ItemCols}) VALUES (@c,@i,@a,@f,@md,@d,@fc,@mi,@fi,@b,@cr,@ex,@cd,@l)", + ("@c", i.ChannelId.ToArray()), ("@i", i.ItemId.ToArray()), ("@a", i.AuthorHash), + ("@f", (int)i.Flags), ("@md", i.MaxDownloads), ("@d", i.Downloads), + ("@fc", i.FrameCount), ("@mi", i.MaxIndex), ("@fi", i.FinalIndex), + ("@b", i.Bytes), ("@cr", i.CreatedAt), ("@ex", i.ExpiresAt), ("@cd", i.CloseDeadline), ("@l", i.Label)); + + public void UpdateItem(Item i) => Run( + "UPDATE items SET downloads=@d, frame_count=@fc, max_index=@mi, final_index=@fi, bytes=@b, expires_at=@ex, close_deadline=@cd WHERE channel_id=@c AND item_id=@i", + ("@d", i.Downloads), ("@fc", i.FrameCount), ("@mi", i.MaxIndex), ("@fi", i.FinalIndex), + ("@b", i.Bytes), ("@ex", i.ExpiresAt), ("@cd", i.CloseDeadline), + ("@c", i.ChannelId.ToArray()), ("@i", i.ItemId.ToArray())); + + public void DeleteItemRows(Id16 channelId, Id16 itemId) + { + byte[] c = channelId.ToArray(), i = itemId.ToArray(); + Run("DELETE FROM frames WHERE channel_id=@c AND item_id=@i", ("@c", c), ("@i", i)); + // 墓碑保留,其余记录(帧、announce)随条目一起删除 + Run("DELETE FROM log WHERE channel_id=@c AND item_id=@i AND kind<>@t", ("@c", c), ("@i", i), ("@t", (int)LogKind.Tombstone)); + Run("DELETE FROM items WHERE channel_id=@c AND item_id=@i", ("@c", c), ("@i", i)); + } + + public int CountItems(Id16 channelId) => + (int)(long)Scalar("SELECT COUNT(*) FROM items WHERE channel_id=@c", ("@c", channelId.ToArray()))!; + + public IReadOnlyList ListOldestItems(Id16 channelId, int limit) => Many( + $"SELECT {ItemCols} FROM items WHERE channel_id=@c ORDER BY created_at, rowid LIMIT @n", + MapItem, ("@c", channelId.ToArray()), ("@n", limit)); + + public Item? FindSnapshotItem(Id16 channelId, Id16 excludeItemId) => One( + $"SELECT {ItemCols} FROM items WHERE channel_id=@c AND item_id<>@x AND (flags & @s)<>0 ORDER BY created_at DESC, rowid DESC LIMIT 1", + MapItem, ("@c", channelId.ToArray()), ("@x", excludeItemId.ToArray()), ("@s", (int)FrameFlags.Snapshot)); + + public IReadOnlyList ListExpiredItems(long now, int limit) => Many( + $"SELECT {ItemCols} FROM items WHERE expires_at<=@now OR (close_deadline IS NOT NULL AND close_deadline<=@now) LIMIT @n", + MapItem, ("@now", now), ("@n", limit)); + + // ───────── 帧 ───────── + + private static StoredFrame MapFrame(SqliteDataReader r) => new( + IdAt(r, 0), IdAt(r, 1), (uint)r.GetInt64(2), r.GetInt64(3), r.GetInt32(4), + Blob(r, 5), BlobOrNull(r, 6), r.IsDBNull(7) ? null : r.GetString(7)); + + public StoredFrame? GetFrame(Id16 channelId, Id16 itemId, uint index) => One( + $"SELECT {FrameCols} FROM frames WHERE channel_id=@c AND item_id=@i AND frame_index=@x", + MapFrame, ("@c", channelId.ToArray()), ("@i", itemId.ToArray()), ("@x", (long)index)); + + public void InsertFrame(StoredFrame f) => Run( + $"INSERT INTO frames ({FrameCols}) VALUES (@c,@i,@x,@s,@len,@dg,@inl,@bk)", + ("@c", f.ChannelId.ToArray()), ("@i", f.ItemId.ToArray()), ("@x", (long)f.Index), + ("@s", f.Seq), ("@len", f.Length), ("@dg", f.Digest), ("@inl", f.Inline), ("@bk", f.BlobKey)); + + public IReadOnlyList ListFrames(Id16 channelId, Id16 itemId, uint fromIndex, int limit) => Many( + $"SELECT {FrameCols} FROM frames WHERE channel_id=@c AND item_id=@i AND frame_index>=@x ORDER BY frame_index LIMIT @n", + MapFrame, ("@c", channelId.ToArray()), ("@i", itemId.ToArray()), ("@x", (long)fromIndex), ("@n", limit)); + + public bool BlobKeyExists(string blobKey) => + Scalar("SELECT 1 FROM frames WHERE blob_key=@k UNION ALL SELECT 1 FROM share_frames WHERE blob_key=@k LIMIT 1", ("@k", blobKey)) is not null; + + // ───────── 日志 ───────── + + public void InsertLog(Id16 channelId, LogEntry e) => Run( + "INSERT INTO log (channel_id, seq, kind, item_id, frame_index, aux, created_at) VALUES (@c,@s,@k,@i,@x,@a,@t)", + ("@c", channelId.ToArray()), ("@s", e.Seq), ("@k", (int)e.Kind), ("@i", e.ItemId.ToArray()), + ("@x", (long)e.FrameIndex), ("@a", (int)e.Aux), ("@t", e.CreatedAt)); + + public IReadOnlyList ReadLog(Id16 channelId, long fromSeq, int limit) => Many( + "SELECT seq, kind, item_id, frame_index, aux, created_at FROM log WHERE channel_id=@c AND seq>=@s ORDER BY seq LIMIT @n", + r => new LogEntry(r.GetInt64(0), (LogKind)r.GetInt32(1), IdAt(r, 2), (uint)r.GetInt64(3), (byte)r.GetInt32(4), r.GetInt64(5)), + ("@c", channelId.ToArray()), ("@s", fromSeq), ("@n", limit)); + + public long PruneTombstones(Id16 channelId, long olderThanMs) + { + byte[] c = channelId.ToArray(); + var max = Scalar("SELECT MAX(seq) FROM log WHERE channel_id=@c AND kind=@k AND created_at<@t", + ("@c", c), ("@k", (int)LogKind.Tombstone), ("@t", olderThanMs)); + if (max is not long seq) return 0; + Run("DELETE FROM log WHERE channel_id=@c AND kind=@k AND seq<=@s AND created_at<@t", + ("@c", c), ("@k", (int)LogKind.Tombstone), ("@s", seq), ("@t", olderThanMs)); + return seq; + } + } +} diff --git a/server/src/WebDrop.Storage/WebDrop.Storage.csproj b/server/src/WebDrop.Storage/WebDrop.Storage.csproj new file mode 100644 index 0000000..a1230ba --- /dev/null +++ b/server/src/WebDrop.Storage/WebDrop.Storage.csproj @@ -0,0 +1,17 @@ + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/server/tests/WebDrop.Api.Tests/CapTests.cs b/server/tests/WebDrop.Api.Tests/CapTests.cs new file mode 100644 index 0000000..156aadb --- /dev/null +++ b/server/tests/WebDrop.Api.Tests/CapTests.cs @@ -0,0 +1,189 @@ +using System.Net; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using WebDrop.Api; +using WebDrop.Core; +using static WebDrop.Api.Tests.ServerFixture; + +namespace WebDrop.Api.Tests; + +/// +/// 只实现 Cap Standalone 的 POST /{site_key}/siteverify:请求体 {secret, response},响应 {success}。 +/// 规则:secret 必须是 "s3cret",token 必须以 "good-" 开头,且每个 token 只能用一次。 +/// +public sealed class FakeCap : IAsyncDisposable +{ + private readonly WebApplication _app; + private readonly HashSet _used = []; + public List<(string Secret, string Response)> Calls { get; } = []; + public bool Broken { get; set; } + public Uri BaseUri { get; private set; } = null!; + + public const string Secret = "s3cret"; + public string SiteverifyUrl => new Uri(BaseUri, "/site1/siteverify").ToString(); + + public FakeCap() + { + var builder = WebApplication.CreateSlimBuilder(); + builder.Configuration["urls"] = "http://127.0.0.1:0"; + builder.Logging.ClearProviders(); + _app = builder.Build(); + + _app.MapPost("/{site}/siteverify", async (HttpContext ctx) => + { + using var doc = await JsonDocument.ParseAsync(ctx.Request.Body); + string secret = doc.RootElement.GetProperty("secret").GetString()!; + string response = doc.RootElement.GetProperty("response").GetString()!; + lock (Calls) Calls.Add((secret, response)); + + if (Broken) return Results.StatusCode(500); + bool ok; + lock (_used) ok = secret == Secret && response.StartsWith("good-", StringComparison.Ordinal) && _used.Add(response); + return Results.Json(new { success = ok }); + }); + } + + public async Task StartAsync() + { + await _app.StartAsync(); + BaseUri = new Uri(_app.Services.GetRequiredService().Features.Get()!.Addresses.First()); + } + + public async Task StopAsync() => await _app.StopAsync(); + + public async ValueTask DisposeAsync() => await _app.DisposeAsync(); +} + +public class CapTests : IAsyncLifetime +{ + private const string WidgetEndpoint = "https://cap.example.test/site1/"; + private readonly FakeCap _cap = new(); + private ServerFixture _server = null!; + + public async Task InitializeAsync() + { + await _cap.StartAsync(); + _server = ServerFixture.WithSettings(new() + { + ["WebDrop:Captcha:Provider"] = "cap", + ["WebDrop:Captcha:ApiEndpoint"] = WidgetEndpoint, + ["WebDrop:Captcha:SiteverifyUrl"] = _cap.SiteverifyUrl, + ["WebDrop:Captcha:Secret"] = FakeCap.Secret, + ["WebDrop:Captcha:TimeoutSeconds"] = "2", + }); + await _server.InitializeAsync(); + } + + public async Task DisposeAsync() + { + await _server.DisposeAsync(); + await _cap.DisposeAsync(); + } + + [Fact] + public async Task InfoAdvertisesTheWidgetEndpointButNeverTheSecret() + { + using var resp = await _server.Http.GetAsync("/api/v1/info"); + string text = await resp.Content.ReadAsStringAsync(); + + var captcha = JsonDocument.Parse(text).RootElement.GetProperty("captcha"); + Assert.Equal("cap", captcha.GetProperty("provider").GetString()); + Assert.True(captcha.GetProperty("enabled").GetBoolean()); + Assert.Equal(WidgetEndpoint, captcha.GetProperty("api_endpoint").GetString()); + + Assert.DoesNotContain(FakeCap.Secret, text); + Assert.DoesNotContain("siteverify", text); + } + + [Fact] + public async Task CreateWithoutATokenIsRefusedAndCapIsNotCalled() + { + var (_, resp) = await _server.TryCreateAsync(); + using (resp) await AssertErrorAsync(resp, 428, "captcha_required"); + Assert.Empty(_cap.Calls); + } + + [Fact] + public async Task CreateWithAnInvalidTokenIsRefused() + { + var (_, resp) = await _server.TryCreateAsync(capToken: "bad-token"); + using (resp) await AssertErrorAsync(resp, 428, "captcha_required"); + Assert.Single(_cap.Calls); + } + + [Fact] + public async Task CreateWithAValidTokenSucceedsAndForwardsTheSecretOnlyToCap() + { + var (ch, resp) = await _server.TryCreateAsync(capToken: "good-1"); + using (resp) Assert.Equal(HttpStatusCode.Created, resp.StatusCode); + + var call = Assert.Single(_cap.Calls); + Assert.Equal(FakeCap.Secret, call.Secret); + Assert.Equal("good-1", call.Response); + + // 创建之后的普通操作不需要验证码 + using var get = await _server.Http.SendAsync(_server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner)); + Assert.Equal(HttpStatusCode.OK, get.StatusCode); + Assert.Single(_cap.Calls); + } + + [Fact] + public async Task ATokenCannotBeUsedTwice() + { + var (_, first) = await _server.TryCreateAsync(capToken: "good-once"); + using (first) Assert.Equal(HttpStatusCode.Created, first.StatusCode); + + var (_, second) = await _server.TryCreateAsync(capToken: "good-once"); + using (second) await AssertErrorAsync(second, 428, "captcha_required"); + } + + [Fact] + public async Task OversizedTokensAreRejectedWithoutBeingForwarded() + { + var (_, resp) = await _server.TryCreateAsync(capToken: "good-" + new string('x', 5000)); + using (resp) await AssertErrorAsync(resp, 428, "captcha_required"); + Assert.Empty(_cap.Calls); + } + + [Fact] + public async Task CapMalfunctionIsReportedAsUnavailableNotAsAUserFailure() + { + _cap.Broken = true; + var (_, resp) = await _server.TryCreateAsync(capToken: "good-while-broken"); + using (resp) await AssertErrorAsync(resp, 503, "captcha_unavailable"); + } + + [Fact] + public async Task CapBeingDownIsReportedAsUnavailable() + { + await _cap.StopAsync(); + var (_, resp) = await _server.TryCreateAsync(capToken: "good-while-down"); + using (resp) await AssertErrorAsync(resp, 503, "captcha_unavailable"); + } +} + +public class CaptchaConfigurationTests +{ + [Theory] + [InlineData("cap", "", "https://cap.example.test/s/", "s3cret")] // 缺 SiteverifyUrl + [InlineData("cap", "https://cap.example.test/s/siteverify", "", "s3cret")] // 缺 ApiEndpoint + [InlineData("cap", "https://cap.example.test/s/siteverify", "https://cap.example.test/s/", "")] // 缺 Secret + [InlineData("cap", "ftp://cap.example.test/s/siteverify", "https://cap.example.test/s/", "s3cret")] // 非 http(s) + [InlineData("bogus", "", "", "")] + public async Task IncompleteOrUnknownConfigurationFailsAtStartup(string provider, string verifyUrl, string apiEndpoint, string secret) + { + var server = ServerFixture.WithSettings(new() + { + ["WebDrop:Captcha:Provider"] = provider, + ["WebDrop:Captcha:SiteverifyUrl"] = verifyUrl, + ["WebDrop:Captcha:ApiEndpoint"] = apiEndpoint, + ["WebDrop:Captcha:Secret"] = secret, + }); + await Assert.ThrowsAsync(() => server.InitializeAsync()); + } +} diff --git a/server/tests/WebDrop.Api.Tests/RestTests.cs b/server/tests/WebDrop.Api.Tests/RestTests.cs new file mode 100644 index 0000000..7592202 --- /dev/null +++ b/server/tests/WebDrop.Api.Tests/RestTests.cs @@ -0,0 +1,432 @@ +using System.Net; +using System.Text.Json; +using WebDrop.Core; +using static WebDrop.Api.Tests.ServerFixture; + +namespace WebDrop.Api.Tests; + +public class RestTests(ServerFixture s) : IClassFixture +{ + private static byte[] Small(string item, byte fill = 0x42) => Frame(item, 0, FinalEnc, EncPayload, fill); + + [Fact] + public async Task InfoIsPublicAndResponsesCarrySecurityHeaders() + { + using var resp = await s.Http.GetAsync("/api/v1/info"); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + + var json = await ReadJsonAsync(resp); + Assert.Equal(1, json.GetProperty("protocol").GetInt32()); + Assert.True(json.GetProperty("transports").GetProperty("websocket").GetBoolean()); + Assert.Equal(1048592, json.GetProperty("limits").GetProperty("max_payload_bytes").GetInt32()); + // 默认不启用验证码,不下发 Cap 的地址 + var captcha = json.GetProperty("captcha"); + Assert.Equal("none", captcha.GetProperty("provider").GetString()); + Assert.False(captcha.GetProperty("enabled").GetBoolean()); + Assert.False(captcha.TryGetProperty("api_endpoint", out _)); + + Assert.Equal("no-referrer", resp.Headers.GetValues("Referrer-Policy").Single()); + Assert.Equal("nosniff", resp.Headers.GetValues("X-Content-Type-Options").Single()); + Assert.Equal("no-store", resp.Headers.GetValues("Cache-Control").Single()); + Assert.Contains("frame-ancestors 'none'", resp.Headers.GetValues("Content-Security-Policy").Single()); + Assert.False(resp.Headers.Contains("Server")); + } + + [Fact] + public async Task CreateReturnsChannelInfoAndRejectsDuplicates() + { + var ch = await s.CreateAsync(); + + using var get = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner)); + var info = await ReadJsonAsync(get); + Assert.Equal(ch.Id, info.GetProperty("id").GetString()); + Assert.Equal("enc", info.GetProperty("mode").GetString()); + Assert.Equal(1, info.GetProperty("seats_used").GetInt32()); + Assert.Equal(2, info.GetProperty("settings").GetProperty("seats").GetInt32()); + Assert.Equal("own", info.GetProperty("settings").GetProperty("delete_policy").GetString()); + Assert.Equal(1, info.GetProperty("oldest_seq").GetInt64()); + + // 同一个 id 再创建一次 + var body = new Dictionary + { + ["id"] = ch.Id, ["mode"] = "enc", + ["owner_hash"] = B64(TokenHash.Compute(ch.OwnerToken)), + ["read_hash"] = B64(TokenHash.Compute(ch.ReadToken)), + }; + using var req = s.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(body)); + await AssertErrorAsync(await s.Http.SendAsync(req), 409, "conflict"); + } + + [Fact] + public async Task CreateWithBadJsonIsRejected() + { + var ch = new Chan(NewId(), ChannelMode.Raw); + using var badJson = s.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, + new StringContent("{not json", System.Text.Encoding.UTF8, "application/json")); + await AssertErrorAsync(await s.Http.SendAsync(badJson), 400, "bad_request"); + } + + [Fact] + public async Task ErrorsUseStableCodes() + { + var ch = await s.CreateAsync(); + + // 令牌错误 + var wrong = new Who("owner", new byte[32], ch.OwnerSecret); + await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", wrong)), 401, "unauthorized"); + // 频道不存在 + await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{NewId()}", ch.Owner)), 404, "not_found"); + // id 格式错误 + await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, "/api/v1/channels/not-an-id", ch.Owner)), 400, "bad_request"); + // 缺少角色头 + using var noRole = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/channels/{ch.Id}"); + await AssertErrorAsync(await s.Http.SendAsync(noRole), 400, "bad_request"); + // 令牌格式错误 + using var badToken = s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner); + badToken.Headers.Authorization = new("Bearer", "###"); + await AssertErrorAsync(await s.Http.SendAsync(badToken), 400, "bad_request"); + // 未入席 + await AssertErrorAsync(await s.AppendAsync(ch, ch.Member, Small(NewId())), 403, "not_joined"); + } + + [Fact] + public async Task AppendIsIdempotentOverHttp() + { + var ch = await s.CreateAsync(); + string item = NewId(); + var frame = Small(item); + + using var first = await s.AppendAsync(ch, ch.Owner, frame); + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + Assert.Equal(1, (await ReadJsonAsync(first)).GetProperty("seq").GetInt64()); + + using var again = await s.AppendAsync(ch, ch.Owner, frame); + Assert.Equal(HttpStatusCode.OK, again.StatusCode); + Assert.Equal(1, (await ReadJsonAsync(again)).GetProperty("seq").GetInt64()); + + await AssertErrorAsync(await s.AppendAsync(ch, ch.Owner, Small(item, 0x43)), 409, "conflict"); + } + + [Fact] + public async Task LogReturnsBinaryRecordsWithLastSeqHeader() + { + var ch = await s.CreateAsync(); + string a = NewId(), b = NewId(); + await s.AppendAsync(ch, ch.Owner, Small(a, 1)); + await s.AppendAsync(ch, ch.Owner, Small(b, 2)); + + var (resp, body) = await s.GetBytesAsync($"/api/v1/channels/{ch.Id}/log?from=0", ch.Owner); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + Assert.Equal("application/octet-stream", resp.Content.Headers.ContentType!.MediaType); + Assert.Equal("2", resp.Headers.GetValues("X-WD-Last-Seq").Single()); + + var records = ParseLog(body); + Assert.Equal([1L, 2L], records.Select(r => r.Seq)); + Assert.All(records, r => Assert.Equal(LogKind.Frame, r.Kind)); + Assert.Equal(Small(a, 1), records[0].Body); + + var (_, tail) = await s.GetBytesAsync($"/api/v1/channels/{ch.Id}/log?from=2", ch.Owner); + Assert.Equal(2, ParseOne(tail).Seq); + var (_, none) = await s.GetBytesAsync($"/api/v1/channels/{ch.Id}/log?from=3", ch.Owner); + Assert.Empty(none); + } + + [Fact] + public async Task LongPollWakesWhenAFrameArrives() + { + var ch = await s.CreateAsync(); + await s.AppendAsync(ch, ch.Owner, Small(NewId())); + + var started = DateTime.UtcNow; + var poll = s.GetBytesAsync($"/api/v1/channels/{ch.Id}/log?from=2&wait=20", ch.Owner); + + await Task.Delay(300); + Assert.False(poll.IsCompleted); // 还在等 + await s.AppendAsync(ch, ch.Owner, Small(NewId())); + + var (_, body) = await poll.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(2, ParseOne(body).Seq); + Assert.True(DateTime.UtcNow - started < TimeSpan.FromSeconds(10)); + } + + [Fact] + public async Task LongPollReturnsEmptyAfterTheWaitExpires() + { + var ch = await s.CreateAsync(); + var (resp, body) = await s.GetBytesAsync($"/api/v1/channels/{ch.Id}/log?from=0&wait=1", ch.Owner); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + Assert.Empty(body); + } + + [Fact] + public async Task OversizedBodiesAreRejected() + { + var ch = await s.CreateAsync(ChannelMode.Raw); + // 超过 1 MiB 负载上限但小于 Kestrel 的 2 MiB:服务自己拒绝 + await AssertErrorAsync(await s.AppendAsync(ch, ch.Owner, Frame(NewId(), 0, 1, 1_500_000)), 413, "too_large"); + // 超过 Kestrel 上限:框架拒绝,仍映射为同一错误码 + await AssertErrorAsync(await s.AppendAsync(ch, ch.Owner, Frame(NewId(), 0, 1, 3_000_000)), 413, "too_large"); + } + + [Fact] + public async Task RawChannelCarriesArbitraryBytesAndIsReadableWithoutAToken() + { + var ch = await s.CreateAsync(ChannelMode.Raw); + string item = NewId(); + using var put = await s.AppendAsync(ch, ch.Owner, Frame(item, 0, 1, 300, 0x7E)); // 非 enc,任意长度 + Assert.Equal(HttpStatusCode.Created, put.StatusCode); + + var anonymous = new Who("read", null, ch.MemberSecret); + using var join = await s.JoinAsync(ch, anonymous); + Assert.Equal(HttpStatusCode.OK, join.StatusCode); + + var (_, body) = await s.GetBytesAsync($"/api/v1/channels/{ch.Id}/log", anonymous); + Assert.Equal(Frame(item, 0, 1, 300, 0x7E), ParseOne(body).Body); + } + + [Fact] + public async Task SeatsJoinListAndRelease() + { + var ch = await s.CreateAsync(); + + using var joined = await s.JoinAsync(ch, ch.Reader); + var j = await ReadJsonAsync(joined); + Assert.Equal(2, j.GetProperty("seats_used").GetInt32()); + Assert.Equal(2, j.GetProperty("seats_max").GetInt32()); + string memberHash = j.GetProperty("endpoint_hash").GetString()!; + + // 第三个终端被拒绝 + var third = new Who("read", ch.ReadToken, System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)); + await AssertErrorAsync(await s.JoinAsync(ch, third), 403, "seats_full"); + + // 成员不能看席位列表,所有者可以 + await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/seats", ch.Reader)), 403, "forbidden"); + using var list = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/seats", ch.Owner)); + Assert.Equal(2, (await ReadJsonAsync(list)).GetArrayLength()); + + // 成员上传加密设备名 + using var label = await s.Http.SendAsync(s.Req(HttpMethod.Put, $"/api/v1/channels/{ch.Id}/seats/me", ch.Reader, Bytes([9, 8, 7]))); + Assert.Equal(HttpStatusCode.NoContent, label.StatusCode); + using var list2 = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/seats", ch.Owner)); + Assert.Contains((await ReadJsonAsync(list2)).EnumerateArray(), e => e.TryGetProperty("label", out var l) && l.GetString() == B64([9, 8, 7])); + + // 所有者释放后,成员失去访问 + using var release = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/seats/{memberHash}", ch.Owner)); + Assert.Equal(HttpStatusCode.NoContent, release.StatusCode); + await AssertErrorAsync(await s.AppendAsync(ch, ch.Member, Small(NewId())), 403, "not_joined"); + } + + [Fact] + public async Task SettingsCanOnlyBeChangedByTheOwner() + { + var ch = await s.CreateAsync(); + await s.JoinAsync(ch, ch.Member); + + var patch = () => JsonBody(new { seats = 4, members_can_upload = false }); + await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Member, patch())), 403, "forbidden"); + + using var ok = await s.Http.SendAsync(s.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, patch())); + var settings = (await ReadJsonAsync(ok)).GetProperty("settings"); + Assert.Equal(4, settings.GetProperty("seats").GetInt32()); + Assert.False(settings.GetProperty("members_can_upload").GetBoolean()); + + await AssertErrorAsync(await s.AppendAsync(ch, ch.Member, Small(NewId())), 403, "forbidden"); + await AssertErrorAsync( + await s.Http.SendAsync(s.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { seats = 0 }))), + 400, "bad_request"); + } + + [Fact] + public async Task DeleteItemLeavesATombstone() + { + var ch = await s.CreateAsync(); + string item = NewId(); + await s.AppendAsync(ch, ch.Owner, Small(item)); + + using var del = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/items/{item}", ch.Owner)); + Assert.Equal(HttpStatusCode.NoContent, del.StatusCode); + + var (_, body) = await s.GetBytesAsync($"/api/v1/channels/{ch.Id}/log", ch.Owner); + var tombstone = ParseOne(body); + Assert.Equal(LogKind.Tombstone, tombstone.Kind); + Assert.Equal((byte)TombstoneReason.Deleted, tombstone.Body[16]); + + await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/items/{item}", ch.Owner)), 404, "not_found"); + } + + [Fact] + public async Task GatedItemIsAnnouncedAndDownloadedWithACountedLimit() + { + var ch = await s.CreateAsync(); + string item = NewId(); + var frames = new[] + { + Frame(item, 0, GatedEnc, EncPayload, 1), + Frame(item, 1, GatedEnc, EncPayload, 2), + Frame(item, 2, GatedFinalEnc, EncPayload, 3), + }; + foreach (var f in frames) Assert.True((await s.AppendAsync(ch, ch.Owner, f, maxDownloads: "1")).IsSuccessStatusCode); + + // 日志里只有一条 announce + var (_, logBody) = await s.GetBytesAsync($"/api/v1/channels/{ch.Id}/log", ch.Owner); + Assert.Equal(LogKind.Announce, ParseOne(logBody).Kind); + + // 第一次下载拿到全部三帧,按顺序 + string path = $"/api/v1/channels/{ch.Id}/items/{item}/frames"; + var (resp, body) = await s.GetBytesAsync(path, ch.Owner); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + Assert.Equal(frames.SelectMany(f => f).ToArray(), body); + + // 上限为 1:第二次从头下载被拒绝,续传(from>0)在宽限期内仍可用 + await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, path, ch.Owner)), 410, "gone"); + var (_, resume) = await s.GetBytesAsync(path + "?from=2", ch.Owner); + Assert.Equal(frames[2], resume); + } + + [Fact] + public async Task DeleteChannelRemovesIt() + { + var ch = await s.CreateAsync(); + await s.AppendAsync(ch, ch.Owner, Small(NewId())); + + await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}", ch.Member)), 403, "forbidden"); + using var del = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}", ch.Owner)); + Assert.Equal(HttpStatusCode.NoContent, del.StatusCode); + await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner)), 404, "not_found"); + } + + [Fact] + public async Task ParallelClientsGetUniqueSeqsOverHttp() + { + var ch = await s.CreateAsync(); + var seqs = await Task.WhenAll(Enumerable.Range(0, 40).Select(async _ => + { + using var r = await s.AppendAsync(ch, ch.Owner, Small(NewId())); + Assert.Equal(HttpStatusCode.Created, r.StatusCode); + return (await ReadJsonAsync(r)).GetProperty("seq").GetInt64(); + })); + Assert.Equal(Enumerable.Range(1, 40).Select(i => (long)i), seqs.Order()); + } +} + +public class RateLimitTests +{ + [Fact] + public async Task ExceedingTheLimitReturns429WithTheStableErrorBody() + { + await using var server = await StartAsync(new() { ["WebDrop:RequestsPerMinute"] = "5" }); + + var codes = new List(); + for (int i = 0; i < 8; i++) + { + using var r = await server.Http.GetAsync("/api/v1/info"); + codes.Add((int)r.StatusCode); + if (r.StatusCode == HttpStatusCode.TooManyRequests) + Assert.Equal("rate_limited", (await ReadJsonAsync(r)).GetProperty("error").GetString()); + } + Assert.Equal(5, codes.Count(c => c == 200)); + Assert.Equal(3, codes.Count(c => c == 429)); + } + + [Fact] + public async Task ChannelCreationHasItsOwnStricterLimit() + { + await using var server = await StartAsync(new() { ["WebDrop:ChannelCreatesPerMinute"] = "2" }); + + var statuses = new List(); + for (int i = 0; i < 4; i++) + { + var ch = new Chan(NewId(), ChannelMode.Raw); + using var req = server.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, + JsonBody(new { id = ch.Id, mode = "raw", owner_hash = B64(TokenHash.Compute(ch.OwnerToken)) })); + using var r = await server.Http.SendAsync(req); + statuses.Add((int)r.StatusCode); + } + Assert.Equal([201, 201, 429, 429], statuses); + } + + private static async Task StartAsync(Dictionary overrides) + { + var f = ServerFixture.WithSettings(overrides); + await f.InitializeAsync(); + return f; + } +} + +public class ErrorResponseHeaderTests(ServerFixture s) : IClassFixture +{ + [Fact] + public async Task ErrorResponsesStillCarrySecurityHeaders() + { + using var resp = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{NewId()}", new Who("read", null, new byte[32]))); + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + Assert.Equal("no-referrer", resp.Headers.GetValues("Referrer-Policy").Single()); + Assert.Equal("nosniff", resp.Headers.GetValues("X-Content-Type-Options").Single()); + Assert.Equal("no-store", resp.Headers.GetValues("Cache-Control").Single()); + } +} + +public class CorsTests +{ + private const string Origin = "https://app.example.test"; + + private static async Task StartAsync() + { + var f = ServerFixture.WithSettings(new() { ["WebDrop:CorsOrigins:0"] = Origin }); + await f.InitializeAsync(); + return f; + } + + [Fact] + public async Task PreflightAllowsTheProtocolHeadersFromTheConfiguredOrigin() + { + await using var server = await StartAsync(); + using var req = new HttpRequestMessage(HttpMethod.Options, $"/api/v1/channels/{NewId()}/frames"); + req.Headers.Add("Origin", Origin); + req.Headers.Add("Access-Control-Request-Method", "POST"); + req.Headers.Add("Access-Control-Request-Headers", "authorization,x-wd-role,x-wd-endpoint,x-wd-max-downloads,x-wd-cap,content-type"); + + using var resp = await server.Http.SendAsync(req); + Assert.Equal(HttpStatusCode.NoContent, resp.StatusCode); + Assert.Equal(Origin, resp.Headers.GetValues("Access-Control-Allow-Origin").Single()); + string allowed = resp.Headers.GetValues("Access-Control-Allow-Headers").Single().ToLowerInvariant(); + Assert.Contains("x-wd-role", allowed); + Assert.Contains("authorization", allowed); + Assert.Contains("x-wd-cap", allowed); // 创建频道要带验证码 token + } + + [Fact] + public async Task ErrorResponsesAreReadableFromTheConfiguredOrigin() + { + await using var server = await StartAsync(); + using var req = server.Req(HttpMethod.Get, $"/api/v1/channels/{NewId()}", new Who("read", null, new byte[32])); + req.Headers.Add("Origin", Origin); + + using var resp = await server.Http.SendAsync(req); + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + // 浏览器端要能读到 4xx 的错误体,响应必须带 CORS 头 + Assert.Equal(Origin, resp.Headers.GetValues("Access-Control-Allow-Origin").Single()); + Assert.Equal("no-referrer", resp.Headers.GetValues("Referrer-Policy").Single()); + } + + [Fact] + public async Task OtherOriginsGetNoCorsHeaders() + { + await using var server = await StartAsync(); + using var req = new HttpRequestMessage(HttpMethod.Get, "/api/v1/info"); + req.Headers.Add("Origin", "https://evil.example.test"); + using var resp = await server.Http.SendAsync(req); + Assert.False(resp.Headers.Contains("Access-Control-Allow-Origin")); + } + + [Fact] + public async Task LastSeqHeaderIsExposedToBrowsers() + { + await using var server = await StartAsync(); + var ch = await server.CreateAsync(); + using var req = server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", ch.Owner); + req.Headers.Add("Origin", Origin); + using var resp = await server.Http.SendAsync(req); + Assert.Contains("X-WD-Last-Seq", resp.Headers.GetValues("Access-Control-Expose-Headers").Single()); + } +} diff --git a/server/tests/WebDrop.Api.Tests/ServerFixture.cs b/server/tests/WebDrop.Api.Tests/ServerFixture.cs new file mode 100644 index 0000000..bd614a7 --- /dev/null +++ b/server/tests/WebDrop.Api.Tests/ServerFixture.cs @@ -0,0 +1,199 @@ +using System.Buffers.Binary; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using WebDrop.Api; +using WebDrop.Core; + +namespace WebDrop.Api.Tests; + +/// 在真实 Kestrel 上启动服务(随机端口、临时数据目录),测试走真实的 HTTP / WebSocket / SSE。 +public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "webdrop-api-" + Guid.NewGuid().ToString("N")); + private readonly Dictionary _overrides; + + public WebApplication App { get; private set; } = null!; + public HttpClient Http { get; private set; } = null!; + public Uri BaseUri { get; private set; } = null!; + + public ServerFixture() : this([]) { } + + private ServerFixture(Dictionary overrides) => _overrides = overrides; + + /// xunit 要求 class fixture 只有一个公开构造函数,带配置覆盖的版本走工厂方法。 + public static ServerFixture WithSettings(Dictionary overrides) => new(overrides); + + public async Task InitializeAsync() + { + App = WebDropApp.Build([], b => + { + b.Configuration["WebDrop:DataDir"] = _dir; + b.Configuration["WebDrop:RequestsPerMinute"] = "1000000"; + b.Configuration["WebDrop:ChannelCreatesPerMinute"] = "1000000"; + foreach (var (key, value) in _overrides) b.Configuration[key] = value; + b.Configuration["urls"] = "http://127.0.0.1:0"; + b.Logging.ClearProviders(); + }); + await App.StartAsync(); + + string address = App.Services.GetRequiredService().Features.Get()!.Addresses.First(); + BaseUri = new Uri(address); + Http = new HttpClient { BaseAddress = BaseUri, Timeout = TimeSpan.FromSeconds(30) }; + } + + public async Task DisposeAsync() + { + Http.Dispose(); + await App.StopAsync(); + await App.DisposeAsync(); + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + try { Directory.Delete(_dir, recursive: true); } catch (IOException) { } + } + + ValueTask IAsyncDisposable.DisposeAsync() => new(DisposeAsync()); + + // ───── 客户端侧的凭据与请求构造(模拟浏览器从 K / A 派生出的令牌) ───── + + public sealed class Chan(string id, ChannelMode mode) + { + public string Id { get; } = id; + public ChannelMode Mode { get; } = mode; + public byte[] ReadToken { get; } = RandomNumberGenerator.GetBytes(32); + public byte[] WriteToken { get; } = RandomNumberGenerator.GetBytes(32); + public byte[] OwnerToken { get; } = RandomNumberGenerator.GetBytes(32); + public byte[] OwnerSecret { get; } = RandomNumberGenerator.GetBytes(32); + public byte[] MemberSecret { get; } = RandomNumberGenerator.GetBytes(32); + + public Who Owner => new("owner", OwnerToken, OwnerSecret); + public Who Member => new("write", WriteToken, MemberSecret); + public Who Reader => new("read", ReadToken, MemberSecret); + } + + public sealed record Who(string Role, byte[]? Token, byte[] EndpointSecret); + + public static string B64(byte[] bytes) => Codec.Encode(bytes); + public static string NewId() => B64(RandomNumberGenerator.GetBytes(16)); + + public HttpRequestMessage Req(HttpMethod method, string path, Who who, HttpContent? content = null) + { + var req = new HttpRequestMessage(method, path) { Content = content }; + req.Headers.Add("X-WD-Role", who.Role); + if (who.Token is not null) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", B64(who.Token)); + req.Headers.Add("X-WD-Endpoint", B64(who.EndpointSecret)); + return req; + } + + public async Task CreateAsync(ChannelMode mode = ChannelMode.Enc, object? settings = null) + { + var (ch, resp) = await TryCreateAsync(mode, settings); + using (resp) Assert.Equal(System.Net.HttpStatusCode.Created, resp.StatusCode); + return ch; + } + + /// 创建频道并返回原始响应,用于测试验证码等失败路径。 + public async Task<(Chan Chan, HttpResponseMessage Resp)> TryCreateAsync( + ChannelMode mode = ChannelMode.Enc, object? settings = null, string? capToken = null) + { + var ch = new Chan(NewId(), mode); + var body = new Dictionary + { + ["id"] = ch.Id, + ["mode"] = mode == ChannelMode.Enc ? "enc" : "raw", + ["owner_hash"] = B64(TokenHash.Compute(ch.OwnerToken)), + ["write_hash"] = B64(TokenHash.Compute(ch.WriteToken)), + }; + if (mode == ChannelMode.Enc) body["read_hash"] = B64(TokenHash.Compute(ch.ReadToken)); + if (settings is not null) body["settings"] = settings; + + using var req = Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(body)); + if (capToken is not null) req.Headers.Add("X-WD-Cap", capToken); + return (ch, await Http.SendAsync(req)); + } + + public static StringContent JsonBody(object value) => + new(JsonSerializer.Serialize(value), System.Text.Encoding.UTF8, "application/json"); + + public static ByteArrayContent Bytes(byte[] bytes) + { + var c = new ByteArrayContent(bytes); + c.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + return c; + } + + public async Task JoinAsync(Chan ch, Who who) => + await Http.SendAsync(Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", who)); + + public async Task AppendAsync(Chan ch, Who who, byte[] frame, string? maxDownloads = null) + { + var req = Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/frames", who, Bytes(frame)); + if (maxDownloads is not null) req.Headers.Add("X-WD-Max-Downloads", maxDownloads); + return await Http.SendAsync(req); + } + + public async Task<(HttpResponseMessage Resp, byte[] Body)> GetBytesAsync(string path, Who who) + { + var resp = await Http.SendAsync(Req(HttpMethod.Get, path, who)); + return (resp, await resp.Content.ReadAsByteArrayAsync()); + } + + public static async Task ReadJsonAsync(HttpResponseMessage resp) => + JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement; + + public static async Task AssertErrorAsync(HttpResponseMessage resp, int status, string code) + { + Assert.Equal(status, (int)resp.StatusCode); + Assert.Equal(code, (await ReadJsonAsync(resp)).GetProperty("error").GetString()); + } + + // ───── 帧与日志的线格式 ───── + + public const int EncPayload = 1024 + 16; + public const byte FinalEnc = 1 | 8; + public const byte GatedEnc = 4 | 8; + public const byte GatedFinalEnc = 4 | 8 | 1; + + public static byte[] Frame(string itemId, uint index, byte flags, int payloadLength, byte fill = 0x42) + { + Codec.TryDecodeExact(itemId, 16, out var item); + var bytes = new byte[26 + payloadLength]; + bytes[0] = 1; + bytes[1] = flags; + item.CopyTo(bytes, 2); + BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(18), index); + BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(22), (uint)payloadLength); + bytes.AsSpan(26).Fill(fill); + return bytes; + } + + public sealed record Rec(long Seq, LogKind Kind, byte[] Body); + + /// 解析 log 接口返回的记录拼接:seq(8) ‖ kind(1) ‖ body。frame 的 body 自带 payload_len,其余定长。 + public static List ParseLog(byte[] data) + { + var list = new List(); + int p = 0; + while (p < data.Length) + { + long seq = BinaryPrimitives.ReadInt64BigEndian(data.AsSpan(p)); + var kind = (LogKind)data[p + 8]; + int bodyLen = kind switch + { + LogKind.Frame => 26 + (int)BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(p + 9 + 22)), + LogKind.Tombstone => 17, + LogKind.Announce => 25, + _ => throw new InvalidOperationException("未知记录类型"), + }; + list.Add(new Rec(seq, kind, data[(p + 9)..(p + 9 + bodyLen)])); + p += 9 + bodyLen; + } + return list; + } + + public static Rec ParseOne(byte[] data) => Assert.Single(ParseLog(data)); +} diff --git a/server/tests/WebDrop.Api.Tests/ShareTests.cs b/server/tests/WebDrop.Api.Tests/ShareTests.cs new file mode 100644 index 0000000..884ebf1 --- /dev/null +++ b/server/tests/WebDrop.Api.Tests/ShareTests.cs @@ -0,0 +1,139 @@ +using System.Net; +using System.Security.Cryptography; +using WebDrop.Core; +using static WebDrop.Api.Tests.ServerFixture; + +namespace WebDrop.Api.Tests; + +public sealed class ShareTests(ServerFixture server) : IClassFixture +{ + private sealed class ShareClient(string id) + { + public string Id { get; } = id; + public byte[] ReadToken { get; } = RandomNumberGenerator.GetBytes(32); + public byte[] OwnerToken { get; } = RandomNumberGenerator.GetBytes(32); + public byte[] OwnerEndpoint { get; } = RandomNumberGenerator.GetBytes(32); + public byte[] VisitorEndpoint { get; } = RandomNumberGenerator.GetBytes(32); + public byte[] OtherEndpoint { get; } = RandomNumberGenerator.GetBytes(32); + + public Who Owner => new("owner", OwnerToken, OwnerEndpoint); + public Who CreatorReader => new("read", ReadToken, OwnerEndpoint); + public Who Visitor => new("read", ReadToken, VisitorEndpoint); + public Who OtherVisitor => new("read", ReadToken, OtherEndpoint); + } + + [Fact] + public async Task DraftSealsIntoReadOnlyShareWithVisitorAndDownloadLimits() + { + var share = new ShareClient(NewId()); + using (var req = server.Req(HttpMethod.Post, "/api/v1/shares", share.Owner, JsonBody(new + { + id = share.Id, + read_hash = B64(TokenHash.Compute(share.ReadToken)), + owner_hash = B64(TokenHash.Compute(share.OwnerToken)), + settings = new { ttl_seconds = 3600, max_visitors = 1, file_max_downloads = 1 }, + }))) + using (var created = await server.Http.SendAsync(req)) + { + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + Assert.Equal("draft", (await ReadJsonAsync(created)).GetProperty("state").GetString()); + } + + using (var draftRead = await server.Http.SendAsync(server.Req(HttpMethod.Get, $"/api/v1/shares/{share.Id}", share.Owner))) + Assert.Equal(HttpStatusCode.OK, draftRead.StatusCode); + await AssertErrorAsync(await server.Http.SendAsync(server.Req(HttpMethod.Get, + $"/api/v1/shares/{share.Id}", share.CreatorReader)), 404, "not_found"); + + string manifestId = NewId(); + byte[] manifest = Frame(manifestId, 0, FinalEnc, EncPayload, 0x11); + using (var putManifest = await server.Http.SendAsync(server.Req(HttpMethod.Post, + $"/api/v1/shares/{share.Id}/frames", share.Owner, Bytes(manifest)))) + Assert.Equal(HttpStatusCode.Created, putManifest.StatusCode); + + string fileId = NewId(); + byte[] first = Frame(fileId, 0, GatedEnc, EncPayload, 0x21); + byte[] last = Frame(fileId, 1, GatedFinalEnc, EncPayload, 0x22); + using (var putFirst = await SendFrame(share, share.Owner, first, "1")) + Assert.Equal(HttpStatusCode.Created, putFirst.StatusCode); + using (var putLast = await SendFrame(share, share.Owner, last)) + Assert.Equal(HttpStatusCode.Created, putLast.StatusCode); + + using (var finish = await server.Http.SendAsync(server.Req(HttpMethod.Post, + $"/api/v1/shares/{share.Id}/finish", share.Owner, JsonBody(new { manifest_item_id = manifestId })))) + { + Assert.Equal(HttpStatusCode.OK, finish.StatusCode); + var info = await ReadJsonAsync(finish); + Assert.Equal("sealed", info.GetProperty("state").GetString()); + Assert.Equal(manifestId, info.GetProperty("manifest_item_id").GetString()); + Assert.Single(info.GetProperty("items").EnumerateArray()); // manifest is not a downloadable file + } + + await AssertErrorAsync(await SendFrame(share, share.Owner, Frame(NewId(), 0, FinalEnc, EncPayload)), 409, "sealed"); + using (var invalidRole = await server.Http.SendAsync(server.Req(HttpMethod.Get, + $"/api/v1/shares/{share.Id}", new Who("write", share.ReadToken, share.OtherEndpoint)))) + await AssertErrorAsync(invalidRole, 400, "bad_request"); + + var (manifestResponse, manifestBody) = await server.GetBytesAsync( + $"/api/v1/shares/{share.Id}/manifest", share.CreatorReader); + using (manifestResponse) Assert.Equal(manifest, manifestBody); + + var (visitorManifestResponse, visitorManifest) = await server.GetBytesAsync( + $"/api/v1/shares/{share.Id}/manifest", share.Visitor); + using (visitorManifestResponse) Assert.Equal(manifest, visitorManifest); + await AssertErrorAsync(await server.Http.SendAsync(server.Req(HttpMethod.Get, + $"/api/v1/shares/{share.Id}/manifest", share.OtherVisitor)), 403, "visitors_full"); + + string downloadPath = $"/api/v1/shares/{share.Id}/items/{fileId}/frames"; + var (downloadResponse, downloaded) = await server.GetBytesAsync(downloadPath, share.Visitor); + using (downloadResponse) Assert.Equal(first.Concat(last), downloaded); + await AssertErrorAsync(await server.Http.SendAsync(server.Req(HttpMethod.Get, downloadPath, share.Visitor)), 410, "gone"); + var (resumeResponse, resumed) = await server.GetBytesAsync(downloadPath + "?from=1", share.Visitor); + using (resumeResponse) Assert.Equal(last, resumed); + + using var finalInfo = await server.Http.SendAsync(server.Req(HttpMethod.Get, + $"/api/v1/shares/{share.Id}", share.CreatorReader)); + var file = (await ReadJsonAsync(finalInfo)).GetProperty("items").EnumerateArray().Single(); + Assert.Equal(0, file.GetProperty("downloads_left").GetInt32()); + } + + [Fact] + public async Task ShareAndChannelIdsShareOneUniqueNamespace() + { + var share = new ShareClient(NewId()); + using (var created = await CreateShare(share)) Assert.Equal(HttpStatusCode.Created, created.StatusCode); + + byte[] channelOwnerToken = RandomNumberGenerator.GetBytes(32); + var channelOwner = new Who("owner", channelOwnerToken, RandomNumberGenerator.GetBytes(32)); + using (var collision = await server.Http.SendAsync(server.Req(HttpMethod.Post, "/api/v1/channels", channelOwner, + JsonBody(new { id = share.Id, mode = "raw", owner_hash = B64(TokenHash.Compute(channelOwnerToken)) })))) + await AssertErrorAsync(collision, 409, "conflict"); + + string channelId = NewId(); + byte[] secondOwnerToken = RandomNumberGenerator.GetBytes(32); + var secondOwner = new Who("owner", secondOwnerToken, RandomNumberGenerator.GetBytes(32)); + using (var channel = await server.Http.SendAsync(server.Req(HttpMethod.Post, "/api/v1/channels", secondOwner, + JsonBody(new { id = channelId, mode = "raw", owner_hash = B64(TokenHash.Compute(secondOwnerToken)) })))) + Assert.Equal(HttpStatusCode.Created, channel.StatusCode); + + using var shareCollision = await CreateShare(new ShareClient(channelId)); + await AssertErrorAsync(shareCollision, 409, "conflict"); + } + + private async Task CreateShare(ShareClient share) + { + using var req = server.Req(HttpMethod.Post, "/api/v1/shares", share.Owner, JsonBody(new + { + id = share.Id, + read_hash = B64(TokenHash.Compute(share.ReadToken)), + owner_hash = B64(TokenHash.Compute(share.OwnerToken)), + })); + return await server.Http.SendAsync(req); + } + + private async Task SendFrame(ShareClient share, Who who, byte[] bytes, string? maxDownloads = null) + { + using var req = server.Req(HttpMethod.Post, $"/api/v1/shares/{share.Id}/frames", who, Bytes(bytes)); + if (maxDownloads is not null) req.Headers.Add("X-WD-Max-Downloads", maxDownloads); + return await server.Http.SendAsync(req); + } +} diff --git a/server/tests/WebDrop.Api.Tests/StreamingTests.cs b/server/tests/WebDrop.Api.Tests/StreamingTests.cs new file mode 100644 index 0000000..df78590 --- /dev/null +++ b/server/tests/WebDrop.Api.Tests/StreamingTests.cs @@ -0,0 +1,242 @@ +using System.Net; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using WebDrop.Core; +using static WebDrop.Api.Tests.ServerFixture; + +namespace WebDrop.Api.Tests; + +public class StreamingTests(ServerFixture s) : IClassFixture +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(15); + + private static byte[] Small(string item, byte fill = 0x42) => Frame(item, 0, FinalEnc, EncPayload, fill); + + private async Task ConnectWs(Chan ch, Who who, long from = 0) + { + var ws = new ClientWebSocket(); + ws.Options.AddSubProtocol("wd.v1"); + ws.Options.AddSubProtocol($"wd.role.{who.Role}"); + if (who.Token is not null) ws.Options.AddSubProtocol($"wd.auth.{B64(who.Token)}"); + ws.Options.AddSubProtocol($"wd.ep.{B64(who.EndpointSecret)}"); + + var uri = new UriBuilder(s.BaseUri) { Scheme = "ws", Path = $"/api/v1/channels/{ch.Id}/ws", Query = $"from={from}" }.Uri; + using var cts = new CancellationTokenSource(Timeout); + await ws.ConnectAsync(uri, cts.Token); + return ws; + } + + private static async Task<(WebSocketMessageType Type, byte[] Data)> Receive(ClientWebSocket ws) + { + using var cts = new CancellationTokenSource(Timeout); + var buffer = new byte[2 * 1024 * 1024]; + int total = 0; + while (true) + { + var r = await ws.ReceiveAsync(buffer.AsMemory(total), cts.Token); + if (r.MessageType == WebSocketMessageType.Close) return (WebSocketMessageType.Close, []); + total += r.Count; + if (r.EndOfMessage) return (r.MessageType, buffer[..total]); + } + } + + [Fact] + public async Task WebSocketReplaysHistoryThenPushesNewFrames() + { + var ch = await s.CreateAsync(); + string a = NewId(), b = NewId(); + await s.AppendAsync(ch, ch.Owner, Small(a, 1)); + + using var ws = await ConnectWs(ch, ch.Owner); + Assert.Equal("wd.v1", ws.SubProtocol); + + var (type, data) = await Receive(ws); + Assert.Equal(WebSocketMessageType.Binary, type); + var first = ParseOne(data); + Assert.Equal(1, first.Seq); + Assert.Equal(Small(a, 1), first.Body); + + // 连接建立之后追加的帧会被实时推送 + await s.AppendAsync(ch, ch.Owner, Small(b, 2)); + var (_, pushed) = await Receive(ws); + Assert.Equal(2, ParseOne(pushed).Seq); + Assert.Equal(Small(b, 2), ParseOne(pushed).Body); + } + + [Fact] + public async Task WebSocketResumesFromTheRequestedSeq() + { + var ch = await s.CreateAsync(); + for (int i = 0; i < 4; i++) await s.AppendAsync(ch, ch.Owner, Small(NewId(), (byte)i)); + + using var ws = await ConnectWs(ch, ch.Owner, from: 3); + Assert.Equal(3, ParseOne((await Receive(ws)).Data).Seq); + Assert.Equal(4, ParseOne((await Receive(ws)).Data).Seq); + } + + [Fact] + public async Task WebSocketDeliversUploadsFromAnotherMember() + { + var ch = await s.CreateAsync(); + await s.JoinAsync(ch, ch.Member); + + using var owner = await ConnectWs(ch, ch.Owner); + using var member = await ConnectWs(ch, ch.Member); + + string item = NewId(); + using var put = await s.AppendAsync(ch, ch.Member, Small(item, 9)); + Assert.Equal(HttpStatusCode.Created, put.StatusCode); + + foreach (var ws in new[] { owner, member }) + { + var (_, data) = await Receive(ws); + Assert.Equal(Small(item, 9), ParseOne(data).Body); + } + } + + [Fact] + public async Task WebSocketUpgradeIsRefusedWithoutValidCredentials() + { + var ch = await s.CreateAsync(); + + // 令牌错误:在升级之前就以 HTTP 401 拒绝,而不是先建立连接再断开 + var bad = new Who("owner", new byte[32], ch.OwnerSecret); + var ex = await Assert.ThrowsAsync(() => ConnectWs(ch, bad)); + Assert.Contains("401", ex.Message); + + // 未入席 + var stranger = new Who("write", ch.WriteToken, new byte[32]); + var ex2 = await Assert.ThrowsAsync(() => ConnectWs(ch, stranger)); + Assert.Contains("403", ex2.Message); + } + + [Fact] + public async Task WebSocketNotifiesSeatChangesAndClosesWhenRevoked() + { + var ch = await s.CreateAsync(); + var joined = await s.JoinAsync(ch, ch.Member); + string memberHash = (await ReadJsonAsync(joined)).GetProperty("endpoint_hash").GetString()!; + + using var member = await ConnectWs(ch, ch.Member); + + // 所有者释放席位:订阅者先收到 seats 控制消息,随后被以 not_joined 关闭 + using var release = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/seats/{memberHash}", ch.Owner)); + Assert.Equal(HttpStatusCode.NoContent, release.StatusCode); + + bool closed = false; + for (int i = 0; i < 5 && !closed; i++) + { + var (type, data) = await Receive(member); + if (type == WebSocketMessageType.Close) + { + closed = true; + Assert.Equal(WebSocketCloseStatus.PolicyViolation, member.CloseStatus); + Assert.Equal("not_joined", member.CloseStatusDescription); + } + else if (type == WebSocketMessageType.Text) + { + Assert.Equal("seats", JsonDocument.Parse(data).RootElement.GetProperty("t").GetString()); + } + } + Assert.True(closed); + } + + [Fact] + public async Task WebSocketEndsWhenTheChannelIsDeleted() + { + var ch = await s.CreateAsync(); + using var ws = await ConnectWs(ch, ch.Owner); + + using var del = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}", ch.Owner)); + Assert.Equal(HttpStatusCode.NoContent, del.StatusCode); + + var (type, _) = await Receive(ws); + Assert.Equal(WebSocketMessageType.Close, type); + Assert.Equal("not_found", ws.CloseStatusDescription); + } + + [Fact] + public async Task WebSocketClientCanCloseCleanly() + { + var ch = await s.CreateAsync(); + using var ws = await ConnectWs(ch, ch.Owner); + using var cts = new CancellationTokenSource(Timeout); + await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, cts.Token); + Assert.Equal(WebSocketState.Closed, ws.State); + } + + // ───── SSE ───── + + private sealed record SseEvent(string Event, string? Id, string Data); + + private async IAsyncEnumerable ReadSse(string path, Who who, Dictionary? extraHeaders = null) + { + using var req = s.Req(HttpMethod.Get, path, who); + foreach (var (k, v) in extraHeaders ?? []) req.Headers.Add(k, v); + using var cts = new CancellationTokenSource(Timeout); + using var resp = await s.Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cts.Token); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + Assert.Equal("text/event-stream", resp.Content.Headers.ContentType!.MediaType); + + await using var stream = await resp.Content.ReadAsStreamAsync(cts.Token); + using var reader = new StreamReader(stream, Encoding.UTF8); + + string ev = "message", data = ""; + string? id = null; + while (await reader.ReadLineAsync(cts.Token) is { } line) + { + if (line.Length == 0) + { + if (data.Length > 0) yield return new SseEvent(ev, id, data); + ev = "message"; data = ""; id = null; + } + else if (line.StartsWith("event: ")) ev = line[7..]; + else if (line.StartsWith("id: ")) id = line[4..]; + else if (line.StartsWith("data: ")) data = line[6..]; + } + } + + private static byte[] DecodeB64(string text) => System.Buffers.Text.Base64Url.DecodeFromChars(text); + + [Fact] + public async Task SseStreamsHistoryThenNewFrames() + { + var ch = await s.CreateAsync(); + string a = NewId(), b = NewId(); + await s.AppendAsync(ch, ch.Owner, Small(a, 1)); + + await using var events = ReadSse($"/api/v1/channels/{ch.Id}/events", ch.Owner).GetAsyncEnumerator(); + + Assert.True(await events.MoveNextAsync()); + Assert.Equal("log", events.Current.Event); + Assert.Equal("1", events.Current.Id); + Assert.Equal(Small(a, 1), ParseOne(DecodeB64(events.Current.Data)).Body); + + await s.AppendAsync(ch, ch.Owner, Small(b, 2)); + Assert.True(await events.MoveNextAsync()); + Assert.Equal("2", events.Current.Id); + Assert.Equal(Small(b, 2), ParseOne(DecodeB64(events.Current.Data)).Body); + } + + [Fact] + public async Task SseResumesFromLastEventId() + { + var ch = await s.CreateAsync(); + for (int i = 0; i < 3; i++) await s.AppendAsync(ch, ch.Owner, Small(NewId(), (byte)i)); + + await using var events = ReadSse($"/api/v1/channels/{ch.Id}/events", ch.Owner, + new() { ["Last-Event-ID"] = "2" }).GetAsyncEnumerator(); + + Assert.True(await events.MoveNextAsync()); + Assert.Equal("3", events.Current.Id); // 断线前处理到 2,续传从 3 开始 + } + + [Fact] + public async Task SseRefusesBadCredentialsWithAJsonError() + { + var ch = await s.CreateAsync(); + var bad = new Who("owner", new byte[32], ch.OwnerSecret); + await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/events", bad)), 401, "unauthorized"); + } +} diff --git a/server/tests/WebDrop.Api.Tests/WebDrop.Api.Tests.csproj b/server/tests/WebDrop.Api.Tests/WebDrop.Api.Tests.csproj new file mode 100644 index 0000000..d381e04 --- /dev/null +++ b/server/tests/WebDrop.Api.Tests/WebDrop.Api.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs b/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs new file mode 100644 index 0000000..306e615 --- /dev/null +++ b/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs @@ -0,0 +1,654 @@ +using System.Buffers.Binary; +using WebDrop.Core; + +namespace WebDrop.Core.Tests; + +public class ChannelServiceTests : IDisposable +{ + private readonly TestEnv _env = new(); + private ChannelService Svc => _env.Service; + private readonly byte[] _ownerEp = TestEnv.Endpoint(); + private readonly byte[] _memberEp = TestEnv.Endpoint(); + + public void Dispose() => _env.Dispose(); + + // ───── 辅助 ───── + + private static async Task AssertCode(ErrorCode code, Func act) => + Assert.Equal(code, (await Assert.ThrowsAsync(act)).Code); + + private static void AssertCode(ErrorCode code, Action act) => + Assert.Equal(code, Assert.Throws(act).Code); + + private Task Append(TestChannel ch, Credentials who, byte[] frame, long? maxDownloads = null, string? label = null) => + Svc.AppendFrameAsync(ch.Id, who, frame, maxDownloads, label); + + private Task AppendSmall(TestChannel ch, Credentials who, Id16? item = null, byte fill = 0x42, FrameFlags extra = FrameFlags.None) => + Append(ch, who, TestEnv.Frame(item ?? TestEnv.NewId(), 0, TestEnv.SmallEnc | extra, TestEnv.EncPayload, fill)); + + private static (long Seq, LogKind Kind) Head(LogRecord r) => + (BinaryPrimitives.ReadInt64BigEndian(r.Bytes), (LogKind)r.Bytes[8]); + + private async Task> LogOf(TestChannel ch, Credentials who, long from = 0) => + (await Svc.ReadLogAsync(ch.Id, who, from, 1000)).Records.Select(Head).ToList(); + + // ───── 创建 ───── + + [Fact] + public void CreateRejectsDuplicateId() + { + var ch = _env.Create(_ownerEp); + var dup = new CreateChannelRequest + { + Id = ch.Id.ToBase64Url(), Mode = ChannelMode.Enc, + ReadHash = Codec.Encode(TokenHash.Compute(ch.ReadToken)), + OwnerHash = Codec.Encode(TokenHash.Compute(ch.OwnerToken)), + }; + AssertCode(ErrorCode.Conflict, () => Svc.CreateChannel(dup, _ownerEp)); + } + + [Fact] + public void CreateEnforcesModeSpecificHashes() + { + string h() => Codec.Encode(TokenHash.Compute(TestEnv.Random(32))); + var rawWithRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw, ReadHash = h(), OwnerHash = h() }; + var encNoRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Enc, OwnerHash = h() }; + var noOwner = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw }; + + AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(rawWithRead, _ownerEp)); + AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(encNoRead, _ownerEp)); + AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(noOwner, _ownerEp)); + } + + [Fact] + public void CreateSeatsTheOwnerAndReportsDefaults() + { + var ch = _env.Create(_ownerEp); + var info = Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)); + Assert.Equal(1, info.SeatsUsed); + Assert.Equal(2, info.Settings.Seats); + Assert.Equal(1, info.OldestSeq); + Assert.Equal(0, info.LastSeq); + } + + // ───── 鉴权与席位 ───── + + [Fact] + public void WrongTokenIsUnauthorized() + { + var ch = _env.Create(_ownerEp); + AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Owner, TestEnv.Random(32), _ownerEp))); + AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Read, null, _ownerEp))); + // 用读令牌冒充所有者 + AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Owner, ch.ReadToken, _ownerEp))); + } + + [Fact] + public void UnknownOrExpiredChannelIsNotFound() + { + var ch = _env.Create(_ownerEp); + AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(TestEnv.NewId(), ch.Owner(_ownerEp))); + _env.Clock.Advance(TimeSpan.FromHours(25)); + AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(ch.Id, ch.Owner(_ownerEp))); + } + + [Fact] + public void JoinIsIdempotentAndEnforcesSeatLimit() + { + var ch = _env.Create(_ownerEp); + var a = Svc.Join(ch.Id, ch.Reader(_memberEp)); + var again = Svc.Join(ch.Id, ch.Reader(_memberEp)); + Assert.Equal(a.EndpointHash, again.EndpointHash); + Assert.Equal(2, again.SeatsUsed); + Assert.Equal(2, again.SeatsMax); + + AssertCode(ErrorCode.SeatsFull, () => Svc.Join(ch.Id, ch.Reader(TestEnv.Endpoint()))); + Assert.Equal(1, _env.Events.Seats); // 只有真正新增时才通知 + } + + [Fact] + public async Task DataOperationsRequireASeat() + { + var ch = _env.Create(_ownerEp); + await AssertCode(ErrorCode.NotJoined, () => AppendSmall(ch, ch.Member(_memberEp))); + await AssertCode(ErrorCode.NotJoined, () => LogOf(ch, ch.Reader(_memberEp))); + } + + [Fact] + public async Task ReleasedSeatLosesAccess() + { + var ch = _env.Create(_ownerEp); + var joined = Svc.Join(ch.Id, ch.Member(_memberEp)); + await AppendSmall(ch, ch.Member(_memberEp)); + + Svc.ReleaseSeat(ch.Id, ch.Owner(_ownerEp), TokenHash.Compute(TestEnv.Random(1)).Length == 0 ? [] : Codec_Decode(joined.EndpointHash)); + await AssertCode(ErrorCode.NotJoined, () => AppendSmall(ch, ch.Member(_memberEp))); + } + + private static byte[] Codec_Decode(string b64) => + Codec.TryDecodeExact(b64, 32, out var bytes) ? bytes : throw new InvalidOperationException(); + + [Fact] + public void OnlyTheOwnerManagesSeatsAndSettings() + { + var ch = _env.Create(_ownerEp); + Svc.Join(ch.Id, ch.Member(_memberEp)); + + AssertCode(ErrorCode.Forbidden, () => Svc.ListSeats(ch.Id, ch.Member(_memberEp))); + AssertCode(ErrorCode.Forbidden, () => Svc.UpdateSettings(ch.Id, ch.Member(_memberEp), new ChannelSettingsPatch { Seats = 5 })); + AssertCode(ErrorCode.Forbidden, () => Svc.DeleteChannel(ch.Id, ch.Member(_memberEp))); + AssertCode(ErrorCode.Forbidden, () => Svc.ReleaseSeat(ch.Id, ch.Member(_memberEp), _ownerEp)); + + var seats = Svc.ListSeats(ch.Id, ch.Owner(_ownerEp)); + Assert.Equal(2, seats.Count); + } + + [Fact] + public void SeatLabelIsStoredAndBounded() + { + var ch = _env.Create(_ownerEp); + Svc.SetSeatLabel(ch.Id, ch.Owner(_ownerEp), [1, 2, 3]); + var mine = Svc.ListSeats(ch.Id, ch.Owner(_ownerEp)).Single(); + Assert.Equal(Codec.Encode([1, 2, 3]), mine.Label); + + AssertCode(ErrorCode.TooLarge, () => Svc.SetSeatLabel(ch.Id, ch.Owner(_ownerEp), new byte[257])); + AssertCode(ErrorCode.BadRequest, () => Svc.SetSeatLabel(ch.Id, ch.Owner(_ownerEp), [])); + } + + [Fact] + public void UpdateSettingsAppliesPatchAndRecomputesExpiry() + { + var ch = _env.Create(_ownerEp); + var before = Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)); + + _env.Clock.Advance(TimeSpan.FromMinutes(1)); + var after = Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { Seats = 3, TtlSeconds = 3600 }); + + Assert.Equal(3, after.Settings.Seats); + Assert.Equal(3600, after.Settings.TtlSeconds); + Assert.Equal(_env.Clock.NowMs + 3600_000L, after.ExpiresAt); + Assert.True(after.ExpiresAt < before.ExpiresAt); + Assert.Equal(1, _env.Events.Settings); + + AssertCode(ErrorCode.BadRequest, () => Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { Seats = 0 })); + } + + // ───── 追加:幂等、形状、权限 ───── + + [Fact] + public async Task AppendIsIdempotentAndDetectsConflicts() + { + var ch = _env.Create(_ownerEp); + var item = TestEnv.NewId(); + + var first = await AppendSmall(ch, ch.Owner(_ownerEp), item); + var same = await AppendSmall(ch, ch.Owner(_ownerEp), item); + Assert.True(first.Created); + Assert.False(same.Created); + Assert.Equal(first.Seq, same.Seq); + + await AssertCode(ErrorCode.Conflict, () => AppendSmall(ch, ch.Owner(_ownerEp), item, fill: 0x43)); + Assert.Single(await LogOf(ch, ch.Owner(_ownerEp))); + } + + [Fact] + public async Task AppendRejectsMalformedShapes() + { + var ch = _env.Create(_ownerEp); + var o = ch.Owner(_ownerEp); + var item = TestEnv.NewId(); + + // 非 GATED 条目必须是单帧且 FINAL + await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, TestEnv.Frame(item, 1, TestEnv.SmallEnc, TestEnv.EncPayload))); + await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Enc, TestEnv.EncPayload))); + // 快照不能是 GATED + await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Snapshot | FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload))); + // enc 负载必须是 档位+16 + await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, TestEnv.Frame(item, 0, TestEnv.SmallEnc, 1000))); + // 不是合法封装 + await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, new byte[10])); + // 超过负载上限 + await AssertCode(ErrorCode.TooLarge, () => Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Final | FrameFlags.Enc, _env.Limits.MaxPayloadBytes + 1))); + } + + [Fact] + public async Task EncFlagMustMatchChannelMode() + { + var enc = _env.Create(_ownerEp, ChannelMode.Enc); + var raw = _env.Create(_ownerEp, ChannelMode.Raw); + + await AssertCode(ErrorCode.ModeMismatch, () => Append(enc, enc.Owner(_ownerEp), TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 100))); + await AssertCode(ErrorCode.ModeMismatch, () => Append(raw, raw.Owner(_ownerEp), TestEnv.Frame(TestEnv.NewId(), 0, TestEnv.SmallEnc, TestEnv.EncPayload))); + + // raw 模式负载任意长度 + var ok = await Append(raw, raw.Owner(_ownerEp), TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 100)); + Assert.True(ok.Created); + } + + [Fact] + public async Task WritePermissionsAreEnforced() + { + var ch = _env.Create(_ownerEp); + Svc.Join(ch.Id, ch.Reader(_memberEp)); + + // 读令牌不能写 + await AssertCode(ErrorCode.Forbidden, () => AppendSmall(ch, ch.Reader(_memberEp))); + // 写令牌可以写 + Assert.True((await AppendSmall(ch, ch.Member(_memberEp))).Created); + + // 关闭成员上传后,写令牌失效,所有者仍可写 + Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { MembersCanUpload = false }); + await AssertCode(ErrorCode.Forbidden, () => AppendSmall(ch, ch.Member(_memberEp))); + Assert.True((await AppendSmall(ch, ch.Owner(_ownerEp))).Created); + } + + [Fact] + public async Task ChannelWithoutWriteHashRejectsWriteRole() + { + var ch = _env.Create(_ownerEp, withWriteHash: false); + Svc.Join(ch.Id, ch.Reader(_memberEp)); + await AssertCode(ErrorCode.Unauthorized, () => AppendSmall(ch, ch.Member(_memberEp))); + } + + [Fact] + public async Task RawChannelIsReadableByIdAloneButEncIsNot() + { + var raw = _env.Create(_ownerEp, ChannelMode.Raw); + var enc = _env.Create(_ownerEp, ChannelMode.Enc); + await Append(raw, raw.Owner(_ownerEp), TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 10)); + + var anonymous = new Credentials(Role.Read, null, _memberEp); + Svc.Join(raw.Id, anonymous); + Assert.Single((await Svc.ReadLogAsync(raw.Id, anonymous, 0, 10)).Records); + + AssertCode(ErrorCode.Unauthorized, () => Svc.Join(enc.Id, anonymous)); + } + + // ───── 日志 ───── + + [Fact] + public async Task LogReturnsRecordsInOrderAndSupportsResume() + { + var ch = _env.Create(_ownerEp); + var frames = new List(); + for (int i = 0; i < 3; i++) + { + var f = TestEnv.Frame(TestEnv.NewId(), 0, TestEnv.SmallEnc, TestEnv.EncPayload, (byte)(i + 1)); + frames.Add(f); + await Append(ch, ch.Owner(_ownerEp), f); + } + + var page = await Svc.ReadLogAsync(ch.Id, ch.Owner(_ownerEp), 0, 100); + Assert.Equal(3, page.LastSeq); + Assert.Equal([1L, 2L, 3L], page.Records.Select(r => Head(r).Seq)); + // 线格式 = seq(8) ‖ kind(1) ‖ 原封装 + for (int i = 0; i < 3; i++) + { + Assert.Equal(LogKind.Frame, Head(page.Records[i]).Kind); + Assert.Equal(frames[i], page.Records[i].Bytes[9..]); + } + + Assert.Equal(2, (await LogOf(ch, ch.Owner(_ownerEp), from: 2)).Count); + Assert.Empty(await LogOf(ch, ch.Owner(_ownerEp), from: 4)); + Assert.Single((await Svc.ReadLogAsync(ch.Id, ch.Owner(_ownerEp), 0, 1)).Records); // limit + } + + [Fact] + public async Task ResumingBeforeCompactionWatermarkIsGone() + { + _env.Limits.TombstoneRetentionSeconds = 1; + var ch = _env.Create(_ownerEp); + var o = ch.Owner(_ownerEp); + + var a = TestEnv.NewId(); + await AppendSmall(ch, o, a); // seq 1 + Svc.DeleteItem(ch.Id, o, a); // 墓碑 seq 2 + _env.Clock.Advance(TimeSpan.FromSeconds(5)); + await AppendSmall(ch, o); // seq 3,顺带清理过期墓碑 + + var info = Svc.GetChannel(ch.Id, o); + Assert.Equal(3, info.OldestSeq); + + var ex = await Assert.ThrowsAsync(() => Svc.ReadLogAsync(ch.Id, o, 2, 10)); + Assert.Equal(ErrorCode.Gone, ex.Code); + Assert.Equal(3, ex.OldestSeq); + + Assert.Single((await Svc.ReadLogAsync(ch.Id, o, 3, 10)).Records); // 水位之后可续传 + Assert.Single((await Svc.ReadLogAsync(ch.Id, o, 0, 10)).Records); // 全量同步永远允许 + } + + // ───── GATED 条目与下载 ───── + + private async Task UploadGated(TestChannel ch, Credentials who, int frames, long? maxDownloads = null) + { + var item = TestEnv.NewId(); + for (uint i = 0; i < frames; i++) + { + var flags = FrameFlags.Gated | FrameFlags.Enc | (i == frames - 1 ? FrameFlags.Final : 0); + await Append(ch, who, TestEnv.Frame(item, i, flags, TestEnv.EncPayload, (byte)(i + 1)), maxDownloads); + } + return item; + } + + [Fact] + public async Task GatedItemIsAnnouncedNotPushedAndFetchedInOrder() + { + var ch = _env.Create(_ownerEp); + var o = ch.Owner(_ownerEp); + var item = await UploadGated(ch, o, 3); + + // 日志里只有一条 announce + var log = await LogOf(ch, o); + Assert.Equal([LogKind.Announce], log.Select(l => l.Kind)); + + Svc.StartItemDownload(ch.Id, o, item, 0); + var chunk = await Svc.ReadItemFramesAsync(ch.Id, o, item, 0, 10); + Assert.Equal(3, chunk.Frames.Count); + Assert.True(chunk.Complete); + Assert.Equal(3u, chunk.NextIndex); + for (int i = 0; i < 3; i++) Assert.Equal((byte)(i + 1), chunk.Frames[i][^1]); + + var tail = await Svc.ReadItemFramesAsync(ch.Id, o, item, 2, 10); + Assert.Single(tail.Frames); + } + + [Fact] + public async Task IncompleteGatedItemStopsAtTheGapAndIsNotComplete() + { + var ch = _env.Create(_ownerEp); + var o = ch.Owner(_ownerEp); + var item = TestEnv.NewId(); + await Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload)); + await Append(ch, o, TestEnv.Frame(item, 2, FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload)); // 乱序到达,缺 1 + + var chunk = await Svc.ReadItemFramesAsync(ch.Id, o, item, 0, 10); + Assert.Single(chunk.Frames); + Assert.Equal(1u, chunk.NextIndex); + Assert.False(chunk.Complete); + + // FINAL 不能早于已存在的更大序号 + await AssertCode(ErrorCode.BadRequest, () => + Append(ch, o, TestEnv.Frame(item, 1, FrameFlags.Gated | FrameFlags.Enc | FrameFlags.Final, TestEnv.EncPayload))); + } + + [Fact] + public async Task GatedFrameFlagsMustStayConsistentWithinAnItem() + { + var ch = _env.Create(_ownerEp); + var o = ch.Owner(_ownerEp); + var item = TestEnv.NewId(); + await Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload)); + + // 后续帧改变 GATED 属性会被 ValidateShape 拒绝(非 GATED 必须是单帧 FINAL) + await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, TestEnv.Frame(item, 1, FrameFlags.Enc | FrameFlags.Final, TestEnv.EncPayload))); + // 帧序号超出已声明的 FINAL + await Append(ch, o, TestEnv.Frame(item, 1, FrameFlags.Gated | FrameFlags.Enc | FrameFlags.Final, TestEnv.EncPayload)); + await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, TestEnv.Frame(item, 2, FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload))); + } + + [Fact] + public async Task DownloadLimitIsEnforcedWithAGraceWindowForResume() + { + var ch = _env.Create(_ownerEp, settings: new ChannelSettings { FileMaxDownloads = 2 }); + var o = ch.Owner(_ownerEp); + Svc.Join(ch.Id, ch.Reader(_memberEp)); + var item = await UploadGated(ch, o, 2); + + Svc.StartItemDownload(ch.Id, ch.Reader(_memberEp), item, 0); + Svc.StartItemDownload(ch.Id, ch.Reader(_memberEp), item, 0); // 第 2 次达到上限,进入宽限期 + AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, ch.Reader(_memberEp), item, 0)); + + // 宽限期内可以续传已开始的下载 + Svc.StartItemDownload(ch.Id, ch.Reader(_memberEp), item, 1); + Assert.Single((await Svc.ReadItemFramesAsync(ch.Id, ch.Reader(_memberEp), item, 1, 10)).Frames); + + // 宽限期过后拒绝,清理任务删除密文并留下墓碑 + _env.Clock.Advance(TimeSpan.FromSeconds(_env.Limits.DownloadGraceSeconds + 1)); + AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, ch.Reader(_memberEp), item, 1)); + + Assert.Equal(1, Svc.Sweep()); + var tombstone = (await Svc.ReadLogAsync(ch.Id, o, 0, 100)).Records.Single(r => Head(r).Kind == LogKind.Tombstone); + Assert.Equal((byte)TombstoneReason.DownloadLimit, tombstone.Bytes[^1]); + AssertCode(ErrorCode.NotFound, () => Svc.StartItemDownload(ch.Id, o, item, 0)); + } + + [Fact] + public async Task ZeroMaxDownloadsMeansUnlimitedAndHeaderOverridesDefault() + { + var ch = _env.Create(_ownerEp); + var o = ch.Owner(_ownerEp); + var unlimited = await UploadGated(ch, o, 1, maxDownloads: 0); + for (int i = 0; i < 25; i++) Svc.StartItemDownload(ch.Id, o, unlimited, 0); + + var one = await UploadGated(ch, o, 1, maxDownloads: 1); + Svc.StartItemDownload(ch.Id, o, one, 0); + AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, o, one, 0)); + } + + // ───── 删除、快照、淘汰、过期 ───── + + [Theory] + [InlineData(DeletePolicy.Own, true, true)] + [InlineData(DeletePolicy.Own, false, false)] + [InlineData(DeletePolicy.OwnerOnly, true, false)] + [InlineData(DeletePolicy.Anyone, false, true)] + public async Task MemberDeletePermissionFollowsPolicy(DeletePolicy policy, bool ownItem, bool allowed) + { + var ch = _env.Create(_ownerEp, settings: new ChannelSettings { DeletePolicy = policy }); + Svc.Join(ch.Id, ch.Member(_memberEp)); + + var item = TestEnv.NewId(); + await AppendSmall(ch, ownItem ? ch.Member(_memberEp) : ch.Owner(_ownerEp), item); + + if (allowed) Svc.DeleteItem(ch.Id, ch.Member(_memberEp), item); + else AssertCode(ErrorCode.Forbidden, () => Svc.DeleteItem(ch.Id, ch.Member(_memberEp), item)); + } + + [Fact] + public async Task OwnerCanAlwaysDeleteAndDeletionLeavesATombstone() + { + var ch = _env.Create(_ownerEp, settings: new ChannelSettings { DeletePolicy = DeletePolicy.OwnerOnly }); + Svc.Join(ch.Id, ch.Member(_memberEp)); + var item = TestEnv.NewId(); + await AppendSmall(ch, ch.Member(_memberEp), item); + + Svc.DeleteItem(ch.Id, ch.Owner(_ownerEp), item); + + var log = await LogOf(ch, ch.Owner(_ownerEp)); + Assert.Equal([LogKind.Tombstone], log.Select(l => l.Kind)); // 帧记录已随条目删除 + AssertCode(ErrorCode.NotFound, () => Svc.DeleteItem(ch.Id, ch.Owner(_ownerEp), item)); + } + + [Fact] + public async Task NewSnapshotSupersedesTheOldOne() + { + var ch = _env.Create(_ownerEp); + var o = ch.Owner(_ownerEp); + await AppendSmall(ch, o, extra: FrameFlags.Snapshot); // seq 1 + var second = await AppendSmall(ch, o, extra: FrameFlags.Snapshot); // seq 2,墓碑 seq 3 + await AppendSmall(ch, o); // 普通条目不受影响,seq 4 + + var page = await Svc.ReadLogAsync(ch.Id, o, 0, 100); + Assert.Equal([(2L, LogKind.Frame), (3L, LogKind.Tombstone), (4L, LogKind.Frame)], page.Records.Select(Head)); + Assert.Equal((byte)TombstoneReason.Superseded, page.Records[1].Bytes[^1]); + Assert.Equal(2, second.Seq); + } + + [Fact] + public async Task ExceedingMaxItemsEvictsTheOldest() + { + var ch = _env.Create(_ownerEp, settings: new ChannelSettings { MaxItems = 3 }); + var o = ch.Owner(_ownerEp); + var ids = new List(); + for (int i = 0; i < 5; i++) + { + ids.Add(TestEnv.NewId()); + await AppendSmall(ch, o, ids[i]); + _env.Clock.Advance(TimeSpan.FromMilliseconds(10)); + } + + var page = await Svc.ReadLogAsync(ch.Id, o, 0, 100); + var frames = page.Records.Where(r => Head(r).Kind == LogKind.Frame).ToList(); + var tombstones = page.Records.Where(r => Head(r).Kind == LogKind.Tombstone).ToList(); + Assert.Equal(3, frames.Count); + Assert.Equal(2, tombstones.Count); + Assert.All(tombstones, t => Assert.Equal((byte)TombstoneReason.Evicted, t.Bytes[^1])); + // 保留的是最新的三个 + Assert.Equal(ids.Skip(2).Select(i => i.ToArray()), frames.Select(f => f.Bytes[(9 + 2)..(9 + 2 + 16)])); + } + + [Fact] + public async Task SweepRemovesExpiredItemsButKeepsLiveOnes() + { + var ch = _env.Create(_ownerEp); + var o = ch.Owner(_ownerEp); + var old = TestEnv.NewId(); + await AppendSmall(ch, o, old); + + _env.Clock.Advance(TimeSpan.FromHours(12)); + await AppendSmall(ch, o); // 续期频道,新条目 TTL 从现在起算 + + _env.Clock.Advance(TimeSpan.FromHours(13)); // 第一个条目已过期,第二个还在 + Assert.Equal(1, Svc.Sweep()); + + var log = await Svc.ReadLogAsync(ch.Id, o, 0, 100); + Assert.Equal([LogKind.Frame, LogKind.Tombstone], log.Records.Select(r => Head(r).Kind)); + Assert.Equal((byte)TombstoneReason.Expired, log.Records[1].Bytes[^1]); + } + + [Fact] + public async Task SweepDeletesExpiredChannelsAndTheirBlobs() + { + var ch = _env.Create(_ownerEp, ChannelMode.Raw); + await Append(ch, ch.Owner(_ownerEp), TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 100_000)); // 走 blob + Assert.True(Directory.Exists(Path.Combine(_env.Dir, "blobs", ch.Id.ToHex()))); + + _env.Clock.Advance(TimeSpan.FromHours(25)); + Svc.Sweep(); + + Assert.False(Directory.Exists(Path.Combine(_env.Dir, "blobs", ch.Id.ToHex()))); + Assert.Equal(1, _env.Events.Deleted); + AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(ch.Id, ch.Owner(_ownerEp))); + } + + [Fact] + public async Task DeleteChannelRemovesEverything() + { + var ch = _env.Create(_ownerEp); + await AppendSmall(ch, ch.Owner(_ownerEp)); + Svc.DeleteChannel(ch.Id, ch.Owner(_ownerEp)); + + AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(ch.Id, ch.Owner(_ownerEp))); + Assert.Equal(1, _env.Events.Deleted); + } + + // ───── blob 路径 ───── + + [Fact] + public async Task LargeFramesGoToBlobStoreAndRoundTrip() + { + var ch = _env.Create(_ownerEp, ChannelMode.Raw); + var o = ch.Owner(_ownerEp); + var item = TestEnv.NewId(); + var frame = TestEnv.Frame(item, 0, FrameFlags.Final, 100_000, 0x7A); + await Append(ch, o, frame); + + string itemDir = Path.Combine(_env.Dir, "blobs", ch.Id.ToHex(), item.ToHex()); + Assert.Single(Directory.GetFiles(itemDir)); + + var page = await Svc.ReadLogAsync(ch.Id, o, 0, 10); + Assert.Equal(frame, page.Records.Single().Bytes[9..]); + + // 幂等重发不会重复落盘 + await Append(ch, o, frame); + Assert.Single(Directory.GetFiles(itemDir)); + + Svc.DeleteItem(ch.Id, o, item); + Assert.False(Directory.Exists(itemDir)); + } + + [Fact] + public async Task ConflictingLargeFrameLeavesNoOrphanBlob() + { + var ch = _env.Create(_ownerEp, ChannelMode.Raw); + var o = ch.Owner(_ownerEp); + var item = TestEnv.NewId(); + await Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Final, 100_000, 0x01)); + + await AssertCode(ErrorCode.Conflict, () => Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Final, 100_000, 0x02))); + Assert.Single(Directory.GetFiles(Path.Combine(_env.Dir, "blobs", ch.Id.ToHex(), item.ToHex()))); + } + + [Fact] + public async Task OrphanBlobSweepRemovesOnlyUnreferencedOldFiles() + { + // 孤立判断基于真实文件时间,所以时钟要先对齐到真实时间,再建频道 + _env.Clock.NowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var ch = _env.Create(_ownerEp, ChannelMode.Raw); + var item = TestEnv.NewId(); + await Append(ch, ch.Owner(_ownerEp), TestEnv.Frame(item, 0, FrameFlags.Final, 100_000)); + + await _env.Blobs.PutAsync("aa/bb/0.dead", new byte[10]); + await _env.Blobs.PutAsync("aa/bb/1.fresh", new byte[10]); + string stale = Path.Combine(_env.Dir, "blobs", "aa", "bb", "0.dead"); + File.SetLastWriteTimeUtc(stale, DateTime.UtcNow.AddHours(-1)); + + Svc.Sweep(includeOrphanBlobs: true); + + Assert.False(File.Exists(stale)); // 孤立且够旧:删除 + Assert.True(File.Exists(Path.Combine(_env.Dir, "blobs", "aa", "bb", "1.fresh"))); // 孤立但太新:保留 + Assert.Single(Directory.GetFiles(Path.Combine(_env.Dir, "blobs", ch.Id.ToHex(), item.ToHex()))); // 被引用:保留 + } + + // ───── 并发 ───── + + [Fact] + public async Task ParallelAppendsGetUniqueContiguousSeqs() + { + var ch = _env.Create(_ownerEp); + var o = ch.Owner(_ownerEp); + + var results = await Task.WhenAll(Enumerable.Range(0, 60).Select(_ => Task.Run(() => AppendSmall(ch, o)))); + + Assert.All(results, r => Assert.True(r.Created)); + Assert.Equal(Enumerable.Range(1, 60).Select(i => (long)i), results.Select(r => r.Seq).Order()); + Assert.Equal(60, (await LogOf(ch, o)).Count); + Assert.Equal(60, Svc.GetChannel(ch.Id, o).LastSeq); + } + + [Fact] + public async Task ParallelIdenticalAppendsCreateExactlyOneFrame() + { + var ch = _env.Create(_ownerEp); + var o = ch.Owner(_ownerEp); + var frame = TestEnv.Frame(TestEnv.NewId(), 0, TestEnv.SmallEnc, TestEnv.EncPayload); + + var results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Run(() => Append(ch, o, frame)))); + + Assert.Equal(1, results.Count(r => r.Created)); + Assert.Single(results.Select(r => r.Seq).Distinct()); + Assert.Single(await LogOf(ch, o)); + } + + [Fact] + public async Task ParallelIdenticalLargeAppendsKeepTheBlob() + { + var ch = _env.Create(_ownerEp, ChannelMode.Raw); + var o = ch.Owner(_ownerEp); + var item = TestEnv.NewId(); + var frame = TestEnv.Frame(item, 0, FrameFlags.Final, 100_000); + + await Task.WhenAll(Enumerable.Range(0, 10).Select(_ => Task.Run(() => Append(ch, o, frame)))); + + var page = await Svc.ReadLogAsync(ch.Id, o, 0, 10); + Assert.Equal(frame, page.Records.Single().Bytes[9..]); // blob 仍在且内容正确 + } + + [Fact] + public async Task AppendPublishesTheNewSeq() + { + var ch = _env.Create(_ownerEp); + var r = await AppendSmall(ch, ch.Owner(_ownerEp)); + Assert.Contains(_env.Events.Advances, e => e.Channel == ch.Id && e.Seq == r.Seq); + } +} diff --git a/server/tests/WebDrop.Core.Tests/PrimitivesTests.cs b/server/tests/WebDrop.Core.Tests/PrimitivesTests.cs new file mode 100644 index 0000000..42f25aa --- /dev/null +++ b/server/tests/WebDrop.Core.Tests/PrimitivesTests.cs @@ -0,0 +1,167 @@ +using System.Buffers.Binary; +using WebDrop.Core; + +namespace WebDrop.Core.Tests; + +public class Id16Tests +{ + [Fact] + public void RoundTrips() + { + var id = TestEnv.NewId(); + Assert.True(Id16.TryParse(id.ToBase64Url(), out var parsed)); + Assert.Equal(id, parsed); + Assert.Equal(22, id.ToBase64Url().Length); + Assert.Equal(32, id.ToHex().Length); + } + + [Theory] + [InlineData("")] + [InlineData("short")] + [InlineData("AAAAAAAAAAAAAAAAAAAAAAA")] // 23 字符 + [InlineData("AAAAAAAAAAAAAAAAAAAA+/")] // 非 url 字母表 + public void RejectsMalformed(string text) => Assert.False(Id16.TryParse(text, out _)); + + [Fact] + public void RejectsNonCanonicalEncoding() + { + // 末字符的低 2 位在 16 字节编码中必须为 0;AAAAAAAAAAAAAAAAAAAAAB 解码后与 ...AA 同值 + Assert.True(Id16.TryParse("AAAAAAAAAAAAAAAAAAAAAA", out var zero)); + Assert.False(Id16.TryParse("AAAAAAAAAAAAAAAAAAAAAB", out _)); + Assert.Equal(new byte[16], zero.ToArray()); + } +} + +public class EnvelopeTests +{ + [Fact] + public void ParsesValidHeader() + { + var item = TestEnv.NewId(); + var bytes = TestEnv.Frame(item, 7, FrameFlags.Final | FrameFlags.Gated | FrameFlags.Enc, 1040); + + Assert.True(Envelope.TryParse(bytes, out var env)); + Assert.Equal(item, env.ItemId); + Assert.Equal(7u, env.FrameIndex); + Assert.Equal(1040, env.PayloadLength); + Assert.True(env.IsFinal && env.IsGated && env.IsEnc && !env.IsSnapshot); + Assert.Equal(FrameFlags.Gated, env.ItemFlags); + } + + [Fact] + public void RejectsBadVersion() + { + var bytes = TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 4); + bytes[0] = 2; + Assert.False(Envelope.TryParse(bytes, out _)); + } + + [Fact] + public void RejectsReservedFlagBits() + { + var bytes = TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 4); + bytes[1] |= 0x10; + Assert.False(Envelope.TryParse(bytes, out _)); + } + + [Fact] + public void RejectsLengthMismatch() + { + var bytes = TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 8); + Assert.False(Envelope.TryParse(bytes.AsSpan(0, bytes.Length - 1), out _)); + Assert.False(Envelope.TryParse([.. bytes, 0], out _)); + Assert.False(Envelope.TryParse(bytes.AsSpan(0, 25), out _)); + } + + [Fact] + public void RejectsHugePayloadLength() + { + var bytes = TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 4); + BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(22), uint.MaxValue); + Assert.False(Envelope.TryParse(bytes, out _)); + } + + [Theory] + [InlineData(1024 + 16, true)] + [InlineData(4096 + 16, true)] + [InlineData(1048576 + 16, true)] + [InlineData(1024, false)] + [InlineData(16, false)] + [InlineData(1000 + 16, false)] + [InlineData(0, false)] + public void EncPayloadMustBeTierPlusTag(int length, bool valid) => + Assert.Equal(valid, PayloadTiers.IsValidEncPayloadLength(length)); +} + +public class AuthTests +{ + [Fact] + public void TokenHashMatchesOnlyTheSameToken() + { + var token = TestEnv.Random(32); + var hash = TokenHash.Compute(token); + + Assert.True(TokenHash.Matches(token, hash)); + Assert.False(TokenHash.Matches(TestEnv.Random(32), hash)); + Assert.False(TokenHash.Matches(token, null)); + Assert.False(TokenHash.Matches(token, hash[..16])); + } +} + +public class PolicyTests +{ + private static Channel Chan(ChannelSettings s, bool writeHash = true) => new( + TestEnv.NewId(), ChannelMode.Enc, s, new byte[32], writeHash ? new byte[32] : null, new byte[32], 0, 0, 0, 0, 0); + + private static Item ItemBy(byte[] author) => new( + TestEnv.NewId(), TestEnv.NewId(), author, FrameFlags.None, 0, 0, 0, -1, null, 0, 0, 0, null, null); + + [Fact] + public void WritePermission() + { + var ep = TestEnv.Endpoint(); + Assert.True(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Owner, ep))); + Assert.True(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Write, ep))); + Assert.False(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Read, ep))); + Assert.False(ChannelPolicy.CanWrite(Chan(new() { MembersCanUpload = false }), new Principal(Role.Write, ep))); + Assert.False(ChannelPolicy.CanWrite(Chan(new(), writeHash: false), new Principal(Role.Write, ep))); + Assert.True(ChannelPolicy.CanWrite(Chan(new() { MembersCanUpload = false }), new Principal(Role.Owner, ep))); + } + + [Theory] + [InlineData(DeletePolicy.OwnerOnly, Role.Write, true, false)] + [InlineData(DeletePolicy.OwnerOnly, Role.Owner, false, true)] + [InlineData(DeletePolicy.Own, Role.Write, true, true)] + [InlineData(DeletePolicy.Own, Role.Write, false, false)] + [InlineData(DeletePolicy.Own, Role.Owner, false, true)] + [InlineData(DeletePolicy.Anyone, Role.Read, false, true)] + public void DeletePermission(DeletePolicy policy, Role role, bool isAuthor, bool expected) + { + var me = TestEnv.Endpoint(); + var item = ItemBy(isAuthor ? me : TestEnv.Endpoint()); + var channel = Chan(new() { DeletePolicy = policy }); + Assert.Equal(expected, ChannelPolicy.CanDeleteItem(channel, item, new Principal(role, me))); + } + + [Fact] + public void SettingsPatchOnlyChangesProvidedFields() + { + var before = new ChannelSettings { Seats = 2, TtlSeconds = 100 }; + var after = new ChannelSettingsPatch { Seats = 5 }.ApplyTo(before); + Assert.Equal(5, after.Seats); + Assert.Equal(100, after.TtlSeconds); + } + + [Theory] + [InlineData(0, 86400, 10)] + [InlineData(999, 86400, 10)] + [InlineData(2, 10, 10)] + [InlineData(2, 99999999, 10)] + [InlineData(2, 86400, 0)] + public void SettingsValidationRejectsOutOfRange(int seats, int ttl, int maxItems) + { + var s = new ChannelSettings { Seats = seats, TtlSeconds = ttl, MaxItems = maxItems }; + var ex = Assert.Throws(() => s.Validate(new ServerLimits())); + Assert.Equal(ErrorCode.BadRequest, ex.Code); + } +} diff --git a/server/tests/WebDrop.Core.Tests/TestEnv.cs b/server/tests/WebDrop.Core.Tests/TestEnv.cs new file mode 100644 index 0000000..efee175 --- /dev/null +++ b/server/tests/WebDrop.Core.Tests/TestEnv.cs @@ -0,0 +1,105 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using WebDrop.Core; +using WebDrop.Storage; + +namespace WebDrop.Core.Tests; + +public sealed class FakeClock : IClock +{ + public long NowMs { get; set; } = 1_700_000_000_000; + public void Advance(TimeSpan span) => NowMs += (long)span.TotalMilliseconds; +} + +public sealed class RecordingEvents : IChannelEvents +{ + public List<(Id16 Channel, long Seq)> Advances { get; } = []; + public int Settings, Seats, Deleted; + public void Advanced(Id16 channelId, long seq) { lock (Advances) Advances.Add((channelId, seq)); } + public void SettingsChanged(Id16 channelId) => Interlocked.Increment(ref Settings); + public void SeatsChanged(Id16 channelId) => Interlocked.Increment(ref Seats); + public void ChannelDeleted(Id16 channelId) => Interlocked.Increment(ref Deleted); +} + +/// 一个频道的全部凭据,模拟客户端从 K / A 派生出的令牌。 +public sealed class TestChannel(Id16 id, ChannelMode mode, byte[] read, byte[] write, byte[] owner) +{ + public Id16 Id { get; } = id; + public ChannelMode Mode { get; } = mode; + public byte[] ReadToken { get; } = read; + public byte[] WriteToken { get; } = write; + public byte[] OwnerToken { get; } = owner; + + public Credentials Owner(byte[] endpoint) => new(Role.Owner, OwnerToken, endpoint); + public Credentials Member(byte[] endpoint) => new(Role.Write, WriteToken, endpoint); + public Credentials Reader(byte[] endpoint) => new(Role.Read, ReadToken, endpoint); +} + +public sealed class TestEnv : IDisposable +{ + public string Dir { get; } = Path.Combine(Path.GetTempPath(), "webdrop-tests-" + Guid.NewGuid().ToString("N")); + public FakeClock Clock { get; } = new(); + public RecordingEvents Events { get; } = new(); + public ServerLimits Limits { get; } = new(); + public SqliteChannelStore Store { get; } + public DiskBlobStore Blobs { get; } + public ChannelService Service { get; private set; } + + public TestEnv() + { + Directory.CreateDirectory(Dir); + Store = new SqliteChannelStore(Path.Combine(Dir, "test.db")); + Blobs = new DiskBlobStore(Path.Combine(Dir, "blobs")); + Service = new ChannelService(Store, Blobs, Clock, Limits, Events); + } + + public static byte[] Random(int n) => RandomNumberGenerator.GetBytes(n); + public static byte[] Endpoint() => TokenHash.Compute(Random(32)); + public static Id16 NewId() => Id16.FromBytes(Random(16)); + + public TestChannel Create( + byte[] ownerEndpoint, + ChannelMode mode = ChannelMode.Enc, + ChannelSettings? settings = null, + bool withWriteHash = true) + { + var id = NewId(); + var read = Random(32); + var write = Random(32); + var owner = Random(32); + + Service.CreateChannel(new CreateChannelRequest + { + Id = id.ToBase64Url(), + Mode = mode, + Settings = settings, + ReadHash = mode == ChannelMode.Enc ? Codec.Encode(TokenHash.Compute(read)) : null, + WriteHash = withWriteHash ? Codec.Encode(TokenHash.Compute(write)) : null, + OwnerHash = Codec.Encode(TokenHash.Compute(owner)), + }, ownerEndpoint); + + return new TestChannel(id, mode, read, write, owner); + } + + /// 构造一个封装。enc 模式的负载长度必须是 档位+16。 + public static byte[] Frame(Id16 item, uint index, FrameFlags flags, int payloadLength, byte fill = 0x42) + { + var bytes = new byte[Envelope.HeaderSize + payloadLength]; + bytes[0] = Envelope.Version; + bytes[1] = (byte)flags; + item.WriteTo(bytes.AsSpan(2)); + BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(18), index); + BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(22), (uint)payloadLength); + bytes.AsSpan(Envelope.HeaderSize).Fill(fill); + return bytes; + } + + public const int EncPayload = 1024 + 16; + public const FrameFlags SmallEnc = FrameFlags.Final | FrameFlags.Enc; + + public void Dispose() + { + Store.Dispose(); + try { Directory.Delete(Dir, recursive: true); } catch (IOException) { } + } +} diff --git a/server/tests/WebDrop.Core.Tests/VectorTests.cs b/server/tests/WebDrop.Core.Tests/VectorTests.cs new file mode 100644 index 0000000..5a8e24a --- /dev/null +++ b/server/tests/WebDrop.Core.Tests/VectorTests.cs @@ -0,0 +1,279 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using WebDrop.Core; + +namespace WebDrop.Core.Tests; + +/// +/// 用 test-vectors/vectors.json 校验服务端这一侧能复现协议的每个字节。 +/// 向量由 node:crypto 独立生成,这里用 .NET 的 HKDF / AesGcm / SHA256 再算一遍; +/// 两套互不相干的实现一致,才说明 protocol.md 没有歧义。 +/// +public class VectorTests +{ + private static readonly JsonElement V = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "vectors.json"))).RootElement; + + private static byte[] H(JsonElement e) => Convert.FromHexString(e.GetString()!); + private static byte[] H(string s) => Convert.FromHexString(s); + + private static byte[] Hkdf(byte[] ikm, byte[] salt, string info) => + HKDF.DeriveKey(HashAlgorithmName.SHA256, ikm, 32, salt, Encoding.UTF8.GetBytes(info)); + + private static byte[] ChannelId => H(V.GetProperty("inputs").GetProperty("channel_id")); + private static byte[] KEnc => Hkdf(H(V.GetProperty("inputs").GetProperty("k")), ChannelId, "webdrop/v1/enc"); + + private static byte[] ItemKey(byte[] itemId) => Hkdf(KEnc, itemId, "webdrop/v1/item"); + + private static byte[] Aad(byte[] channelId, byte[] envelope) + { + // "WD1" ‖ channel_id ‖ envelope[0..22) + var aad = new byte[3 + 16 + 22]; + "WD1"u8.CopyTo(aad); + channelId.CopyTo(aad, 3); + envelope.AsSpan(0, 22).CopyTo(aad.AsSpan(19)); + return aad; + } + + /// 用 AES-GCM 解密一帧,返回明文;认证失败抛 CryptographicException。 + private static byte[] Open(byte[] channelId, byte[] envelope) + { + Assert.True(Envelope.TryParse(envelope, out var env)); + var iv = new byte[12]; + BinaryPrimitives.WriteUInt32BigEndian(iv.AsSpan(8), env.FrameIndex); + var payload = envelope.AsSpan(Envelope.HeaderSize); + var plain = new byte[payload.Length - 16]; + using var gcm = new AesGcm(ItemKey(env.ItemId.ToArray()), 16); + gcm.Decrypt(iv, payload[..^16], payload[^16..], plain, Aad(channelId, envelope)); + return plain; + } + + [Fact] + public void KeyDerivationMatchesTheVectors() + { + var inputs = V.GetProperty("inputs"); + var kdf = V.GetProperty("kdf"); + var k = H(inputs.GetProperty("k")); + var a = H(inputs.GetProperty("a")); + + Assert.Equal(H(kdf.GetProperty("k_enc")), Hkdf(k, ChannelId, "webdrop/v1/enc")); + Assert.Equal(H(kdf.GetProperty("t_read")), Hkdf(k, ChannelId, "webdrop/v1/token/read")); + Assert.Equal(H(kdf.GetProperty("t_write")), Hkdf(k, ChannelId, "webdrop/v1/token/write")); + Assert.Equal(H(kdf.GetProperty("t_owner")), Hkdf(a, ChannelId, "webdrop/v1/token/owner")); + Assert.Equal(H(kdf.GetProperty("k_label")), Hkdf(KEnc, ChannelId, "webdrop/v1/label")); + + foreach (var item in V.GetProperty("item_keys").EnumerateArray()) + Assert.Equal(H(item.GetProperty("k_item")), ItemKey(H(item.GetProperty("item_id")))); + } + + [Fact] + public void ServerTokenHashMatchesTheStoredHashes() + { + // 服务端存的是令牌的 SHA-256;向量里的 *_hash 必须与 TokenHash.Compute 一致, + // 客户端才能发出服务端认得的哈希 + var kdf = V.GetProperty("kdf"); + foreach (var (token, hash) in new[] { ("t_read", "read_hash"), ("t_write", "write_hash"), ("t_owner", "owner_hash"), ("raw_write_token", "raw_write_hash") }) + { + byte[] t = H(kdf.GetProperty(token)); + Assert.Equal(H(kdf.GetProperty(hash)), TokenHash.Compute(t)); + Assert.True(TokenHash.Matches(t, H(kdf.GetProperty(hash)))); + } + } + + [Fact] + public void IvAndAadMatchTheVectors() + { + foreach (var iv in V.GetProperty("iv").EnumerateArray()) + { + var expected = new byte[12]; + BinaryPrimitives.WriteUInt32BigEndian(expected.AsSpan(8), (uint)iv.GetProperty("index").GetInt64()); + Assert.Equal(H(iv.GetProperty("iv")), expected); + } + + foreach (var c in V.GetProperty("aad").EnumerateArray()) + { + // 用一个只含头部的封装(payload_len = 0)构造 AAD 的前 22 字节 + var env = new byte[Envelope.HeaderSize]; + env[0] = 1; + env[1] = (byte)c.GetProperty("flags").GetInt32(); + H(c.GetProperty("item_id")).CopyTo(env, 2); + BinaryPrimitives.WriteUInt32BigEndian(env.AsSpan(18), (uint)c.GetProperty("index").GetInt64()); + Assert.Equal(H(c.GetProperty("aad")), Aad(ChannelId, env)); + } + } + + [Fact] + public void PaddingTiersMatchThePolicy() + { + foreach (var p in V.GetProperty("padding").EnumerateArray()) + { + int len = p.GetProperty("data_len").GetInt32(); + int tier = p.GetProperty("plaintext_len").GetInt32(); + Assert.True(PayloadTiers.IsValidEncPayloadLength(tier + 16), $"data_len={len}"); + Assert.True(tier >= 4 + len); + } + } + + [Fact] + public void EveryFrameVectorDecryptsToTheExpectedPlaintextAndPassesServerValidation() + { + foreach (var f in V.GetProperty("frames").EnumerateArray()) + { + string name = f.GetProperty("name").GetString()!; + byte[] envelope = H(f.GetProperty("envelope")); + + // 服务端的封装解析与校验:这些帧就是合法客户端会发出的 + Assert.True(Envelope.TryParse(envelope, out var env), name); + Assert.Equal((FrameFlags)f.GetProperty("flags").GetInt32(), env.Flags); + Assert.Equal((uint)f.GetProperty("index").GetInt64(), env.FrameIndex); + Assert.True(env.IsEnc, name); + Assert.True(PayloadTiers.IsValidEncPayloadLength(env.PayloadLength), name); + + // 解密得到的明文(含 padding)必须逐字节一致 + byte[] plaintext = Open(ChannelId, envelope); + Assert.Equal(H(f.GetProperty("plaintext")), plaintext); + + // 明文布局:u32 data_len ‖ data ‖ 零填充 + int dataLen = (int)BinaryPrimitives.ReadUInt32BigEndian(plaintext); + Assert.Equal(H(f.GetProperty("data")), plaintext.AsSpan(4, dataLen).ToArray()); + Assert.All(plaintext.AsSpan(4 + dataLen).ToArray(), b => Assert.Equal(0, b)); + } + } + + [Fact] + public void ItemVectorsFollowTheServerFramingRules() + { + foreach (var item in V.GetProperty("items").EnumerateArray()) + { + string name = item.GetProperty("name").GetString()!; + bool gated = item.GetProperty("gated").GetBoolean(); + var frames = item.GetProperty("frames").EnumerateArray().Select(e => H(e)).ToList(); + + var content = new List(); + for (int i = 0; i < frames.Count; i++) + { + Assert.True(Envelope.TryParse(frames[i], out var env), name); + Assert.Equal((uint)i, env.FrameIndex); + Assert.Equal(gated, env.IsGated); + Assert.Equal(item.GetProperty("snapshot").GetBoolean(), env.IsSnapshot); + Assert.Equal(i == frames.Count - 1, env.IsFinal); + + byte[] plain = Open(ChannelId, frames[i]); + int dataLen = (int)BinaryPrimitives.ReadUInt32BigEndian(plain); + var data = plain.AsSpan(4, dataLen).ToArray(); + if (i == 0) + { + // 第 0 帧:u16 meta_len ‖ meta_json ‖ 内容 + int metaLen = BinaryPrimitives.ReadUInt16BigEndian(data); + Assert.Equal(item.GetProperty("meta_json").GetString(), Encoding.UTF8.GetString(data.AsSpan(2, metaLen))); + content.AddRange(data.AsSpan(2 + metaLen).ToArray()); + } + else + { + content.AddRange(data); + } + } + Assert.Equal(H(item.GetProperty("content")), content.ToArray()); + + // 非 GATED 条目必须是单帧且 FINAL,这是服务端强制的规则 + if (!gated) Assert.Single(frames); + } + } + + [Fact] + public void RawFrameVectorsParse() + { + foreach (var f in V.GetProperty("raw_frames").EnumerateArray()) + { + Assert.True(Envelope.TryParse(H(f.GetProperty("envelope")), out var env)); + Assert.False(env.IsEnc); + Assert.Equal((FrameFlags)f.GetProperty("flags").GetInt32(), env.Flags); + Assert.Equal(H(f.GetProperty("payload")).Length, env.PayloadLength); + } + } + + [Fact] + public void LabelVectorsDecryptWithTheDerivedKey() + { + byte[] kLabel = Hkdf(KEnc, ChannelId, "webdrop/v1/label"); + foreach (var l in V.GetProperty("labels").EnumerateArray()) + { + byte[] blob = H(l.GetProperty("blob")); + Assert.True(blob.Length <= 256, "设备名总长不得超过 256 字节(服务端上限)"); + + var plain = new byte[blob.Length - 12 - 16]; + using var gcm = new AesGcm(kLabel, 16); + gcm.Decrypt(blob.AsSpan(0, 12), blob.AsSpan(12, plain.Length), blob.AsSpan(blob.Length - 16), plain, H(l.GetProperty("aad"))); + Assert.Equal(l.GetProperty("name").GetString(), Encoding.UTF8.GetString(plain)); + } + } + + [Fact] + public void LogRecordVectorsMatchTheServerEncoders() + { + foreach (var r in V.GetProperty("log_records").EnumerateArray()) + { + var parsed = r.GetProperty("parsed"); + long seq = parsed.GetProperty("seq").GetInt64(); + byte[] expected = H(r.GetProperty("hex")); + + switch (parsed.GetProperty("kind").GetString()) + { + case "tombstone": + var item = Id16.FromBytes(H(parsed.GetProperty("item_id"))); + Assert.Equal(expected, LogRecord.EncodeTombstone(seq, item, (TombstoneReason)parsed.GetProperty("reason").GetInt32())); + break; + case "announce": + Assert.Equal(expected, LogRecord.EncodeAnnounce(seq, Id16.FromBytes(H(parsed.GetProperty("item_id"))), + (FrameFlags)parsed.GetProperty("flags").GetInt32(), parsed.GetProperty("created_at").GetInt64())); + break; + case "frame": + Assert.Equal(expected, LogRecord.Encode(seq, LogKind.Frame, H(parsed.GetProperty("envelope")))); + break; + } + } + } + + [Fact] + public void LinkIdsAreCanonicalForTheServer() + { + string id = V.GetProperty("inputs").GetProperty("channel_id_b64").GetString()!; + Assert.True(Id16.TryParse(id, out var parsed)); + Assert.Equal(ChannelId, parsed.ToArray()); + Assert.False(Id16.TryParse("AAAAAAAAAAAAAAAAAAAAAB", out _)); // 非规范编码,对应向量 non_canonical_id + } + + [Fact] + public void NegativeVectorsAreRejectedByTheServerSideChecksOrByAuthentication() + { + byte[] original = ChannelId; + foreach (var n in V.GetProperty("negative").EnumerateArray()) + { + string name = n.GetProperty("name").GetString()!; + string expect = n.GetProperty("expect").GetString()!; + byte[] envelope = H(n.GetProperty("envelope")); + byte[] channel = n.TryGetProperty("channel_id", out var c) ? H(c) : original; + + switch (expect) + { + case "bad_envelope": + Assert.False(Envelope.TryParse(envelope, out _), name); + break; + case "auth_failed": + // 结构合法,但 GCM 认证必须失败(篡改、换头部、换频道都被 AAD 抓住) + Assert.True(Envelope.TryParse(envelope, out _), name); + Assert.ThrowsAny(() => Open(channel, envelope)); + break; + case "wrong_mode": + // 没有 ENC 标志的帧:封装合法,但在 enc 频道上服务端会以 mode_mismatch 拒绝 + Assert.True(Envelope.TryParse(envelope, out var env), name); + Assert.False(env.IsEnc, name); + break; + default: + Assert.Fail($"未知的期望类型 {expect}"); + break; + } + } + } +} diff --git a/server/tests/WebDrop.Core.Tests/WebDrop.Core.Tests.csproj b/server/tests/WebDrop.Core.Tests/WebDrop.Core.Tests.csproj new file mode 100644 index 0000000..2766c39 --- /dev/null +++ b/server/tests/WebDrop.Core.Tests/WebDrop.Core.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + +