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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+