Files
justme d946fc5e8a feat(api)!: fold devices into users and report member presence
Contract 7.0.0 (breaking). Devices become an attribute of the account;
members are managed per user, and only unauthenticated guests keep a
per-terminal registration.

- rename seats -> max_members, seats_used -> members_used,
  seats_full -> members_full, SeatJoined -> JoinResult, the `seats`
  stream control message -> `members`, and POST /channels/{id}/seats ->
  POST /channels/{id}/join (stored channel settings are migrated on start)
- drop per-terminal encrypted device labels and the seat list/release
  endpoints; add GET/DELETE /channels/{id}/guests for guest terminals
- authenticate account members by account session only: the terminal key
  is optional for logged-in accounts, no seat row is written for them,
  and gated-download grants are counted per user (per terminal for guests)
- GET /channels/{id}/members now returns online, devices and
  online_devices per account; presence is tracked in memory from active
  WebSocket/SSE subscriptions and is not pushed over the stream
- owner invitation list no longer returns accounts that already joined
2026-10-14 22:18:59 +08:00

125 lines
4.9 KiB
Python

#!/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}/join", member_h)
check(status == 200 and json.loads(body)["members_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)