85 Commits
Author SHA1 Message Date
justmeandClaude Sonnet 5.5 4d2a0c089a feat(files): store files on the host when a channel has no file domain
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image-api Pipeline was successful
Chat stays usable when the file service is off or down. Files up to 256 MB are hosted like chat; downloads try the host first and fall back to file-service tickets.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> [CI]
2026-10-19 14:25:25 +08:00
justmeandClaude Sonnet 5.5 4b4ab4ee4d feat(files): store files on the host when a channel has no file domain
Chat stays usable when the file service is off or down. Files up to 256 MB are hosted like chat; downloads try the host first and fall back to file-service tickets.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> [CI]
2026-10-19 14:24:41 +08:00
justmeandClaude Sonnet 5.5 7e6ef5e587 chore(contract): regenerate API contract after AccountProfile comment change
ci/woodpecker/push/web-build Pipeline failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image-api unknown status
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> [CI]
2026-10-19 14:19:30 +08:00
justmeandClaude Sonnet 5.5 7274390aa1 perf(host): store avatars as content-addressed files and serve them from disk
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/web-build Pipeline failed
ci/woodpecker/push/dev-image-api unknown status
Space info now carries avatar_version instead of the inlined thumbnail; the worker fetches the thumbnail once per version. Legacy base64 rows are migrated to files on startup, and the daily backup uploads avatar files.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> [CI]
2026-10-19 14:16:50 +08:00
justme 8af4f284a0 ci: build and release webdrop-api and webdrop-files as separate images [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image-files Pipeline was successful
ci/woodpecker/push/dev-image-api Pipeline was successful
- dev images are built only when the push touched code that image uses (path filters);
  webdrop-files does not wait for the frontend build
- one v* tag releases both images (:tag and :latest) after the shared checks
- registry build and push live in scripts/ci-publish-image.sh, used by every workflow
- containers: fix the api Containerfile (missing Sync project), add Containerfile.files,
  Quadlet units for the file service (prod and dev), env examples, and registry
  auto-update for the production units so each machine pulls only the image it runs
2026-10-19 13:38:59 +08:00
justme 5489407241 refactor(web): restructure into api/domain/services/worker; capabilities, file domains, video covers
- dissolve core-bridge into api (wire contract), domain (models), services (what the UI
  calls) and worker (everything that runs in the Web Worker, grouped by account, crypto,
  space, files, shares, runtime); regroup features, lib and store by responsibility and
  delete unused components
- the UI only reads backend capabilities (domain/capabilities); one tokenRole picks the
  token, hasCode/isRetryable handle errors; pre-checks that decided permissions on the
  client are gone; tests fail if those rules are reimplemented
- files go straight to the file domain (create, upload, download, delete, commit, cancel);
  the host sees one registration per file; creating a channel allocates a file domain and
  works even when the file service is down
- file panel shows which channel the files belong to and its space usage; unlimited files
  preview in chat; videos get a stored cover; the file service status is shown
- fix: console flooded by Retry-After reads on every upload frame
2026-10-19 13:38:51 +08:00
justme 17638b6e20 refactor(host): single-source ChannelPolicy, space-signed file grants, whole-file registration
- all authorization rules (owner/creator, per-channel read/write grants, who may
  remove whom, delete rules, structure management) live in ChannelPolicy; a test
  fails if they are reimplemented elsewhere
- ChannelInfo carries what the caller may do (can_*), member and invitation lists
  say who can be removed; clients display it and never derive it
- the host no longer stores or calls the file service: it only signs short-lived
  tickets from space permissions (create/put/get/delete/domain), and keeps an opaque
  fileDomain reference on the channel
- a whole file is registered in one request (last frame header + per-frame length and
  digest) instead of one request per 1 MiB frame; the signed manifest still has to
  match every digest
- drop file quota/threshold, receipts, reconcile and delete-queue code from the host
- contract generator emits constants for enums, error codes, token roles, permissions
  and share states
2026-10-19 13:38:40 +08:00
justme ccb2c074d6 feat(files): file service owns file domains, quotas and S3 backup
- file domains are created, queried, sized and deleted by the file service
  (POST/GET/DELETE /file/v1/domains), with per-domain quota enforced on write
- /file/v1/options publishes the quota choices; nothing about files lives on
  the host any more
- tickets carry the issuing space and a unique id: a domain only accepts tickets
  from its own space, and read/delete tickets work once (no replaying a counted
  download)
- uploads stay pending until the client confirms them; abandoned ones are swept
  after 24h, and an upload ticket can cancel its own bytes
- backup target is S3 (SigV4 client, no SDK) instead of WebDAV
- CORS allows the methods the browser really uses (POST, DELETE) and exposes Retry-After
2026-10-19 13:38:29 +08:00
justme d3da3597cc Merge master into feat/file-api 2026-10-19 09:56:30 +08:00
justme d048fedc5e feat: keep every frame in animated avatar thumbnails and raise the thumbnail limit to 800k chars [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-17 21:37:35 +08:00
justme ea7895d85c feat(web): upload large avatars in two steps so the thumbnail applies immediately 2026-10-17 21:37:30 +08:00
justme a27534b2dd chore: regenerate api contract [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-17 21:31:15 +08:00
justme d1ff8bab7d feat(web): role groups, last seen and unread markers in members, channels and spaces; lock channels without keys 2026-10-17 21:31:15 +08:00
justme 5043c798ff feat(server): member last seen and a live per-account space activity stream 2026-10-17 21:31:15 +08:00
justme 78b4e2aa63 fix(server): trim every workspace structure response to the caller's grants and restrict avatars to shared spaces
- structure mutations returned the full structure to any member who could call them (moving an item after sending a message exposed channels the member has no access to)
- moving items now requires write access to the destination channel; new folders require an existing channel
- account avatars are only readable by the owner or accounts sharing a space
2026-10-17 21:31:15 +08:00
justme 72a5e5b839 feat(server): split file storage into a separate file service with tickets, receipts and WebDAV backup
- new projects WebDrop.FileApi (file service), WebDrop.FileProtocol (shared tickets and receipts) and WebDrop.Sync (WebDAV)
- file content of any size goes to the file service; chat, avatars and space structure stay on the main backend
- tickets issued by the main backend, receipts by the file service, deletion queue and daily reconciliation
- daily database snapshot and file sync to external storage (OpenList/WebDAV) with a 30 day retention
- reorganize Core, Storage and Api into feature folders (namespaces unchanged)
- web client uploads file frames to the file service and reads them with tickets
2026-10-17 15:25:16 +08:00
justme d66c93fe98 feat(web): avatar thumbnails with original preview, registration avatar, pure-JS crypto fallback for http [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-17 14:35:42 +08:00
justme 283196938e feat(web): stream video and audio while decrypting, paste images, 16px inputs on touch 2026-10-17 14:35:41 +08:00
justme 31ae3bc39e feat(server): lossy avatar thumbnails, gzip upload and separate avatar endpoints 2026-10-17 14:35:41 +08:00
justme ba0b6c258a feat(web): space avatars with GIF support and live space list sync [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-17 13:44:16 +08:00
justme 96f92c2ba9 feat(web): inline previews in chat and per-upload download limit choice 2026-10-17 13:44:16 +08:00
justme 1a4069576b feat(server): space avatars and full member profiles in the structure response 2026-10-17 13:44:16 +08:00
justme 77c9017129 chore: regenerate API contract after role color doc changes [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-17 13:26:38 +08:00
justme a08aa7568f feat(web): animated GIF avatars that refresh across chat and member list [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/dev-image unknown status
2026-10-17 13:25:14 +08:00
justme 3b062687cd feat(web): role colors with picker, Material 3 draw, effects and inline markdown 2026-10-17 13:25:14 +08:00
justme bef8898cd8 feat(server): owner role group, custom role colors with effects and animated avatars 2026-10-17 13:25:13 +08:00
justme 7ef298f587 fix(web): make cancel, delete and chat work while a file is uploading [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-16 19:51:45 +08:00
justme 1245aedfd2 docs: record how to measure upload speed without the Vite proxy [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-16 19:42:33 +08:00
justme a574583943 feat(web): space roles, upload queue with pipelined frames, background resume and faster sending 2026-10-16 19:42:33 +08:00
justme 96c4335cb5 feat(api): count upload frames separately and send Retry-After when rate limited 2026-10-16 19:42:33 +08:00
justme 82d55a97f9 fix(api): let authors and owners delete unfinished uploads 2026-10-16 19:42:33 +08:00
justme 1dd54243ae feat(api): add space roles with delegated member removal and structure management 2026-10-16 19:42:31 +08:00
justme 059a29a8d5 feat(web): ask for a display name when creating an account [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-16 18:30:59 +08:00
justme 915992776d feat(web): show the space validity period in the top bar 2026-10-16 18:30:59 +08:00
justme 6d6490055e feat(web): tidy the channel sidebar and let owners delete groups 2026-10-16 18:30:58 +08:00
justme 6310f93469 fix(web): verify deletions against the signer and read crypto state afresh 2026-10-16 18:30:58 +08:00
justme fc1dc08ebe feat(api): delete workspace groups and record who signed a content event 2026-10-16 18:30:58 +08:00
justme 55f1b70d89 feat(web): crop avatars in the browser with a live message preview [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-16 17:54:32 +08:00
justme 18c871ec85 feat(web): use the Material settings gear and add rotate icons 2026-10-16 17:54:32 +08:00
justme 18185c5bd9 refactor(web): drop the settings entry from the space header 2026-10-16 17:54:32 +08:00
justme b847916a5b feat(web): group space settings into basic, content and join tabs 2026-10-16 17:54:31 +08:00
justme 79bfd560cb feat(web): call the login name the account ID and list sign-up requirements 2026-10-16 17:54:31 +08:00
justme 700be2ebae fix(storage): retry a read transaction that fails to begin under load 2026-10-16 17:54:30 +08:00
justme fb5800f81d fix(web): show the author of incoming messages right away [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/dev-image unknown status
Messages from other members or devices used to render as an unknown member
until the space structure refreshed. Use the author account the server
records for the object, together with the loaded profiles, when the item
arrives.
2026-10-16 07:04:31 +08:00
justme 12980ba6fe docs: describe login-only spaces and the 8.0.0 contract [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-16 06:46:16 +08:00
justme 8233369cd4 feat(web)!: sign in before joining a space and drop the stranger option
Opening a space link while signed out shows the sign-in page first and keeps
the link in memory. The channel settings no longer offer the stranger switch,
and the client supports API contract major 8.
2026-10-16 06:46:15 +08:00
justme c858bd2a09 feat(server)!: require a logged-in account for every channel request
Drop unauthenticated guests from channels: the allow_guests setting, the
/guests endpoints and the guest seat table are gone, and requests without an
account session get 401 account_required. Share links are unaffected.
Contract version is now 8.0.0.
2026-10-16 06:46:15 +08:00
justme b1ac11e1d7 perf(web): shorten the cold start when opening a saved space
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
Activate the account with parallel requests, run the full vault sync in the
background, skip the duplicate space list request, and send the rejoin
together with the first screen of data. [CI]
2026-10-16 06:00:17 +08:00
justme f2e118c966 perf(server): compress JSON and frontend responses with brotli or gzip 2026-10-16 06:00:16 +08:00
justme d77ca1f113 perf(web): reuse cached crypto state and structure when decrypting and sending
Decrypting each history item no longer refetches the whole crypto state, and
sending a message only needs the object and frame requests.
2026-10-16 06:00:16 +08:00
justme 593eeba18e docs(ui): describe the loading skeleton [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-16 05:44:58 +08:00
justme 22f272e361 feat(web): show a skeleton while a space loads
Draw the page frame instead of a blank screen, fade it in only after 300ms,
and surface load errors with retry and back-to-home actions.
2026-10-16 05:44:58 +08:00
justme e79fbf1112 docs(ui): describe the phone chat shell [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-16 05:34:38 +08:00
justme 1db3795d6a feat(web): open spaces as a plain chat on phones with a side drawer
Spaces, channels, files, members and the account move into a left drawer,
the top bar keeps only the essentials, and touch devices get a tap menu
instead of always-visible delete buttons. Also fixes the history checkboxes
in the channel settings sheet.
2026-10-16 05:34:38 +08:00
justme 06fe077ef1 perf(web): shorten the first open of a space for new members
Accepting an invitation no longer waits for the vault sync, the redundant
join right after joining is skipped, and the crypto state, first log page and
channel metadata are requested in parallel.
2026-10-16 05:34:38 +08:00
justme d4a72bf177 docs(ui): describe account settings, sign-in and the home zones [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-14 22:42:06 +08:00
justme d0aefb3d6e feat(web): split the home page into quick share and spaces zones
Two side-by-side zones (stacked on narrow screens) with an icon and a
one-line positioning each: quick share is temporary, read-only and needs
no account; spaces are long-lived, multi-user and need a login. Shorter
copy, my shares listed under the share input, and an empty state for
spaces.
2026-10-14 22:42:06 +08:00
justme 0d31e3d058 feat(web): redesign account settings and the sign-in page
- account settings: sidebar sections (profile, devices, security,
  invitations, sync and sign-in) instead of one long scroll; inline device
  rename, confirmations for destructive actions and toast feedback
- sign-in/register: split layout with a quiet brand column, text tabs and
  underlined inputs; registering asks for the password twice and explains
  that it cannot be recovered
- move invitation accept/label helpers into store/invitations so the
  toast and the settings inbox share them
- add the home page zone styles used by the next commit
2026-10-14 22:42:06 +08:00
justme 4b8d0b5a5f feat(web): discord-style chat, members pane, invitations and optimistic send [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
- chat: left-aligned discord-style messages with grouped senders, hover
  actions, an auto-growing composer and file cards
- send: render messages and files locally first and replace the
  placeholder when the worker publishes the real item; failures keep the
  text with a retry action
- navigation: space rail plus a flatter channel list with the space name
  header and collapsible groups
- members: right pane tabs (files / members) grouped by online, offline
  and pending users, with a prominent invite action and a top bar shortcut
- notifications: toast host for invite results; invitees get a toast with
  an accept action, and the invitation payload now carries the space name
  and inviter inside the encrypted grant
- users over devices: drop device names, the device list and device
  rename/release; senders show the account (guests show as guest) and the
  share panel only keeps links
- follow contract 7.0.0 (max_members, members_used, members_full, /join)
- docs: ui-design and frontend updated for the new layout and concepts
2026-10-14 22:19:25 +08:00
justme 5fd4ddd411 refactor(core): drop per-terminal device labels
Remove sealLabel/openLabel and their label constants; devices are no
longer named per channel. Rename the create setting seats to
max_members and point the e2e join helper at POST /join.
2026-10-14 22:19:24 +08:00
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
justme 0fd2a269d9 Merge pull request 'feat(files): add per-channel storage quota [CI]' (#2) from feat/channel-storage-quota into master
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-14 05:42:56 -07:00
justme a6f078d009 feat(files): add per-channel storage quota [CI]
ci/woodpecker/pr/web-build Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
2026-10-14 20:42:08 +08:00
justme 441ed0e5f0 Merge pull request 'feat: add account workspaces and signed content events' (#1) from feat/account-workspace-signatures into master
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-10-14 05:29:35 -07:00
justme fe39b30332 fix(core): satisfy strict signature test typing
ci/woodpecker/pr/web-build Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
2026-10-14 20:28:52 +08:00
justme 58506f82de docs: describe workspace signing boundaries
ci/woodpecker/pr/web-build Pipeline failed
ci/woodpecker/pr/ci Pipeline was successful
2026-10-14 04:06:29 +08:00
justme b5cf3d5958 feat(web): add account workspaces and signed content flows 2026-10-14 04:06:03 +08:00
justme 7b8d61d1ba feat(api): add account workspaces and signed content events 2026-10-14 04:05:30 +08:00
justme 937b774a93 test(web): cover workspace and file interaction workflows
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-10-13 01:39:26 +08:00
justme c2ea1b7f64 docs: consolidate account workspace architecture 2026-10-13 01:39:25 +08:00
justme b65e32885f feat(auth): remember encrypted local login credentials 2026-10-13 01:39:24 +08:00
justme f6cfb12716 feat(web): add spaces, grouped channels and file chat 2026-10-13 01:39:23 +08:00
justme c7d8ef15bc feat(api): bind encrypted workspaces to accounts and devices 2026-10-13 01:38:19 +08:00
justme 019b15041a build(deploy): switch hosting to Podman Quadlet 2026-10-13 01:38:18 +08:00
justme 10aa0304e4 feat(web): separate build sources and localize time
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-10-12 16:50:15 +08:00
justme 89cd92f7ee feat(web): show build info and theme menu [CI]
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-12 16:40:50 +08:00
justme 98a5e67f82 ci: reuse NativeAOT build image [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-12 02:09:50 +08:00
justme b9c620304c fix(ci): stabilize oversized body check [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was canceled
2026-10-12 01:58:41 +08:00
justme 9f02a17c85 ci: align Zot login with MoShiro [CI]
ci/woodpecker/push/web-build Pipeline failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/dev-image unknown status
2026-10-12 01:56:08 +08:00
justme d570435236 ci: use Zot API key at registry.oeo.one [CI]
ci/woodpecker/push/web-build Pipeline failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/dev-image unknown status
2026-10-12 01:54:25 +08:00
justme fd01120d3a ci: gate development images on checks [CI] 2026-10-12 01:47:24 +08:00
justme c0d155c608 fix: stabilize mobile uploads and show transfer speed 2026-10-03 01:37:43 +08:00
justme 09d66092f5 ci: fix latest image tag publishing commands
ci/woodpecker/push/ci Pipeline failed
2026-10-02 06:21:04 +00:00
justme ca62b9dd6e ci: publish latest image tag on release
ci/woodpecker/push/ci Pipeline failed
2026-10-02 06:20:05 +00:00
justme c68acfd78b fix: allow embedded worker API requests
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/tag/release-backend Pipeline was successful
2026-10-02 14:12:14 +08:00
478 changed files with 28691 additions and 5251 deletions

No files matched your search

View File
File renamed without changes.
+1 -1
View File
@@ -1,7 +1,7 @@
# API only. Loopback binding lets a local frontend or host reverse proxy reach it.
API_BIND_ADDRESS=127.0.0.1
API_PORT=5000
WEBDROP_IMAGE_REPO=zot.oeo.one/justme/webdrop-api
WEBDROP_IMAGE_REPO=registry.oeo.one/justme/webdrop-api
WEBDROP_IMAGE_TAG=local
WEBDROP_REQUESTS_PER_MINUTE=600
+2
View File
@@ -21,6 +21,8 @@ server/.idea/
!.env.example
# Editor and OS files
.idea/
.playwright-mcp/
.vscode/
.DS_Store
Thumbs.db
+21 -39
View File
@@ -1,5 +1,10 @@
when:
- event: [push, manual]
- event: pull_request
- event: push
branch: master
- event: push
branch: main
- event: manual
labels:
ci: domain-linux-native
@@ -13,45 +18,22 @@ clone:
from_secret: gitea_clone_webdrop
lfs: false
steps:
prefetch-dependencies:
image: bash
commands:
- mkdir -p "$HOME/.cache/woodpecker/webdrop/npm" "$HOME/.cache/woodpecker/webdrop/nuget"
- cd packages/core
- npm ci --cache "$HOME/.cache/woodpecker/webdrop/npm" --prefer-offline --no-audit
- cd ../../web
- npm ci --cache "$HOME/.cache/woodpecker/webdrop/npm" --prefer-offline --no-audit
- cd ..
- NUGET_PACKAGES="$HOME/.cache/woodpecker/webdrop/nuget" dotnet restore server/WebDrop.slnx
api-contract:
steps:
prefetch-dependencies:
image: bash
commands:
- mkdir -p "$HOME/.cache/woodpecker/webdrop/nuget"
- NUGET_PACKAGES="$HOME/.cache/woodpecker/webdrop/nuget" dotnet restore server/WebDrop.slnx
api-contract:
image: bash
commands:
- node scripts/check-api-contract.mjs
core-and-web:
image: bash
depends_on: [prefetch-dependencies]
commands:
- cd packages/core
- npm run typecheck
- npm test
- npm run vectors:check
- npm run build
web:
image: bash
depends_on: [prefetch-dependencies]
commands:
- cd web
- npm run typecheck
- npm run lint:css
- npm test
- npm run build
server:
image: bash
depends_on: [prefetch-dependencies]
commands:
- NUGET_PACKAGES="$HOME/.cache/woodpecker/webdrop/nuget" dotnet test server/WebDrop.slnx --configuration Release --verbosity minimal --no-restore
server:
image: bash
depends_on: [prefetch-dependencies]
when:
- evaluate: 'not (CI_COMMIT_MESSAGE contains "[SKIP TESTS]")'
commands:
- NUGET_PACKAGES="$HOME/.cache/woodpecker/webdrop/nuget" dotnet test server/WebDrop.slnx --configuration Release --verbosity minimal --no-restore
+51
View File
@@ -0,0 +1,51 @@
# 主后端(含内嵌前端)的开发镜像:提交信息带 [CI],并且这次推送改到了主后端用到的代码时才构建。
# 推送 webdrop-api:dev(开发机器的 podman auto-update 跟踪它)和 webdrop-api:dev-<提交>
when:
- event: push
branch: [master, main]
evaluate: 'CI_COMMIT_MESSAGE contains "[CI]"'
path:
include:
- 'web/**'
- 'packages/core/**'
- 'server/Containerfile'
- 'server/Directory.Build.props'
- 'server/src/WebDrop.Api/**'
- 'server/src/WebDrop.Core/**'
- 'server/src/WebDrop.Storage/**'
- 'server/src/WebDrop.Frontend/**'
- 'server/src/WebDrop.FileProtocol/**'
- 'server/src/WebDrop.Sync/**'
- 'scripts/ci-publish-image.sh'
- '.woodpecker/dev-image-api.yml'
- event: manual
labels:
ci: domain-linux-native
platform: linux/amd64
clone:
- name: git
image: woodpeckerci/plugin-git:2.9.3
settings:
remote:
from_secret: gitea_clone_webdrop
lfs: false
depends_on:
- ci
- web-build
concurrency: 1
steps:
publish-dev-image:
image: bash
environment:
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
ZOT_REGISTRY: registry.oeo.one
commands:
- sha="${CI_COMMIT_SHA:0:7}"
- bash scripts/ci-publish-image.sh webdrop-api server/Containerfile "dev-$sha" dev
+46
View File
@@ -0,0 +1,46 @@
# 文件服务(fileAPI)的开发镜像:提交信息带 [CI],并且这次推送改到了文件服务用到的代码时才构建。
# 推送 webdrop-files:dev(放文件的机器上的 podman auto-update 跟踪它)和 webdrop-files:dev-<提交>
when:
- event: push
branch: [master, main]
evaluate: 'CI_COMMIT_MESSAGE contains "[CI]"'
path:
include:
- 'server/Containerfile.files'
- 'server/Directory.Build.props'
- 'server/src/WebDrop.FileApi/**'
- 'server/src/WebDrop.FileProtocol/**'
- 'server/src/WebDrop.Sync/**'
- 'scripts/ci-publish-image.sh'
- '.woodpecker/dev-image-files.yml'
- event: manual
labels:
ci: domain-linux-native
platform: linux/amd64
clone:
- name: git
image: woodpeckerci/plugin-git:2.9.3
settings:
remote:
from_secret: gitea_clone_webdrop
lfs: false
# 文件服务不带前端,不用等前端构建
depends_on:
- ci
concurrency: 1
steps:
publish-dev-image:
image: bash
environment:
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
ZOT_REGISTRY: registry.oeo.one
commands:
- sha="${CI_COMMIT_SHA:0:7}"
- bash scripts/ci-publish-image.sh webdrop-files server/Containerfile.files "dev-$sha" dev
@@ -1,59 +1,65 @@
when:
- event: tag
ref: refs/tags/v*
- event: manual
labels:
ci: domain-linux-native
platform: linux/amd64
clone:
- name: git
image: woodpeckerci/plugin-git:2.9.3
settings:
remote:
from_secret: gitea_clone_webdrop
lfs: false
steps:
checkout-release-source:
image: bash
commands:
- set -euo pipefail
- release_tag="${CI_COMMIT_TAG:-${RELEASE_TAG:-}}"
- test -n "$release_tag"
- node scripts/check-api-contract.mjs "$release_tag"
- git fetch --force origin "refs/tags/$release_tag:refs/tags/$release_tag"
- git checkout --detach "refs/tags/$release_tag"
release-checks:
image: bash
commands:
# 发布:打 vMAJOR.MINOR.PATCH tag(或手动运行并传 RELEASE_TAG)。
# 一个 tag 同时发布两个镜像:webdrop-api:<tag> / :latest 和 webdrop-files:<tag> / :latest。
# 两边共用票据格式和文件协议,版本号一致最不容易配错;各机器只会拉自己用的那个镜像。
when:
- event: tag
ref: refs/tags/v*
- event: manual
labels:
ci: domain-linux-native
platform: linux/amd64
clone:
- name: git
image: woodpeckerci/plugin-git:2.9.3
settings:
remote:
from_secret: gitea_clone_webdrop
lfs: false
steps:
checkout-release-source:
image: bash
commands:
- set -euo pipefail
- release_tag="${CI_COMMIT_TAG:-${RELEASE_TAG:-}}"
- test -n "$release_tag"
- node scripts/check-api-contract.mjs "$release_tag"
- git fetch --force origin "refs/tags/$release_tag:refs/tags/$release_tag"
- git checkout --detach "refs/tags/$release_tag"
release-checks:
image: bash
commands:
- release_tag="${CI_COMMIT_TAG:-${RELEASE_TAG:-}}"
- test -n "$release_tag"
- node scripts/check-api-contract.mjs "$release_tag"
- bash scripts/ci-frontend.sh
- mkdir -p "$HOME/.cache/woodpecker/webdrop/nuget"
- NUGET_PACKAGES="$HOME/.cache/woodpecker/webdrop/nuget" dotnet restore server/WebDrop.slnx
- NUGET_PACKAGES="$HOME/.cache/woodpecker/webdrop/nuget" dotnet test server/WebDrop.slnx --configuration Release --verbosity minimal --no-restore
publish-backend:
image: bash
environment:
ZOT_USERNAME:
from_secret: registry_username
ZOT_PASSWORD:
from_secret: registry_password
ZOT_REGISTRY: 192.168.2.162:5000
commands:
- set -euo pipefail
- release_tag="${CI_COMMIT_TAG:-${RELEASE_TAG:-}}"
- test -n "$release_tag"
- ': "${WOODPECKER_BACKEND_LOCAL_TEMP_DIR:=$HOME/woodpecker/work}"'
- buildah_store="$(dirname "$WOODPECKER_BACKEND_LOCAL_TEMP_DIR")/buildah-cache/webdrop"
- mkdir -p "$buildah_store/storage" "$buildah_store/run"
- 'buildah() { command buildah --root "$buildah_store/storage" --runroot "$buildah_store/run" "$@"; }'
- printf '%s' "$ZOT_PASSWORD" | buildah login --tls-verify=false "$ZOT_REGISTRY" --username "$ZOT_USERNAME" --password-stdin
- trap 'buildah logout "$ZOT_REGISTRY" >/dev/null 2>&1 || true' EXIT
- image="$ZOT_REGISTRY/justme/webdrop-api:$release_tag"
- buildah bud --layers --network host --format docker --platform linux/amd64 --tag "$image" --file server/Dockerfile .
- buildah push --tls-verify=false "$image" "docker://$image"
publish-api:
image: bash
depends_on: [release-checks]
environment:
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
ZOT_REGISTRY: registry.oeo.one
commands:
- release_tag="${CI_COMMIT_TAG:-${RELEASE_TAG:-}}"
- bash scripts/ci-publish-image.sh webdrop-api server/Containerfile "$release_tag" latest
publish-files:
image: bash
depends_on: [release-checks]
environment:
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
ZOT_REGISTRY: registry.oeo.one
commands:
- release_tag="${CI_COMMIT_TAG:-${RELEASE_TAG:-}}"
- bash scripts/ci-publish-image.sh webdrop-files server/Containerfile.files "$release_tag" latest
+25
View File
@@ -0,0 +1,25 @@
when:
- event: pull_request
- event: push
branch: master
- event: push
branch: main
- event: manual
labels:
ci: domain-linux-native
platform: linux/amd64
clone:
- name: git
image: woodpeckerci/plugin-git:2.9.3
settings:
remote:
from_secret: gitea_clone_webdrop
lfs: false
steps:
web-build:
image: bash
commands:
- bash scripts/ci-frontend.sh
+5 -4
View File
@@ -10,7 +10,7 @@
|---|---|
| 协议、API、威胁模型 | 已定稿(`docs/`) |
| `test-vectors/` | 已完成。由 `node:crypto` 独立生成,服务端(.NET)和 `@webdrop/core`(WebCrypto)各自复现 |
| `server/` | 频道与便捷分享 API 已实现;含独立的可选 `WebDrop.Frontend` 静态资源模块;NativeAOT 发布 |
| `server/` | 频道与便捷分享 API 已实现;含独立的可选 `WebDrop.Frontend` 静态资源模块;NativeAOT 发布为 OCI 镜像 |
| `packages/core/` | 已完成并验证。168 个测试,含对着真实服务端的端到端 |
| `web/` | 前端,开发中 |
@@ -19,6 +19,7 @@
| 文档 | 内容 |
|---|---|
| [docs/threat-model.md](docs/threat-model.md) | 信任边界、攻击者模型、每种模式的保证、元数据泄露、不做的事 |
| [docs/e2ee-boundary-comparison.md](docs/e2ee-boundary-comparison.md) | 当前信任边界、签名授权/事件的目标设计与分阶段路线 |
| [docs/protocol.md](docs/protocol.md) | 密钥派生、令牌、帧格式、加密和 padding、校验算法、保留策略(协议权威文档) |
| [docs/api.md](docs/api.md) | HTTP / WebSocket / SSE 接口、鉴权头、错误码 |
| [docs/architecture.md](docs/architecture.md) | 组件、技术栈、NativeAOT 约束、存储模型、部署、阶段划分 |
@@ -27,14 +28,14 @@
| [web/AGENTS.md](web/AGENTS.md) | 前端契约:权威顺序、范围、必须遵守与禁止项 |
| [packages/core/README.md](packages/core/README.md) | `@webdrop/core` 的接口、前端要做和不用做的事、必须注意的几点 |
| [server/README.md](server/README.md) | 服务端的结构、配置、AOT 发布、验证码(Cap)接入 |
| [docs/deployment.md](docs/deployment.md) | Docker Compose 自托管部署与 Woodpecker CI |
| [docs/deployment.md](docs/deployment.md) | Podman Quadlet 自托管部署与 Woodpecker CI |
| [test-vectors/README.md](test-vectors/README.md) | 协议测试向量的生成与使用 |
## 核心概念
- **频道**:唯一的顶层概念,一条只追加的帧流,有自己的 id、密钥、设置和席位。
- **频道**:唯一的顶层概念,一条只追加的帧流,有自己的 id、密钥、设置和账号成员人数上限。
- **条目**:频道里的一项内容(文本、JSON、文件),由若干帧组成,可复制、下载、删除。
- **终端**:加入频道的设备或会话,占一个席位(默认 2 个)。
- **成员**:频道里的人是用户(账号);设备只是账号的属性,同一账号的多设备只算一人,请求凭账号会话鉴权。频道强制登录,没有未登录访客(便捷分享不受影响)。
- **所有者 / 成员**:所有者持有管理密钥,成员通过频道链接加入。
- **模式**:`enc`(默认,浏览器端 AES-256-GCM)和 `raw`(负载原样,服务器可见,用于公开或用户自行加密的内容)。
-32
View File
@@ -1,32 +0,0 @@
services:
api:
build:
context: .
dockerfile: server/Dockerfile
image: ${WEBDROP_IMAGE_REPO:-webdrop-api}:${WEBDROP_IMAGE_TAG:-local}
platform: linux/amd64
restart: unless-stopped
init: true
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=16m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
environment:
ASPNETCORE_URLS: http://+:5000
WebDrop__DataDir: /data
WebDrop__RequestsPerMinute: ${WEBDROP_REQUESTS_PER_MINUTE:-600}
WebDrop__ChannelCreatesPerMinute: ${WEBDROP_CREATES_PER_MINUTE:-30}
WebDrop__SweepIntervalSeconds: ${WEBDROP_SWEEP_INTERVAL_SECONDS:-30}
WebDrop__TrustForwardedHeaders: ${WEBDROP_TRUST_FORWARDED_HEADERS:-false}
WebDrop__Captcha__Provider: ${CAPTCHA_PROVIDER:-none}
WebDrop__Captcha__ApiEndpoint: ${CAPTCHA_API_ENDPOINT:-}
WebDrop__Captcha__SiteverifyUrl: ${CAPTCHA_SITEVERIFY_URL:-}
WebDrop__Captcha__Secret: ${CAPTCHA_SECRET:-}
WebDrop__Captcha__TimeoutSeconds: ${CAPTCHA_TIMEOUT_SECONDS:-5}
ports:
- "${API_BIND_ADDRESS:-127.0.0.1}:${API_PORT:-5000}:5000"
volumes:
- ./data:/data
+1 -1
View File
@@ -1,3 +1,3 @@
{
"version": "1.0.0"
"version": "8.2.0"
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,4 @@
[Timer]
OnCalendar=
OnBootSec=1min
OnUnitActiveSec=5min
+27
View File
@@ -0,0 +1,27 @@
[Unit]
Description=WebDrop development environment from registry
[Container]
ContainerName=webdrop-dev
Image=registry.oeo.one/justme/webdrop-api:dev
Pull=missing
AutoUpdate=registry
PublishPort=127.0.0.1:5001:5000
Environment=ASPNETCORE_URLS=http://+:5000
Environment=WebDrop__DataDir=/data
Environment=DOTNET_EnableDiagnostics=0
EnvironmentFile=-%h/podman/webdrop-dev/webdrop.env
Volume=%h/podman/webdrop-dev/data:/data:Z,U
ReadOnly=true
ReadOnlyTmpfs=false
Tmpfs=/tmp:rw,noexec,nosuid,size=16m
DropCapability=ALL
NoNewPrivileges=true
[Service]
Restart=always
TimeoutStartSec=120
TimeoutStopSec=60
[Install]
WantedBy=default.target
+29
View File
@@ -0,0 +1,29 @@
[Unit]
Description=WebDrop file service (fileAPI) development environment from registry
Wants=network-online.target
After=network-online.target
[Container]
ContainerName=webdrop-files-dev
Image=registry.oeo.one/justme/webdrop-files:dev
Pull=missing
AutoUpdate=registry
PublishPort=127.0.0.1:5101:5100
Environment=ASPNETCORE_URLS=http://+:5100
Environment=FileApi__DataDir=/data
Environment=DOTNET_EnableDiagnostics=0
EnvironmentFile=%h/podman/webdrop-files-dev/webdrop-files.env
Volume=%h/podman/webdrop-files-dev/data:/data:Z,U
ReadOnly=true
ReadOnlyTmpfs=false
Tmpfs=/tmp:rw,noexec,nosuid,size=16m
DropCapability=ALL
NoNewPrivileges=true
[Service]
Restart=always
TimeoutStartSec=120
TimeoutStopSec=60
[Install]
WantedBy=default.target
+31
View File
@@ -0,0 +1,31 @@
[Unit]
Description=WebDrop file service (fileAPI)
Wants=network-online.target
After=network-online.target
[Container]
ContainerName=webdrop-files
# 正式环境:跟踪打 v* tag 发布的 latest。放文件的机器(例如家里的 NAS)只拉这个镜像,不需要主后端的
Image=registry.oeo.one/justme/webdrop-files:latest
Pull=missing
AutoUpdate=registry
PublishPort=127.0.0.1:5100:5100
Environment=ASPNETCORE_URLS=http://+:5100
Environment=FileApi__DataDir=/data
Environment=DOTNET_EnableDiagnostics=0
# 和主后端共用的密钥、允许的页面来源、备份设置等,不进仓库。例子见 webdrop-files.env.example
EnvironmentFile=%h/podman/webdrop-files/webdrop-files.env
Volume=%h/podman/webdrop-files/data:/data:Z,U
ReadOnly=true
ReadOnlyTmpfs=false
Tmpfs=/tmp:rw,noexec,nosuid,size=16m
DropCapability=ALL
NoNewPrivileges=true
[Service]
Restart=always
TimeoutStartSec=120
TimeoutStopSec=60
[Install]
WantedBy=default.target
+18
View File
@@ -0,0 +1,18 @@
# 文件服务(家里的 NAS)的机器配置:复制到 ~/podman/webdrop-files/webdrop-files.env 并按实际情况修改。不要提交真实值
# 和主后端共用的密钥,至少 32 个字符。两边必须一致
FileApi__Secret=
# 页面所在的源:浏览器直接把文件传到这里,必须列出,否则浏览器会拦下跨域请求
FileApi__CorsOrigins__0=https://app.example.com
# 创建频道时可选的文件空间档位(字节)和默认值;不配用内置的 1/5/10/50/100 GiB,默认 10 GiB
# FileApi__QuotaChoices__0=1073741824
# FileApi__QuotaChoices__1=10737418240
# FileApi__DefaultQuota=10737418240
# 文件服务最多存多少字节,0 为不限
# FileApi__MaxStoreBytes=0
# 每天把新增的文件备份到 S3 协议的存储
# FileApi__Backup__Enabled=true
# FileApi__Backup__Time=04:30
# FileApi__Backup__S3__Endpoint=https://s3.example.com
# FileApi__Backup__S3__Bucket=webdrop
# FileApi__Backup__S3__AccessKey=
# FileApi__Backup__S3__SecretKey=
+31
View File
@@ -0,0 +1,31 @@
[Unit]
Description=WebDrop API with embedded frontend
Wants=network-online.target
After=network-online.target
[Container]
ContainerName=webdrop
# 正式环境:跟踪打 v* tag 发布的 latest。podman-auto-update.timer 每隔几分钟检查 registry,有新镜像就拉取并重启这个单元
Image=registry.oeo.one/justme/webdrop-api:latest
Pull=missing
AutoUpdate=registry
PublishPort=127.0.0.1:5000:5000
Environment=ASPNETCORE_URLS=http://+:5000
Environment=WebDrop__DataDir=/data
Environment=DOTNET_EnableDiagnostics=0
# 机器上的配置(文件服务的地址和共用密钥等),不进仓库。例子见 webdrop.env.example
EnvironmentFile=-%h/podman/webdrop/webdrop.env
Volume=%h/podman/webdrop/data:/data:Z,U
ReadOnly=true
ReadOnlyTmpfs=false
Tmpfs=/tmp:rw,noexec,nosuid,size=16m
DropCapability=ALL
NoNewPrivileges=true
[Service]
Restart=always
TimeoutStartSec=120
TimeoutStopSec=60
[Install]
WantedBy=default.target
+7
View File
@@ -0,0 +1,7 @@
# 主后端(VPS)的机器配置:复制到 ~/podman/webdrop/webdrop.env 并按实际情况修改。不要提交真实值
# 打开文件服务:文件内容不放主后端,由 fileAPI 保存;主后端只签发访问票据
WebDrop__Files__Enabled=true
# 浏览器访问文件服务的地址(必须 https,和文件服务机器上配的对外地址一致)
WebDrop__Files__PublicUrl=https://files.example.com
# 和文件服务共用的密钥,至少 32 个字符:openssl rand -base64 48。两边必须一致
WebDrop__Files__Secret=
+47
View File
@@ -0,0 +1,47 @@
# 账号、设备、频道与加密的统一模型
本文件收束账号频道工作区的模型;密码学细节仍以 protocol.md 为准。临时 Share 是独立的对外发布功能,只复用文件传输和密码学,不作为频道成员模型。
## 领域边界
### 已确认的下一版规则
注册设置初始密码。密码和绑定的 Passkey 是账号认证/解锁入口,不要求旧设备批准新设备;Passkey 的 WebAuthn 验证由 .NET 10 官方组件完成。支持 PRF 时浏览器封装保险库解密凭据;PRF 输出不传给服务器。不支持 PRF 的 Passkey 可登录,资源解密仍需密码。绑定需重新验证密码,撤销后该凭据不能再创建会话。
Member 加入空间后自动获得默认频道的聊天读取/发言和文件读取/上传权,只能修改自己的内容,不能管理频道或邀请成员。其他频道单独授权;历史聊天和文件由 Owner 分别决定是否向新成员开放。历史选择在接受邀请时生效:关闭时分别轮换该数据域 epoch,仅给新成员当前 epoch;已获授权的成员保留历史访问。之后修改开关只影响后续邀请,不撤回已交付密钥。
Account 使用不可修改的 username 作为稳定身份,displayName/avatar 由后端个人资料接口管理。AccountDevice 是账号下的浏览器身份,具有独立登录 session、名称与撤销状态;每个空间的设备 S 独立生成。空间成员绑定账号与角色,不绑定某次登录或设备。
空间是成员管理与同步的边界,不是内容密钥边界。频道属于空间,分组归属空间,文件夹属于频道,资源归属由后端校验和保存。新空间与初始“常规”频道在同一事务中创建。每频道独立维护 chat 与 files 两个数据域及 epoch;分组仅组织频道,不创建密钥域。
## 模块互通
账号模块提供当前身份、个人资料、设备会话与解锁后的账号密钥。空间模块负责创建空间、初始频道、成员邀请与加入;频道模块负责频道、分组、文件夹和频道内的聊天/文件访问。权限、历史范围、epoch 和授权收件人由后端计算、持久化和校验。浏览器只根据服务端返回的授权计划生成密钥并进行封装;它不能选择或扩大授权集合。加密模块不读取 UI 或数据库,只提供账号密钥封装、随机对象密钥封装和帧加解密。Worker 是 API 与密码学之间的编排层,不是业务规则来源。
账号登录授权访问服务端;频道内容密钥授权解密内容。这两种能力分开校验,服务端不能通过登录 token 推出频道密钥。频道主的成员记录才是 owner 权限的最终依据,不能凭复制管理密钥冒充另一个账号。
## 密钥与多设备
账号邀请私钥保存在密码加密的保险库中,多台设备登录同一账号后解锁同一账号私钥。空间创建时生成空间访问凭据,Owner 凭据与 Owner 账号密钥包由后端原子存储;它们不再加密频道内容。频道 chat/files 域密钥在浏览器生成,并通过 Owner 账号 ECDH 公钥封装;服务端保存密文,获授权成员的账号可领取并在本机解封。
已加入成员的新设备只凭账号登录即可解封账号分发给该账号的数据域密钥,不需要邀请者在线,也不需要复制带内容密钥的链接。每个浏览器有独立设备登录凭据(账号设备),成员请求凭账号 session 鉴权,不再有按空间的终端密钥或设备席位;读取账号空间列表不占成员名额。同一账号在一个空间只占一个成员名额,设备数量不受成员上限影响。设备撤销会撤销其账号 sessions;账号空间成员身份及其人数名额保留,其他设备继续访问。服务端按账号统计在线状态与设备数,成员栏只展示用户。
本机记住登录保存 session token、不可导出的保险库 CryptoKey 和加密保险库。勾选记住账号密码时,将填充资料用另一把不可导出的本机 AES-GCM 密钥加密保存;不保存 verifier。退出清除自动恢复 session,保留已选择保存的填充资料,明确清除时全部移除。登录态与本机资源数据库在同一启动屏障后恢复,避免先加载访客资源再切换账号。
## 文件目录与内容
频道、分组、目录和资源归属通过服务端结构接口管理,前端拉取快照并渲染。上传文件完成密文传输后提交资源归属;归属操作也保存在本机队列中,断网重试不丢失。文件名称、类型、大小继续保存在加密 file.published 元数据中,读取元数据不消耗下载次数;下载内容后校验元数据一致性。
不迁移旧加密结构或旧账户资料,不提供导入接口。新结构与新资料直接由后端管理,客户端不再处理结构控制记录。
空间访问凭据不再用于频道内容加密。每频道 chat/files 域各自生成随机 32 字节 epoch 密钥,按账号公钥封装;每个聊天条目、文件及加密发布元数据再独立生成随机对象密钥,由对应域密钥加密封装。对象封装的 AAD 绑定空间、频道、域、epoch 和 itemId。保留现有逐帧认证、FINAL、连续性、padding、ID/AAD 校验与持久化密文重试。下载逐块解密到本机临时存储,完整性和大小校验成功才交付文件;出错清除临时内容。OPFS 不可用时使用 Blob 回退并明确限制。
## 安全边界
频道域 epoch 与随机对象密钥允许新成员按设置只领取当前 epoch,服务端也会在写入时检查作者、频道授权及当前 epoch。`WorkspaceChannel` 是唯一频道聚合;Chat 与 FileGroup 是独立域,授权和密钥隔离,但共享对象帧存储。账号设备已具备根签证书和 FINAL 对象帧签名验证基础;频道结构命令、删除事件和成员/设备撤销后的域轮换仍未完成。已获得的历史密钥和明文无法被收回,因此不能声称已实现前向保密。
账号签名根公钥和根签名设备证书已有基础实现,首次从服务端获得的其他账号根公钥使用 TOFU 固定;首次密钥替换仍无法检测。频道结构、权限、作者归属和聊天/文件生命周期目前尚未改为签名事件,客户端尚未校验 Owner Capability 或事件签名。密钥透明性、授权/事件签名和成员撤销密钥轮换仍未完成,不能宣称已抵御主动恶意服务端。前端代码与运行终端仍是信任根。
账号昵称与设备名是不同概念,昵称不参与密钥派生。头像接受有大小上限的 PNG/JPEG/WebP,由后端管理,禁止第三方头像 URL 和可执行 SVG。聊天发送者由服务端条目作者和账号资料提供,不接受加密内容中的身份声明。
界面术语采用「空间 → 频道分组 → 频道 → 文件夹/聊天」。旧资源路由 /channels 和帧协议 threadId 保留,分别映射空间/频道;新结构接口使用 /spaces。账号保险库保存私钥和访问凭据,资料与结构不再由客户端合并同步。
+74 -28
View File
@@ -1,4 +1,12 @@
# API v1
# API 契约 6.0
账户资料使用 `GET/PATCH /accounts/profile`;头像为 PNG/JPEG/WebP data URI,禁止远程 URL 和 SVG。不提供旧资料导入。
空间结构使用 `GET /spaces/{id}/structure`,包含 revision、channels、groups、folders、locations。频道与分组分别通过 `/spaces/{id}/channels` 和 `/spaces/{id}/groups` 创建;响应同时包含完整结构快照和服务端新建实体及 ID。其他字段通过 PATCH 重命名;频道归属通过 `PUT /spaces/{id}/channels/{channelId}/group` 修改。文件夹通过 `/spaces/{id}/folders` 创建及 PATCH 重命名,文件归属通过 `PUT /spaces/{id}/locations` 修改。
频道、分组和目录写操作需要账号会话和空间写权限;允许上传的成员可以关联和移动自己的资源,文件移动必须满足每个条目的作者或管理权限,跨空间、跨频道的错误分组/目录关联被拒绝。结构修改后通知订阅设备重新拉取。
URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `8.2.0`(8.2 新增身份组 `/spaces/{id}/roles`:结构快照多了 `roles`、`rolesSeeded`,内容事件记录多了 `signerAccount`;8.1 新增 `DELETE /spaces/{id}/groups/{groupId}`:删除分组,组内频道回到未分组)。v8 让频道强制登录:去掉未登录访客(设置 `allow_guests`、`/guests` 接口、访客终端席位),频道与空间的所有接口都要求账号会话,缺少时返回 `401 account_required`;便捷分享(`/shares`)不受影响。v7 把「设备」收束为账号的属性:成员只按用户管理,`seats` 系列命名改为 `max_members` / `members_used` / `members_full`,去掉设备名与设备清单接口,登录账号不再需要终端密钥。v6 引入账号签名根密钥、设备证书和频道对象签名事件;不兼容的旧主版本客户端会被契约预检拒绝。账号资料与空间结构由服务端管理。创建频道/分享以及所有 owner 管理操作必须携带有效账号 session;频道链接只对登录账号有效,未登录的人先登录再按加入规则加入。
`GET /info` 的 `defaults` 包含 `channel`、`share` 创建设置;省略创建请求的 settings 时服务端使用相同默认值。`choices.max_members` 与 `limits.max_members` 表示成员人数范围,不代表设备数。频道设置 JSON 字段为 `max_members`(旧存储里的 `seats` 在启动时迁移)。其他选项按部署限制收束,前端只格式化选项文案,不提供替代业务默认值。
基础路径 `/api/v1`。所有请求和响应的二进制数据格式见 [protocol.md](protocol.md)。前端托管在独立域名,服务端需要配置 CORS 白名单。
@@ -8,37 +16,57 @@
|---|---|
| `X-WD-Role` | `read` / `write` / `owner`,指明 `Authorization` 里的令牌属于哪种角色 |
| `Authorization` | `Bearer <base64url(令牌)>` |
| `X-WD-Endpoint` | `base64url(S)`,终端密钥,用于席位 |
| `X-WD-Endpoint` | `base64url(S)`,终端密钥。**只用于便捷分享**(未登录的创建者与读者);频道与空间不使用,可省略 |
| `X-WD-Cap` | 创建频道时的 Cap 验证码 token(服务端启用验证码时必填,见 protocol §11) |
| `X-WD-Label` | raw 模式条目标签,≤255 B |
| `X-WD-Max-Downloads` | 第 0 帧上带,条目的下载上限,0 为不限 |
| `X-WD-Account` | 账号 session token。与频道/分享的能力令牌分开;频道、空间、创建和管理请求必填 |
| `X-WD-Event-*` | 已登记签名设备提交频道对象 FINAL 帧时必填,绑定对象 ID、域/epoch、事件类型与完整密文帧 manifest,见 protocol §21 |
raw 模式的读请求不需要令牌,但仍需要 `X-WD-Endpoint`。
raw 模式的读请求不需要令牌,但仍需要账号会话。
WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, wd.role.<role>, wd.auth.<base64url(令牌)>, wd.ep.<base64url(S)>`,服务端回选 `wd.v1`。不要放进 URL,否则会被代理记录。
WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, wd.role.<role>, wd.auth.<base64url(令牌)>, wd.account.<base64url(账号 session)>`,服务端回选 `wd.v1`。不要放进 URL,否则会被代理记录。
## 2. 权限矩阵
| 操作 | 需要 |
|---|---|
| 读日志、拉取条目 | `read`(enc)或知道 id(raw),并占有席位 |
| 读日志、拉取条目 | `read`(enc)或知道 id(raw),并已是该频道的成员(账号) |
| 追加帧 | `write`(`members_can_upload` 为 true 时)或 `owner` |
| 删除条目 | `owner`;`delete_policy=own` 时终端哈希等于条目作者;`anyone` 时任意已入席终端 |
| 改设置、删频道、看和释放席位 | `owner`;这些管理操作不占席位 |
| 删除条目 | `owner`;`delete_policy=own` 时账号等于条目作者;`anyone` 时任意成员 |
| 改设置、删频道、撤销成员 | `owner`;这些管理操作不占成员名额 |
## 3. 接口
### 账号与保险库
用户名为 3–32 位 ASCII 字母、数字、`_` 或 `-`,大小写不敏感。客户端 PBKDF2-SHA256(600,000 次)派生口令根,再分别派生登录凭据和 AES-GCM 保险库密钥。登录凭据仅经 HTTPS 发送;服务端再用随机 salt 和 PBKDF2-SHA256(210,000 次)存储慢哈希,数据库里的哈希不能直接用于登录。服务端不接收口令根或保险库密钥。二进制字段使用标准 Base64,保险库密文最大 1.3 MB。
- `POST /api/v1/accounts` 注册 `{username,salt,verifier,vault,public_key,signing_public_key}`。`public_key` 是 P-256 ECDH 公钥,`signing_public_key` 是 P-256 ECDSA 账号根公钥;对应私钥只保存在客户端加密保险库中。API v6 不兼容旧账号保险库协议,不自动迁移旧签名身份。
- `POST /api/v1/accounts/salt` 请求 `{username}`,返回 `{salt}`,供客户端派生登录凭据。
- `POST /api/v1/accounts/session` 请求 `{username,verifier,device_id?,device_name?}`。成功返回 `{username,token}`;session token 随机生成,只保存 SHA-256,期限 30 天。
- `GET /api/v1/accounts/vault` 使用 `Authorization: Bearer <token>` 读取 `{revision,vault}`。
- `PUT /api/v1/accounts/vault` 使用同一鉴权,发送 `{revision,vault}`。revision 乐观并发控制;冲突返回 `409 vault_conflict`,客户端必须先读取并合并,不能静默覆盖。
- `DELETE /api/v1/accounts/session` 撤销当前 bearer session;退出登录时调用。
- `GET /api/v1/accounts/users/{username}/key` 已登录用户查询 ECDH 邀请公钥和可选的账号签名根公钥。
- `PUT /api/v1/accounts/devices/signing-key` 当前设备提交 `{device_id,public_key,signature}`。服务端核对账号 session 绑定的 device_id,并验证账号根密钥对 P-256 设备公钥证书的签名后保存。
- `GET /api/v1/accounts/users/{username}/key` 返回账号 ECDH 公钥与签名根公钥;签名对象携带设备公钥和根签设备证书,客户端首次 TOFU 固定根公钥后离线验设备证书。
- `GET /api/v1/accounts/invitations` 返回当前账号的邀请,payload 为绑定收件用户名和频道 ID 的 ECDH + HKDF + AES-GCM 密文。
没有邮箱找回流程。忘记密码后保险库无法恢复。保险库仅同步资源凭据和草稿清单,不同步设备 S、设备名、缓存、原始文件或发送队列。浏览器存储按账号与访客分库;切换账号会中止订阅并清空内存会话。资源清单移除使用带时间戳的墓碑同步,避免旧设备将条目恢复。
签名根密钥保存在加密保险库;设备签名私钥以不可导出的 CryptoKey 保存在本机账户数据库,不随保险库同步。已登记签名设备写入 Workspace 对象时,FINAL 帧和删除请求必须带签名事件;尚未把签名要求扩展到频道结构和成员/权限变更。
### `GET /api/v1/info`
返回服务端能力:协议版本、限制、验证码配置(`captcha.provider`、`captcha.enabled`、`captcha.api_endpoint`,即 Cap widget 要连接的地址)、传输能力。验证码的 secret 不会出现在这里。`transports.http3` 只是部署层(反向代理或 Kestrel 配置)声明已启用 HTTP/3,服务端进程本身不内置 QUIC。
返回服务端能力:协议版本、限制、验证码配置(`captcha.provider`、`captcha.enabled`、`captcha.api_endpoint`,即 Cap widget 要连接的地址)、传输能力以及创建默认值/选项。`limits.max_workspace_bytes` 是单个空间全部资源的上限,`limits.max_workspace_channel_bytes` 是单个频道 Files 域的文件容量上限;频道结构的 `fileBytes` 为当前已用容量。前端只展示服务端返回的用量和限制。`defaults.channel_mode`、`defaults.channel` 和 `choices` 是前端创建表单的初始值与可选项;服务端独立校验提交的设置。验证码的 secret 不会出现在这里。`transports.http3` 只是部署层(反向代理或 Kestrel 配置)声明已启用 HTTP/3,服务端进程本身不内置 QUIC。
### `POST /api/v1/channels`
创建频道。服务端启用验证码时需要 `X-WD-Cap`;验证服务不可用时返回 `503 captcha_unavailable`,与「用户没通过验证」(`428 captcha_required`)区分。
创建者必须登录。创建请求必须提供 `settings.name`,服务端拒绝缺失、空白或纯空格名称并返回 `400 space_name_required`,存储时去除首尾空格;名称长度最多 48 个字符。只有一套频道/成员/线程模型,`mode=enc|raw` 仅决定客户端内容是否端到端加密,不改变业务权限。创建者自动写入 owner 成员记录并占一个成员名额,同时登记当前设备终端;该账号的其他设备不增加名额。服务端启用验证码时还需要 `X-WD-Cap`。
```json
{
"id": "<channel_id>",
"mode": "enc",
"settings": { "seats": 2, "members_can_upload": true, "delete_policy": "own",
"settings": { "name": "项目组", "max_members": 2, "members_can_upload": true, "delete_policy": "own",
"ttl_seconds": 86400, "max_items": 1000, "file_max_downloads": 10 },
"read_hash": "<sha256(T_read)>",
"write_hash": "<sha256(T_write)>",
@@ -46,29 +74,35 @@ WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, w
}
```
raw 模式省略 `read_hash`;`write_hash` 仅在成员可上传时提供。创建者的终端(`X-WD-Endpoint`)自动入席。返回 `201`,响应体与「读取频道信息」相同(`ChannelInfo`)。
raw 模式省略 `read_hash`;`write_hash` 仅在成员可上传时提供。创建者自动成为频道的所有者成员。返回 `201`,响应体与「读取频道信息」相同(`ChannelInfo`)。
### `GET /api/v1/channels/{id}`
返回 `ChannelInfo`:设置、模式、席位占用、`oldest_seq`、`last_seq`、时间。**不占席位**,任意角色可调用,用于加入前预览和核对模式。
返回 `ChannelInfo`:设置(包含所有成员共享的频道名)、模式、成员名额占用(`members_used`)、`oldest_seq`、`last_seq`、时间。`members_used` 统计唯一账号成员,不统计账号的设备数。**不占成员名额**,需要账号会话,用于加入前预览和核对模式。频道名是普通设置,服务端可见。
### `POST /api/v1/channels/{id}/seats`
加入频道,占用一个席位。幂等:同一终端重复调用返回同一席位。席位已满返回 `403 seats_full`。读取频道信息和 owner 管理接口不占席位;频道内容、席位信息/设备名等其余操作要求终端已入席,否则返回 `403 not_joined`。
### `POST /api/v1/channels/{id}/join`
加入频道,必须带账号会话(没有则 `401 account_required`)。只登记成员关系:账号第一次加入时占一个成员名额,同一账号的更多设备不增加名额,此后仅凭账号会话鉴权。同一账号重复调用幂等。成员名额已满时返回 `403 members_full`。读取频道信息和 owner 管理接口不占成员名额;访问频道内容前必须已加入,否则返回 `403 not_joined`。响应 `JoinResult`:`{joined_at, members_used, members_max}`,不再返回终端标识。
可发送 JSON `{password}`。`join_policy=open` 允许持链接的登录账号加入;`join_policy=invite` 要求该账号已受邀。频道主、已加入的账号成员和受邀账号无需再次输入频道密码。密码由服务端保存加盐 PBKDF2 哈希,从不通过频道信息返回。设备是账号的属性:同一账号可在任意多台设备登录,只占一个成员名额;账号成员的读写请求出示有效账号 session 即可,不再绑定到某个终端。
`GET /channels/{id}/members` 返回频道内账号成员及 `owner|member` 角色,每项附带账号级 `online`(该账号在本频道有活动 WebSocket/SSE 订阅)、`devices`(该账号的登录设备数,不小于 `online_devices`)与 `online_devices`(该账号此刻在本频道有订阅的设备数);设备是账号的属性,不单独作为成员。在线状态只来自内存里的订阅登记,不写库、不经订阅流推送,客户端按需轮询。`GET /channels/{id}/invitations` 不再返回已经成为成员的账号。邀请前,频道主调用 `GET /channels/{id}/invitations/{username}/crypto-plan` 获取服务端根据成员权限和历史设置计算的域/epoch/收件人计划。浏览器只为计划中的收件人生成并封装域密钥;随后 `POST /channels/{id}/invitations` 原子提交账号链接包与 `domain_envelopes`。服务端会重新计算计划并校验每个 epoch 和收件人,不接受前端自行增加授权或选择轮换范围。`DELETE /channels/{id}/invitations/{username}` 撤销邀请、成员授权与域密钥包,并阻止重新加入,直到再次邀请。不能移除频道主。移除访客用 `DELETE /channels/{id}/guests/{endpoint_hash}`,同样阻止该终端重新加入。
频道设置、成员权限、邀请、加入密码与加密模式统一作用于同一 Channel。每次上传、发言、线程/目录控制条目写入均经服务器校验账号会话、频道成员关系与写入权限,不能靠客户端隐藏按钮绕过。移除账号成员才会释放成员名额;撤销账号的某台登录设备只使该设备的会话失效,不影响成员身份。WebSocket 账号 session 经 `wd.account.<base64url(token)>` 子协议传递,并在推送循环中重新检查有效性。
### `PATCH /api/v1/channels/{id}/settings`
所有者修改设置。`ttl_seconds` 的修改从现在起算。
所有者修改设置。可通过 `name` 更新共享频道名,传空字符串清除名称;`max_members` 调整成员人数上限,不能改变已有成员身份;`ttl_seconds` 的修改从现在起算。
### `DELETE /api/v1/channels/{id}`
所有者删除整个频道和全部密文。
### `POST /api/v1/channels/{id}/frames`
追加一帧。请求体是完整封装(`application/octet-stream`)。
追加一帧。请求体是完整封装(`application/octet-stream`)。当账号设备已登记签名证书且对象在 `/spaces/{id}/crypto/objects` 声明时,最终帧必须携带 `X-WD-Event-Id/Kind/Channel/Domain/Epoch/Frames/Manifest/Signature`(编辑可带 `X-WD-Event-Target`)。服务端验签并核对此前帧摘要与当前 FINAL 帧的清单;缺少或无效签名拒绝提交。签名随对象元数据进入 `CryptoState`,成员读取时仍按数据域权限过滤。
| 响应 | 含义 |
|---|---|
| `201 {"seq"}` | 新追加 |
| `200 {"seq"}` | 幂等重发,返回原 seq |
| `409` | 同一 `(item_id, frame_index)` 内容不同 |
| `403` | 无权、席位已满、模式或 ENC 标志不符 |
| `403` | 无权、成员人数已满、模式或 ENC 标志不符 |
| `413` | 超出限制 |
| `429` | 限流 |
@@ -78,7 +112,7 @@ raw 模式省略 `read_hash`;`write_hash` 仅在成员可上传时提供。创
### `GET /api/v1/channels/{id}/ws?from=<seq>`
WebSocket 订阅(降级链的第一级)。服务端向下推送:
- 二进制消息:一条日志记录。
- 文本消息(JSON 控制消息):`{"t":"hb","seq":N}` 心跳,`{"t":"settings"}` 设置变更,`{"t":"seats"}` 席位变更。
- 文本消息(JSON 控制消息):`{"t":"hb","seq":N}` 心跳,`{"t":"settings"}` 设置变更,`{"t":"members"}` 成员变更。
上传仍走 HTTP `POST`,便于幂等重试。
@@ -89,19 +123,18 @@ SSE 订阅(降级链的第二级)。记录以 base64url 放在 `data:` 中,`id:`
WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20 秒,45 秒收不到任何消息即判定死链。重连使用指数退避(1 秒到 30 秒)加抖动,并携带 `from=last_seq+1`。
### `GET /api/v1/channels/{id}/items/{item_id}/frames?from=<index>`
拉取条目的帧(GATED 条目的取数方式)。`from=0` 计一次下载;`from>0` 只有同一终端此前对同一条目成功启动过 `from=0` 才能续传,否则返回 `410 gone`。条目尚未 FINAL 时,响应会随帧到达持续输出,直到 FINAL。达到下载上限后返回 `410`,并在 5 分钟宽限期后删除密文,宽限期内已开始的拉取可继续续传。
拉取条目的帧(GATED 条目的取数方式)。`from=0` 计一次下载;`from>0` 只有同一下载方(按用户)此前对同一条目成功启动过 `from=0` 才能续传,否则返回 `410 gone`。条目尚未 FINAL 时,响应会随帧到达持续输出,直到 FINAL。达到下载上限后返回 `410`,并在 5 分钟宽限期后删除密文,宽限期内已开始的拉取可继续续传。
### `DELETE /api/v1/channels/{id}/items/{item_id}`
按 `delete_policy` 授权后删除条目,并在日志中追加 tombstone。
按 `delete_policy` 授权后删除条目,并在日志中追加 tombstone。已登记签名设备还必须提交对应 `chat.delete`/`file.delete` 事件头;服务端验证其绑定的设备证书、权限和原对象帧 manifest 后保存删除签名,浏览器验签后才应用 tombstone。
### 席位
- `GET /api/v1/channels/{id}/seats`:所有者查看(终端哈希、加密设备名、加入时间)。
- `PUT /api/v1/channels/{id}/seats/me`:终端上传加密设备名(≤256 B)。
- `DELETE /api/v1/channels/{id}/seats/{endpoint_hash}`:所有者释放席位。
### 已移除的接口
- v8:`GET /channels/{id}/guests`、`DELETE /channels/{id}/guests/{endpoint_hash}`(频道不再有未登录访客;成员按用户管理:`/members`、`/invitations`)。
- v7:`PUT /channels/{id}/seats/me`、`PUT /channels/{id}/seats/{endpoint_hash}/label`、`GET/DELETE /channels/{id}/seats`。设备名只在账号设备管理里存在(`/accounts/devices`)。
## 4. 便捷分享接口
便捷分享是与频道并列的另一种资源,语义见 [protocol.md §15](protocol.md)。**没有 WebSocket、SSE、日志、席位和成员上传**。接口前缀 `/api/v1/shares`,只支持 enc 模式,鉴权头只用 `read` 和 `owner` 两种角色(`write` 会被拒绝为 `bad_request`)。
便捷分享是与频道并列的另一种资源,语义见 [protocol.md §15](protocol.md)。**没有 WebSocket、SSE、日志、成员登记和成员上传**。接口前缀 `/api/v1/shares`,只支持 enc 模式,鉴权头只用 `read` 和 `owner` 两种角色(`write` 会被拒绝为 `bad_request`)。
| 接口 | 角色 | 阶段 | 说明 |
|---|---|---|---|
@@ -176,11 +209,12 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20
|---|---|---|
| `bad_request` | 400 | 封装格式或参数错误 |
| `unauthorized` | 401 | 令牌缺失或错误 |
| `account_required` | 401 | 需要账号会话:频道与空间只接受已登录账号,账号会话缺失或已失效 |
| `forbidden` | 403 | 角色不够 |
| `not_joined` | 403 | 终端未入席,或席位已被所有者释放 |
| `not_joined` | 403 | 账号不是该频道成员(未加入或已被所有者移除) |
| `visitors_full` | 403 | 便捷分享的访客名额已满,且这个终端未登记 |
| `sealed` | 409 | 便捷分享已封存,不能再修改;封存后再用所有者令牌发请求也是这个错误 |
| `seats_full` | 403 | 席位已满 |
| `members_full` | 403 | 成员人数已满 |
| `mode_mismatch` | 403 | 帧的 ENC 标志与频道模式不符 |
| `not_found` | 404 | 频道或条目不存在,或已过期 |
| `conflict` | 409 | 幂等冲突 |
@@ -189,3 +223,15 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20
| `captcha_required` | 428 | 需要验证码:缺少 `X-WD-Cap`,或 token 无效、已用过 |
| `captcha_unavailable` | 503 | 验证码服务暂时不可用(服务端到 Cap 的调用失败),可稍后重试 |
| `rate_limited` | 429 | 限流 |
### 按账号恢复频道密钥
- `GET /api/v1/accounts/channels`:只需 `X-WD-Account`,返回当前账号已加入频道的 `{channel_id,role,payload,created_at}` 密钥包;不返回其他账号或未接受邀请的密钥包,不占成员名额。
- `PUT /api/v1/channels/{id}/key-package`:需要该频道 owner 能力和 owner 账号,仅更新当前 owner 自己的 `{payload}`,用于补齐已创建频道的账号密钥包。
- 创建频道可携带 `key_package`(1–16384 字符 opaque 密文),与频道和 owner 成员记录原子保存。前端必须提供;底层 API 允许省略供纯协议客户端使用。
- 发送邀请时原子保存收件账号的空间密钥包、频道数据域 envelopes、默认权限和 epoch 更新;接受后仍可跨设备领取。撤销邀请/成员会删除服务端密钥包与访问记录,但无法收回对方此前保存的密钥,成员移除后的 epoch 轮换仍未实现。删除空间时清理全部密钥包。
### 账号设备与资料
GET /accounts/devices 返回本账号设备 {id,name,created_at,last_seen,current}。PATCH /accounts/devices/{id} 发送 {name};DELETE 撤销该设备的全部登录 sessions,不移除账号成员。新客户端登录时携带稳定浏览器 device_id(UUID)和设备名,各设备单独记录。个人名称/头像存储在客户端加密保险库,修改后按 modifiedAt 合并,不写入服务端明文账号表。昵称不改变固定用户名或参与密钥派生。
+34 -13
View File
@@ -1,7 +1,17 @@
# 架构
公共业务数据(空间设置、成员、设备、邀请、默认值和部署限制)由后端 DTO 和服务端响应定义;前端类型由构建工具生成。本地资源保险库只携带访问凭据与离线副本,不覆盖在线响应中的设置。在线刷新不改变资源的用户修改时间,也不产生删除标记;只在网络故障时回退缓存。
账户昵称头像、空间、频道、分组、文件夹和文件归属由后端管理。空间结构变更经服务端验证关联和权限后持久化,再通知设备重新拉取。新空间和初始“常规”频道在一个事务中创建。
消息、文件内容和文件名称等资源元数据由客户端加密,后端保存密文和加密密钥包。账号保险库保存解密凭据,不再作为个人资料或空间结构的数据源。
不提供旧数据迁移或导入接口。新空间结构由后端直接创建和管理,客户端不解码或重放旧结构控制记录。
## 1. 组件
账号、设备、频道、thread 与内容加密的统一边界见 [account-channel-architecture.md](account-channel-architecture.md)。该文档明确账号成员与设备会话分离、按账号领取频道密钥包及加密文件目录。
```
浏览器 (React, 独立托管或 API 同源嵌入) 服务端 (.NET 10, NativeAOT)
┌──────────────────────────────┐ ┌─────────────────────────────┐
@@ -9,10 +19,10 @@
│ @webdrop/core (加解密、帧) │ 密文 + 令牌 │ 鉴权: 只比对令牌哈希 │
│ Worker: 流式加解密 │◄───────────►│ 日志: 分配 seq、幂等、推送 │
│ IndexedDB: 发送队列、本地收藏 │ │ BackgroundService: 过期清理 │
│ Service Worker: 流式下载 │ └──────┬───────────┬──────────┘
│ OPFS: 逐块写入临时文件 │ └──────┬───────────┬──────────┘
└──────────────────────────────┘ │ │
SQLite IBlobStore
(元数据、日志) (磁盘,预留 S3)
SQLite IBlobStore
(频道、账号密文库) (磁盘,预留 S3)
```
服务端没有解密函数,也没有任何能触达明文或 K 的代码路径。可选的 `WebDrop.Frontend` 模块只提供嵌入的静态资源,不访问频道存储或加解密代码。
@@ -42,7 +52,7 @@
| 全球化 | `InvariantGlobalization=true` |
| 库 | 所有项目标记 `IsAotCompatible=true`,把 IL2026、IL3050 等 AOT 和裁剪警告视为错误 |
| HTTP/3 | `CreateSlimBuilder` 不含 HTTP/3。服务进程本身**暂不内置 QUIC**,由反向代理终结 HTTP/3,后端用 HTTP/1.1 或 HTTP/2;`WebDrop:EnableHttp3` 只是在 `/info` 里通告。需要内置时再接入 Kestrel 的 QUIC(依赖原生 msquic) |
| 密码学 | 服务端只做 SHA-256 和常量时间比较,`System.Security.Cryptography` 在 AOT 下可用 |
| 密码学 | 内容加解密在客户端;服务端做令牌 SHA-256、登录/加入密码的加盐 PBKDF2 与常量时间比较,全部兼容 AOT |
**验证结果**(win-x64,.NET 10.0.204):
- Slim builder、Minimal API、原生 WebSocket、SSE、`BackgroundService`、`Microsoft.Data.Sqlite`(随包的 e_sqlite3 原生库,SQLite 3.53)在 NativeAOT 下都能正常运行。
@@ -55,13 +65,19 @@
## 4. 存储模型
应用契约 2 使用 `DataDir/v2/webdrop.db` 和 `DataDir/v2/blobs`,仅包含当前表结构,不维护旧 schema/旧账号保险库/旧频道认领/旧明文负载的迁移或兼容模型。开发数据可以重新创建,历史目录不自动导入。浏览器采用新的按账号/访客隔离的数据库命名空间。
SQLite 存元数据和小帧,磁盘存大帧。
| 表 | 字段 |
|---|---|
| `channels` | `id` PK, `mode`, `settings`(JSON), `read_hash`, `write_hash`, `owner_hash`, `created_at`, `expires_at`, `last_seq`, `compacted_through`, `bytes` |
| `channels` | `id` PK, `mode`(内容加密选项), `creator_account`, `settings`, 能力令牌哈希, 加入密码哈希/salt, 时间、序号、字节数 |
| `channel_members` | 频道 ID + username,频道内 owner/member 角色、加入时间;创建者自动为 owner |
| `channel_invitations` | 频道 ID + username、客户端加密的邀请 payload、时间 |
| `channel_join_denials` | 被移除的账号或设备标识,再次邀请账号时清除账号禁入 |
| `accounts/account_sessions` | 登录 salt、登录凭据慢哈希及独立 salt、邀请公钥、保险库密文/revision;随机 session token 的 SHA-256 与过期时间 |
| `shares` | `id` PK(与频道共用全局 id 空间), `settings`(JSON), `read_hash`, `owner_hash`(封存时清空), `creator_endpoint_hash`, `created_at`, `expires_at`, `sealed_at`, `manifest_item_id`, `bytes` |
| `seats` | `channel_id`, `endpoint_hash`, `label` BLOB, `joined_at`;PK(`channel_id`, `endpoint_hash`) |
| `channel_members` | 频道成员(账号):`channel_id`, `username`, `role`, `joined_at`。v8 起不再有访客席位,旧库里的 `seats` 表在启动时丢弃 |
| `share_visitors` | `share_id`, `endpoint_hash`, `joined_at`;PK(`share_id`, `endpoint_hash`),创建者终端不写入此表 |
| `items` | `channel_id`, `item_id`, `author_hash`, `flags`, `max_downloads`, `downloads`, `frame_count`, `max_index`, `final_index`, `bytes`, `created_at`, `expires_at`, `close_deadline`, `label`;PK(`channel_id`, `item_id`)。完整与否由 `final_index` 和 `frame_count` 推出,是否进入宽限期由 `close_deadline` 表示 |
| `share_items` | 分享条目的帧状态和下载次数,独立于频道条目 |
@@ -78,7 +94,7 @@ SQLite 存元数据和小帧,磁盘存大帧。
**并发模型**:写入由单个 SQLite 连接加锁串行执行(WAL 模式),读取使用独立连接,每次 `Read` 在显式事务里,看到一致的快照。订阅枢纽 `ChannelHub` 只传递「有变化」的信号,订阅者被唤醒后自己按游标读日志,所以不存在回放与实时之间漏帧的竞态,也无需为慢客户端缓冲数据。代价是目前只支持**单实例**部署。
便捷分享与频道共用 ID 唯一性检查,但条目和帧使用独立表。分享不创建席位或日志;封存时在单个事务里清空 `owner_hash`、记录清单 id 并启动 TTL。
便捷分享与频道共用 ID 唯一性检查,但条目和帧使用独立表。分享不登记成员或日志;封存时在单个事务里清空 `owner_hash`、记录清单 id 并启动 TTL。
后台服务每 30 秒清理:过期条目、下载达上限且宽限期已过的条目、过期频道,每次产生对应的墓碑并删除密文;每 20 轮额外清理超过 10 分钟的孤立 blob。超出 `max_items` 的淘汰和新快照取代旧快照,在追加时同步完成。
@@ -97,8 +113,10 @@ SQLite 存元数据和小帧,磁盘存大帧。
- 加解密在 Worker 里流式处理,文件不整份进内存。
- 发送队列落在 IndexedDB,恢复网络后自动补发,依赖协议的幂等追加。
- 条目预览在沙箱 iframe 中渲染;raw 内容只当文本展示或下载。
- 下载大文件使用 Service Worker 流式写入,兼容没有 File System Access API 的 iOS Safari。
- 本地「我的频道」:链接和密钥保存在 IndexedDB,只在本机。
- 下载大文件优先使用 OPFS 流式写入临时文件,认证完成后提供文件;不支持 OPFS 时回退 Blob,限制 64 MiB。
- 首页先注册/登录;频道链接只对登录账号有效(未登录先登录),便捷分享链接任何人可读。创建与管理在服务端绑定账号,成员人数按账号去重,设备只是账号的属性(账号设备与登录 session 在 `/accounts/devices` 管理)。所有频道写入都检查账号会话、频道角色、成员名额和能力权限。
- IndexedDB 按账号分库;切换时中止后台订阅并清空内存。保险库同步私钥、资源凭据和移除墓碑;设备 S、设备标签、草稿内容、缓存、原始文件和 outbox 保留本机。新设备生成独立 S 后重新加入。
- 空间统一管理成员、权限与密钥,后端管理频道、分组和目录。资源日志仅保存内容及资源元数据;CONTROL 资源元数据记录随空间删除。
## 7. 阶段划分
@@ -106,19 +124,22 @@ SQLite 存元数据和小帧,磁盘存大帧。
|---|---|---|
| 0 | 文档、`test-vectors/` | 向量由独立脚本生成并人工复核 |
| 1 | `@webdrop/core`:密钥派生、封装、加解密、padding、条目装配 | **已完成**:Vitest 通过全部向量和负例,并对着真实服务端端到端通过 |
| 2 | 服务端(AOT):频道与便捷分享、帧存储、频道日志、WebSocket/SSE/长轮询、席位、访客名额、清理 | xUnit 通过;目标平台 AOT 发布并冒烟通过 |
| 2 | 服务端(AOT):频道与便捷分享、帧存储、频道日志、WebSocket/SSE/长轮询、成员、在线状态、便捷分享访客名额、清理 | xUnit 通过;目标平台 AOT 发布并冒烟通过 |
| 3 | 前端:创建频道、文本和 JSON 实时同步、断线重连、发送队列、「我的频道」 | Playwright:双浏览器内容一致,断网恢复后无丢失无重复,抓包无明文和 K |
| 4 | 文件:流式加解密、GATED 拉取、下载计数、预览、粘贴和拖拽 | 100 MB 文件往返一致,下载计数正确 |
| 5 | GB 级文件:并行上传和断点续传 | 中途断网后续传成功,内存占用平稳 |
| 6 | PWA、Web Share Target、二维码和短配对码(PAKE) | 手机端走通 |
| 7 | 可选账号、配额;CLI | 另行设计 |
| 7 | 账号登录、客户端加密资源保险库 | 已实现客户端派生口令 verifier/保险库密钥、SQLite opaque vault、资源同步;口令丢失时明确告知无法恢复 |
| 8 | 空间、频道、分组、文件夹与聊天 | 结构和作者归属由后端管理,客户端加解密资源并渲染后端快照 |
每个阶段开始前,先更新 protocol.md 和测试向量,再改代码。
## 8. 验收时的检查清单
- `dotnet test`、`vitest`、Playwright 全部通过。
- 对 SQLite 文件和 blob 目录 grep 明文关键字,必须查不到。
- 对 SQLite 文件和 blob 目录检查资源内容与文件名明文关键字,必须查不到;账号资料和业务结构按设计对后端可见。
- 服务端日志中没有 K、令牌和频道 id 明文。
- 抓包确认请求里没有 K 和明文。
- 抓包确认资源上传请求不包含 K 或资源明文;账户资料和结构请求使用后端业务接口。
- AOT 发布产物能在没有 .NET 运行时的机器上运行。
界面术语采用「空间 → 频道分组 → 频道 → 文件夹/聊天」。内部资源 Channel 与帧元数据 threadId 保留,分别映射空间/频道;分组和目录由后端结构接口管理,不引入新密钥体系。
+91 -28
View File
@@ -1,49 +1,112 @@
# 本地后端 Docker Compose
# Podman Quadlet 部署
Compose 当前只启动一个 WebDrop API 容器;构建镜像时会先构建 `web/`,再由独立的 `WebDrop.Frontend` 模块将静态资源嵌入后端。访问 `http://127.0.0.1:5000/` 打开前端,API 使用同源 `/api/v1`。API 默认只绑定 loopback,SQLite 数据保存在项目的 `./data` 目录。
WebDrop 使用 rootless Podman 运行单个 API 容器。前端先构建成静态文件,再嵌入 NativeAOT 后端;容器镜像使用 OCI 格式。仓库不再维护 Compose 配置。
## 启动
## WSL 本机部署
在 Windows 工作区运行:
```powershell
Copy-Item .env.example .env
docker compose config
docker compose up -d --build
docker compose ps
.\scripts\update-wsl-podman.ps1
```
API 地址为 `http://127.0.0.1:5000`。停止服务使用 `docker compose down`;数据库和服务器存储的帧会保留在 `./data`。删除该目录会永久删除这些数据。
本机开发直接从当前 Windows 工作区调用 WSL Podman。脚本会把源码增量同步到 WSL 的 `~/podman/webdrop/src`,在 WSL Linux 文件系统里构建 OCI 镜像,构建成功后重启 Quadlet 服务使用新镜像。构建失败时不会更新正在运行的容器;本机流程不经过镜像仓库。
## 前端连接
部署目录:
本机开发仍可使用 `cd web; npm run dev`,Vite 会代理 `/api/v1` 到本地 API。独立托管前端时,可单独构建 `web/` 并在前端托管层反向代理 `/api/v1`;跨域直连则配置明确的 `WebDrop__CorsOrigins`。
- `~/podman/webdrop/src`:从当前 Windows 工作区同步的源码副本。
- `~/podman/webdrop/data`:SQLite 数据和服务端帧文件。首次运行时从仓库 `data/` 拷贝一次,后续更新不会覆盖它。
- `~/podman/webdrop/webdrop.container`:Quadlet 单元源文件,链接到 `~/.config/containers/systemd/webdrop.container`。
公网部署时,在 API 前配置 HTTPS 反向代理,并让代理连接 API 的 loopback 端口。不要把未加密 HTTP 直接暴露到公网。只有当该 API 端口仅能由可信代理访问时,才将 `WEBDROP_TRUST_FORWARDED_HEADERS` 设为 `true`。
服务地址为 `http://127.0.0.1:5000`。管理服务:
嵌入模式减少自托管组件,但前端代码与 API 来自同一个部署信任域。若要让 API 节点被攻破时仍不能替换前端,应使用独立域名托管前端。
## 验证码
默认 `CAPTCHA_PROVIDER=none`。启用 Cap 时,在 `.env` 设置:
```dotenv
CAPTCHA_PROVIDER=cap
CAPTCHA_API_ENDPOINT=https://captcha.example.com/<site-key>/
CAPTCHA_SITEVERIFY_URL=https://captcha.example.com/<site-key>/siteverify
CAPTCHA_SECRET=<server-side-secret>
```bash
systemctl --user status webdrop.service
systemctl --user stop webdrop.service
systemctl --user start webdrop.service
```
`CAPTCHA_API_ENDPOINT` 会提供给浏览器;`CAPTCHA_SITEVERIFY_URL` 和 `CAPTCHA_SECRET` 仅供 API 容器使用。不要提交 `.env`。
本机开发前端仍可运行 `cd web && npm run dev`,Vite 会代理 `/api/v1` 到本地 API。公网部署时,在 API 前配置 HTTPS 反向代理;不要把未加密 HTTP 直接暴露到公网。只有当 API 端口仅能由可信代理访问时,才信任 forwarded headers。
### 开发时测上传速度
经 Vite 开发代理(`npm run dev` 默认)转发上传,速度上限约 20–25 MB/s,这是 Node 代理的限制,和应用本身无关。要测到真实速度,让前端直连后端:
```sh
# 后端:允许前端所在的来源跨域(端口按实际的前端端口填)
WebDrop__CorsOrigins__0=http://127.0.0.1:5173 ASPNETCORE_URLS=http://127.0.0.1:5000 dotnet run --project server/src/WebDrop.Api --no-launch-profile
# 前端:把 API 指向后端,不走代理
cd web && VITE_WEBDROP_API_URL=http://127.0.0.1:5000/api/v1 npm run dev
```
本机实测直连约 90 MB/s(300 MB 随机文件 3.3 秒)。上传帧的限流和普通接口分开计数(`WebDrop:FrameRequestsPerMinute`,默认 6000/分钟),上传请求体用 Blob 发送(浏览器对字节数组请求体要多拷贝几次,会慢 5 倍以上)。
## 其他 Linux 主机
构建 OCI 镜像:
```bash
podman build --layers --format oci --platform linux/amd64 \
--file server/Containerfile --tag localhost/webdrop:local .
```
将 `deploy/webdrop.container` 安装到 `~/.config/containers/systemd/` 前,把数据卷路径改为该主机专用目录。然后执行:
```bash
systemctl --user daemon-reload
systemctl --user start webdrop.service
```
WSL 更新脚本在每次构建成功后直接重启 Quadlet 服务。这里不使用 registry auto-update,因为新镜像已经在本机刚刚构建完成。
## NAS 开发与生产
NAS 开发环境使用 `deploy/webdrop-dev.container`,镜像固定跟踪 `registry.oeo.one/justme/webdrop-api:dev`;开发 CI 成功后会推送该标签。此单元设置 `AutoUpdate=registry`,并将用户级 timer 调整为启动后一分钟检查、之后每五分钟检查一次,发现新镜像就重启 Quadlet 服务;也可以手动执行 `podman auto-update` 立即检查。它使用独立的 `webdrop-dev` 容器、5001 本机端口和 `~/podman/webdrop-dev/data`,与 WSL 本机开发及生产数据隔离。
在 NAS 安装该单元前,先以 `justme` 登录 registry 并把凭据保存在用户级 auth file:
```bash
mkdir -p ~/.config/containers
printf '%s' "$ZOT_SECRET" | podman login --authfile ~/.config/containers/auth.json \
--username justme --password-stdin registry.oeo.one
chmod 600 ~/.config/containers/auth.json
mkdir -p ~/.config/containers/systemd ~/podman/webdrop-dev/data
cp deploy/webdrop-dev.container ~/.config/containers/systemd/
mkdir -p ~/.config/systemd/user/podman-auto-update.timer.d
cp deploy/podman-auto-update.timer.d/override.conf \
~/.config/systemd/user/podman-auto-update.timer.d/override.conf
systemctl --user daemon-reload
systemctl --user start webdrop-dev.service
systemctl --user enable --now podman-auto-update.timer
systemctl --user restart podman-auto-update.timer
```
为确保 NAS 注销 SSH 会话后 user service 仍持续运行,管理员需要为该账号启用 lingering:`sudo loginctl enable-linger justme`。
不要把 `ZOT_SECRET` 写入仓库或 Quadlet 文件。生产使用 release workflow 发布的 `vMAJOR.MINOR.PATCH` 标签,应固定到明确版本,不要跟踪 `dev`。
验证码配置写在 Quadlet 的 `[Container]` 段,例如:
```ini
Environment=WebDrop__Captcha__Provider=cap
Environment=WebDrop__Captcha__ApiEndpoint=https://captcha.example.com/<site-key>/
Environment=WebDrop__Captcha__SiteverifyUrl=https://captcha.example.com/<site-key>/siteverify
Environment=WebDrop__Captcha__Secret=<server-side-secret>
```
`SiteverifyUrl` 和 `Secret` 仅供 API 容器使用。修改后运行 `systemctl --user daemon-reload` 并重启服务。
## Woodpecker CI
`.woodpecker/ci.yml` 在 push 和手动运行时执行 API 契约、Core、Web、服务端检查;每次 push 都会构建前端。`.woodpecker/release-backend.yml` 响应 `vMAJOR.MINOR.PATCH` SemVer tag;也可手动运行并传入 `RELEASE_TAG`(例如 `v0.1.0`)。测试通过后用 Linux native agent 的 Buildah 构建并推送同名后端镜像 tag。两者都通过 Woodpecker secret `gitea_clone_webdrop` 从 `https://git.oeo.one` 克隆公开仓库。
`.woodpecker/ci.yml` 在默认分支 push、pull request 和手动运行时执行 API 契约与服务端检查;`.woodpecker/web-build.yml` 同时执行 Core 测试向量、类型检查、Web CSS lint、测试和构建。两个 workflow 独立运行,让前后端验证并行。默认分支当前为 `master`,配置也兼容迁移后的 `main`。
两条流水线固定到 `ci: domain-linux-native` 的 `linux/amd64` Agent;该主机需要 Node 24、.NET 10 和 Buildah。release job 在 `WOODPECKER_BACKEND_LOCAL_TEMP_DIR` 对应的持久目录缓存 Buildah 层,Dockerfile 单独缓存 NuGet restore 层。Gitea webhook 走同 Docker 网络中的 `woodpecker-server:8000`,代码 clone 走公网 Git 域名。
默认分支每次 push 和 PR 都会跑常规检查。提交信息含 `[SKIP TESTS]` 时只跳过服务端测试;API 契约检查和前端/Core 检查照常运行。提交信息含 `[CI]` 时,`.woodpecker/dev-image.yml` 会等 `ci` 和 `web-build` 都通过后,构建并推送带提交 SHA 的开发镜像以及可变 `dev` 标签。开发镜像不会覆盖 release tag 或 `latest`。发布目标为 `registry.oeo.one/justme/webdrop-api`;Buildah 使用 Woodpecker secret `zot_secret` 作为 Zot API key 的密码字段,用户名为 `justme`,并启用 TLS 校验。Gitea clone 凭据保存在 `gitea_clone_webdrop` secret。
后端镜像发布到 `zot.oeo.one/justme/webdrop-api`。Woodpecker Linux agent 通过内网 `192.168.2.162:5000` 访问同一 Zot 仓库,发布需要 Buildah 与 tag/manual 事件可用的 secrets `registry_username`、`registry_password`。使用已发布镜像时,在 `.env` 中设置 `WEBDROP_IMAGE_TAG`,仓库名已填入 `.env.example`。
`.woodpecker/release-backend.yml` 响应 `vMAJOR.MINOR.PATCH` SemVer tag;也可手动运行并传入 `RELEASE_TAG`。发布 workflow 会重新执行 Core、Web 和服务端完整检查,全部通过后才用 Linux native agent 的 Buildah 构建 OCI 镜像并推送后端镜像 tag 与 `latest`。所有 workflow 都通过 Woodpecker secret `gitea_clone_webdrop` 从 `https://git.oeo.one` 克隆公开仓库。
API 契约版本以 `contracts/api-contract.json` 为准,并同步到 OpenAPI 与后端 `/api/v1/info`。加字段等向后兼容更新递增 minor,兼容性修复递增 patch,破坏性更新递增 major。前端声明支持的契约主版本;后端主版本变化时,前端 CI 契约检查和运行时连接检查都会失败,直到前端同步支持该主版本。
两条流水线固定到 `ci: domain-linux-native` 的 `linux/amd64` Agent;该主机需要 Node 24、.NET 10 和 Buildah。release job 在 `WOODPECKER_BACKEND_LOCAL_TEMP_DIR` 对应的持久目录缓存 Buildah 层;`server/Containerfile` 使用带 NativeAOT 前置工具的 `10.0-noble-aot` SDK 镜像,不再运行 apt 安装 clang/zlib;NuGet restore 层也由 Buildah 缓存。Gitea webhook 使用 Woodpecker server 所在网络,代码 clone 走公网 Git 域名。
## 数据备份
停止服务或使用一致性快照后备份 `./data`。目录里保存 SQLite 元数据和服务端收到的帧字节;加密频道/分享的内容帧是密文。备份仍需按敏感数据保护,因为 raw 频道按设计保存明文负载。
停止服务或使用一致性快照后备份 `~/podman/webdrop/data`。目录里保存 SQLite 元数据和服务端收到的帧字节;加密频道/分享的内容帧是密文。备份仍需按敏感数据保护,因为 raw 频道按设计保存明文负载。
+116
View File
@@ -0,0 +1,116 @@
# E2EE 协作架构边界对照
对照来源:用户提供的 `D:/Users/JustMe/Downloads/E2EE_Collaboration_Architecture.md`。
核查对象:2026-10-13 当前工作区代码,包含尚未提交、尚未部署的后端业务模型改造。此次对照不修改密钥协议,不迁移旧数据。
## 判断
当前实现已推进为“后端管理业务结构和访问策略、浏览器持有频道数据域密钥并加解密资源”的模型。
参考文档是“后端协调存储、客户端验证授权”的设备身份、频道数据域和 epoch 模型。
两者的主要差距在身份可信来源、密钥分发粒度、授权验证和撤销后的密码学隔离,而非 CRUD 或界面布局。
## 对照
| 边界 | 参考文档 | 当前代码 | 判断 |
|---|---|---|---|
| 业务结构 | 后端提供 Identity、Space、Channel、Chat、File、Sync 模块 | 账户资料、空间成员、频道、分组、目录、文件归属已放到后端,前端接口类型从 DTO 生成 | 模块方向一致 |
| 资源加密 | 设备加密,服务端保存密文 | Worker + WebCrypto,AES-GCM 分帧,AAD/ID/FINAL/大小校验,持久化密文重试 | 可沿用基础能力 |
| 密码与数据密钥 | 账户认证不直接充当数据加密根 | 密码/可用的 Passkey PRF 解锁保险库;随机账号 ECDH 私钥解开账号 Key Envelope;频道数据域密钥独立随机生成 | 密码不直接生成资源密钥;账号恢复仍与 E2EE 身份私钥恢复耦合 |
| 用户身份 | Signing Identity 与 Device Keys | 账号保险库保存 P-256 签名根密钥;本机保存不可导出的设备签名私钥,根签名设备证书由后端校验并登记 | 身份密钥基础已落地;尚未用于业务事件 |
| 新设备 | 可信恢复或已有设备审批 | 用户已决定不要求旧设备审批;新设备以账号密码/支持的 Passkey PRF 解锁同一保险库私钥 | 与参考文档的设备审批模型不同;账号被攻破会暴露该账号全部空间密钥 |
| 设备分发 | 每个获授权设备各有 Key Envelope | 按 username 分发;设备只有 UUID 和 session(v7 起不再有按空间的席位或终端密钥,访客除外),没有设备解包公钥 | 分发对象不同 |
| Space | 主要是授权管理范围 | 成员、频道目录、日志与同步边界;不再作为频道内容根 | 业务边界已收束,仍由服务端定义 |
| Channel | Chat 与 FileGroup 有独立数据域和密钥 | 每频道独立 chat/files 随机 epoch key;对象另有随机 key,由域密钥封装 | 数据域已落地,密钥包仍按账号而非设备 |
| 授权链 | Owner 签名 Capability,客户端验证 | owner/member + bearer 能力令牌 + 服务端成员表,客户端没有验证签名授权链 | 服务端仍是授权判断的信任方 |
| 管理与读取 | Admin 可以只管理而没有解密权限 | 没有 Admin Capability;Owner 管理凭据与频道数据域密钥格式分开,但 Owner 同时能管理并读取其已授权频道 | 仍缺管理授权与数据读取权限分离 |
| 细分权限 | 按频道、聊天/文件域授予 read/write/upload 等权限 | 默认频道权限、历史策略、epoch 与收件人计划由后端计算;邀请提交时原子校验域密钥包集合,日志、对象下载和写入按权限检查 | 初始权限和邀请策略已落地;其他频道授权 UI 和成员撤销后的轮换待补 |
| Epoch | 成员或设备撤销触发新 epoch,排除被撤销者 | Owner 关闭新成员历史时,邀请流程按 chat/files 分别轮换 epoch;服务端拒绝旧 epoch 写入 | 新成员历史隔离已实现;成员撤销后轮换、并发与离线恢复未实现 |
| FileKey | 每文件随机 FileKey,由 FileGroupKey 封装 | 每条目(文件、消息、加密元数据)独立随机对象密钥,由对应频道域 epoch key 封装 | 已具备独立重封装基础,批量 rewrap/撤销流程未做 |
| 消息事件 | 已签名的 create/edit/delete/reaction,客户端 reduce | 完整对象的最终帧经账号设备签名并绑定密文 manifest;编辑是签名新对象后再删除旧对象,删除/频道结构命令尚无签名事件 | 创建/发布事件基础已接入,删除和结构授权仍缺 |
| 作者证明 | 消息签名绑定用户/设备 | 后端 AuthorAccount 与账号资料决定显示身份 | 能提供正常服务下的身份归属,不能抵抗恶意服务端改写 |
| 前端业务缓存 | 后端定义账户、空间、频道、权限及 API 契约 | DTO 从后端生成;IndexedDB `SavedResource` 仍缓存服务端设置/角色与本机凭据,在线发送不再根据缓存上传开关授权 | 服务端是在线权限来源;本地快照仍需与密钥/队列拆分 |
| 存储部署 | PostgreSQL、Redis、S3 | SQLite、本机 blob、进程内 ChannelHub;NativeAOT + WebSocket/SSE/HTTP 已有 | 部署规模不同,不是首要密码学差距 |
已收回后端的有:邀请/历史授权计划、默认访问权限、epoch 选择与域密钥包原子提交;上传权限不再读取本地 `membersCanUpload` 缓存;频道/分组创建直接返回服务端生成的实体 ID;`chat/files` 与 `open/invite` 已由后端枚举定义并生成前端联合类型。
## 关键代码依据
1. [accountCrypto.ts](../web/src/core-bridge/worker/accountCrypto.ts):密码派生登录 verifier 和保险库密钥;账号保险库另含 ECDSA P-256 根签名密钥。`generateInvitationKeys()` 仍专用于 ECDH 邀请。
2. [account.ts](../web/src/core-bridge/worker/account.ts)、[accountChannels.ts](../web/src/core-bridge/worker/accountChannels.ts):新设备登录后恢复同一账号私钥,按账号领取空间授权包。
3. [SqliteAccountStore.cs](../server/src/WebDrop.Storage/SqliteAccountStore.cs)、[SqliteAccountSigning.cs](../server/src/WebDrop.Storage/SqliteAccountSigning.cs):账号保存根签名公钥,设备目录保存根签名证书;尚无设备解包公钥/旧设备审批。
4. [domainCrypto.ts](../web/src/core-bridge/worker/domainCrypto.ts)、[channelDataKeys.ts](../web/src/core-bridge/worker/channelDataKeys.ts):频道 chat/files epoch key 与随机对象 key 在客户端生成,账号 ECDH envelope 与对象封装也在客户端执行。`packages/core/kdf.ts` 仍服务于访问凭据和独立 Share/旧资源路径,不是新 Workspace 的内容密钥路径。
5. [ChannelWorkspace.cs](../server/src/WebDrop.Core/ChannelWorkspace.cs):后端创建频道、分组、目录并校验资源归属;`WorkspaceChannel` 保存 chat/files epoch 与新成员历史策略。
6. [ChannelCrypto.cs](../server/src/WebDrop.Core/ChannelCrypto.cs):后端计算邀请 epoch/收件人计划,在单事务校验并提交域 envelopes、默认访问权和 epoch;按频道、域、epoch 过滤对象并校验历史起点、日志读取、下载与写入权限。客户端只生成密钥并封装到该计划指定的账号。
7. [channelSend.ts](../web/src/core-bridge/worker/channelSend.ts):文本编辑目前是新内容上传成功后删除旧条目。
8. [reduce.ts](../web/src/store/reduce.ts):作者和资源位置来自后端快照,没有作者签名或签名状态验证。
## 用参考文档的威胁模型审视当前实现
### 正常服务和被动泄露
加密模式下,数据库和 blob 不保存资源明文或未封装的空间 K。数据密钥随机生成,不能仅凭登录 token 或数据库中的 verifier 直接推出保险库密钥或内容密钥。
机密性仍依赖密码强度、端点安全和实际密钥分发过程;不能将此结论扩展为对主动恶意服务端的完整防护。
### 主动恶意服务端
- 邀请时,发送端从服务端目录读取收件账号公钥。当前没有可信签名身份、公钥固定或授权链验证;服务端替换公钥可使新邀请的密钥包被攻击者解开。
- 服务端可改写未签名的成员、权限、作者和结构数据。客户端即使正确校验资源 AEAD,也不能据此验证作者、授权链或当前业务归属。
- 已移除成员仍持有此前领取的频道域密钥。成员移除后的 epoch 轮换未实现;若恶意服务端继续向其提供后续密文,当前协议不能保证其无法解密。
- 客户端没有验证 Owner 签名授权链;恶意服务端可伪造访问快照、作者归属或返回过期签名状态。
因此,当前实现仍没有达到参考文档对“不可信服务器”的完整目标。现在的后端执行频道/域授权,并由客户端只解开获授权的密钥包;这能收束正常服务下的访问边界,但后端仍是身份目录、授权与事件归属的信任方。后端访问控制与客户端独立验证签名授权,是不同的安全保证。
## 正确的前后端责任划分
后端继续定义、存储和提供业务实体及接口,包括账户资料、设备目录、空间、频道、分组、文件夹、成员和同步游标。
客户端负责生成和保管私钥、建立设备信任、签署并验证授权、校验 Key Envelope/epoch/消息签名、加解密资源,然后把验证通过的数据交给渲染层。
这些客户端职责不应被当作“重复的业务实现”删除。CryptoProtocol 的结构可以统一定义并生成类型,但密码学验证必须在客户端执行,不能由不可信服务器替客户端宣布验证成功。
## 当前前端可继续收束的部分
已经由后端定义并生成前端 DTO 的有:账户资料、空间/频道/分组/文件夹、资源位置、频道设置与限制、成员和同步接口。`ChannelInfo`、`Item`、`PreviewKind` 等 camelCase 类型是 UI 或解密后的投影,不是第二份后端实体;AES 帧解析、账号私钥、Key Envelope 和对象 key 也必须留在客户端。
仍有几处边界值得收回或明确:
1. `SavedResource` 仍把 endpoint secret、链接等本机凭据与频道名称、成员人数上限等服务端展示快照放在同一结构;账号保险库也同步部分快照。建议拆为本机凭据、服务端快照和传输队列,离线快照只用于只读展示。
2. Worker 从本地 `maxDownloads` 提供上传表单初始值;服务端已校验每个 GATED 文件的下载上限,缓存仅影响表单显示,不决定授权。
3. `account.ts` 仍包含用户名输入规范化和账号本地恢复编排。服务端负责账户验证、设备 session、个人资料和保险库并发版本;浏览器只生成/持有私钥,并执行解锁加密保险库所需的本地 KDF。
文件名、MIME、大小等 E2EE 正文结构和 `file.published` 不能移到服务器定义,否则服务器需解析或生成加密正文。有一处重复是有意的:加密文件元数据保留上传时原始 thread/folder,供解密后完整性核对;后端 WorkspaceLocation 表示当前可移动的位置。两者应明确命名为“原始来源”和“当前目录位置”,不能混为一个字段。
参考文档把 Server 定义为不可信,同时要求客户端验证身份、签名授权和事件。后端权威管理账户与空间业务状态并不冲突,但这不代表客户端能防止恶意后端篡改状态。若保留“不信任 Server”的安全目标,后端可负责存储与 API 真值,签名身份、Capability 和 epoch 变更仍须客户端验证;在这些协议实现前,产品只能承诺正常服务下的后端权限执行与内容 E2EE,不能声称抵抗主动恶意服务器。
边界原则:除端到端密钥生成、封装、解密与签名验证外,所有可由请求参数决定的业务规则都必须由后端计算和校验。前端校验、禁用按钮和隐藏菜单只能改善交互,不能决定账户角色、频道成员范围、权限、默认值、资源归属或状态转换。
## 参考文档还需补足的协议细节
### 已确认的实施目标(尚未部署)
- 账号根签名密钥签发设备证书;设备私钥独立生成。新设备通过密码或 Passkey 解锁保险库恢复根密钥,不要求旧设备批准。
- 首次收到的账号根公钥采用 TOFU 并展示“未核验”;之后密钥变化必须阻止静默更新。可选使用独立渠道/二维码核对指纹。TOFU 不防服务端在首次接触时替换密钥。
- 第一阶段覆盖频道结构命令与聊天 create/edit/delete 事件。服务端拒绝无效签名并执行权限规则;浏览器同样验证签名、证书和授权链后才更新投影。
- 现有服务端仍保存/排序/推送密文与事件;明文加解密继续只在浏览器。服务端分配的 seq 不由客户端签名,事件需绑定客户端 event ID、作者设备、类型、频道/epoch 和密文摘要。
- **可信根**:首次确认用户/Owner 签名公钥的方式,避免服务端替换整个身份及授权链。
- **设备恢复**:项目当前选择不要求旧设备审批;需明确密码、支持 PRF 的 Passkey 和不支持 PRF 的 Passkey 各自能恢复哪些账号/频道密钥,以及账号失陷的影响范围。
- **签名格式**:确定签名覆盖的 subject/device/scope/permissions/version/epoch/密钥指纹等字段、规范编码和验证规则。
- **状态一致性**:签名不能单独防止服务端返回旧的合法授权。需要定义版本、前序状态、可信检查点,以及跨设备回滚/分叉的检测范围。
- **轮换过程**:原子提交新 epoch 与剩余设备的 envelopes,拒绝撤销后的旧 epoch 写入,处理离线设备、并发轮换和上传中的文件。
- **历史读取策略**:新成员能否读历史、移除成员后哪些旧 FileKey 需要重新封装。重新封装不能撤回已被保存的旧密钥或明文。
- **元数据可见性**:明确账号资料、频道/目录名称、资源归属和访问时间哪些允许服务端看到。文档中的“用户数据”不能替代字段级隐私范围。
Epoch 轮换可以隔离正确切换后新数据的读取权限,不自动提供完整前向保密。服务端拒绝服务及删除密文仍属于无法单靠加密消除的可用性风险。
## 按依赖推进的顺序
1. 确定签名身份、可信根、设备证书与设备解包密钥;定义新协议版本,不迁移旧数据。
2. 设计 Owner/Admin/Member 的签名 Capability,将管理能力和数据读取权拆开,并让后端给出可验证、原子提交的授权变更计划。
3. 将账号级 envelopes 升级为设备级 envelopes,明确项目选择无旧设备审批后,Passkey 恢复、设备失陷与私钥复制的边界。
4. 为成员/设备撤销实现每个 ChannelChat/ChannelFileGroup 的原子 epoch 轮换,覆盖离线成员、并发邀请和上传中的资源。
5. 引入签名 ChatEvent、作者验证、可信同步检查点,再接入客户端投影和界面。
6. 拆分 `SavedResource` 中的后端快照与本机密钥/队列,移除 Worker 对缓存权限字段的业务判断,在线操作以服务端授权为准。
7. 根据多实例和存储规模需求替换数据库、事件通知和对象存储适配器。
已完成的后端 CRUD、契约生成、Worker 分层、频道数据域、对象 key 封装、分帧 AEAD、完整性检查及持久化重试可以作为基础;下一阶段重点应转向可验证授权、设备级密钥分发、撤销轮换和前端缓存收束。
+47 -15
View File
@@ -4,13 +4,21 @@
## 1. 技术栈
### 数据来源与契约
HTTP 请求和响应类型由后端 DTO 生成到 `core-bridge/generated/api.ts`,不手写第二份接口模型。
运行 `dotnet run --project tools/ContractGenerator -- <仓库目录>` 更新;`scripts/check-api-contract.mjs` 检查生成物是否与后端来源一致。
`/info` 下发创建默认值、可选设置和部署限制,创建与设置页面通过 store 读取,不在组件里定义业务默认值。
在线空间与分享列表从服务端刷新元数据;IndexedDB 作为离线缓存、凭据与发送队列。网络故障可显示缓存,认证或契约错误不能当作离线成功。
账户资料、频道、分组、目录和资源归属通过后端接口加载及修改。加密资源元数据使用独立协议模型,不序列化 UI 的进度、预览、选中或历史版本状态。Worker 解密资源,store 保存服务端结构和资源投影,组件渲染。
| 项 | 选择 | 说明 |
|---|---|---|
| 构建 | Vite | 纯客户端 SPA,不做 SSR |
| 语言 | TypeScript,`strict` + `noUncheckedIndexedAccess` | |
| UI | React 19.3(稳定版,含 `ViewTransition`、`Activity`) | 不使用 canary |
| 状态 | Zustand | 按领域拆 store |
| 本地存储 | `idb`(IndexedDB) | 发送队列、「我的频道」、草稿预览所需的本机文件 |
| 本地存储 | `idb`(IndexedDB) | 发送队列、「我的频道」、账号资源工作副本、草稿预览所需的本机文件 |
| 样式 | 原生 CSS + CSS 变量 + CSS Modules | 无 Tailwind、无 CSS-in-JS |
| 路由 | 自写极简导航(`app/navigation.ts`) | 只有两三个路由,且需统一挂 view transition |
| 测试 | Vitest(单元)、Playwright(端到端) | |
@@ -20,6 +28,8 @@
**依赖策略**:新增运行时依赖需要书面理由(解决什么、为何不能自写、体积)。不引入 UI 组件库、动画库、图标库、日期库。依赖越少,越容易审计;前端是整个方案的信任根。
生产构建将 `web/package.json` 的版本、前后端各自的 commit/tag 和 UTC 构建时间编译进前端,首页右下角将时间换算为 Asia/Shanghai 并格式化为 `yyyy-MM-dd HH:mm`。部署构建通过 `WEBDROP_FRONTEND_COMMIT`、`WEBDROP_FRONTEND_TAG`、`WEBDROP_BACKEND_COMMIT`、`WEBDROP_BACKEND_TAG`、`WEBDROP_BUILD_TIME` 提供元数据;本地构建没有 CI 环境时尝试读取 Git,时间取本次构建时间。
## 2. 目录与分层
```
@@ -91,6 +101,8 @@ type ItemState =
| 降级防御 | 链接含 `k` 就只接受 enc 且认证通过的帧 |
| 不把密钥、令牌、明文写入 `console`、错误上报、URL、`localStorage` | 「我的频道」只存 IndexedDB |
| 便捷分享草稿预览 | 原始文件只缓存在创建者本机 IndexedDB,不会随文件帧上传;移除条目或封存时清理,用于刷新后继续预览草稿 |
| 账号资源保险库 | Worker 派生登录凭据与 AES-GCM 密钥;同步资源访问凭据、邀请私钥和移除墓碑;不含个人资料、草稿内容、访客终端 S 或 Blob。按账号/访客分库,会话通过本机 IndexedDB 恢复 |
| 频道与文件夹 | 后端持久化结构和资源归属,Worker 读取结构快照;加密文件元数据中的原始位置用于内容校验,UI 使用后端当前归属 |
| 条目预览在沙箱 iframe 渲染;raw 内容只当纯文本显示或下载 | 无内联渲染,不使用 `dangerouslySetInnerHTML` |
| 严格 CSP,所有脚本样式带 SRI,不使用内联脚本 | 与 architecture §5 一致 |
| 不引入任何第三方脚本、统计、字体 CDN | 字体自托管 |
@@ -155,20 +167,40 @@ type ItemState =
| 4 | 文件:流式上传下载、GATED 拉取、下载计数、预览、粘贴与拖放 |
| 6 | PWA、Web Share Target、二维码与短配对码 |
在 `@webdrop/core` 未就绪前,`core-bridge/` 按 protocol.md 定义的接口先放适配层和假实现,真实实现就绪后替换,组件与 store 不改。
`@webdrop/core` 与真实 Worker 已接入,原型假数据与模拟连接实现已移除。接口测试使用测试替身,不保留第二套业务实现。
## 12. 与 `@webdrop/core` 对接备忘
## 12. 实际边界与会话
`core-bridge/api.ts` 是前端与后端的唯一边界,现在由 `mock.ts` 实现。接 `@webdrop/core`([packages/core/README.md](../packages/core/README.md))时对应关系:
core-bridge/instance.ts 使用真实 Worker 实现。账号、设备和个人资料由 worker/account.ts 与 accountProfile.ts 编排;账号密钥包通过 accountChannels.ts 与 channelGrant.ts 领取和核对;空间成员/设置/加入通过 channelResources.ts 调用 API;频道、分组和目录由 channelThreads.ts 调用后端。workspaceRecords.ts 只读取加密文件元数据,不处理账户或结构操作。
| 前端需要 | core / 协议 |
|---|---|
| 新建频道或分享 | `ChannelSession.create({ mode, settings, endpointSecret })` → `POST /channels`;分享的 `settings` 为 `members_can_upload=false`、`delete_policy=owner_only`,并设 `seats`、`ttl_seconds`、`file_max_downloads` |
| 草稿阶段调整设置 | `PATCH /channels/{id}/settings`(需所有者令牌) |
| **完成分享** | 客户端丢弃所有者密钥 A:用成员链接重新 `ChannelSession.open(...)`,此后 `session.can('owner')` 为 false。丢弃是客户端的自觉行为,服务端不知道;若要强制,需后端提供「封存」让服务端作废 `owner_hash`(属于后端与协议改动,前端只提请求) |
| 文件下载次数 | 每个条目的 `maxDownloads` 会强制 GATED,并带 `X-WD-Max-Downloads`,**必须随第 0 帧发送**。文本不要设,否则读文本也要拉取 |
| 内联渲染不烧次数 | GATED 条目每次 `GET /items/{id}/frames?from=0` 计一次下载;自动加载只用于非 GATED(见 ui-design §6.5) |
| 终端密钥 | `session.endpointSecret` 必须持久化到 IndexedDB,否则每次打开都占一个新席位。原型暂未做 |
| 创建频道、便捷分享的验证码 | 读 `GET /info` 的 `captcha`;需要时按需加载打包在前端的官方 `cap-widget`,求解后只把一次性 token 放进创建请求的 `X-WD-Cap`。不从 CDN 加载脚本 |
| 校验失败 | reader 的 `rejected` 事件不能忽略,至少在界面提示「内容校验失败」 |
| Worker | `ChannelSession` 不能跨线程,在 Worker 里用链接和 `endpointSecret` 重建 |
@webdrop/core 仅实现纯密码学与帧协议,不访问数据库或网络。sessions.ts 管本机已解锁会话。transfer.ts 管持久化密文发送队列,队列按资源/创建顺序索引,确保文件密文帧先于目录记录上传。downloadStorage.ts 优先使用 OPFS 逐块写入临时文件,认证结束后交付;不支持时最多 64 MiB 的 Blob 回退。
空间和 Share 各有独立领域 API 与生命周期;Share 封存由服务端事务清除 owner_hash,不能再写。账号保险库同步邀请私钥、资源凭据和移除墓碑;个人资料从后端读取,设备 S、文件缓存与 outbox 保留本机。
## 13. 账号会话恢复与频道密钥领取
本机 IndexedDB 保存账号 session token、不可导出的保险库 CryptoKey 和最近验证的加密保险库,不保存密码或登录 verifier。启动时先恢复账号并验证服务端 session,再读取该账号的资源数据库;网络离线保留本机身份,服务端明确拒绝 session 时清除登录凭据。退出登录清除本机自动恢复记录。记住登录允许使用本浏览器的人访问账号资源。
登录/同步后按账号领取 /accounts/channels 密钥包,解密并核对频道 ID、模式和角色,再补齐本机频道;本机主动移除的资源墓碑不会被旧密钥包重新加入。新设备不复用设备 S。打开无 fragment 的频道地址时,已登录账号可以领取授权包,待接受邀请需用户确认加入。未授权时显示需要频道主邀请,不再只要求含密钥链接。
## 14. 用户概念映射与分组
界面 Space 对应既有顶层 ChannelInfo 与 /c/:id;界面频道对应后端 WorkspaceChannel 和帧协议 threadId。store 直接渲染服务端结构快照,不重放创建/重命名/分组事件。ThreadInfo 与 FolderInfo 只将后端可空字段转成 UI 的可选字段。分组不是成员或密钥边界。
## 15. 文件交互边界
FileExplorer 编排选择、菜单、拖放和目录导航,ExplorerFileRow 只订阅单个文件。选择范围与拖放封装在 lib/explorer.ts 中做纯逻辑校验;拖放数据仅含随机文件 ID 和当前空间/频道标识,不包含链接、密钥或文件内容。移动、删除、下载均经上层回调和 store 完成。
ContextMenu 是无业务 UI,负责 portal、视口限制、键盘焦点和关闭。悬停预览由独立组件挂载,受限文件只渲染元数据。文件详情的显式解密流程继续沿用原有认证和下载计数。
## 16. 文件聊天消息与身份快照
ItemBase.author 由服务端资源作者和账号资料投影得到,晚到的解密内容不能覆盖服务器提供的作者与位置。客户端提供的作者声明不能覆盖服务端资料。这是服务端身份归属,不是端对端逐账号签名。
ChatPane 订阅当前频道的条目 ID(乐观发送:store 的 sendText/sendFiles 先写入 `local:` 占位项,worker 经 `localId` 带回后由 reduce 替换,失败可 `retrySend`),ChatMessage 单独订阅条目状态和上传进度;文件目录变成文件消息,不重复创建独立文本通知。预览/下载继续经已有 store action,移动文件不会重新上传。GATED 内容只有明确操作后才读取。
## 17. 本机凭据保存
accountPersistence 新增按 API endpoint 隔离的 credentials store。用户名和密码使用随机 IV 的 AES-GCM 密文保存;不可导出的 CryptoKey 通过 IndexedDB structured clone 保存在本机。只有登录表单读取时才在 Worker 解密并填充,不写入 localStorage、URL 或日志,不上传原始密码。加密存储不能防止已被控制的同源前端或终端。
AccountSession.remember 控制会话落盘,所有同步、资料更新与登录保存路径统一检查该标记。关闭记住或清除本机保存后,不能被轮询重新保存。正常退出保留已保存填充信息,明确清除或服务端拒绝 session 时删除。旧版保存的会话视为开启自动恢复,但没有原始密码,不伪造密码填充。
+924 -235
View File
File diff suppressed because it is too large. Load diff
+82 -18
View File
@@ -1,5 +1,15 @@
# 协议 v1
### 频道数据域边界
新工作区的空间凭据只用于空间访问与管理,不作为频道内容密钥。每个频道有独立 chat/files 数据域与 epoch;客户端为每个条目生成随机 32 字节对象密钥,以对应数据域的 epoch key 用 AES-256-GCM 封装。封装上下文绑定空间、频道、数据域、epoch 和条目 ID。服务端保存对象归属与密钥封装,不能获得对象明文密钥。客户端解密必须先取得对应数据域授权,禁止回退到空间密钥。
域密钥由创建者随机生成,再通过现有账号 ECDH 公钥信封逐账号封装;域信封上下文包括空间 ID、频道 ID、域名和 epoch。邀请前服务端按成员权限和历史设置生成 epoch/收件人计划;浏览器只为计划中的收件人封装密钥,服务端在单事务中重算并校验邀请、权限、envelopes 和 epoch。服务端根据登录身份只返回该账号可读域的信封和匹配对象索引,并在日志读取、文件下载及写入时执行成员权限检查。默认频道的新成员获得 chat read/write、files read/upload;新增频道的访问不会由空间成员身份自动推导。Owner 可分别决定新成员能否读取聊天和文件历史。关闭某域历史时,邀请流程生成新 epoch,并将新密钥给现有授权成员与新成员;新成员不获旧 epoch 信封。未启用 E2EE 时,后端用服务端维护的 crypto revision 作为访问起点,过滤历史日志和目录位置。该策略不撤回已经交付的密钥;已移除成员的撤销轮换目前尚未实现。
该模型的服务端是授权执行方,而不是可被客户端验证的授权来源:目前没有 Owner 签名 Capability、账号签名身份、设备证书或事件签名。首次邀请公钥也来自服务端目录,公钥替换风险仍存在。不能把 AEAD 校验或服务端权限过滤描述成对恶意服务端的端到端授权验证。
新版本的频道、分组、目录和文件归属通过后端空间结构接口管理。资源内容继续采用浏览器端加密。`file.published` 的 item 使用独立协议结构,不序列化 UI 的 state、preview、remaining 或版本列表。接收端只重建资源渲染状态,不从资源正文构建账户或空间结构。
本文是协议的权威来源。所有实现(服务端、`@webdrop/core`、将来的 CLI)必须通过 `test-vectors/` 中的向量。
## 1. 编码约定
@@ -12,18 +22,18 @@
|---|---|---|
| `channel_id` | 16 B | 随机,客户端生成 |
| `item_id` | 16 B | 随机,客户端生成 |
| `K` | 32 B | 频道密钥,仅 enc 模式,只存在于 URL fragment |
| `K` | 32 B | 空间/临时频道访问凭据;新工作区内容加密不使用它,可选能力链接使用 fragment |
| `A` | 32 B | 管理密钥,独立随机,只存在于所有者链接 |
| `W` | 32 B | 写密钥,仅 raw 模式且成员可上传时 |
| `S` | 32 B | 终端密钥,每个频道每个终端各生成一个,仅存本地 |
| `S` | 32 B | 便捷分享的终端密钥(创建者与读者),仅存本地;频道与空间不使用,只凭账号会话鉴权 |
## 2. 模式与链接
频道创建时二选一,之后不可更改。
只有一个频道模型。创建时可选择是否启用端到端加密,默认 enc;raw 表示不加密内容。账号成员、所有者、加入规则、线程、目录、日志和权限完全共用,差别限于客户端的负载加解密。创建后不能改变加密方式。
| | enc(默认) | raw |
|---|---|---|
| 负载 | AES-256-GCM 密文 | 原样字节,服务端可见 |
| 负载 | AES-256-GCM 密文 | 共享条目结构的明文,服务端可见 |
| 所有者链接 | `/c/{id}#k=<K>&a=<A>` | `/c/{id}#a=<A>[&w=<W>]` |
| 成员链接 | `/c/{id}#k=<K>` | `/c/{id}[#w=<W>]` |
| 读权限 | 持 `T_read` | 知道 id 即可(能力链接) |
@@ -70,7 +80,8 @@ raw 模式下 `T_owner` 同样由 `A` 派生;`T_write = W`。
| 1 | SNAPSHOT | 该条目是频道的「最新快照」,取代之前的快照条目 |
| 2 | GATED | 不通过实时推送内联分发,只能按条目拉取,并计下载次数 |
| 3 | ENC | 负载为 AES-GCM 密文,必须与频道模式一致 |
| 4-7 | 保留 | 必须为 0 |
| 4 | CONTROL | 加密资源元数据记录 |
| 5-7 | 保留 | 必须为 0 |
服务端拒绝不符合上述规则的封装,但从不解析负载。
@@ -119,16 +130,22 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
| `mime` | 可选 |
| `size` | 可选,内容总字节数(流式时可省略) |
| `ts` | 毫秒时间戳,仅供参考,排序以 seq 为准 |
| `dev` | 可选,设备名 |
| `dev` | 已弃用(v7 起客户端不再写入,读取时忽略);发送者以服务端记录的账号为准 |
| `thread_id` | 可选,频道 thread ID |
| `folder_id` | 可选,文件的虚拟文件夹 ID |
第 1 帧起,`data` 全部是内容字节。小内容(文本、JSON)一帧完成,没有额外开销。
**raw 模式**:负载就是内容字节,没有 meta。条目名称等信息由用户在上传时放进 `X-WD-Label`(明文,≤255 B)。
**频道 thread 与文件夹**:客户端把 thread/folder 元数据放在上述共享元数据中(enc 时加密,raw 时服务端可见)。频道、分组、文件夹和条目归属由后端结构接口管理,不写入内容日志。新建空间与初始“常规”频道由后端原子创建。GATED 的 announce 不含元数据,file.published 加密记录提供名称和原始归属;目录记录未到达时显示等待同步占位符。
**raw 模式(API 契约 2)**:负载使用与 enc 相同的 `u16 meta_len ‖ meta_json ‖ content` 条目结构,只是不加密。名称、thread/folder ID 与其他元数据对服务端可见。旧版“纯字节、无元数据”的 raw 负载不属于新应用契约,旧客户端会被 API 主版本检查拒绝。
**未完成的条目**:没有 FINAL 的条目是「进行中」,接收方可以渐进消费(流式播放、边收边写),但必须标记为不完整,直到收到已认证的 FINAL。
## 8. 日志、序号与条目生命周期
`CONTROL=0x10` 标记加密资源元数据记录;保留位为 `0xE0`。控制条目必须是非 GATED、非 SNAPSHOT 的 FINAL 单帧,payload 最多 16,400 B;其生命周期为频道生命周期,不受普通内容 TTL 或 max_items 淘汰。每频道最多 4096 条控制记录,仍计入总字节配额。加密和非加密频道使用相同控制正文与日志处理。
频道维护一条服务端排序的**日志**,每条记录的序号 `seq` 是 u64,频道内单调递增。
| kind | 值 | 内容 |
@@ -144,7 +161,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
**推送规则**:
- 非 GATED 条目以 frame 记录推送给订阅者。服务端强制:**非 GATED 条目必须是单帧且带 FINAL**(`frame_index = 0`)。
- GATED 条目只推送一条 announce,字节通过条目拉取接口取得(见 api.md),`from=0` 的拉取计入下载次数。
- `from>0` 只有在同一终端此前已对同一条目成功发起 `from=0` 后才是有效续传;服务端按资源、条目和终端绑定这份续传许可,任意跳到中间帧不能绕过下载次数。
- `from>0` 只有在同一下载方(频道按用户)此前已对同一条目成功发起 `from=0` 后才是有效续传;服务端按资源、条目和下载方绑定这份续传许可,任意跳到中间帧不能绕过下载次数。
- 因此客户端上传**多帧条目(文件)或带下载上限的条目时,必须设置 GATED**。
- 同一条目的所有帧,SNAPSHOT 与 GATED 标志必须一致;FINAL 不能早于已存在的更大序号;已有 FINAL 之后不能再追加更大序号的帧。GATED 条目的帧允许乱序到达(并行上传),条目拉取只返回从起点开始连续的帧。
@@ -158,17 +175,18 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
带 SNAPSHOT 的条目取代频道中之前的快照条目(最后写入者胜)。快照必须是**非 GATED 的单帧**(整份状态不超过一帧,约 1 MiB),所以新快照一被接受,服务端就在同一个事务里删除旧快照并产生一条 `reason = 4` 的 tombstone。一个频道在 v1 里只有一个快照槽,多文档请使用多个频道。
## 10. 终端与席位
## 10. 用户、设备与成员人数
- 终端在本地为每个频道生成随机 `S`,请求中带 `S`;服务端保存 `SHA-256(S)` 作为终端标识。
- 终端通过显式的加入请求(`POST .../seats`)占用一个席位;创建者的终端在创建时自动入席。席位已满则拒绝新终端(`seats_full`),已入席的终端可重复加入(幂等)和重连。未入席的终端访问频道返回 `not_joined`。
- 不同频道使用不同的 `S`,避免服务端把同一设备关联起来。
- 终端可以加密一个设备名上传,所有者能看到成员列表和设备名,并可以释放席位。
- **设备名格式(enc)**:`iv(12 B 随机) ‖ AES-256-GCM(K_label, utf8 名称) ‖ tag(16 B)`,AAD = `"WD1L"`(4 B ASCII)‖ `channel_id` ‖ `endpoint_hash`(32 B,即 `SHA-256(S)`)。总长不超过 256 B,所以名称最多 228 字节 UTF-8。使用派生子密钥 `K_label` 而不是直接用 `K_enc`,避免同一密钥同时用于 HKDF 和 AES-GCM;AAD 绑定终端,服务端无法把一个终端的设备名挪到另一个席位上。raw 模式下设备名是明文 UTF-8。
- **成员是用户(账号)**。设备只是账号的属性:同一账号可在任意多台设备登录,只占一个成员名额;账号成员的请求凭有效的账号 session 鉴权,不再需要终端密钥,也不登记终端。
- **频道强制登录**(v8):没有账号会话的请求一律返回 `account_required`,不再有未登录访客(没有 `allow_guests`、`/guests`、访客终端席位)。持链接的人先登录,再通过显式的加入请求(`POST .../join`)成为成员;未加入的账号访问频道内容返回 `not_joined`;所有者通过撤销邀请移除成员。便捷分享不受影响。
- 频道设置字段 `max_members`(v7 前叫 `seats`)是**最大成员人数**。owner 和每个唯一账号各计一人,同一账号的额外设备不增加人数。成员人数达到上限时,新的成员加入返回 `members_full`。
- 受限下载(GATED)的计数单位是「下载方」:按用户;同一下载方对同一条目重复发起 `from=0` 才算续传。
- 在线状态由服务端按账号统计(`members` 接口的 `online`、`devices`、`online_devices`),只来自内存里的订阅登记,不写库。
- **已移除**:每终端的加密设备名(`K_label`、AAD `WD1L`、`PUT .../seats/me` 与 `.../seats/{hash}/label`)和设备席位清单。设备名只在账号设备管理里存在(`/accounts/devices`)。`K_label` 的派生仍保留在 §3,为已发布的测试向量保持稳定,但不再被使用。
## 11. 创建与验证码
创建频道是匿名接口,需要防刷。验证码**完全交给 [Cap](https://github.com/tiagozip/cap)**(基于 SHA-256 工作量证明的验证码),本协议不定义、也不实现任何 PoW。
创建频道需要账号登录,仍需要防刷。验证码**完全交给 [Cap](https://github.com/tiagozip/cap)**(基于 SHA-256 工作量证明的验证码),本协议不定义、也不实现任何 PoW。
流程:
1. 前端从 `GET /api/v1/info` 读到 `captcha.enabled` 和 `captcha.api_endpoint`。
@@ -180,11 +198,12 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
## 12. 设置
频道设置对服务端可见,服务端据此执行:
频道设置对服务端可见,服务端据此执行。`name` 是共享频道名,所有加入者都从频道信息中读取:
| 设置 | 默认 | 范围 |
|---|---|---|
| `seats` | 2 | 1..64(服务端可配置上限) |
| `name` | 无 | 可选,最多 48 个字符;所有频道成员和服务端可见 |
| `max_members`(v7 前叫 `seats`,最大成员人数) | 2 | 1..64(服务端可配置上限);每个账号计一人,其多设备不增加人数 |
| `members_can_upload` | true | |
| `delete_policy` | `own` | `owner_only` / `own` / `anyone` |
| `ttl_seconds` | 86400 | 最长 604800,每个条目从创建起算 |
@@ -203,6 +222,8 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
| 每频道字节数 | 1 GiB |
| 每频道条目数 | `max_items` |
工作区对象帧使用独立容量限制:单个工作区默认最多 1 TiB,单个 `WorkspaceChannel` 的 Files 域默认最多 10 GiB;Files 域用量以非 CONTROL 帧字节计,删除条目后释放用量。其他未绑定工作区对象的旧式频道仍遵循上表 1 GiB 限制。实际配置值由 `/info` 返回,客户端不得自行推导或执行配额。
## 14. 接收方校验算法
对 enc 频道的每个条目:
@@ -223,7 +244,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
| 模式 | enc 或 raw | **只有 enc** |
| 谁能写 | 所有者和成员 | 只有创建者,且只在草稿阶段 |
| 寿命 | 长期(TTL 滚动续期) | 短,封存后到期即清 |
| 访客 | 成员,占席位 | 只读访客,按终端计数,不展示人数 |
| 谁能读 | 已登录的成员(账号) | 持链接的任何人,无需账号;访客按终端计数,不展示人数 |
分享与频道**共用同一个 16 字节 id 空间**,服务端保证全局唯一,所以同一个 id 不可能既是频道又是分享。分享 id 在频道接口上返回 `not_found`,反之亦然。
@@ -320,3 +341,46 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
- 协议版本在封装的 `ver` 字段和 HKDF 的 `info`(`webdrop/v1/...`)中同时体现。
- `test-vectors/` 提供固定输入、固定输出:密钥派生、令牌、加解密、padding、封装编解码,以及篡改、重排、截断、换频道的负例,必须全部通过。
- 本版**预留**但不实现:口令叠加(Argon2id)、短配对码(PAKE)、多快照槽。
## 17. 账号凭据与频道域密钥
空间访问凭据用于服务端资源/终端鉴权,不是频道内容根。创建者用自己的账号 P-256 公钥封装 Owner 凭据,随空间、默认频道、Owner 成员记录原子保存。账号接受空间邀请后,可凭账号 session 从 `/accounts/channels` 取回自己的空间凭据;账号成员不登记终端,实际打开空间时只确认成员关系(`POST .../join` 幂等)。
内容加密密钥由浏览器生成。每个频道各有独立 `chat` 与 `files` 域,每域从 epoch 1 开始使用随机 32 B key。域信封沿用账号 ECDH、HKDF、AES-GCM,AAD 绑定账号、空间 ID、频道 ID、域名与 epoch。服务端保存 opaque 密文,只向通过频道/域读取授权的账号返回信封。每条消息、文件和加密发布元数据使用独立随机 32 B 对象密钥;对象密钥由域 key 的 AES-GCM 封装,AAD 绑定空间、频道、域、epoch 和 item ID。
默认频道成员权限是 chat read/write 与 files read/upload;其它频道不能从空间成员身份推导访问权。Owner 邀请时可分别允许新成员读取已有聊天和文件。关闭历史会对对应域创建新 epoch,把新 key 封装给现有授权账号和新成员,不向新成员分发旧 epoch。此策略不撤回此前已交付的密钥,也不等于成员移除时的完整密码学撤销;已加入成员撤销后的轮换、离线和并发协议仍待实现。
服务端会按账号权限过滤频道结构、域信封、对象索引和日志/下载,并拒绝无写入授权或旧 epoch 的新帧。这是服务端执行的访问策略,不是客户端可独立验证的签名 Capability。域密钥封装目前按账号而非设备,目录公钥替换风险、恶意服务端伪造结构/作者和事件签名缺失仍然存在。
## 18. 频道加密文件目录
文件密文帧之后,在同一发送队列追加 CONTROL file.published,正文包含 item.id、kind=file、threadId、folderId(可选)、name、mime、size、ts、deviceId、gated。资源记录使用 webdrop.workspace v1 正文。名称/类型/大小/原始 thread/folder 必须与文件第 0 帧元数据相符。目录允许先显示名称,但不得因此自动下载 GATED 内容。
客户端只有在当前日志存在对应文件帧或 announce 时展示目录;文件墓碑清除目录可见状态,全量重放时不恢复不存在/过期文件。目录记录属于 CONTROL,仍受 4096 条控制记录和频道字节配额限制。文件移动由服务端更新归属,不重新加密原始文件,不改变最初认证的内容元数据。
## 19. 空间内的频道分组
界面顶层“空间”对应 API space/底层同步资源,界面“频道”是空间中的 WorkspaceChannel,帧元数据 threadId 映射到频道 ID。频道和分组由后端结构接口创建、重命名和管理归属,名称限制与关联验证由后端实施。成员与同步日志归属于空间,频道各自拥有 chat/files 密钥域;分组仅组织频道,不建立新授权或密钥。客户端不重放 group.create、thread.create 等结构记录。
## 20. 发送者展示快照
聊天发送者由服务端已存条目的 authorAccount 与后端个人资料决定。file.published 不写入作者声明。头像在 Worker 内缩放为最多 64×64 后用于渲染,不影响加密帧或鉴权。
服务端提供的作者归属与个人资料不代表端对端发送者签名;资源密文仍由浏览器验证。未关联账号的访客资源回退设备来源,不推测当前用户。
## 21. 签名扩展基础
本节定义后续签名授权和签名事件共用的签名原语;在对应 API 与事件模型完成前,它**不改变现有无签名请求的行为,也不表示频道操作已启用验签**。
- 算法为 ECDSA P-256 / SHA-256。签名线格式为 64 字节 IEEE P1363 `r ‖ s`;公钥按 P-256 JWK 的 `x`、`y` 表示,各为 32 字节 base64url。
- 被签名字节为 `"WDS\x01" ‖ kind_len(u16) ‖ kind(ASCII) ‖ payload_len(u32) ‖ payload`,整数大端。`kind` 为 1–64 字节、匹配 `[a-z][a-z0-9._-]*`;payload 最大 1 MiB。
- `kind` 必须区分对象用途,不能跨类型复用签名。payload 后续按设备证书、Capability、ChatEvent 各自定义唯一字段顺序和二进制编码;禁止直接签未经规范化的 JSON。
- 浏览器和服务端都必须先校验 payload 结构、身份绑定、授权范围和上下文,再验签;任一步失败均拒绝操作。服务端接受请求不代替浏览器验签。
- 此原语只证明某密钥持有者签署了这些字节,不证明公钥属于声称的账号,也不防止服务端隐藏、回滚或向不同客户端展示分叉状态。
`device.certificate` 的 payload 为 `username_len(u8) ‖ lowercase_username(ASCII) ‖ device_id_len(u8) ‖ lowercase_device_id(ASCII UUID D 格式) ‖ device_public_x(32 B) ‖ device_public_y(32 B)`。账号根密钥签发此证书;设备密钥预留用于签署频道结构命令和聊天/文件事件。新设备通过密码/Passkey 解锁保险库恢复账号根密钥,不要求旧设备在线批准。其他账号第一次看到某账号根公钥时采用 TOFU:首次固定并展示“未核验”状态,后续变化不得静默接受;这不能防止首次接触时服务端替换密钥。成员可通过独立渠道比较指纹。透明日志、跨客户端一致性证明和历史检查点属于后续阶段。
`content.event` 的 payload 顺序为 `space_id(16 B) ‖ channel_id_len(u8) ‖ channel_id(ASCII UUID) ‖ username_len(u8) ‖ username(lowercase ASCII) ‖ device_id_len(u8) ‖ device_id(lowercase ASCII UUID) ‖ event_id(16 B) ‖ event_kind_len(u8) ‖ event_kind(ASCII) ‖ domain(u8:chat=1,files=2) ‖ epoch(u32) ‖ item_id(16 B) ‖ frame_count(u32) ‖ manifest_hash(32 B) ‖ target_present(u8) ‖ target_item_id(16 B 或全零)`。`manifest_hash = SHA-256("WD1M" ‖ frame_count(u32) ‖ (frame_index(u32) ‖ SHA-256(完整 frame envelope))...)`,帧必须从 0 连续排列。事件类型包括 `chat.create/edit/snapshot/delete`、`file.publish/catalog/delete`。FINAL 上传头带事件字段;后端确认证书、签名、对象作者/domain/epoch/权限及数据库帧清单一致后,在对象元数据保存签名。`CryptoState` 按域授权过滤后把签名材料返回客户端。浏览器在投影完整帧前验设备证书、TOFU 根 key、事件签名和本地 manifest。
当前已接通新账号根公钥、设备证书校验,以及有账号设备签名密钥的对象最终帧验签/作者 manifest 校验;已登记签名设备的删除也要求签名并把删除证明保存到对象元数据;未注册签名设备的旧账号仍走兼容路径。**频道结构命令和成员/权限变更尚未签名**。TOFU 首次接触风险、服务器分叉/回滚、撤销设备密钥轮换仍未解决,不能宣称已完整抵抗主动恶意服务端。
+21 -4
View File
@@ -16,7 +16,7 @@
| 能看到 | 说明 |
|---|---|
| 频道 id、模式、设置 | 席位数、权限、TTL、下载上限等,服务端需要据此执行 |
| 频道 id、模式、设置 | 包括共享频道名、成员人数上限、权限、TTL、下载上限等,服务端需要据此执行 |
| 帧数量、时间、序号 | 追加顺序由服务端分配 |
| 密文大小 | 经过档位 padding,见 protocol §6 |
| 帧标志 | FINAL、SNAPSHOT、GATED、ENC |
@@ -24,12 +24,14 @@
| 令牌的哈希、终端的哈希 | 不是原值,但每次请求中会短暂看到令牌原值 |
| 下载次数 | 服务端计数 |
| 传输层信息 | IP、TLS 指纹、访问时间 |
| 账号服务可见 | 用户名、口令 verifier、保险库密文大小和更新时间;不会看到保险库明文、频道密钥或分享链接 |
| 业务结构和个人资料 | 账号昵称头像、空间和频道名称、分组、文件夹名称、资源归属、资源作者;由后端管理,不属于资源内容的端对端加密承诺 |
| 看不到(enc 模式) | 说明 |
|---|---|
| 内容 | 文本、JSON、文件字节 |
| 文件名、类型、总大小、设备名 | 都在加密的条目元数据里 |
| 密钥 K、管理密钥 A | 只在 URL fragment 中,不会发给服务器 |
| 文件名、类型、总大小、文件内设备标签 | 都在加密的条目元数据里 |
| 密钥 K、管理密钥 A | 由客户端保管,以账号公钥封装的密文可存服务器;可选分享链接使用 fragment |
`raw` 模式下服务端能看到全部负载,界面必须明确提示。
@@ -52,14 +54,20 @@
| 模式降级(enc 换成 raw) | 链接带 `k` 就只接受 enc 且认证通过的帧,不匹配直接拒绝 |
| 重放旧帧 | 同一条目内 (item_id, frame_index) 唯一,服务端幂等,客户端按条目去重 |
| 服务端隐瞒或删除条目 | **无法防止**(可用性),见 §6 |
| 链接泄露 | 链接即能力。席位上限限制加入数,所有者可释放席位和删除频道 |
| 链接泄露 | 链接即能力。成员人数上限限制账号成员数,频道只接受登录账号,所有者可撤销成员和删除频道 |
| fragment 外泄 | 取出后立即 `history.replaceState`;`Referrer-Policy: no-referrer`;不把 K 放进查询串 |
| XSS | 严格 CSP;条目预览在沙箱中渲染;`raw` 内容不内联渲染,下载带 `attachment` 和 `nosniff` |
| 暴力猜频道 id | id 128 位随机 |
| 撞库/截获账号登录 | 客户端 PBKDF2 派生登录凭据,经 HTTPS 传输;服务端另存独立随机 salt 的 PBKDF2 慢哈希,并限流。TLS 或运行终端泄露仍可能暴露可登录凭据 |
| 账号数据库泄露 | 数据库只有加盐慢哈希,不能直接用于登录;弱口令仍可被离线猜测。登录凭据不能直接派生保险库密钥 |
| 账号混用/设备身份混用 | 按账号隔离 IndexedDB,切换时中止后台任务;保险库不包含设备 S,每台设备单独生成 |
| 绕过频道加入规则 | 服务端检查 owner/member 角色、邀请、密码、账号 session(没有则一律拒绝)、成员人数;加密选项不会绕开权限 |
| 匿名滥用与 DoS | 创建时的 Cap 验证码(工作量证明由 Cap 提供)、限流、频道配额、条目数和字节数上限 |
## 5. 前端信任根(残余风险)
邀请密文使用收件账号的 P-256 公钥派生 AES-GCM 密钥,私钥在密码保护的保险库中。但首次邀请公钥来自服务端目录,目前没有外部指纹核验:恶意服务端替换公钥会影响新邀请的保密性。此限制与已有频道内容的密钥/帧认证边界不同。
端到端加密的前提是浏览器执行的是正确的代码。如果同一个被攻破的后端同时下发前端,它可以换成偷 K 的页面。
隔离部署的缓解:
@@ -81,6 +89,8 @@
- **不提供不可否认性**:帧没有逐发送者签名,所有成员共享同一密钥。
- **不防已被攻破的终端**。
- **不做口令叠加、后量子**,留待后续版本。
- **账号密码不是频道访问控制**。账号保险库只同步持有的资源链接;拿到频道链接的人仍按频道自身能力访问。
- **无密码重置**。忘记密码会失去保险库解密能力;未来若需要找回,须增加独立恢复密钥,而不能由服务端重置后解密旧保险库。
## 7. 服务端内存与日志的要求
@@ -88,3 +98,10 @@
- 令牌只做哈希比对,使用常量时间比较,不写日志。
- 日志不记录请求头、请求体、完整 URL;频道 id 在日志中截断或哈希。
- 错误信息不回显令牌和任何用户提交的字节。
## 8. 账号设备与本机缓存
同一账号的各设备拥有独立 sessions,但在频道成员上限中只计一个账号成员,成员请求凭账号 session 鉴权。撤销设备会删除该设备 sessions;不会移除账号成员或释放其成员名额,也不会影响其他设备。撤销不能擦除已下载数据、已持有的频道 K 或被复制的账号私钥。当前未实现密钥轮换,不声称前向保密。未登录访客无法跨设备去重,每个访客终端单独计入人数上限。
本机记住登录允许本浏览器使用者访问账号,退出时删除自动恢复凭据。OPFS 存储解密后临时文件,正常退出账号时清理;浏览器崩溃可能留下本机缓存,它不属于服务端密文。
+87 -94
View File
@@ -74,54 +74,19 @@ blur 的性能约束:
## 6. 布局与页面
WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,不按「谁发的」;发送设备只作为一行小字。
WebDrop 是**暂存区**,频道工作区:聊天按 thread 展示,文件按 thread/文件夹组织;发送设备作为来源信息。
### 6.1 手机(< 900px):最新 + 分类 + 底部动作栏
### 6.1 手机:频道导航与聊天/文件切换
```
┌────────────────────────────┐
│▒ abc123 🔒加密 ●●○ 2/3 🔍 ◐ ⇧│ ← 模糊顶栏
├────────────────────────────┤
│ 最新 │ 最近一项放大,拇指区一键取走
│ [图标] 屏幕录制.webm │
│ iPhone · 14:20 · 1.8 MB │
│ [ 预览 ] [ 下载 ] │ 文本则是 [ 复制 ]
├────────────────────────────┤
│ ▸ 便签 待办… │ 折叠,点开编辑
├────────────────────────────┤
│ [全部][文件][文本][图片]… │ 类型分组常驻(可横向滑动)
│ [缩略] project-assets.zip ⬇│
│ [缩略] 设计稿.svg v3 有更新 ⬇│
│ [文本] 明天 10 点开会… ⧉ │
├────────────────────────────┤
│▒ 粘贴 文件 相册 拍照 扫码 写 │ ← 模糊底部动作栏
└────────────────────────────┘
```
进入频道就是纯聊天界面:单行顶栏(≡、# 频道名、加密图标、搜索、⋯)加底部输入框,没有页签。空间切换、频道列表、本空间的「文件 / 成员 / 邀请 / 分享」、账号与外观都在左侧抽屉(≡ 打开,返回键关闭);点「文件」「成员」整页打开,左上角 ‹ 回到聊天。⋯ 里是空间设置、分享、邀请。触屏没有悬停,消息上不常驻删除按钮,点一下消息弹出菜单(复制、删除)。新建频道自动创建“常规”thread。文件页与桌面共用目录路径、上传、文件夹、排序、移动和预览组件。
- 底部是**动作**而不是输入框:粘贴(读剪贴板,文字或图片)、文件、相册、拍照、扫码、写。点「写」才弹出输入。
- 点列表项或「最新」卡打开底部抽屉详情(预览、操作、历史)。
频道加载中先画出页面框架(骨架屏:顶栏、频道列表、消息、输入框的灰色占位),不留白屏;等待不足约 300ms 时不显示,避免闪烁;占位轻微呼吸闪动,尊重「减少动效」。不用假进度条。加载失败时在骨架屏上给出错误与「重试」「回到首页」。
### 6.2 电脑(≥ 900px):列表 + 内联预览 + 窄栏
### 6.2 电脑:频道与 threads | 聊天 | 线程文件
```
┌───────────────────────────────────────────────────┬───┐
│ abc123 🔒加密 ●●○ 2/3 🔍 ◐ │ ‹ │
├────────────────────┬──────────────────────────────┤2/3│
│ [粘贴][文件][扫码][写]│ 屏幕录制.webm │ ⇧ │
│ ▸ 便签 待办… │ iPhone · 14:20 · 剩 8 次下载 │ │
│ [全部 文件 文本 …] │ [下载] [删除] │ │
│ ☐ [缩略] zip ⬇ │ ┌────────────────────────┐ │ │
│ ☐ [缩略] 设计稿 ⬇ │ │ 内联预览(视频/图片/目录) │ │ │
│ ☐ [文本] 开会… ⧉ │ └────────────────────────┘ │ │
│ 已选 2 项 [下载] │ 修改历史 v3 当前 / v2 / v1 │ │
└────────────────────┴──────────────────────────────┴───┘
全局:Ctrl+V 粘贴即发送;拖到窗口任意位置即上传
```
左侧固定频道导航与 thread 列表,可直接切换已加入频道、新建频道、新建/重命名 thread,并打开账号与设备设置。中间完整高度展示当前 thread 的聊天,底部输入支持文字和文件附件。右侧是资源管理器式文件区:路径面包屑、上传文件、新建文件夹、按名称/大小/时间排序、多选下载、移动、双击预览。
- 点左侧一行,右侧内联预览,不弹窗来回开关;默认选中最新一项。
- 多选后可批量下载。
- 设备与分享是低频操作,默认收成右侧窄栏(在线数、分享图标),需要时展开。
- 窗口任意位置 `Ctrl+V` 都能直接发送(在输入框内粘贴时不拦截);拖到窗口任意位置即上传。
设备与分享收进顶栏弹层,不占第四列。预览在独立弹层打开,不挤占聊天。加密目录先同步文件名、大小与 thread/folder 归属;受限文件必须显式点击“解密并预览文件”或下载后才读取内容,不能因选择列表项或刷新消耗次数。
### 6.3 顶栏
@@ -136,32 +101,26 @@ WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,
### 6.4 首页(初始状态)与页面
首页把「临时分享」与「设备频道」作为两个并列入口:前者用于发一段临时文本或文件,后者用于多设备长期同步。
首页把「便捷分享」与「空间」做成两个并列分区(宽屏左右并列,窄屏上下排列,中间一条细线分隔),各有图标和一句话定位:
```
┌──────────────────────────────────┐
│ WebDrop │
│ 在自己的设备之间传文件和文本,也可分享给别人。│
│ ┌ 临时分享 ───────────────────┐ │
│ │ 输入或粘贴临时文本… │ │
│ │ 拖入文件 / 文件名列表 │ │
│ │ [选择文件] [粘贴] [创建临时分享]│ │
│ └────────────────────────────┘ │
│ 我的频道(有才显示) │
│ abc123… 🔒加密 所有者 3 台设备 │
│ ┌ 设备频道 ───────────────────┐ │
│ │ 多台设备之间长期同步 │ │
│ │ [ 新建频道 ] [ 加入频道 ] │ │
│ └────────────────────────────┘ │
└──────────────────────────────────┘
```
- **便捷分享**(左):临时 · 只读 · 对方无需账号,端到端加密。输入区(文字、拖入/选择/粘贴文件)加「创建分享」,下面紧跟「我的分享」列表。是写完即发的操作区。
- **空间**(右):长期 · 多人 · 登录使用。先列「我的空间」(名称、加密、角色、人数上限),底部是「新建空间」「加入空间」。是进入长期工作区的入口。
主页右上角提供外观菜单,右下角显示前端和后端的 tag 与短 commit hash 及上海时间的构建时间;无法取得 tag 时如实显示「未标记」。
| 操作 | 结果 |
|---|---|
| 快速分享(输入文本、拖入、粘贴、选择、相册、拍照) | 先在首页编辑临时文本并收集文件;点「创建临时分享」后建立加密草稿并带入内容;点「完成分享」后生成链接与二维码 |
| 新建频道 | 抽屉:模式(加密 / 明文)、设备数、保留时间、允许成员上传;创建后直接展示分享面板与二维码 |
| 新建频道 | 登录后创建,自动成为该频道所有者;可选择是否端到端加密,默认启用;成员人数上限、TTL、受限下载次数和上传权限仍用同一频道模型 |
| 加入频道 | 扫码或粘贴链接 → 加入预览 → 加入 |
| 我的频道 | 点一行进入;垃圾桶只是从本机移除,**不会删除频道**,需二次确认 |
| 我的频道 | 点一行进入;垃圾桶从当前账号资源清单移除,**不会删除频道**,需二次确认;账号清单移除会同步到其他设备 |
| 账号与同步 | 用户名密码注册或登录;本地频道/分享凭据在 Worker 加密后同步,另一台设备登录后恢复;退出时撤销会话 |
登录/注册页是居中的单列卡片(标题与标语在上,卡片在下);创建账号需要再输入一次密码确认,两次不一致时就地提示并禁用提交。账号设置是左侧导航 + 右侧单页(个人资料 / 我的设备 / 安全 / 空间邀请 / 同步与登录;手机上导航变为顶部横向页签),结果统一用 toast 反馈,设备重命名就地编辑,移除设备与退出登录需确认。
账号密码不提供找回流程。忘记密码会导致账号保险库无法解密;界面需在注册区域明确提示。本机记住登录通过 IndexedDB 保存 session 和不可导出的保险库 CryptoKey,刷新后恢复身份;退出或设备被撤销后清除。
未登录访问首页时先显示注册/登录页;收到的分享链接任何人可读;收到的频道链接要求登录,未登录先显示登录页,登录后继续加入。频道加入页按当前规则显示密码输入。右上角频道设置集中管理加入方式(登录后持链接加入 / 仅受邀账号)、频道密码、成员角色、邀请与撤销,不另设权限入口。
| 路由 | 页面 |
|---|---|
@@ -172,37 +131,17 @@ WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,
频道分享面板与分享页里的二维码只给**成员链接**;所有者链接含管理权限,只提供复制,不出二维码。二维码固定深色码点、浅色底,暗色主题下也不反转,否则很多扫码器识别不了。
### 6.4.1 分享(快速分享的结果)
### 6.4.1 便捷分享
**分享和频道是两种东西,底层只维护一套**:分享是频道的一个预设——成员不可上传、删除策略仅所有者,并且创建者在点「完成分享」的那一刻**丢弃所有者密钥**,从此没有房主,任何人(包括创建者)都不能再追加、修改或删除,只能等 TTL 到期。
Share 是独立的只读发布资源,不创建频道成员或 threads。创建者在草稿中添加内容和设置,明确确认后封存;服务端清除 owner_hash,前端丢弃管理密钥,此后只有读取入口。访客打开分享链接直接读取加密清单,下载受限文件时计次数,不经过频道加入流程。详见 protocol.md §15。
```
草稿(创建者) 已封存(创建者与访客)
┌────────────────────────────────┐ ┌──────────────────────┐
│ 新建分享 · 草稿 [粘贴][文件][写]│ │ 我的分享 · 已封存只读 │
├───────────────┬────────────────┤ │ 分享链接 [复制][二维码]│
│ 内容清单 │ 选中的内容 │ │ 3 项内容 [全部下载] │
│ ▢ a.zip 46MB ✕│ [文件预览] │ │ 〃 开会地址… │
│ 〃 文本 120B ✕ │ [替换文件] │ │ ▢ a.zip [下载] │
│ │ │ └──────────────────────┘
│ │ 文本可直接编辑 │
├───────────────┴────────────────┤
│ 有效期 · 最多访客 · 下载次数 │
│ □ 我已了解,完成后无法再修改 │
│ [ 完成分享 ] │
└────────────────────────────────┘
```
### 6.5 频道 thread、文件管理与聊天
| 阶段 | 说明 |
|---|---|
| 草稿 | 左侧列出内容,选择后在右侧预览;创建者还握着所有者密钥,可直接修改文本、替换或移除文件,并调整有效期、最多访客、每个文件下载次数。**此时没有分享链接**,不会被别人看到 |
| 完成分享 | 页面内勾选「完成后会生成分享链接和二维码。此后任何人(包括你)都不能再添加、修改或删除内容,只能等它过期」后,按钮才可用;点击即生成链接与二维码 |
| 已封存(创建者) | 只读列表 + 「分享给别人」卡片(链接、二维码、说明) |
| 已封存(访客) | 只读列表,只能预览、下载、复制;「全部下载」按钮 |
频道内桌面布局为左侧频道/thread 导航、中央聊天、右侧线程文件管理器。新建频道自动创建“常规”thread;用户可以继续创建和命名其他 thread,在各自 thread 中发言或上传文件。聊天消息显示发送设备名称和时间。
- 访客打开链接后先看加入预览,确认才占席位。访客那一端如何识别「分享」:`members_can_upload=false` 即渲染为只读收件页,不需要新协议字段。
- 创建者的所有者链接不展示、不出二维码。
- 首页分成「我的分享」(草稿 / 已封存 / 收到的,带剩余时间)和「我的频道」。
左栏按 thread 显示虚拟文件夹与文件。可以创建/重命名 thread、创建/重命名文件夹、多选文件并移动到当前 thread 的文件夹。文件预览仍使用中央预览区;消息不混入文件列表。手机上 thread 和文件树可横向/纵向收缩,聊天输入固定在文件列表下方、上传动作栏上方。
频道名、文件夹名和移动操作通过后端结构接口管理,其他设备收到通知后重新读取。文件名称和类型仍通过加密资源元数据同步,GATED 内容仅在显式操作后读取。
其他弹层:
@@ -211,7 +150,8 @@ WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,
| 条目详情 | 手机为底部抽屉,电脑为内联预览区;内容一致 |
| 扫码加入 | 相机识别二维码(浏览器自带 `BarcodeDetector`,不支持或无权限时退回「粘贴链接」)→ 加入预览(模式、权限)→ 加入后进入该频道 |
| 写文本 | 抽屉,`Ctrl+Enter` 发送;首页上用于快速分享 |
| 频道设置 | 尚未实现(席位管理等) |
| 频道设置 | 顶栏齿轮打开设置面板;所有者可修改名称、成员人数上限、成员上传、保留时间和受限文件下载次数 |
| 设备管理 | 设备面板分别显示成员人数与已连接设备;成员人数按账号去重,同一账号可在多台设备登录;设备可重命名,所有者可移除任意设备 |
扫到的链接含密钥,只放在内存里,不写日志、不持久化。
@@ -292,11 +232,18 @@ WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,
| 历史可读 | 加入说明处写明「持有链接的人可读取此频道全部历史」 |
| 元数据 | 创建与设置页注明「不隐藏 IP 与访问时间」 |
### 7.5 空与错误状态
### 7.5 频道文件预览与下载限制
- 新上传文件默认允许单帧内容直接随频道同步。加入频道的成员可直接看到文件名、类型、大小;PNG 等图片和 TXT 等文本可直接预览,不消耗下载次数。文件名与类型仍只存在于加密帧中。
- 超出单帧容量的文件保持 GATED,列表不自动读取内容。成员主动打开预览时才取回文件,并消耗一次下载次数;取回后显示解密出的文件名、类型和大小。EXE 等不支持内联预览的类型仍提供下载。
- 上传者可关闭直接预览并为文件设置下载次数上限。
- 设备标签优先显示浏览器提供的机型和系统版本,例如「Pixel 9 (Android 16) · A1B2C3D4」「Windows 11 · 1234ABCD」。浏览器没有暴露具体机型时显示能识别的平台名称;保留端点短标识以区分同型号设备。
### 7.6 空与错误状态
| 码 | 页面 |
|---|---|
| `seats_full` | 加入预览页:「席位已满」,告知联系所有者 |
| `members_full` | 加入预览页:「成员人数已满」,告知联系所有者 |
| `not_joined`(被释放) | 频道页整体替换为「你已被移出此频道」 |
| `not_found` / `gone` | 「频道不存在或已过期」 |
| `rate_limited` | Toast,带重试倒计时 |
@@ -309,8 +256,10 @@ WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,
| 发送文本 | 点「写」弹出输入,`Ctrl+Enter` 发送 |
| 粘贴 | 窗口任意位置 `Ctrl+V` 直接发送;手机点「粘贴」读取剪贴板(无权限时退回「写」)。文件或图片直接入队,先出现「传输中」条目 |
| 拖放 | 桌面整页接收拖放;拖入时出现覆盖提示,不改变布局 |
| 文件上传 | 上传前可设置是否让单帧文件直接同步预览,以及受限文件的下载次数上限 |
| 频道设置 | 顶栏齿轮打开;设置通过现有 `PATCH /channels/{id}/settings` 更新,服务端广播变更给在线成员 |
| 复制 | 点击复制,按钮短暂变为「已复制」,不弹 Toast |
| 删除 | 单击删除后条目消失并给 5 秒「撤销」(撤销只在本地延迟提交删除) |
| 删除 | 确认删除后提交删除,并同步频道墓碑 |
| 新条目到达 | 若用户已滚到底部则自动跟随,否则底部出现「N 条新内容」 |
| 触控 | 可点击区域 ≥ 44px;底栏避开安全区(`env(safe-area-inset-*)`) |
@@ -362,5 +311,49 @@ WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,
| 相机 | 「拍照」用 `<input capture="environment">`,iOS 与 Android 都会直接拉起相机;扫码在 Android Chrome 用 `BarcodeDetector`,iOS Safari 不支持,提示用系统相机扫码后打开链接,或粘贴链接。相机、剪贴板读取都要求 https(`localhost` 除外) |
| 剪贴板 | iOS 读取时会弹出系统「粘贴」确认,Android 会要求权限;被拒绝时退回「写」 |
| 图片格式 | iPhone 的 HEIC 照片在非 Safari 浏览器里无法显示,预览失败时提示直接下载 |
| 下载 | iOS 上 `a[download]` 配合 blob 会弹出存储菜单;大文件要走 Service Worker 流式下载(架构 §6,阶段 4) |
| 下载 | iOS 上 `a[download]` 配合 blob 会弹出存储菜单;大文件优先走 OPFS 临时存储;不支持时 Blob 回退最多 64 MiB |
## 13. 空间、频道与频道分组
界面层级统一为「空间 → 频道分组(可选)→ 频道 → 聊天与文件夹」。原顶层频道对用户称为空间,原 thread 对用户称为频道。新建空间自动创建“常规”频道。空间负责成员、邀请、成员人数上限和加密边界;账号内设备不重复计入人数,设备只是账号的属性,只在账号设置里管理。频道分组只组织导航,不改变权限或加密密钥。
桌面最左是 64px 的空间窄栏(每个空间一个首字方块,当前空间反色;下方是新建空间与加入空间),其右 240px 的频道列:顶部是空间名与加密状态(点击打开空间设置),下面按分组展示频道,分组标题小号灰字,悬停才显示 + 与 ⋯,频道行 32px、选中为整行淡底;便签并入频道列底部。账号卡片贴在左下角横跨两栏。手机上这些都收进左侧抽屉:顶部是横向一排空间方块,其下是空间名栏与频道列表。支持新建/重命名分组、折叠分组、在分组内新建频道、将已有频道移入其他分组或移回未分组。旧 thread 自动作为未分组频道展示。中间显示当前频道聊天,右侧显示当前频道文件夹和文件。
空间设置集中管理成员、邀请和空间权限;频道管理提供名称与所属分组。分组名及归属由后端持久化并通过通知刷新,刷新与其他设备保持一致。协议/API 内部保留 channels/thread_id 等标识,避免因界面术语变更重建已有数据。
### 13.1 成员栏、邀请与通知
右侧栏顶部是「文件 / 成员 n」页签,默认文件;顶栏的人数按钮直接打开成员页,空间主还有一个 + 直接打开邀请弹窗。手机上没有页签,「文件」「成员」放在左侧抽屉里。
成员页以用户为单位,按 Discord 的分组排列:在线 — n、离线 — n、待接受 — n(仅空间主可见,有「撤销」)。每行头像、昵称(有昵称时下方小字显示用户名),空间主带「空间主」标签;一个用户有多台设备时小字显示「在线设备/设备数 台设备在线」。**设备不再单独列出,只是用户的属性。**在线状态与设备数由后端按账号统计(`GET /channels/{id}/members` 的 `online`、`devices`、`online_devices`,在线 = 该账号在本频道有活动订阅),前端在成员页可见期间每 8 秒读取一次,不改动订阅流。空间主在页头有醒目的「+ 邀请成员」,成员行悬停出现「移除」(需确认)。邀请与成员管理不再放在空间设置里,设置里只留一句指引。
邀请弹窗只收用户名;成功、撤销、移除、失败都弹 toast。toast 在右下角,4 秒消失(带操作按钮的 10 秒),最多 4 条,成功 / 失败 / 提示用左侧色条区分,`aria-live`。被邀请方登录后每 8 秒轮询账号邀请,出现新邀请就弹「xxx 邀请你加入 yyy [接受]」,点「接受」直接加入并进入空间;账号设置里的邀请收件箱同样显示空间名与邀请人。空间名与邀请人放在邀请 payload 的加密内容里(`ChannelGrant.name / invitedBy`,均为可选字段),服务端不可见;旧邀请没有这两个字段时回退为「空间 + ID 前 8 位」。
## 14. 文件管理器交互
文件区支持单击选中、Ctrl/Command 多选、Shift 连选、双击预览、右键菜单和键盘 Enter/Delete/Ctrl+A/Escape。文件夹单击选中、双击或菜单“打开文件夹”进入;面包屑用于快速返回。文件行提供更多菜单按钮,触屏也能访问操作。
右键文件提供预览、下载、复制名称、移动和删除;文件夹提供打开和重命名;空白区域提供上传、新建文件夹、全选。菜单和悬停卡通过 portal 挂载到 body,贴近指针且限制在视口内,支持 Escape、外部点击和键盘导航。
文件可拖入当前频道的文件夹或面包屑目录,多选文件可一起移动;跨空间/频道的内部拖放拒绝。外部文件可拖入指定文件夹上传。内部拖拽移动由后端更新资源归属,不重新加密或拉取文件。
悬停卡延迟显示名称、类型、大小、时间;已同步且非 GATED 的小图片/文本可显示缩略预览。GATED、上传中或校验失败的文件不会自动取内容,悬停不消耗下载次数。右侧文件区与聊天之间可拖动分隔条调整宽度,分隔条也支持键盘左右键。
## 15. 频道中的文件消息与发送者
聊天时间线采用 Discord 式布局:所有消息靠左,同一条左边线,左侧头像、右侧「名称 + 时间」一行与占满宽度的正文,不再区分自己和他人。同一发送者 5 分钟内连发的消息合并,只缩进对齐,悬停时在头像位置显示时间;悬停整行有淡底,右上角出现删除。文件消息是 360px 以内的文件卡片(图标、名称、大小与类型,右侧预览与下载);没有头像时显示名称首字。受限文件不自动加载内容或媒体缩略图。
输入区是整体圆角框(左 + 附件、中间自增高输入、右发送),占位文案「给 #频道名 发消息」,Enter 发送、Shift+Enter 换行(输入法组合中不触发)。
**乐观发送**:点发送的同一帧就清空输入框并在时间线底部画出消息(半透明,aria-busy),后台再加密上传,不等后端;可以连续快速发送。worker 发布真实条目时带回 localId,在同一次状态更新里替换占位项,不闪烁、不重复。失败时占位项保留原文本并显示「发送失败 · 重试」,可删除;离线沿用本机发送队列。文件同理:确认上传选项后卡片立刻出现并显示进度。
发送者名称与头像从后端账号资料读取,回退固定用户名。资源作者由后端记录,昵称修改后其他设备同步更新展示。没有账号归属(未登录访客)时统一显示「访客」;界面里不再展示设备名,设备只在账号设置的「我的设备」里管理。
头像缩略图在 Worker 内生成,最大 64×64、编码后不超过 8 KB,仅使用 PNG/JPEG/WebP data URL;不使用第三方头像链接。圆形头像使用专门的 --radius-avatar token,消息气泡仍遵守现有圆角规范。
## 16. 记住账号和密码
登录/注册表单提供“记住账号和密码(本机)”选项,默认勾选。成功验证后才保存本机加密凭据,下次需要登录时自动填充用户名和密码;有效会话仍优先自动恢复。取消勾选时,只保留当前页面会话,后台同步不能再次写入自动登录数据。
正常退出登录清除会话,但保留用户明确选择保存的填充信息。“清除本机保存”同时清除保存的账号密码和自动恢复会话;如果当前已登录,可以继续使用当前页面,刷新后需重新登录。会话被服务端拒绝时清除本机恢复资料。界面提示只在自己的设备使用记住功能,并兼容浏览器自带密码管理器。
+4 -8
View File
@@ -51,7 +51,6 @@ ws.onmessage = async (m) => {
| 加密成帧、padding、IV、AAD | 发送队列(IndexedDB)、失败重发 |
| 解密、完整性校验、乱序/重复/篡改处理 | 把事件渲染成界面 |
| 链接生成与解析、降级防御 | 持久化链接和**终端密钥** |
| 设备名加解密 | 复制、下载(把流写到文件)、预览 |
| 日志记录解析 | **Cap 验证码**(见下文) |
## API
@@ -67,7 +66,6 @@ ws.onmessage = async (m) => {
| `session.link(origin, 'member' \| 'owner')` | 生成分享链接 |
| `session.verifyChannelInfo({ id, mode })` | 核对服务端返回的频道信息,不一致抛 `ModeMismatchError` / `LinkError` |
| `session.id` / `.mode` / `.role` / `.endpointSecret` / `.endpointHash` | 只读属性 |
| `session.deviceName` | 设置后,加密的条目元数据里会带上设备名 |
| `session.dispose()` | 尽力清零密钥材料(JS 无法保证擦除) |
### 发送
@@ -91,8 +89,7 @@ ws.onmessage = async (m) => {
| `snapshot` | 快照必须是单帧(约 1 MiB 以内),不能 GATED |
| `itemId` | 重试和续传必须复用同一个 id |
| `fromFrame` | 上传断点续传:从第 k 帧开始产出(字节与完整加密完全相同)。仅支持 `Blob` / `Uint8Array` |
| `ts`, `dev` | 覆盖时间戳和设备名 |
| `label` | 仅 raw 模式:`X-WD-Label` |
| `ts` | 覆盖时间戳 |
**GATED 的判断**:来源长度已知时创建计划时就确定;来源是流时,要产出第 0 帧之后 `plan.gated` 和 `frame0Headers` 才确定,请在拿到第 0 帧后再读取。
@@ -132,13 +129,12 @@ const { complete, nextIndex, meta } = await result;
| 调用 | 说明 |
|---|---|
| `parseLogRecords(bytes)` / `parseLogRecord(bytes)` | 解析 `GET /log` 的响应或一条 WebSocket 消息 |
| `session.sealLabel(name)` / `session.openLabel(blob, endpointHash)` | 设备名(`PUT /seats/me` 的请求体 / 席位列表里的 `label`) |
| `parseLink(url)` / `buildLink(origin, parts)` | 链接 |
| `splitEnvelopes(stream)` | 把「封装首尾相接」的字节流切成一个个封装 |
## 必须注意的几点
**1. 终端密钥必须持久化。** `session.endpointSecret`(32 字节)标识「这台设备在这个频道里」,服务端按它占用席位。不持久化的话,每次打开链接都会占用一个新席位。用 IndexedDB 保存,下次 `open(link, { endpointSecret })` 传回来。
**1. 终端密钥只用于便捷分享。** `session.endpointSecret`(32 字节)标识「这个终端」,便捷分享的创建者与读者靠它登记和续传;频道与空间只凭账号会话鉴权,不使用它。分享读者不持久化的话,每次打开链接都会登记一个新访客终端,用 IndexedDB 保存。
**2. Worker。** `ChannelSession` 实例不能跨线程传递(它持有 `CryptoKey` 和闭包)。在 Worker 里用链接和 `endpointSecret` 重建它,主线程只传链接、帧字节和事件。
@@ -168,10 +164,10 @@ npm run vectors:check # 校验 test-vectors/vectors.json 是最新的
| 文件 | 内容 |
|---|---|
| `vectors.test.ts` | 逐字节复现 `test-vectors/` 里 node:crypto 独立生成的全部向量,含 17 个负例 |
| `session.test.ts` | 创建、链接、角色、降级防御、收发、设备名、raw 模式、条目计划 |
| `session.test.ts` | 创建、链接、角色、降级防御、收发、raw 模式、条目计划 |
| `reader.test.ts` | 日志记录、重放、乱序、缺帧、FINAL 冲突、标志变化、模式隔离 |
| `stream.test.ts` | 任意切分的字节流、流式解密、篡改/拼接/丢帧/截断、断点续传 |
| `properties.test.ts` | 每个字节每一位的篡改、padding 档位、随机往返、来源块边界对齐、性能底线 |
| `e2e.test.ts` | 真实服务端:HTTP、WebSocket、大文件、下载上限、上传与下载续传、设备名 |
| `e2e.test.ts` | 真实服务端:HTTP、WebSocket、大文件、下载上限、上传与下载续传 |
另外对测试集做过**变异检验**:故意在实现里注入 10 类典型错误(AAD 少一字节、跳过零填充检查、去掉模式/条目 id 校验、base64 不查规范性、分块边界差一、IV 不含帧序号、重复帧不比较、FINAL 后不拒绝、链接不拒绝查询串密钥),测试都能变红。其中「分块边界差一」最初漏网,说明测试有缺口,已补上专门的边界对齐用例。
+1 -4
View File
@@ -13,6 +13,7 @@ export const Flags = {
Snapshot: 2,
Gated: 4,
Enc: 8,
Control: 16,
} as const;
/** 档位 padding 的明文长度(protocol §6)。 */
@@ -37,11 +38,7 @@ export const Labels = {
/** AAD 前缀。 */
export const AAD_FRAME = 'WD1';
export const AAD_LABEL = 'WD1L';
/** 设备名总长上限(iv + 密文 + tag),与服务端 MaxSeatLabelBytes 一致。 */
export const MAX_LABEL_BYTES = 256;
export const LABEL_IV_SIZE = 12;
export const POW_NONCE_SIZE = 8;
+2 -2
View File
@@ -49,7 +49,7 @@ export interface Envelope {
export function parseEnvelope(data: Uint8Array): Envelope {
if (data.length < HEADER_SIZE || data[0] !== 1) throw new IntegrityError('bad_envelope');
const flags = data[1] as number;
if ((flags & 0xf0) !== 0) throw new IntegrityError('bad_envelope');
if ((flags & 0xe0) !== 0) throw new IntegrityError('bad_envelope');
const payloadLength = readU32(data, 22);
if (data.length !== HEADER_SIZE + payloadLength) throw new IntegrityError('bad_envelope');
@@ -109,7 +109,7 @@ export interface SealParams {
*/
export async function sealFrame(itemKey: CryptoKey, channelId: Uint8Array, p: SealParams): Promise<Bytes> {
if ((p.flags & Flags.Enc) === 0) throw new RangeError('enc 帧必须带 ENC 标志');
if ((p.flags & 0xf0) !== 0) throw new RangeError('保留标志位必须为 0');
if ((p.flags & 0xe0) !== 0) throw new RangeError('保留标志位必须为 0');
const plaintext = padPlaintext(p.data);
const header = concat(new Uint8Array([1, p.flags]), p.itemId, writeU32(p.index), writeU32(plaintext.length + TAG_SIZE));
+3
View File
@@ -13,6 +13,7 @@
*/
export { ChannelSession, openLink } from './session.js';
export { parseEnvelope } from './frame.js';
export type {
AuthRole,
ChannelSettings,
@@ -40,6 +41,8 @@ export { buildLink, parseLink } from './link.js';
export type { LinkParts, ParsedLink } from './link.js';
export { fromBase64Url, toBase64Url } from './bytes.js';
export { contentEventPayload, frameManifestHash, generateSigningKeyPair, importSigningPublicKey, signStatement, verifyStatement } from './signatures.js';
export type { ContentEventStatement } from './signatures.js';
export { Flags, MAX_FRAME_DATA, PROTOCOL_VERSION } from './constants.js';
export type { ChannelMode, ItemKind } from './constants.js';
+1 -1
View File
@@ -7,7 +7,7 @@ import { Labels } from './constants.js';
import { utf8, type Bytes } from './bytes.js';
export { encodeEnvelope, frameAad, frameIv, padPlaintext, parseEnvelope, sealFrame, tierFor, unpadPlaintext } from './frame.js';
export { encodeMeta, decodeFirstFrame, planEncItem, planRawItem } from './item.js';
export { encodeMeta, decodeFirstFrame, planItem } from './item.js';
export { deriveChannelKeys, deriveItemKey, deriveOwnerToken, sha256 } from './kdf.js';
async function hkdfBytes(ikm: Uint8Array, salt: Uint8Array, label: string): Promise<Bytes> {
+47 -44
View File
@@ -1,4 +1,4 @@
import { Flags, MAX_FRAME_DATA, RAW_MAX_PAYLOAD, type ItemKind } from './constants.js';
import { Flags, MAX_FRAME_DATA, type ItemKind } from './constants.js';
import { concat, readU16, toBytes, tryDecodeUtf8, utf8, writeU16, toBase64Url, type Bytes } from './bytes.js';
import { IntegrityError, LimitError } from './errors.js';
@@ -14,6 +14,10 @@ export interface ItemMeta {
ts: number;
/** 设备名。 */
dev?: string;
/** WebDrop 频道中的 thread ID,加密元数据。 */
threadId?: string;
/** 文件所在的虚拟文件夹 ID,加密元数据。 */
folderId?: string;
}
const KINDS: readonly ItemKind[] = ['text', 'json', 'file', 'manifest', 'other'];
@@ -26,6 +30,8 @@ export function encodeMeta(meta: ItemMeta): Bytes {
if (meta.size !== undefined) o.size = meta.size;
o.ts = meta.ts;
if (meta.dev !== undefined) o.dev = meta.dev;
if (meta.threadId !== undefined) o.thread_id = meta.threadId;
if (meta.folderId !== undefined) o.folder_id = meta.folderId;
const json = utf8(JSON.stringify(o));
if (json.length > 0xffff) throw new LimitError('条目元数据过大');
@@ -64,6 +70,12 @@ export function decodeFirstFrame(data: Uint8Array): { meta: ItemMeta; content: B
if (typeof v !== 'string') throw new IntegrityError('bad_meta');
meta[key] = v;
}
for (const [wire, key] of [['thread_id', 'threadId'], ['folder_id', 'folderId']] as const) {
const v = o[wire];
if (v === undefined) continue;
if (typeof v !== 'string' || v.length > 80) throw new IntegrityError('bad_meta');
meta[key] = v;
}
if (o.size !== undefined) {
if (typeof o.size !== 'number' || !Number.isSafeInteger(o.size) || o.size < 0) throw new IntegrityError('bad_meta');
meta.size = o.size;
@@ -76,6 +88,7 @@ export function decodeFirstFrame(data: Uint8Array): { meta: ItemMeta; content: B
export type ContentSource = Uint8Array | Blob | ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>;
export interface PlanOptions {
control?: boolean;
/** 指定条目 id(重试、续传时必须复用同一个);默认随机生成。 */
itemId?: Uint8Array;
/** 作为频道的「最新快照」。快照必须是单帧,所以内容不能超过一帧。 */
@@ -109,7 +122,7 @@ export interface ItemPlan {
* 要等第 0 帧产出之后才确定,所以请在拿到第 0 帧之后再读取它和 frame0Headers。
*/
readonly gated: boolean;
/** 第 0 帧要带的附加请求头(X-WD-Max-Downloads / X-WD-Label)。 */
/** 第 0 帧要带的附加请求头(X-WD-Max-Downloads)。 */
frame0Headers: Record<string, string>;
/** 按序产出加密后的帧。同一个计划只能迭代一次。 */
frames: AsyncGenerator<EncodedFrame, void, void>;
@@ -134,7 +147,22 @@ async function* chunksOf(source: ContentSource): AsyncGenerator<Uint8Array, void
return;
}
if (typeof Blob !== 'undefined' && source instanceof Blob) {
yield* chunksOf(source.stream());
if (typeof source.stream === 'function') {
yield* chunksOf(source.stream());
return;
}
// Older Safari releases don't expose Blob.stream(). Read bounded slices
// instead of materializing the complete file in memory.
const sliceSize = 256 * 1024;
for (let offset = 0; offset < source.size; offset += sliceSize) {
const part = source.slice(offset, Math.min(offset + sliceSize, source.size));
if (typeof part.arrayBuffer === 'function') {
yield new Uint8Array(await part.arrayBuffer());
} else {
yield await readBlobPart(part);
}
}
return;
}
if (typeof ReadableStream !== 'undefined' && source instanceof ReadableStream) {
@@ -153,6 +181,18 @@ async function* chunksOf(source: ContentSource): AsyncGenerator<Uint8Array, void
yield* source as AsyncIterable<Uint8Array>;
}
function readBlobPart(blob: Blob): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => reader.result instanceof ArrayBuffer
? resolve(new Uint8Array(reader.result))
: reject(new TypeError('文件读取失败'));
reader.onerror = () => reject(reader.error ?? new TypeError('文件读取失败'));
reader.onabort = () => reject(new DOMException('文件读取已取消', 'AbortError'));
reader.readAsArrayBuffer(blob);
});
}
interface Chunk {
data: Bytes;
last: boolean;
@@ -229,11 +269,12 @@ function resolveGating(opts: PlanOptions, fitsInOneFrame: boolean | undefined):
* enc 模式:把内容加密成一个条目(protocol §7)。
* 小内容单帧、非 GATED;放不下就拆成多帧并标记 GATED。第 0 帧携带元数据。
*/
export function planEncItem(
export function planItem(
seal: (p: { flags: number; itemId: Uint8Array; index: number; data: Uint8Array }) => Promise<Bytes>,
source: ContentSource,
meta: ItemMeta,
opts: PlanOptions = {},
encrypted = true,
): ItemPlan {
const idBytes: Bytes = opts.itemId ? toBytes(opts.itemId) : newItemId();
const maxData = opts.maxFrameData ?? MAX_FRAME_DATA;
@@ -254,9 +295,10 @@ export function planEncItem(
if (opts.maxDownloads !== undefined) frame0Headers['X-WD-Max-Downloads'] = String(opts.maxDownloads);
const snapshot = opts.snapshot === true;
if (opts.control && (snapshot || gated)) throw new RangeError('控制条目必须是非快照的单帧');
async function* frames(): AsyncGenerator<EncodedFrame, void, void> {
const base = (gatedNow: boolean) => (gatedNow ? Flags.Gated : 0) | (snapshot ? Flags.Snapshot : 0) | Flags.Enc;
const base = (gatedNow: boolean) => (gatedNow ? Flags.Gated : 0) | (snapshot ? Flags.Snapshot : 0) | (opts.control ? Flags.Control : 0) | (encrypted ? Flags.Enc : 0);
const emit = async (index: number, last: boolean, data: Uint8Array, gatedNow: boolean): Promise<EncodedFrame> => ({
index,
final: last,
@@ -299,42 +341,3 @@ export function planEncItem(
return { itemId: toBase64Url(idBytes), idBytes, get gated() { return gated; }, frame0Headers, frames: frames() };
}
/**
* raw 模式:负载原样,没有加密、没有元数据。单帧放不下时拆成 GATED 多帧。
* 注意 raw 内容对服务端完全可见。
*/
export function planRawItem(
build: (p: { flags: number; itemId: Uint8Array; index: number; payload: Uint8Array }) => Bytes,
source: ContentSource,
opts: PlanOptions & { label?: string } = {},
): ItemPlan {
const idBytes: Bytes = opts.itemId ? toBytes(opts.itemId) : newItemId();
const cap = Math.min(opts.maxFrameData ?? RAW_MAX_PAYLOAD, RAW_MAX_PAYLOAD);
const snapshot = opts.snapshot === true;
const size = source instanceof Uint8Array ? source.length : typeof Blob !== 'undefined' && source instanceof Blob ? source.size : undefined;
let gated = resolveGating(opts, size === undefined ? undefined : size <= cap);
const frame0Headers: Record<string, string> = {};
if (opts.maxDownloads !== undefined) frame0Headers['X-WD-Max-Downloads'] = String(opts.maxDownloads);
if (opts.label !== undefined) frame0Headers['X-WD-Label'] = opts.label;
async function* frames(): AsyncGenerator<EncodedFrame, void, void> {
let index = 0;
for await (const chunk of rechunk(source, cap, cap)) {
if (index === 0) {
if (chunk.last && opts.gated !== true && opts.maxDownloads === undefined) gated = false;
else if (!chunk.last) {
if (snapshot) throw new LimitError('快照必须是单帧,内容超过了一帧');
gated = true;
}
}
const flags = (gated ? Flags.Gated : 0) | (snapshot ? Flags.Snapshot : 0) | (chunk.last ? Flags.Final : 0);
yield { index, final: chunk.last, bytes: build({ flags, itemId: idBytes, index, payload: chunk.data }) };
index++;
}
}
return { itemId: toBase64Url(idBytes), idBytes, get gated() { return gated; }, frame0Headers, frames: frames() };
}
+13 -7
View File
@@ -3,6 +3,7 @@ import { concat, equalBytes, tryDecodeUtf8, toBase64Url, type Bytes } from './by
import { IntegrityError, type IntegrityReason } from './errors.js';
import { openFrame, parseEnvelope, type Envelope } from './frame.js';
import { sha256 } from './kdf.js';
import { frameManifestHash } from './signatures.js';
import { decodeFirstFrame, type ItemMeta } from './item.js';
import { parseLogRecord, type LogRecord, type RemovedReason } from './log.js';
@@ -14,7 +15,7 @@ export interface ReaderContext {
itemKey(itemId: Uint8Array): Promise<CryptoKey>;
}
export type ReceivedKind = ItemKind | 'raw';
export type ReceivedKind = ItemKind;
export interface ReceivedItem {
itemId: string;
@@ -29,6 +30,8 @@ export interface ReceivedItem {
complete: boolean;
/** 到第一个缺口为止已收到的内容。大文件请改用 decryptItemStream,不要在这里缓冲。 */
content: Bytes;
/** SHA-256 manifest over the complete ciphertext envelopes in frame-index order. */
ciphertextManifestHash?: Bytes;
}
export type ItemEvent =
@@ -104,7 +107,7 @@ export class ItemReader {
const itemId = toBase64Url(env.itemId);
try {
return await this.#accept(env, itemId, seq);
return await this.#accept(env, itemId, seq, Uint8Array.from(envelope));
} catch (e) {
if (!(e instanceof IntegrityError)) throw e;
this.#open.delete(itemId);
@@ -118,13 +121,13 @@ export class ItemReader {
this.#done.delete(itemId);
}
async #accept(env: Envelope, itemId: string, seq: number | undefined): Promise<ItemEvent[]> {
async #accept(env: Envelope, itemId: string, seq: number | undefined, envelope: Bytes): Promise<ItemEvent[]> {
const isEncFrame = (env.flags & Flags.Enc) !== 0;
// 降级防御:enc 会话只接受认证过的 enc 帧,服务端不能注入明文帧;raw 会话反之
if (isEncFrame !== (this.#ctx.mode === 'enc')) throw new IntegrityError('wrong_mode');
if (this.#done.has(itemId)) return [];
const itemFlags = env.flags & (Flags.Gated | Flags.Snapshot);
const itemFlags = env.flags & (Flags.Gated | Flags.Snapshot | Flags.Control);
const gated = (env.flags & Flags.Gated) !== 0;
const isFinal = (env.flags & Flags.Final) !== 0;
// 非 GATED 条目必须是单帧且 FINAL(协议规则,服务端也强制)
@@ -139,7 +142,7 @@ export class ItemReader {
}
// 重复帧:内容必须逐字节相同,相同则视为重放,不产生事件
const digest = await sha256(env.payload);
const digest = await sha256(envelope);
const dup = state?.frames.get(env.frameIndex);
if (dup) {
if (!equalBytes(dup.digest, digest)) throw new IntegrityError('duplicate_mismatch');
@@ -156,7 +159,7 @@ export class ItemReader {
let meta = state?.meta;
let content = data;
if (this.#ctx.mode === 'enc' && env.frameIndex === 0) {
if (env.frameIndex === 0) {
const first = decodeFirstFrame(data);
meta = first.meta;
content = first.content;
@@ -179,13 +182,16 @@ export class ItemReader {
const item: ReceivedItem = {
itemId,
seq,
kind: this.#ctx.mode === 'raw' ? 'raw' : (state.meta?.kind ?? 'other'),
kind: state.meta?.kind ?? 'other',
snapshot: (itemFlags & Flags.Snapshot) !== 0,
gated,
complete,
content: concat(...parts),
};
if (state.meta) item.meta = state.meta;
if (complete) item.ciphertextManifestHash = Uint8Array.from(await frameManifestHash(
Array.from({ length: state.finalIndex! + 1 }, (_, index) => state!.frames.get(index)!.digest),
));
if (complete) {
this.#open.delete(itemId);
+19 -74
View File
@@ -1,10 +1,10 @@
import { AAD_LABEL, ID_SIZE, KEY_SIZE, LABEL_IV_SIZE, MAX_LABEL_BYTES, TAG_SIZE, type ChannelMode } from './constants.js';
import { concat, fromBase64Url, randomBytes, toBase64Url, tryDecodeUtf8, utf8, wipe, type Bytes } from './bytes.js';
import { CapabilityError, IntegrityError, LimitError, LinkError, ModeMismatchError } from './errors.js';
import { ID_SIZE, KEY_SIZE, type ChannelMode } from './constants.js';
import { randomBytes, toBase64Url, utf8, wipe, type Bytes } from './bytes.js';
import { CapabilityError, LinkError, ModeMismatchError } from './errors.js';
import { encodeEnvelope, sealFrame } from './frame.js';
import { deriveChannelKeys, deriveItemKey, deriveOwnerToken, sha256, type ChannelKeys } from './kdf.js';
import { buildLink, parseLink, type ParsedLink } from './link.js';
import { planEncItem, planRawItem, type ContentSource, type ItemMeta, type ItemPlan, type PlanOptions } from './item.js';
import { planItem, type ContentSource, type ItemMeta, type ItemPlan, type PlanOptions } from './item.js';
import { ItemReader, type ReaderContext } from './reader.js';
import { decryptItemStream, type DecryptStreamOptions, type ItemDecodeResult } from './stream.js';
@@ -12,7 +12,9 @@ export type AuthRole = 'read' | 'write' | 'owner';
/** 频道设置,字段名与 API 一致(snake_case)。 */
export interface ChannelSettings {
seats?: number;
join_policy?: 'open' | 'invite';
name?: string;
max_members?: number;
members_can_upload?: boolean;
delete_policy?: 'owner_only' | 'own' | 'anyone';
ttl_seconds?: number;
@@ -56,8 +58,10 @@ export interface PlanItemOptions extends PlanOptions {
ts?: number;
/** 覆盖设备名。默认使用 session.deviceName。 */
dev?: string;
/** 仅 raw 模式:条目标签,作为 X-WD-Label 随第 0 帧发送(明文,服务端可见)。enc 模式下忽略,名字放在加密的元数据里。 */
label?: string;
/** Workspace thread ID, encoded only in encrypted item metadata. */
threadId?: string;
/** Virtual folder ID for files, encoded only in encrypted item metadata. */
folderId?: string;
}
export interface FileMetaOverrides {
@@ -87,6 +91,8 @@ interface SessionInit {
* JS 无法保证这些内存被真正擦除。调用 dispose() 只能尽力清零。
*/
export class ChannelSession {
/** Applications may supply independently authorized object keys instead of the space root. */
objectKeyResolver?: (itemId: string) => Promise<CryptoKey>;
readonly id: string;
readonly idBytes: Bytes;
readonly mode: ChannelMode;
@@ -242,6 +248,7 @@ export class ChannelSession {
/** 一个条目的 AES-GCM 密钥,带缓存。 */
itemKey(itemId: Uint8Array): Promise<CryptoKey> {
if (this.objectKeyResolver) return this.objectKeyResolver(toBase64Url(itemId));
if (!this.#keys) throw new CapabilityError('enc');
const cacheKey = toBase64Url(itemId);
let key = this.#itemKeys.get(cacheKey);
@@ -255,22 +262,17 @@ export class ChannelSession {
#meta(kind: ItemMeta['kind'], opts: PlanItemOptions, extra: Partial<ItemMeta> = {}): ItemMeta {
const meta: ItemMeta = { kind, ts: opts.ts ?? Date.now(), ...extra };
if (opts.threadId !== undefined) meta.threadId = opts.threadId;
if (opts.folderId !== undefined) meta.folderId = opts.folderId;
const dev = opts.dev ?? this.deviceName;
if (dev !== undefined) meta.dev = dev;
return meta;
}
#plan(source: ContentSource, meta: ItemMeta, opts: PlanItemOptions): ItemPlan {
if (this.mode === 'enc') {
return planEncItem(
async (p) => sealFrame(await this.itemKey(p.itemId), this.idBytes, p),
source,
meta,
opts,
);
}
// raw:没有元数据,内容原样放进负载
return planRawItem((p) => encodeEnvelope(p.flags, p.itemId, p.index, p.payload), source, opts);
return planItem(async (p) => this.mode === 'enc'
? sealFrame(await this.itemKey(p.itemId), this.idBytes, p)
: encodeEnvelope(p.flags, p.itemId, p.index, p.data), source, meta, opts, this.mode === 'enc');
}
encryptBytes(content: Uint8Array, meta: Partial<ItemMeta> & { kind?: ItemMeta['kind'] } = {}, opts: PlanItemOptions = {}): ItemPlan {
@@ -328,63 +330,6 @@ export class ChannelSession {
return decryptItemStream(this.#readerContext, source, opts);
}
// ───────────────────────── 设备名(protocol §10) ─────────────────────────
/**
* 加密设备名,作为 PUT /seats/me 的请求体。格式:iv(12) ‖ AES-GCM(K_label, 名称) ‖ tag(16),
* AAD = "WD1L" ‖ channel_id ‖ endpoint_hash。raw 模式下设备名是明文 UTF-8。
* @param opts.iv 仅用于测试向量,真实使用必须让库随机生成。
*/
async sealLabel(name: string, opts: { iv?: Uint8Array } = {}): Promise<Bytes> {
const plain = utf8(name);
if (this.mode === 'raw') {
if (plain.length > MAX_LABEL_BYTES) throw new LimitError('设备名过长');
return plain;
}
if (plain.length === 0) throw new RangeError('设备名不能为空');
if (plain.length + LABEL_IV_SIZE + TAG_SIZE > MAX_LABEL_BYTES) {
throw new LimitError(`设备名过长(最多 ${MAX_LABEL_BYTES - LABEL_IV_SIZE - TAG_SIZE} 字节 UTF-8)`);
}
const iv = opts.iv ?? randomBytes(LABEL_IV_SIZE);
const sealed = await globalThis.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv as Bytes, additionalData: this.#labelAad(this.endpointHash), tagLength: 128 },
(this.#keys as ChannelKeys).labelKey,
plain,
);
return concat(iv, new Uint8Array(sealed));
}
/** 解密席位列表里某个终端的设备名。endpointHash 可以是 base64url 文本或字节。 */
async openLabel(blob: Uint8Array | string, endpointHash: Uint8Array | string): Promise<string> {
const raw = typeof blob === 'string' ? fromBase64Url(blob) : blob;
const hash = typeof endpointHash === 'string' ? fromBase64Url(endpointHash, 32) : endpointHash;
if (!raw || !hash) throw new IntegrityError('bad_envelope');
if (this.mode === 'raw') {
const text = tryDecodeUtf8(raw);
if (text === null) throw new IntegrityError('bad_meta');
return text;
}
if (raw.length < LABEL_IV_SIZE + TAG_SIZE + 1) throw new IntegrityError('bad_envelope');
try {
const plain = await globalThis.crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: raw.slice(0, LABEL_IV_SIZE), additionalData: this.#labelAad(hash), tagLength: 128 },
(this.#keys as ChannelKeys).labelKey,
raw.slice(LABEL_IV_SIZE),
);
const text = tryDecodeUtf8(new Uint8Array(plain));
if (text === null) throw new IntegrityError('bad_meta');
return text;
} catch (e) {
if (e instanceof IntegrityError) throw e;
throw new IntegrityError('auth_failed');
}
}
#labelAad(endpointHash: Uint8Array): Bytes {
return concat(utf8(AAD_LABEL), this.idBytes, endpointHash);
}
// ───────────────────────── 清理 ─────────────────────────
/** 尽力清零可以清零的字节并清空缓存。JS 无法保证擦除,这只是缩小暴露窗口。 */
+110
View File
@@ -0,0 +1,110 @@
const SIGNATURE_MAGIC = new Uint8Array([0x57, 0x44, 0x53, 0x01]); // WDS\x01
const MAX_KIND_BYTES = 64;
const MAX_PAYLOAD_BYTES = 1_048_576;
const source = (value: Uint8Array): ArrayBuffer => {
const copy = new Uint8Array(value.length);
copy.set(value);
return copy.buffer;
};
export interface ContentEventStatement {
spaceId: Uint8Array;
channelId: string;
username: string;
deviceId: string;
eventId: Uint8Array;
eventKind: string;
domain: 'chat' | 'files';
epoch: number;
itemId: Uint8Array;
frameCount: number;
manifestHash: Uint8Array;
targetItemId?: Uint8Array;
}
export function contentEventPayload(event: ContentEventStatement): Uint8Array {
const channel = new TextEncoder().encode(event.channelId.toLowerCase());
const username = new TextEncoder().encode(event.username.toLowerCase());
const device = new TextEncoder().encode(event.deviceId.toLowerCase());
const kind = new TextEncoder().encode(event.eventKind);
const target = event.targetItemId ?? new Uint8Array();
if (event.spaceId.length !== 16 || event.eventId.length !== 16 || event.itemId.length !== 16 || event.manifestHash.length !== 32
|| target.length !== 0 && target.length !== 16 || !Number.isInteger(event.epoch) || event.epoch < 0
|| !Number.isInteger(event.frameCount) || event.frameCount < 1 || event.frameCount > 65_536
|| !/^[0-9a-f-]{36}$/.test(event.channelId.toLowerCase()) || !/^[0-9a-f-]{36}$/.test(event.deviceId.toLowerCase())
|| !/^[a-z0-9_-]{3,32}$/.test(event.username.toLowerCase()) || !/^[a-z][a-z.\-]{0,31}$/.test(event.eventKind))
throw new TypeError('签名事件字段无效');
const bytes = new Uint8Array(16 + 1 + channel.length + 1 + username.length + 1 + device.length + 16 + 1 + kind.length + 1 + 4 + 16 + 4 + 32 + 1 + 16);
let offset = 0;
bytes.set(event.spaceId, offset); offset += 16;
bytes[offset++] = channel.length; bytes.set(channel, offset); offset += channel.length;
bytes[offset++] = username.length; bytes.set(username, offset); offset += username.length;
bytes[offset++] = device.length; bytes.set(device, offset); offset += device.length;
bytes.set(event.eventId, offset); offset += 16;
bytes[offset++] = kind.length; bytes.set(kind, offset); offset += kind.length;
bytes[offset++] = event.domain === 'chat' ? 1 : 2;
new DataView(bytes.buffer).setUint32(offset, event.epoch, false); offset += 4;
bytes.set(event.itemId, offset); offset += 16;
new DataView(bytes.buffer).setUint32(offset, event.frameCount, false); offset += 4;
bytes.set(event.manifestHash, offset); offset += 32;
bytes[offset++] = target.length === 0 ? 0 : 1;
if (target.length) bytes.set(target, offset);
return bytes;
}
export async function frameManifestHash(frameDigests: readonly Uint8Array[]): Promise<Uint8Array> {
if (frameDigests.length < 1 || frameDigests.length > 65_536 || frameDigests.some(digest => digest.length !== 32))
throw new RangeError('帧清单无效');
const bytes = new Uint8Array(8 + frameDigests.length * 36);
bytes.set([0x57, 0x44, 0x31, 0x4d]); // WD1M
new DataView(bytes.buffer).setUint32(4, frameDigests.length, false);
let offset = 8;
for (let index = 0; index < frameDigests.length; index++) {
new DataView(bytes.buffer).setUint32(offset, index, false); offset += 4;
bytes.set(frameDigests[index]!, offset); offset += 32;
}
return new Uint8Array(await crypto.subtle.digest('SHA-256', source(bytes)));
}
function signingBytes(kind: string, payload: Uint8Array): Uint8Array {
if (!/^[a-z][a-z0-9._-]{0,63}$/.test(kind)) throw new TypeError('签名类型无效');
if (payload.length > MAX_PAYLOAD_BYTES) throw new RangeError('签名内容过大');
const kindBytes = new TextEncoder().encode(kind);
if (kindBytes.length > MAX_KIND_BYTES) throw new RangeError('签名类型过长');
const bytes = new Uint8Array(SIGNATURE_MAGIC.length + 2 + kindBytes.length + 4 + payload.length);
let offset = 0;
bytes.set(SIGNATURE_MAGIC, offset); offset += SIGNATURE_MAGIC.length;
new DataView(bytes.buffer).setUint16(offset, kindBytes.length, false); offset += 2;
bytes.set(kindBytes, offset); offset += kindBytes.length;
new DataView(bytes.buffer).setUint32(offset, payload.length, false); offset += 4;
bytes.set(payload, offset);
return bytes;
}
export async function generateSigningKeyPair(extractable = false): Promise<CryptoKeyPair> {
return crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, extractable, ['sign', 'verify']);
}
export async function signStatement(privateKey: CryptoKey, kind: string, payload: Uint8Array): Promise<Uint8Array> {
const signature = await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' }, privateKey, source(signingBytes(kind, payload)),
);
const bytes = new Uint8Array(signature);
if (bytes.length !== 64) throw new Error('签名格式无效');
return bytes;
}
export async function verifyStatement(
publicKey: CryptoKey, kind: string, payload: Uint8Array, signature: Uint8Array,
): Promise<boolean> {
if (signature.length !== 64) return false;
return crypto.subtle.verify(
{ name: 'ECDSA', hash: 'SHA-256' }, publicKey, source(signature), source(signingBytes(kind, payload)),
);
}
export async function importSigningPublicKey(key: JsonWebKey): Promise<CryptoKey> {
if (key.kty !== 'EC' || key.crv !== 'P-256' || !key.x || !key.y || key.d !== undefined)
throw new TypeError('签名公钥格式无效');
return crypto.subtle.importKey('jwk', key, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
}
+17 -9
View File
@@ -3,6 +3,8 @@ import { equalBytes, type Bytes } from './bytes.js';
import { IntegrityError } from './errors.js';
import { MAX_ENC_PAYLOAD, openFrame, parseEnvelope, peekEnvelopeLength } from './frame.js';
import { decodeFirstFrame, type ItemMeta } from './item.js';
import { sha256 } from './kdf.js';
import { frameManifestHash } from './signatures.js';
import type { ReaderContext } from './reader.js';
/** 字节队列:接收任意切分的块,按需取出前 n 个字节。 */
@@ -57,13 +59,15 @@ class ByteQueue {
async function* bytesOf(source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>): AsyncGenerator<Uint8Array, void, void> {
if (typeof ReadableStream !== 'undefined' && source instanceof ReadableStream) {
const reader = source.getReader();
let finished = false;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) return;
if (done) { finished = true; return; }
yield value;
}
} finally {
if (!finished) await reader.cancel().catch(() => {});
reader.releaseLock();
}
}
@@ -104,6 +108,8 @@ export interface ItemDecodeResult {
nextIndex: number;
/** 第 0 帧里的元数据;从中间帧续传时没有。 */
meta: ItemMeta | undefined;
/** Present only for complete reads starting at frame zero. */
ciphertextManifestHash?: Bytes;
}
/**
@@ -123,8 +129,10 @@ export async function* decryptItemFrames(
let flagsMask: number | undefined;
let finalSeen = false;
let meta: ItemMeta | undefined;
const frameDigests: Bytes[] = [];
for await (const bytes of splitEnvelopes(source)) {
frameDigests.push(await sha256(bytes));
const env = parseEnvelope(bytes);
const isEnc = (env.flags & Flags.Enc) !== 0;
@@ -136,28 +144,27 @@ export async function* decryptItemFrames(
if (finalSeen) throw new IntegrityError('frame_after_final');
if (env.frameIndex !== expected) throw new IntegrityError('gap');
const itemFlags = env.flags & (Flags.Gated | Flags.Snapshot);
const itemFlags = env.flags & (Flags.Gated | Flags.Snapshot | Flags.Control);
if (flagsMask === undefined) flagsMask = itemFlags;
else if (flagsMask !== itemFlags) throw new IntegrityError('flags_changed');
let data: Bytes;
if (ctx.mode === 'enc') {
data = await openFrame(await ctx.itemKey(env.itemId), ctx.channelId, env);
if (env.frameIndex === 0) {
const first = decodeFirstFrame(data);
meta = first.meta;
data = first.content;
}
} else {
data = env.payload;
}
if (env.frameIndex === 0) {
const first = decodeFirstFrame(data); meta = first.meta; data = first.content;
}
expected++;
if ((env.flags & Flags.Final) !== 0) finalSeen = true;
if (data.length > 0) yield data;
}
return { complete: finalSeen, nextIndex: expected, meta };
return { complete: finalSeen, nextIndex: expected, meta,
...(finalSeen && (opts.fromIndex ?? 0) === 0 ? { ciphertextManifestHash: Uint8Array.from(await frameManifestHash(frameDigests)) } : {}) };
}
/** 把 decryptItemFrames 包成 ReadableStream,便于直接 pipe 到文件。 */
@@ -190,7 +197,8 @@ export function decryptItemStream(
controller.error(e);
}
},
async cancel() {
async cancel(reason) {
reject(reason ?? new DOMException('解密已取消', 'AbortError'));
await gen.return({ complete: false, nextIndex: opts.fromIndex ?? 0, meta: undefined });
},
});
+43 -54
View File
@@ -30,7 +30,9 @@ const exe = process.env.WEBDROP_SERVER ?? join(root, 'out', 'win-x64', 'webdrop.
const linuxExe = join(root, 'out', 'linux-x64', 'webdrop');
const dll = process.env.WEBDROP_SERVER_DLL ?? join(root, 'src', 'WebDrop.Api', 'bin', 'Debug', 'net10.0', 'webdrop.dll');
const command: [string, string[]] | undefined = existsSync(exe)
const command: [string, string[]] | undefined = !process.env.WEBDROP_SERVER && existsSync(dll)
? ['dotnet', [dll]]
: existsSync(exe)
? [exe, []]
: existsSync(linuxExe)
? [linuxExe, []]
@@ -41,6 +43,11 @@ const command: [string, string[]] | undefined = existsSync(exe)
let proc: ChildProcess | undefined;
let dataDir = '';
let base = '';
let accountToken = '';
function headersFor(session: ChannelSession, role: AuthRole): Record<string, string> {
// 频道只接受登录账号:测试里所有角色共用同一个账号,权限差异由角色令牌体现
return { ...session.authHeaders(role), 'X-WD-Account': accountToken };
}
beforeAll(async () => {
if (!command) return;
@@ -69,6 +76,19 @@ beforeAll(async () => {
});
proc!.on('exit', (code) => reject(new Error(`服务端提前退出,代码 ${code}`)));
});
const verifier = crypto.getRandomValues(new Uint8Array(32));
const keys = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
const pubkey = await crypto.subtle.exportKey('jwk', keys.publicKey);
const signingKeys = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
const signingPublicKey = await crypto.subtle.exportKey('jwk', signingKeys.publicKey);
const encoded = (value: Uint8Array) => Buffer.from(value).toString('base64');
const registered = await fetch(`${base}/api/v1/accounts`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
username: 'core_e2e', salt: encoded(crypto.getRandomValues(new Uint8Array(16))), verifier: encoded(verifier), vault: encoded(new Uint8Array(28)),
public_key: JSON.stringify(pubkey), signing_public_key: JSON.stringify(signingPublicKey),
}) });
expect(registered.status).toBe(204);
const session = await fetch(`${base}/api/v1/accounts/session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'core_e2e', verifier: encoded(verifier) }) });
expect(session.status).toBe(200); accountToken = ((await session.json()) as { token: string }).token;
}, 30_000);
afterAll(async () => {
@@ -82,10 +102,10 @@ afterAll(async () => {
const api = (path: string) => `${base}/api/v1${path}`;
async function createChannel(opts: Parameters<typeof ChannelSession.create>[0] = {}) {
const created = await ChannelSession.create(opts);
const created = await ChannelSession.create({ ...opts, settings: { name: 'Core E2E', ...opts.settings } });
const res = await fetch(api('/channels'), {
method: 'POST',
headers: { ...created.request.headers, 'Content-Type': 'application/json' },
headers: { ...created.request.headers, 'X-WD-Account': accountToken, 'Content-Type': 'application/json' },
body: JSON.stringify(created.request.body),
});
expect(res.status).toBe(201);
@@ -95,7 +115,7 @@ async function createChannel(opts: Parameters<typeof ChannelSession.create>[0] =
}
async function joinChannel(session: ChannelSession, role: AuthRole = 'read') {
const res = await fetch(api(`/channels/${session.id}/seats`), { method: 'POST', headers: session.authHeaders(role) });
const res = await fetch(api(`/channels/${session.id}/join`), { method: 'POST', headers: headersFor(session, role) });
expect(res.status, await res.clone().text()).toBe(200);
}
@@ -105,7 +125,7 @@ async function send(session: ChannelSession, plan: ItemPlan, role: AuthRole) {
for await (const frame of plan.frames) {
const res = await fetch(api(`/channels/${session.id}/frames`), {
method: 'POST',
headers: { ...session.authHeaders(role), 'Content-Type': 'application/octet-stream', ...(first ? plan.frame0Headers : {}) },
headers: { ...headersFor(session, role), 'Content-Type': 'application/octet-stream', ...(first ? plan.frame0Headers : {}) },
body: frame.bytes,
});
expect(res.status, await res.clone().text()).toBeLessThan(300);
@@ -116,16 +136,17 @@ async function send(session: ChannelSession, plan: ItemPlan, role: AuthRole) {
}
async function readLog(session: ChannelSession, role: AuthRole, from = 0) {
const res = await fetch(api(`/channels/${session.id}/log?from=${from}`), { headers: session.authHeaders(role) });
const res = await fetch(api(`/channels/${session.id}/log?from=${from}`), { headers: headersFor(session, role) });
expect(res.status).toBe(200);
return parseLogRecords(new Uint8Array(await res.arrayBuffer()));
}
/** 连接 WebSocket(凭据放在子协议里),收集事件直到满足 until。 */
function subscribe(session: ChannelSession, role: AuthRole, until: (events: ItemEvent[]) => boolean, from = 0) {
const h = session.authHeaders(role);
const h = headersFor(session, role);
const protocols = ['wd.v1', `wd.role.${role}`, `wd.ep.${h['X-WD-Endpoint']!}`];
if (h.Authorization) protocols.push(`wd.auth.${h.Authorization.replace('Bearer ', '')}`);
protocols.push(`wd.account.${accountToken.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`);
const ws = new WebSocket(`${base.replace('http', 'ws')}/api/v1/channels/${session.id}/ws?from=${from}`, protocols);
ws.binaryType = 'arraybuffer';
@@ -227,7 +248,7 @@ suite('端到端(真实服务端)', () => {
const frame = (await collectFrames(again))[0]!;
const res = await fetch(api(`/channels/${owner.id}/frames`), {
method: 'POST',
headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream' },
headers: { ...headersFor(owner, 'owner'), 'Content-Type': 'application/octet-stream' },
body: frame.bytes,
});
expect(res.status).toBe(200);
@@ -239,7 +260,7 @@ suite('端到端(真实服务端)', () => {
const marker = 'UNIQUE-PLAINTEXT-MARKER-7f3a9c';
await send(owner, owner.encryptText(marker), 'owner');
const res = await fetch(api(`/channels/${owner.id}/log`), { headers: owner.authHeaders('owner') });
const res = await fetch(api(`/channels/${owner.id}/log`), { headers: headersFor(owner, 'owner') });
const wire = new Uint8Array(await res.arrayBuffer());
expect(new TextDecoder('latin1').decode(wire)).not.toContain(marker);
expect(new TextDecoder('latin1').decode(wire)).not.toContain('"kind"');
@@ -260,7 +281,7 @@ suite('端到端(真实服务端)', () => {
expect(log.map((r) => r.kind)).toEqual(['announce']);
// 流式下载 + 流式解密
const res = await fetch(api(`/channels/${owner.id}/items/${plan.itemId}/frames`), { headers: member.authHeaders('read') });
const res = await fetch(api(`/channels/${owner.id}/items/${plan.itemId}/frames`), { headers: headersFor(member, 'read') });
expect(res.status).toBe(200);
const { content: stream, result } = member.decryptItemStream(res.body!, { itemId: plan.idBytes });
const out = await readAll(stream);
@@ -278,15 +299,15 @@ suite('端到端(真实服务端)', () => {
await send(owner, plan, 'owner');
const url = api(`/channels/${owner.id}/items/${plan.itemId}/frames`);
const first = await fetch(url, { headers: owner.authHeaders('owner') });
const first = await fetch(url, { headers: headersFor(owner, 'owner') });
const decoded = owner.decryptItemStream(first.body!);
expect(Buffer.compare(await readAll(decoded.content), content)).toBe(0);
const second = await fetch(url, { headers: owner.authHeaders('owner') });
const second = await fetch(url, { headers: headersFor(owner, 'owner') });
expect(second.status).toBe(410);
expect(((await second.json()) as { error: string }).error).toBe('gone');
const resumed = await fetch(`${url}?from=1`, { headers: owner.authHeaders('owner') });
const resumed = await fetch(`${url}?from=1`, { headers: headersFor(owner, 'owner') });
expect(resumed.status).toBe(200);
await resumed.arrayBuffer();
}, 60_000);
@@ -308,7 +329,7 @@ suite('端到端(真实服务端)', () => {
if (sent === 2) break;
const res = await fetch(api(`/channels/${owner.id}/frames`), {
method: 'POST',
headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream', ...(isFirst ? first.frame0Headers : {}) },
headers: { ...headersFor(owner, 'owner'), 'Content-Type': 'application/octet-stream', ...(isFirst ? first.frame0Headers : {}) },
body: f.bytes,
});
expect(res.status).toBe(201);
@@ -322,14 +343,14 @@ suite('端到端(真实服务端)', () => {
for await (const f of rest.frames) {
const res = await fetch(api(`/channels/${owner.id}/frames`), {
method: 'POST',
headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream' },
headers: { ...headersFor(owner, 'owner'), 'Content-Type': 'application/octet-stream' },
body: f.bytes,
});
statuses.push(res.status);
}
expect(statuses).toEqual([200, 201, 201]); // 第 1 帧已存在(幂等),第 2、3 帧是新的
const res = await fetch(api(`/channels/${owner.id}/items/${toBase64Url(itemId)}/frames`), { headers: owner.authHeaders('owner') });
const res = await fetch(api(`/channels/${owner.id}/items/${toBase64Url(itemId)}/frames`), { headers: headersFor(owner, 'owner') });
const { content: stream, result } = owner.decryptItemStream(res.body!);
expect(Buffer.compare(await readAll(stream), content)).toBe(0);
expect((await result).complete).toBe(true);
@@ -343,7 +364,7 @@ suite('端到端(真实服务端)', () => {
const url = api(`/channels/${owner.id}/items/${plan.itemId}/frames`);
// 第一次:只读前约 1.5 帧就断开
const first = await fetch(url, { headers: owner.authHeaders('owner') });
const first = await fetch(url, { headers: headersFor(owner, 'owner') });
const reader = first.body!.getReader();
const chunks: Uint8Array[] = [];
let got = 0;
@@ -361,7 +382,7 @@ suite('端到端(真实服务端)', () => {
})();
expect(part1.result.complete).toBe(false);
const second = await fetch(`${url}?from=${part1.result.nextIndex}`, { headers: owner.authHeaders('owner') });
const second = await fetch(`${url}?from=${part1.result.nextIndex}`, { headers: headersFor(owner, 'owner') });
expect(second.status).toBe(200);
const dec2 = owner.decryptItemStream(second.body!, { fromIndex: part1.result.nextIndex });
const part2 = await readAll(dec2.content);
@@ -370,38 +391,6 @@ suite('端到端(真实服务端)', () => {
expect(Buffer.compare(concatAll([part1.bytes, part2]), content)).toBe(0);
}, 60_000);
it('设备名:成员加密上传,所有者在席位列表里解开', async () => {
const owner = await createChannel();
const member = await ChannelSession.open(owner.link(ORIGIN));
await joinChannel(member, 'read');
const blob = await member.sealLabel('我的 iPhone');
const put = await fetch(api(`/channels/${owner.id}/seats/me`), {
method: 'PUT',
headers: { ...member.authHeaders('read'), 'Content-Type': 'application/octet-stream' },
body: blob,
});
expect(put.status).toBe(204);
const res = await fetch(api(`/channels/${owner.id}/seats`), { headers: owner.authHeaders('owner') });
const seats = (await res.json()) as Array<{ endpoint_hash: string; label?: string }>;
const mine = seats.find((s) => s.endpoint_hash === toBase64Url(member.endpointHash))!;
expect(await owner.openLabel(mine.label!, mine.endpoint_hash)).toBe('我的 iPhone');
// 服务端存的是密文
expect(Buffer.from(mine.label!, 'base64url').toString('utf8')).not.toContain('iPhone');
});
it('最长的设备名(228 字节)也被服务端接受', async () => {
const owner = await createChannel();
const blob = await owner.sealLabel('x'.repeat(228));
const put = await fetch(api(`/channels/${owner.id}/seats/me`), {
method: 'PUT',
headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream' },
body: blob,
});
expect(put.status).toBe(204);
});
it('raw 频道:匿名成员无需令牌即可读取,服务端看得到明文', async () => {
const owner = await createChannel({ mode: 'raw' });
const member = await ChannelSession.open(owner.link(ORIGIN));
@@ -415,11 +404,11 @@ suite('端到端(真实服务端)', () => {
for (const r of records) out.push(...(await reader.push(r)));
return out;
})());
expect(item!.kind).toBe('raw');
expect(item!.kind).toBe('text');
expect(itemText(item!)).toBe('raw 明文 visible');
// 对比 enc:raw 的明文确实出现在服务端返回的字节里
const res = await fetch(api(`/channels/${owner.id}/log`), { headers: member.authHeaders('read') });
const res = await fetch(api(`/channels/${owner.id}/log`), { headers: headersFor(member, 'read') });
expect(new TextDecoder().decode(await res.arrayBuffer())).toContain('raw 明文 visible');
});
@@ -428,8 +417,8 @@ suite('端到端(真实服务端)', () => {
const member = await ChannelSession.open(owner.link(ORIGIN));
await joinChannel(member, 'read');
const res = await fetch(api(`/channels/${owner.id}/seats`), { headers: { ...member.authHeaders('read') } });
const res = await fetch(api(`/channels/${owner.id}/settings`), { method: 'PATCH', headers: { ...headersFor(member, 'read'), 'Content-Type': 'application/json' }, body: '{}' });
expect(res.status).toBe(403);
expect(() => member.authHeaders('owner')).toThrow();
expect(() => headersFor(member, 'owner')).toThrow();
});
});
+14 -46
View File
@@ -3,7 +3,6 @@ import { fromBase64Url, toBase64Url } from '../src/bytes.js';
import {
CapabilityError,
ChannelSession,
IntegrityError,
LimitError,
LinkError,
ModeMismatchError,
@@ -61,8 +60,8 @@ describe('创建 enc 频道', () => {
});
it('设置原样放进请求体', async () => {
const { request } = await ChannelSession.create({ settings: { seats: 3, delete_policy: 'anyone' } });
expect(request.body.settings).toEqual({ seats: 3, delete_policy: 'anyone' });
const { request } = await ChannelSession.create({ settings: { max_members: 3, delete_policy: 'anyone' } });
expect(request.body.settings).toEqual({ max_members: 3, delete_policy: 'anyone' });
});
});
@@ -281,63 +280,31 @@ describe('发送和接收(enc)', () => {
});
});
describe('设备名', () => {
it('所有者能读出成员的设备名,其他人换个终端哈希读不出', async () => {
const { session: owner } = await ChannelSession.create();
const member = await ChannelSession.open(owner.link(ORIGIN));
const blob = await member.sealLabel('办公室电脑');
expect(await owner.openLabel(blob, member.endpointHash)).toBe('办公室电脑');
expect(await owner.openLabel(toBase64Url(blob), toBase64Url(member.endpointHash))).toBe('办公室电脑');
await expect(owner.openLabel(blob, owner.endpointHash)).rejects.toThrowError(IntegrityError);
});
it('每次加密使用新的随机 IV', async () => {
const { session } = await ChannelSession.create();
const a = await session.sealLabel('x');
const b = await session.sealLabel('x');
expect(toHex(a.slice(0, 12))).not.toBe(toHex(b.slice(0, 12)));
});
it('篡改后解不开', async () => {
const { session } = await ChannelSession.create();
const blob = await session.sealLabel('abc');
blob[blob.length - 1]! ^= 1;
await expect(session.openLabel(blob, session.endpointHash)).rejects.toThrowError(IntegrityError);
});
it('raw 模式下设备名是明文', async () => {
const { session } = await ChannelSession.create({ mode: 'raw' });
const blob = await session.sealLabel('plain');
expect(new TextDecoder().decode(blob)).toBe('plain');
expect(await session.openLabel(blob, session.endpointHash)).toBe('plain');
});
});
describe('raw 模式往返', () => {
it('单帧:负载就是内容,没有加密标志', async () => {
it('单帧:保留统一元数据,没有加密标志', async () => {
const { session: owner } = await ChannelSession.create({ mode: 'raw' });
const member = await ChannelSession.open(owner.link(ORIGIN));
const [frame] = await collectFrames(owner.encryptText('明文内容'));
expect(frame!.bytes[1]! & 8).toBe(0); // 没有 ENC 标志
expect(new TextDecoder().decode(frame!.bytes.slice(26))).toBe('明文内容');
expect(new TextDecoder().decode(frame!.bytes.slice(26))).toContain('明文内容');
const item = itemOf(await member.createItemReader().pushFrame(frame!.bytes));
expect(item.kind).toBe('raw');
expect(item.meta).toBeUndefined();
expect(item.kind).toBe('text');
expect(item.meta?.kind).toBe('text');
expect(itemText(item)).toBe('明文内容');
});
it('大内容拆成 GATED 多帧', async () => {
const { session } = await ChannelSession.create({ mode: 'raw' });
const content = pseudoRandom(100, 5);
const plan = session.encryptBytes(content, {}, { maxFrameData: 40 });
const plan = session.encryptBytes(content, {}, { maxFrameData: 80 });
const frames = await collectFrames(plan);
expect(plan.gated).toBe(true);
expect(frames.map((f) => f.bytes.length - 26)).toEqual([40, 40, 20]);
expect(frames.map((f) => f.final)).toEqual([false, false, true]);
expect(frames.length).toBeGreaterThan(1);
expect(frames.every((f) => f.bytes.length - 26 <= 80)).toBe(true);
expect(frames.at(-1)?.final).toBe(true);
const reader = session.createItemReader();
let last: ReceivedItem | undefined;
@@ -346,10 +313,11 @@ describe('raw 模式往返', () => {
expect(toHex(last!.content)).toBe(toHex(content));
});
it('label 放进第 0 帧的附加请求头', async () => {
it('名称使用与加密条目一致的元数据', async () => {
const { session } = await ChannelSession.create({ mode: 'raw' });
const plan = session.encryptBytes(new Uint8Array(3), {}, { label: 'note.txt' });
expect(plan.frame0Headers['X-WD-Label']).toBe('note.txt');
const plan = session.encryptBytes(new Uint8Array(3), { name: 'note.txt' });
const [frame] = await collectFrames(plan);
expect(itemOf(await session.createItemReader().pushFrame(frame!.bytes)).meta?.name).toBe('note.txt');
});
});
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { fromBase64Url } from '../src/bytes.js';
import { contentEventPayload, frameManifestHash, generateSigningKeyPair, signStatement, verifyStatement } from '../src/signatures.js';
import { h, vectors } from './helpers.js';
describe('协议签名原语', () => {
it('跨语言固定向量可以验签,且类型和负载均受签名保护', async () => {
const fixture = vectors.signatures;
const publicKey = await crypto.subtle.importKey('jwk', {
kty: 'EC', crv: 'P-256', x: fixture.x, y: fixture.y, ext: true,
}, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
const payload = h(fixture.payload);
const signature = h(fixture.signature);
expect(await verifyStatement(publicKey, fixture.kind, payload, signature)).toBe(true);
expect(await verifyStatement(publicKey, 'chat.edit', payload, signature)).toBe(false);
payload[0] = payload[0]! ^ 1;
expect(await verifyStatement(publicKey, fixture.kind, payload, signature)).toBe(false);
expect(await verifyStatement(publicKey, fixture.kind, h(fixture.payload), signature.subarray(1))).toBe(false);
expect(fromBase64Url(fixture.x)).toHaveLength(32);
});
it('浏览器签名可验证,篡改被拒绝', async () => {
const pair = await generateSigningKeyPair();
expect(pair.privateKey.extractable).toBe(false);
const payload = new TextEncoder().encode('signed command bytes');
const signature = await signStatement(pair.privateKey, 'workspace.channel.create', payload);
expect(signature).toHaveLength(64);
expect(await verifyStatement(pair.publicKey, 'workspace.channel.create', payload, signature)).toBe(true);
expect(await verifyStatement(pair.publicKey, 'workspace.group.create', payload, signature)).toBe(false);
await expect(signStatement(pair.privateKey, 'Workspace.Channel.Create', payload)).rejects.toThrow();
});
it('聊天/文件签名上下文与帧清单跨语言向量一致', async () => {
const event = vectors.content_event;
const manifest = await frameManifestHash(event.frame_digests.map((digest: string) => h(digest)));
expect(manifest).toEqual(h(event.manifest_hash));
expect(contentEventPayload({
spaceId: h(event.space_id), channelId: event.channel_id, username: event.username, deviceId: event.device_id,
eventId: h(event.event_id), eventKind: event.kind, domain: event.domain, epoch: event.epoch,
itemId: h(event.item_id), frameCount: event.frame_count, manifestHash: manifest, targetItemId: h(event.target_item_id),
})).toEqual(h(event.payload));
});
});
+3 -24
View File
@@ -149,7 +149,10 @@ describe('条目分帧', () => {
itemId: h(item.item_id),
ts: m.ts,
...(m.dev !== undefined && { dev: m.dev }),
...(m.thread_id !== undefined && { threadId: m.thread_id }),
...(m.folder_id !== undefined && { folderId: m.folder_id }),
snapshot: item.snapshot,
control: item.control,
gated: item.force_gated ? true : 'auto',
maxFrameData: item.max_frame_data,
},
@@ -179,30 +182,6 @@ describe('raw 帧', () => {
});
});
describe('设备名', () => {
it('固定 IV 下的密文一致,并能解回', async () => {
const session = await openVectorSession();
for (const l of vectors.labels) {
const sealed = await session.sealLabel(l.name, { iv: h(l.iv) });
expect(toHex(sealed)).toBe(l.blob);
expect(sealed.length).toBeLessThanOrEqual(256);
expect(await session.openLabel(sealed, session.endpointHash)).toBe(l.name);
}
});
it('换一个终端的哈希就解不开(AAD 绑定终端)', async () => {
const session = await openVectorSession();
const sealed = await session.sealLabel('我的 iPhone');
await expect(session.openLabel(sealed, new Uint8Array(32))).rejects.toThrowError(IntegrityError);
});
it('超过 228 字节 UTF-8 时拒绝', async () => {
const session = await openVectorSession();
await expect(session.sealLabel('x'.repeat(229))).rejects.toThrow();
await expect(session.sealLabel('x'.repeat(228))).resolves.toHaveLength(256);
});
});
describe('链接', () => {
it('合法链接解析结果与向量一致', () => {
for (const v of vectors.links.valid) {
+149
View File
@@ -0,0 +1,149 @@
// Run with the Playwright browser_run_code tool's filename option against an isolated development API.
async (page) => {
const origin = new URL(page.url()).origin;
const password = 'Architecture-test-password-2026';
const suffix = Date.now().toString();
const ownerName = `smoke_owner_${suffix}`;
const memberName = `smoke_member_${suffix}`;
const browser = page.context().browser();
const contexts = [];
const results = [];
async function login(target, username, create = false, path = '/') {
await target.goto(origin + path);
if (!create) await target.getByRole('button', { name: '已有账号', exact: true }).click();
await target.getByLabel('用户名', { exact: true }).fill(username);
await target.getByLabel('密码', { exact: true }).fill(password);
await target.getByRole('button', { name: create ? '创建账号' : '登录并同步', exact: true }).click();
await target.getByRole('button', { name: '新建空间', exact: true }).waitFor();
}
async function newDevice() {
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
contexts.push(context); return context.newPage();
}
try {
const owner = await newDevice();
await login(owner, ownerName, true);
await owner.getByLabel('个人名称', { exact: true }).fill('多设备测试用户');
const avatar = await owner.evaluate(() => {
const canvas = document.createElement('canvas'); canvas.width = 32; canvas.height = 32;
const context = canvas.getContext('2d'); context.fillRect(0, 0, 32, 32); return canvas.toDataURL('image/png').split(',')[1];
});
await owner.getByLabel('头像', { exact: true }).setInputFiles({ name: 'avatar.png', mimeType: 'image/png', buffer: Buffer.from(avatar, 'base64') });
await owner.getByRole('img', { name: '个人头像', exact: true }).waitFor();
await owner.getByRole('button', { name: '保存个人资料', exact: true }).click();
await owner.getByText('个人资料已保存并同步。', { exact: true }).waitFor();
await owner.reload();
await owner.getByRole('button', { name: '新建空间', exact: true }).waitFor();
if (await owner.getByLabel('个人名称', { exact: true }).inputValue() !== '多设备测试用户') throw new Error('Profile did not survive reload');
await owner.getByRole('img', { name: '个人头像', exact: true }).waitFor();
results.push('server-owned profile and avatar survive login and reload');
await owner.getByRole('button', { name: '新建空间', exact: true }).click();
await owner.getByLabel('空间名称(加入者和服务端可见)').fill('架构验收空间');
await owner.getByRole('button', { name: '创建', exact: true }).click();
await owner.getByRole('button', { name: '常规', exact: true }).waitFor();
await owner.getByRole('button', { name: '新建频道分组', exact: true }).click();
await owner.getByLabel('分组名称', { exact: true }).fill('工作');
await owner.getByRole('button', { name: '创建', exact: true }).click();
await owner.getByRole('button', { name: '在 工作 新建频道', exact: true }).click();
await owner.getByLabel('频道名称', { exact: true }).fill('讨论');
await owner.getByRole('button', { name: '创建', exact: true }).click();
await owner.getByRole('button', { name: '讨论', exact: true }).waitFor();
await owner.getByRole('button', { name: '常规', exact: true }).click();
await owner.getByRole('button', { name: '频道设置 常规', exact: true }).click();
await owner.getByLabel('所属分组').selectOption({ label: '工作' });
await owner.getByRole('button', { name: '保存', exact: true }).click();
await owner.getByRole('button', { name: '保存', exact: true }).waitFor({ state: 'hidden' });
await owner.getByRole('button', { name: '重命名分组 工作', exact: true }).click();
await owner.getByLabel('分组名称', { exact: true }).fill('项目');
await owner.getByRole('button', { name: '保存', exact: true }).click();
await owner.getByRole('button', { name: '保存', exact: true }).waitFor({ state: 'hidden' });
await owner.getByRole('button', { name: '分组 项目', exact: true }).click();
if (await owner.getByRole('button', { name: '讨论', exact: true }).isVisible()) throw new Error('Group did not collapse');
await owner.getByRole('button', { name: '分组 项目', exact: true }).click();
await owner.reload();
await owner.getByRole('region', { name: '频道分组 项目', exact: true }).getByRole('button', { name: '常规', exact: true }).waitFor();
await owner.getByRole('region', { name: '频道分组 项目', exact: true }).getByRole('button', { name: '讨论', exact: true }).waitFor();
await owner.getByRole('button', { name: '讨论', exact: true }).click();
await owner.getByRole('button', { name: '频道设置 讨论', exact: true }).click();
await owner.getByLabel('所属分组').selectOption({ label: '未分组' });
await owner.getByRole('button', { name: '保存', exact: true }).click();
await owner.getByRole('button', { name: '保存', exact: true }).waitFor({ state: 'hidden' });
if (await owner.getByRole('region', { name: '频道分组 项目', exact: true }).getByRole('button', { name: '讨论', exact: true }).count()) throw new Error('Ungrouping did not remove group membership');
await owner.getByRole('button', { name: '频道设置 讨论', exact: true }).click();
await owner.getByLabel('所属分组').selectOption({ label: '项目' });
await owner.getByRole('button', { name: '保存', exact: true }).click();
await owner.getByRole('button', { name: '保存', exact: true }).waitFor({ state: 'hidden' });
await owner.getByRole('button', { name: '常规', exact: true }).click();
results.push('backend channels and groups create, rename, collapse and survive reload');
const path = new URL(owner.url()).pathname;
if (owner.url().includes('#')) throw new Error('Account channel navigation retained a secret fragment');
await owner.locator('textarea').last().fill('常规 thread 聊天验证');
await owner.getByRole('button', { name: '发送消息', exact: true }).click();
await owner.locator('article p').filter({ hasText: '常规 thread 聊天验证' }).waitFor();
const payload = '端到端文件验证:只有成员设备可解密。'.repeat(60000);
await owner.getByRole('region', { name: '频道文件管理器' }).locator('input[type=file]').setInputFiles({ name: 'architecture-file.txt', mimeType: 'text/plain', buffer: Buffer.from(payload) });
await owner.getByRole('button', { name: '继续上传', exact: true }).click();
await owner.getByRole('button', { name: 'architecture-file.txt', exact: true }).waitFor();
results.push('default thread, center chat and right file explorer work');
const second = await newDevice();
await login(second, ownerName, false, path);
await second.getByRole('button', { name: 'architecture-file.txt', exact: true }).waitFor();
await second.locator('article p').filter({ hasText: '常规 thread 聊天验证' }).waitFor();
await second.getByRole('region', { name: '频道分组 项目', exact: true }).getByRole('button', { name: '讨论', exact: true }).waitFor();
results.push('second independent device recovers owner channel without a secret link');
const member = await newDevice();
await login(member, memberName, true);
await owner.getByRole('button', { name: '空间设置', exact: true }).click();
await owner.getByLabel('被邀请用户的用户名').fill(memberName);
await owner.getByRole('button', { name: '发送邀请', exact: true }).click();
await owner.getByText(`${memberName} · 已邀请`, { exact: true }).waitFor();
await owner.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click();
let downloads = 0;
member.on('request', (request) => { if (/\/items\/[^/]+\/frames/.test(request.url())) downloads++; });
await member.goto(origin + path);
await member.getByRole('button', { name: '接受并加入空间', exact: true }).click();
await member.getByRole('button', { name: 'architecture-file.txt', exact: true }).waitFor();
if (downloads !== 0) throw new Error('File catalog consumed a gated download');
await member.getByRole('button', { name: 'architecture-file.txt', exact: true }).dblclick();
await member.getByRole('button', { name: '解密并预览文件', exact: true }).click();
await member.locator('pre').filter({ hasText: '端到端文件验证' }).waitFor();
if (downloads !== 1) throw new Error(`Expected one explicit gated read, got ${downloads}`);
await member.reload();
await member.getByRole('button', { name: 'architecture-file.txt', exact: true }).waitFor();
if (downloads !== 1) throw new Error('Refreshing consumed another download');
results.push('account invitation automatically unlocks ciphertext and catalog never auto-downloads');
await owner.getByRole('button', { name: '新建文件夹', exact: true }).click();
await owner.getByLabel('文件夹名称', { exact: true }).fill('资料目录');
await owner.getByRole('button', { name: '创建', exact: true }).click();
await owner.getByRole('button', { name: '资料目录', exact: true }).waitFor();
await owner.getByRole('checkbox', { name: '选择文件 architecture-file.txt', exact: true }).check();
await owner.getByLabel('移动所选文件到文件夹').selectOption({ label: '资料目录' });
await owner.getByRole('button', { name: '资料目录', exact: true }).dblclick();
await owner.getByRole('button', { name: 'architecture-file.txt', exact: true }).waitFor();
results.push('backend folders and file locations update without re-encrypting content');
await second.goto(origin);
const selfRow = second.getByRole('region', { name: '账号设备' }).locator('div').filter({ has: second.getByText('本机', { exact: true }) });
await selfRow.locator('input').fill('验收副设备');
await selfRow.getByRole('button', { name: '重命名', exact: true }).click();
await second.getByLabel('验收副设备 的设备名称', { exact: true }).waitFor();
await owner.goto(origin);
const otherRow = owner.getByRole('region', { name: '账号设备' }).locator('div').filter({ has: owner.getByLabel('验收副设备 的设备名称', { exact: true }) });
await owner.evaluate(() => { window.confirm = () => true; });
await otherRow.getByRole('button', { name: '移除设备', exact: true }).click();
await second.reload();
await second.getByRole('button', { name: '创建账号', exact: true }).waitFor();
await owner.reload();
await owner.getByRole('button', { name: '新建空间', exact: true }).waitFor();
results.push('revoked device loses remembered login while the other device stays logged in');
await member.goto(origin + path);
for (const width of [1440, 390]) {
await member.setViewportSize({ width, height: 900 });
for (const colorScheme of ['light', 'dark']) {
await member.emulateMedia({ colorScheme });
if (await member.evaluate(() => document.documentElement.scrollWidth > innerWidth)) throw new Error(`Horizontal overflow at ${width}/${colorScheme}`);
}
}
results.push('desktop/mobile and light/dark layouts fit viewport');
return { passed: results };
} finally { for (const context of contexts) await context.close(); }
}
+53
View File
@@ -0,0 +1,53 @@
// Works against either the development proxy or the embedded production frontend.
async (page) => {
const origin = new URL(page.url()).origin;
const context = await page.context().browser().newContext({ viewport: { width: 1440, height: 900 } });
const owner = await context.newPage(); const results = [];
try {
await owner.goto(origin);
await owner.getByLabel('用户名', { exact: true }).fill(`backend_${Date.now()}`);
await owner.getByLabel('密码', { exact: true }).fill('Backend-data-test-password');
await owner.getByRole('button', { name: '创建账号', exact: true }).click();
await owner.getByRole('button', { name: '新建空间', exact: true }).click();
await owner.getByLabel('空间名称(加入者和服务端可见)').fill('后端数据源测试');
await owner.getByRole('button', { name: '创建', exact: true }).click();
await owner.getByRole('button', { name: '常规', exact: true }).waitFor();
const spaceId = new URL(owner.url()).pathname.split('/').pop();
await owner.getByRole('button', { name: '新建频道分组', exact: true }).click();
await owner.getByLabel('分组名称', { exact: true }).fill('前端分组');
const requestPromise = owner.waitForRequest(request => request.method() === 'POST' && request.url().endsWith(`/spaces/${spaceId}/groups`));
await owner.getByRole('button', { name: '创建', exact: true }).click();
const request = await requestPromise;
const allHeaders = await request.allHeaders();
const headers = Object.fromEntries(['authorization', 'x-wd-role', 'x-wd-endpoint', 'x-wd-account', 'content-type'].filter(key => allHeaders[key]).map(key => [key, allHeaders[key]]));
await owner.getByRole('button', { name: '分组 前端分组', exact: true }).waitFor();
const initial = await context.request.get(`${origin}/api/v1/spaces/${spaceId}/structure`, { headers }).then(r => r.json());
const groupId = initial.structure.groups[0].id;
const renamed = await context.request.patch(`${origin}/api/v1/spaces/${spaceId}/groups/${groupId}`, { headers, data: { name: '后端分组' } });
if (!renamed.ok()) throw new Error(`Backend rename failed ${renamed.status()}`);
await owner.getByRole('button', { name: '分组 后端分组', exact: true }).waitFor();
const created = await context.request.post(`${origin}/api/v1/spaces/${spaceId}/channels`, { headers, data: { name: '后端频道', groupId } });
if (created.status() !== 201) throw new Error('Backend channel creation failed');
await owner.getByRole('button', { name: '后端频道', exact: true }).waitFor();
results.push('direct backend group and channel edits update the UI without client structure records');
const locationResponse = owner.waitForResponse(response => response.request().method() === 'PUT' && response.url().endsWith(`/spaces/${spaceId}/locations`));
await owner.getByRole('region', { name: '常规 聊天', exact: true }).locator('textarea').fill('后端作者归属测试');
await owner.getByRole('button', { name: '发送消息', exact: true }).click();
if (!(await locationResponse).ok()) throw new Error('Backend item association failed');
const profile = await context.request.patch(`${origin}/api/v1/accounts/profile`, { headers, data: { displayName: '后端昵称' } });
if (!profile.ok()) throw new Error('Backend profile update failed');
await owner.getByRole('article', { name: '后端昵称 的消息', exact: true }).getByText('后端作者归属测试', { exact: true }).waitFor();
results.push('backend account profile and stored authors determine message identity');
const spaces = await context.request.get(`${origin}/api/v1/accounts/spaces`, { headers }).then(r => r.json());
if (!spaces.some(space => space.id === spaceId) || spaces.some(space => 'read_hash' in space || 'ownerLink' in space || 'memberLink' in space)) throw new Error('Space metadata catalog is invalid');
for (const path of ['/accounts/profile/import', `/spaces/${spaceId}/structure/import`]) {
const response = await context.request.post(`${origin}/api/v1${path}`, { headers, data: {} });
if (response.status() !== 404 && response.status() !== 405) throw new Error('Unexpected legacy import route');
}
await owner.reload();
await owner.getByRole('button', { name: '后端频道', exact: true }).waitFor();
await owner.getByRole('article', { name: '后端昵称 的消息', exact: true }).waitFor();
results.push('server-owned state survives reload and there are no migration endpoints');
return { passed: results };
} finally { await context.close(); }
}
+50
View File
@@ -0,0 +1,50 @@
async (page) => {
const origin = page.url().startsWith('http') ? new URL(page.url()).origin : 'http://127.0.0.1:5179'; const browser = page.context().browser(); const contexts = []; const results = [];
const password = 'Channel-domains-test-password'; const suffix = Date.now(); const ownerName = `domains_owner_${suffix}`; const memberName = `domains_member_${suffix}`;
let owner, member; const failures = [];
async function account(username) {
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); contexts.push(context); const target = await context.newPage();
await target.goto(origin); await target.getByLabel('用户名', { exact: true }).fill(username); await target.getByLabel('密码', { exact: true }).fill(password);
await target.getByRole('button', { name: '创建账号', exact: true }).click(); await target.getByRole('button', { name: '新建空间', exact: true }).waitFor(); return target;
}
async function send(target, text) { await target.getByRole('region', { name: '常规 聊天', exact: true }).locator('textarea').fill(text); await target.getByRole('button', { name: '发送消息', exact: true }).click(); await target.getByRole('article').getByText(text, { exact: true }).waitFor(); }
try {
owner = await account(ownerName); member = await account(memberName);
member.on('response', async response => { if (response.status() >= 400 && response.url().includes('/api/v1/')) failures.push({ status: response.status(), method: response.request().method(), path: new URL(response.url()).pathname, code: (await response.json().catch(() => ({}))).error }); });
await owner.getByRole('button', { name: '新建空间', exact: true }).click(); await owner.getByLabel('空间名称(加入者和服务端可见)').fill('独立数据域测试'); await owner.getByRole('button', { name: '创建', exact: true }).click();
await owner.getByRole('button', { name: '常规', exact: true }).waitFor(); const path = new URL(owner.url()).pathname;
await send(owner, '加入前的历史聊天');
const ownerExplorer = owner.getByRole('region', { name: '频道文件管理器' });
await ownerExplorer.locator('input[type=file]').setInputFiles({ name: 'old-domain-file.txt', mimeType: 'text/plain', buffer: Buffer.from('historical file must remain private') });
await owner.getByRole('button', { name: '继续上传', exact: true }).click(); await owner.getByRole('button', { name: 'old-domain-file.txt', exact: true }).waitFor();
await owner.getByRole('button', { name: '在未分组新建频道', exact: true }).click(); await owner.getByLabel('频道名称', { exact: true }).fill('单独授权频道'); await owner.getByRole('button', { name: '创建频道', exact: true }).click();
await owner.getByRole('button', { name: '单独授权频道', exact: true }).waitFor(); await owner.getByRole('button', { name: '常规', exact: true }).click();
await owner.getByRole('button', { name: '频道设置 常规', exact: true }).click(); await owner.getByLabel('新成员可读历史聊天').uncheck(); await owner.getByLabel('新成员可读历史文件').uncheck();
await owner.getByRole('button', { name: '保存', exact: true }).click(); await owner.getByRole('button', { name: '保存', exact: true }).waitFor({ state: 'hidden' });
await owner.getByRole('button', { name: '空间设置', exact: true }).click(); await owner.getByLabel('被邀请用户的用户名').fill(memberName); await owner.getByRole('button', { name: '发送邀请', exact: true }).click();
await owner.getByText(`${memberName} · 已邀请`, { exact: true }).waitFor(); await owner.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click();
await member.goto(origin + path); await member.getByRole('button', { name: '接受并加入空间', exact: true }).click(); await member.getByRole('button', { name: '常规', exact: true }).waitFor();
if (await member.getByRole('button', { name: '单独授权频道', exact: true }).count()) throw new Error('Member received an unauthorized channel');
if (await member.getByRole('article').getByText('加入前的历史聊天', { exact: true }).count()) throw new Error('Member received historical chat keys');
if (await member.getByRole('button', { name: 'old-domain-file.txt', exact: true }).count()) throw new Error('Member received historical file keys');
await send(owner, '加入后的新聊天'); await member.getByRole('article').getByText('加入后的新聊天', { exact: true }).waitFor();
const ownerMessage = owner.locator('article').filter({ hasText: '加入后的新聊天' });
const memberCopy = member.locator('article').filter({ hasText: '加入后的新聊天' });
if (await ownerMessage.evaluate(element => getComputedStyle(element).flexDirection) !== 'row-reverse'
|| await memberCopy.evaluate(element => getComputedStyle(element).flexDirection) !== 'row') throw new Error('Messages are not aligned by sender');
await send(member, '成员发言验证'); await owner.getByRole('article').getByText('成员发言验证', { exact: true }).waitFor();
results.push('default-channel access is automatic; other channels and past epochs remain inaccessible');
const explorer = ownerExplorer;
await explorer.locator('input[type=file]').setInputFiles({ name: 'new-domain-file.txt', mimeType: 'text/plain', buffer: Buffer.from('independent random file key verification') });
await owner.getByRole('button', { name: '继续上传', exact: true }).click(); await member.getByRole('button', { name: 'new-domain-file.txt', exact: true }).waitFor();
await member.getByRole('button', { name: 'new-domain-file.txt', exact: true }).dblclick();
await member.getByRole('dialog').locator('pre').filter({ hasText: 'independent random file key verification' }).waitFor();
results.push('file-group key unwraps an independent file key and decrypts the file on another account');
await member.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click(); await member.reload(); await member.getByRole('article').getByText('加入后的新聊天', { exact: true }).waitFor();
if (await member.getByRole('article').getByText('加入前的历史聊天', { exact: true }).count()) throw new Error('Reload restored disallowed history');
results.push('authorization and epoch boundaries survive reload');
return { passed: results };
} catch (error) {
throw new Error(`${error.message}; failures=${JSON.stringify(failures)}; owner alerts=${JSON.stringify(owner ? await owner.getByRole('alert').allTextContents() : [])}; member alerts=${JSON.stringify(member ? await member.getByRole('alert').allTextContents() : [])}`);
} finally { for (const context of contexts) await context.close(); }
}
+62
View File
@@ -0,0 +1,62 @@
async (page) => {
const origin = new URL(page.url()).origin; const browser = page.context().browser();
const contexts = [await browser.newContext({ viewport: { width: 1440, height: 900 } }), await browser.newContext({ viewport: { width: 1440, height: 900 } })];
for (const context of contexts) context.setDefaultTimeout(10000);
const sender = await contexts[0].newPage(); const viewer = await contexts[1].newPage();
const stamp = Date.now(); const alice = `uploader_${stamp}`; const bob = `reader_${stamp}`;
async function register(target, username, name) {
await target.goto(origin); await target.getByLabel('用户名', { exact: true }).fill(username); await target.getByLabel('密码', { exact: true }).fill('Chat-files-smoke-password-2026');
await target.getByRole('button', { name: '创建账号', exact: true }).click(); await target.getByRole('button', { name: '新建空间', exact: true }).waitFor();
await target.getByLabel('个人名称', { exact: true }).fill(name);
const avatar = await target.evaluate(() => { const canvas = document.createElement('canvas'); canvas.width = 256; canvas.height = 256;
const ctx = canvas.getContext('2d'); ctx.fillStyle = '#447788'; ctx.fillRect(0, 0, 256, 256); return canvas.toDataURL('image/png').split(',')[1]; });
await target.getByLabel('头像', { exact: true }).setInputFiles({ name: 'profile.png', mimeType: 'image/png', buffer: Buffer.from(avatar, 'base64') });
await target.getByRole('img', { name: '个人头像', exact: true }).waitFor(); await target.getByRole('button', { name: '保存个人资料', exact: true }).click();
await target.getByText('个人资料已保存并同步。', { exact: true }).waitFor();
}
const results = [];
try {
await register(sender, alice, '小白'); await register(viewer, bob, '小黑');
await sender.getByRole('button', { name: '新建空间', exact: true }).click(); await sender.getByLabel('空间名称(加入者和服务端可见)').fill('文件聊天验收');
await sender.getByRole('button', { name: '创建', exact: true }).click(); await sender.getByRole('button', { name: '常规', exact: true }).waitFor();
const path = new URL(sender.url()).pathname;
await sender.getByRole('region', { name: '常规 聊天', exact: true }).locator('textarea').fill('你好,这是一份项目文件。');
await sender.getByRole('button', { name: '发送消息', exact: true }).click();
await sender.getByRole('region', { name: '频道文件管理器' }).locator('input[type=file]').setInputFiles({ name: 'project-notes.txt', mimeType: 'text/plain', buffer: Buffer.from('project-content\n'.repeat(140000)) });
await sender.getByRole('button', { name: '继续上传', exact: true }).click(); await sender.getByRole('article', { name: '小白 上传了 project-notes.txt', exact: true }).waitFor();
await sender.getByRole('button', { name: '空间设置', exact: true }).click(); await sender.getByLabel('被邀请用户的用户名').fill(bob);
await sender.getByRole('button', { name: '发送邀请', exact: true }).click(); await sender.getByText(`${bob} · 已邀请`, { exact: true }).waitFor();
await sender.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click();
let downloads = 0; viewer.on('request', (r) => { if (/\/items\/[^/]+\/frames/.test(r.url())) downloads++; });
await viewer.goto(origin + path); await viewer.getByRole('button', { name: '接受并加入空间', exact: true }).click();
const file = viewer.getByRole('article', { name: '小白 上传了 project-notes.txt', exact: true }); await file.waitFor();
await file.getByRole('img', { name: '小白 的头像', exact: true }).waitFor();
const avatar = await file.getByRole('img').evaluate((img) => ({ width: img.naturalWidth, source: img.src.length }));
if (avatar.width !== 64 || avatar.source > 8000) throw new Error('Avatar was not safely resized');
await viewer.getByRole('article', { name: '小白 的消息', exact: true }).getByText('你好,这是一份项目文件。', { exact: true }).waitFor();
if (downloads !== 0) throw new Error('Displaying a file message consumed a download');
results.push('file and text bubbles show the uploader name and bounded avatar on another account');
await file.getByRole('button', { name: '预览', exact: true }).click(); await viewer.getByRole('button', { name: '解密并预览文件', exact: true }).click();
await viewer.getByRole('dialog').locator('pre').filter({ hasText: 'project-content' }).waitFor(); await viewer.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click();
if (downloads !== 1) throw new Error('Explicit file preview did not use exactly one download');
await file.getByRole('img', { name: '小白 的头像', exact: true }).waitFor();
await viewer.reload(); await viewer.getByRole('article', { name: '小白 上传了 project-notes.txt', exact: true }).getByRole('img', { name: '小白 的头像', exact: true }).waitFor();
if (downloads !== 1) throw new Error('Reloading file bubbles consumed a download');
results.push('server-owned identity survives download updates and reload without automatic content reads');
await viewer.getByRole('region', { name: '常规 聊天', exact: true }).locator('textarea').fill('我收到了。'); await viewer.getByRole('button', { name: '发送消息', exact: true }).click();
await sender.getByRole('article', { name: '小黑 的消息', exact: true }).getByText('我收到了。', { exact: true }).waitFor();
results.push('each participant keeps their own sender identity');
await viewer.screenshot({ path: 'C:/Users/JustMe/Desktop/webdrop-proj/.playwright-mcp/chat-file-messages.png', fullPage: true });
for (const width of [1440, 390]) { await viewer.setViewportSize({ width, height: 900 }); for (const colorScheme of ['light', 'dark']) {
await viewer.emulateMedia({ colorScheme });
// Allow the media-query subscription to switch the React workspace layout.
await viewer.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
if (await viewer.evaluate(() => document.documentElement.scrollWidth > innerWidth)) {
const overflow = await viewer.evaluate(() => ({ width: innerWidth, scroll: document.documentElement.scrollWidth,
elements: [...document.querySelectorAll('body *')].filter(e => e.getBoundingClientRect().right > innerWidth + 1).slice(0, 12).map(e => ({ tag: e.tagName, cls: e.className, right: e.getBoundingClientRect().right })) }));
throw new Error(`Chat layout overflow: ${JSON.stringify(overflow)}`);
}
} }
return { passed: results };
} finally { for (const context of contexts) await context.close(); }
}
+99
View File
@@ -0,0 +1,99 @@
async (page) => {
const origin = new URL(page.url()).origin;
const browser = page.context().browser();
const firstContext = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const secondContext = await browser.newContext({ viewport: { width: 1440, height: 900 } });
firstContext.setDefaultTimeout(10000); secondContext.setDefaultTimeout(10000);
const owner = await firstContext.newPage(); const device = await secondContext.newPage();
const username = `explorer_${Date.now()}`; const password = 'Explorer-smoke-password-2026';
const results = [];
try {
await owner.goto(origin); await owner.getByLabel('用户名', { exact: true }).fill(username); await owner.getByLabel('密码', { exact: true }).fill(password);
await owner.getByRole('button', { name: '创建账号', exact: true }).click(); await owner.getByRole('button', { name: '新建空间', exact: true }).click();
await owner.getByLabel('空间名称(加入者和服务端可见)').fill('文件交互验收'); await owner.getByRole('button', { name: '创建', exact: true }).click();
await owner.getByRole('button', { name: '常规', exact: true }).waitFor();
const path = new URL(owner.url()).pathname;
const png = await owner.evaluate(() => { const c = document.createElement('canvas'); c.width = 32; c.height = 32; c.getContext('2d').fillRect(0, 0, 32, 32); return c.toDataURL('image/png').split(',')[1]; });
const explorer = owner.getByRole('region', { name: '频道文件管理器' });
await explorer.locator('input[type=file]').setInputFiles([
{ name: 'a-note.txt', mimeType: 'text/plain', buffer: Buffer.from('hover-alpha') },
{ name: 'b-note.txt', mimeType: 'text/plain', buffer: Buffer.from('hover-beta') },
{ name: 'c-image.png', mimeType: 'image/png', buffer: Buffer.from(png, 'base64') },
{ name: 'z-locked.bin', mimeType: 'application/octet-stream', buffer: Buffer.alloc(2 * 1024 * 1024, 7) },
]);
await owner.getByRole('button', { name: '继续上传', exact: true }).click(); await owner.getByRole('button', { name: 'z-locked.bin', exact: true }).waitFor();
await owner.getByRole('button', { name: '新建文件夹', exact: true }).click(); await owner.getByLabel('文件夹名称', { exact: true }).fill('资料');
await owner.getByRole('button', { name: '创建', exact: true }).click(); await owner.getByRole('button', { name: '资料', exact: true }).waitFor();
await device.goto(origin + path); await device.getByRole('button', { name: '已有账号', exact: true }).click();
await device.getByLabel('用户名', { exact: true }).fill(username); await device.getByLabel('密码', { exact: true }).fill(password);
await device.getByRole('button', { name: '登录并同步', exact: true }).click(); await device.getByRole('button', { name: 'z-locked.bin', exact: true }).waitFor();
let reads = 0; device.on('request', (r) => { if (/\/items\/[^/]+\/frames/.test(r.url())) reads++; });
await device.getByRole('button', { name: 'z-locked.bin', exact: true }).hover(); await device.getByRole('tooltip', { name: '悬浮文件预览' }).waitFor();
if (reads) throw new Error('Hover downloaded a gated file');
await device.getByRole('button', { name: 'a-note.txt', exact: true }).hover();
await device.getByRole('tooltip').locator('pre').filter({ hasText: 'hover-alpha' }).waitFor();
await device.getByRole('button', { name: 'c-image.png', exact: true }).hover(); await device.getByRole('img', { name: '文件缩略预览' }).waitFor();
if (reads) throw new Error('Hover unexpectedly fetched file frames'); results.push('hover previews synced text/images and never reads gated content');
const area = device.getByRole('region', { name: '频道文件管理器' });
await device.getByRole('button', { name: 'a-note.txt', exact: true }).click();
await device.getByRole('button', { name: 'b-note.txt', exact: true }).click({ modifiers: ['Control'] });
if (await area.locator('input[type=checkbox]:checked').count() !== 2) throw new Error('Ctrl multi-select failed');
await device.getByRole('button', { name: 'z-locked.bin', exact: true }).click({ modifiers: ['Shift'] });
if (await area.locator('input[type=checkbox]:checked').count() !== 3) throw new Error('Shift range selection failed');
await device.keyboard.press('Control+a'); if (await area.locator('input[type=checkbox]:checked').count() !== 4) throw new Error('Select all failed');
await device.keyboard.press('Escape'); if (await area.locator('input[type=checkbox]:checked').count()) throw new Error('Escape did not clear selection');
results.push('left click, Ctrl/Shift ranges and keyboard selection work');
await device.getByRole('button', { name: 'a-note.txt', exact: true }).click({ button: 'right' });
await device.getByRole('menu', { name: '文件操作菜单' }).waitFor(); await device.keyboard.press('ArrowDown'); await device.keyboard.press('Escape');
await device.getByRole('button', { name: '文件操作 a-note.txt', exact: true }).click(); await device.getByRole('menuitem', { name: '预览', exact: true }).click();
await device.getByRole('dialog').locator('pre').filter({ hasText: 'hover-alpha' }).waitFor(); await device.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click();
results.push('right click, accessible more-menu and explicit preview work');
const row = (name) => device.getByRole('button', { name, exact: true }).locator('xpath=ancestor::tr');
async function drag(source, target) {
await source.locator('input[type=checkbox]').check();
const start = await source.boundingBox();
await device.mouse.move(start.x + 80, start.y + start.height / 2); await device.mouse.down();
await device.mouse.move(start.x + 95, start.y + start.height / 2, { steps: 4 });
const end = await target.boundingBox();
await device.mouse.move(end.x + end.width / 2, end.y + end.height / 2, { steps: 12 });
await device.mouse.move(end.x + end.width / 2 + 1, end.y + end.height / 2, { steps: 2 }); await device.mouse.up();
}
await row('a-note.txt').dragTo(row('资料'));
await device.getByRole('button', { name: 'a-note.txt', exact: true }).waitFor({ state: 'hidden' });
await device.getByRole('button', { name: '资料', exact: true }).dblclick(); await device.getByRole('button', { name: 'a-note.txt', exact: true }).waitFor();
await drag(row('a-note.txt'), area.locator('button[data-drop-folder="root"]'));
await device.getByRole('button', { name: 'a-note.txt', exact: true }).waitFor({ state: 'hidden', timeout: 10000 }); await area.getByRole('button', { name: '根目录', exact: true }).click();
await device.getByRole('button', { name: 'a-note.txt', exact: true }).waitFor();
results.push('drag moves files into folders and breadcrumb drops return to root');
const transfer = await device.evaluateHandle(() => { const value = new DataTransfer(); value.items.add(new File(['external drop'], 'external-note.txt', { type: 'text/plain' })); return value; });
await row('资料').dispatchEvent('dragover', { dataTransfer: transfer }); await row('资料').dispatchEvent('drop', { dataTransfer: transfer });
await device.getByRole('button', { name: '继续上传', exact: true }).click(); await device.getByRole('button', { name: '资料', exact: true }).dblclick();
await device.getByRole('button', { name: 'external-note.txt', exact: true }).waitFor(); await area.getByRole('button', { name: '根目录', exact: true }).click(); await transfer.dispose();
results.push('external file drops upload directly into the target folder');
await device.getByRole('button', { name: 'a-note.txt', exact: true }).click(); await device.getByRole('button', { name: 'b-note.txt', exact: true }).click({ modifiers: ['Control'] });
await row('a-note.txt').dragTo(row('资料')); await device.getByRole('button', { name: 'b-note.txt', exact: true }).waitFor({ state: 'hidden' });
await device.getByRole('button', { name: '资料', exact: true }).dblclick(); await device.getByRole('button', { name: 'b-note.txt', exact: true }).waitFor();
await device.getByRole('button', { name: 'a-note.txt', exact: true }).click(); await device.getByRole('button', { name: 'b-note.txt', exact: true }).click({ modifiers: ['Control'] });
await device.getByRole('button', { name: 'a-note.txt', exact: true }).click({ button: 'right' }); await device.getByRole('menuitem', { name: '移动到文件夹…', exact: true }).click();
await device.getByRole('button', { name: '移动', exact: true }).click(); await area.getByRole('button', { name: '根目录', exact: true }).click();
await device.getByRole('button', { name: 'a-note.txt', exact: true }).waitFor(); await device.getByRole('button', { name: 'b-note.txt', exact: true }).waitFor();
if (reads) throw new Error('Moving files read file contents'); results.push('batch drag and move dialog preserve encrypted content without downloading');
const handle = device.getByRole('separator', { name: '调整文件区宽度', exact: true }); const box = await handle.boundingBox();
const before = (await area.boundingBox()).width; await device.mouse.move(box.x + 3, box.y + 50); await device.mouse.down(); await device.mouse.move(box.x - 80, box.y + 50); await device.mouse.up();
const wider = (await area.boundingBox()).width; if (wider < before + 60) throw new Error('File panel did not resize');
await handle.focus(); await device.keyboard.press('ArrowRight'); if ((await area.boundingBox()).width >= wider) throw new Error('Keyboard resize failed');
results.push('pointer and keyboard resize the file pane');
await device.evaluate(() => { window.confirm = () => true; });
await device.getByRole('button', { name: 'a-note.txt', exact: true }).click(); await device.getByRole('button', { name: '文件操作 a-note.txt', exact: true }).click();
await device.getByRole('menuitem', { name: '删除', exact: true }).click(); await device.getByRole('button', { name: 'a-note.txt', exact: true }).waitFor({ state: 'hidden' });
await device.reload(); await device.getByRole('button', { name: 'b-note.txt', exact: true }).waitFor();
if (await device.getByRole('button', { name: 'a-note.txt', exact: true }).count()) throw new Error('Deleted file returned after replay');
results.push('context deletion persists after reload');
for (const width of [1440, 390]) {
await device.setViewportSize({ width, height: 900 });
if (width < 900) await device.getByRole('button', { name: '文件', exact: true }).click();
for (const colorScheme of ['light', 'dark']) { await device.emulateMedia({ colorScheme }); if (await device.evaluate(() => document.documentElement.scrollWidth > innerWidth)) throw new Error('Explorer overflow'); }
}
return { passed: results };
} finally { await firstContext.close(); await secondContext.close(); }
}
+44
View File
@@ -0,0 +1,44 @@
async (page) => {
const origin = new URL(page.url()).origin;
const context = await page.context().browser().newContext(); const owner = await context.newPage();
const cdp = await context.newCDPSession(owner); const results = [];
const username = `passkey_${Date.now()}`; const password = 'Passkey-test-password-2026';
const errors = []; const calls = [];
owner.on('pageerror', error => errors.push(error.message));
owner.on('request', request => { if (request.url().includes('/api/v1/')) calls.push(new URL(request.url()).pathname); });
await cdp.send('WebAuthn.enable');
const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', { options: { protocol: 'ctap2', ctap2Version: 'ctap2_1', transport: 'internal', hasResidentKey: true,
hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true, hasPrf: true } });
let secretExtensionSent = false;
owner.on('request', request => { if (request.url().includes('/accounts/passkeys/') && request.postData()) {
const body = JSON.parse(request.postData()); if (body.credential && JSON.parse(body.credential).clientExtensionResults?.prf?.results) secretExtensionSent = true;
} });
try {
await owner.goto(origin); await owner.getByLabel('用户名', { exact: true }).fill(username); await owner.getByLabel('密码', { exact: true }).fill(password);
await owner.getByRole('button', { name: '创建账号', exact: true }).click(); await owner.getByRole('button', { name: '新建空间', exact: true }).waitFor();
await owner.getByLabel('Passkey名称', { exact: true }).fill('测试恢复凭据'); await owner.getByLabel('验证账号密码', { exact: true }).fill(password);
await owner.getByRole('button', { name: '绑定Passkey', exact: true }).click();
await owner.getByText('测试恢复凭据 · 登录与解密恢复', { exact: true }).waitFor();
if (secretExtensionSent) throw new Error('PRF results were sent to the API');
results.push('verified WebAuthn binding enables encrypted recovery without sending PRF outputs to the server');
await owner.getByRole('button', { name: '新建空间', exact: true }).click(); await owner.getByLabel('空间名称(加入者和服务端可见)').fill('Passkey恢复测试');
await owner.getByRole('button', { name: '创建', exact: true }).click(); await owner.getByRole('button', { name: '常规', exact: true }).waitFor();
await owner.getByRole('region', { name: '常规 聊天', exact: true }).locator('textarea').fill('Passkey恢复后的私密内容'); await owner.getByRole('button', { name: '发送消息', exact: true }).click();
await owner.getByRole('article').getByText('Passkey恢复后的私密内容', { exact: true }).waitFor();
const path = new URL(owner.url()).pathname;
await owner.goto(origin); await owner.getByRole('button', { name: '退出登录', exact: true }).click();
await owner.getByRole('button', { name: '使用Passkey登录', exact: true }).waitFor(); await owner.getByRole('button', { name: '使用Passkey登录', exact: true }).click();
await owner.getByRole('button', { name: '新建空间', exact: true }).waitFor(); await owner.goto(origin + path);
await owner.getByRole('article').getByText('Passkey恢复后的私密内容', { exact: true }).waitFor();
results.push('Passkey restores the vault and decrypts resources without entering the password');
await owner.goto(origin); await owner.getByRole('button', { name: '撤销 测试恢复凭据', exact: true }).click(); await owner.getByText('Passkey已撤销', { exact: true }).waitFor();
await owner.getByRole('button', { name: '退出登录', exact: true }).click();
await owner.getByLabel('用户名', { exact: true }).fill(username); await owner.getByLabel('密码', { exact: true }).fill(password);
await owner.getByRole('button', { name: '登录并同步', exact: true }).click(); await owner.getByRole('button', { name: '新建空间', exact: true }).waitFor();
results.push('revocation removes the credential while password recovery remains available');
return { passed: results };
} catch (error) {
const inputs = await owner.locator('input').evaluateAll(inputs => inputs.map(input => ({ name: input.name, type: input.type, length: input.value.length, valid: input.validity.valid, message: input.validationMessage })));
throw new Error(`Passkey smoke failed after ${results.length} checks: ${error.message}; body=${(await owner.locator('body').innerText()).slice(0, 800)}; inputs=${JSON.stringify(inputs)}; errors=${JSON.stringify(errors)}; calls=${JSON.stringify(calls)}`);
} finally { await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }).catch(() => {}); await context.close(); }
}
+38
View File
@@ -0,0 +1,38 @@
async (page) => {
const origin = new URL(page.url()).origin; const browser = page.context().browser(); const context = await browser.newContext(); context.setDefaultTimeout(10000);
let target = await context.newPage(); const username = `remember_${Date.now()}`; const password = 'Remember-login-smoke-password'; const results = [];
async function stored() {
return target.evaluate(async () => {
const db = await new Promise((resolve, reject) => { const req = indexedDB.open('webdrop-account-local-2'); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); });
const tx = db.transaction(['sessions', 'credentials'], 'readonly');
const read = (name) => new Promise((resolve, reject) => { const req = tx.objectStore(name).getAll(); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); });
const [sessions, credentials] = await Promise.all([read('sessions'), read('credentials')]); db.close();
return { sessions: sessions.length, credentials: credentials.length, plaintext: credentials.some((value) => 'password' in value || 'username' in value), exportable: credentials.some((value) => value.key.extractable) };
});
}
async function reopen() { await target.close(); target = await context.newPage(); await target.goto(origin); }
try {
await target.goto(origin); await target.getByLabel('用户名', { exact: true }).fill(username); await target.getByLabel('密码', { exact: true }).fill(password);
await target.getByRole('button', { name: '创建账号', exact: true }).click(); await target.getByRole('button', { name: '退出登录', exact: true }).waitFor();
const record = await stored(); if (record.credentials !== 1 || record.plaintext || record.exportable) throw new Error('Login was not stored as non-exportable encrypted data');
await reopen(); await target.getByRole('button', { name: '退出登录', exact: true }).waitFor(); results.push('fresh page restores the remembered account');
await target.getByRole('button', { name: '退出登录', exact: true }).click(); await target.getByRole('button', { name: '登录并同步', exact: true }).waitFor();
if (await target.getByLabel('用户名', { exact: true }).inputValue() !== username || await target.getByLabel('密码', { exact: true }).inputValue() !== password) throw new Error('Saved account/password were not filled');
results.push('normal logout preserves encrypted credentials and fills the login form');
await target.getByRole('checkbox', { name: '记住账号和密码(本机)', exact: true }).uncheck(); await target.getByRole('button', { name: '登录并同步', exact: true }).click();
await target.getByRole('button', { name: '退出登录', exact: true }).waitFor(); await target.getByRole('button', { name: '立即同步', exact: true }).click();
await target.getByText('资源保险库已同步。', { exact: true }).waitFor();
const temporary = await stored(); if (temporary.sessions || temporary.credentials) throw new Error('Unremembered login was persisted by sync');
await reopen(); await target.getByLabel('用户名', { exact: true }).waitFor();
if (await target.getByLabel('用户名', { exact: true }).inputValue() || await target.getByLabel('密码', { exact: true }).inputValue()) throw new Error('Temporary login reappeared');
results.push('unchecked remember only keeps the current page session');
await target.getByRole('button', { name: '已有账号', exact: true }).click(); await target.getByLabel('用户名', { exact: true }).fill(username); await target.getByLabel('密码', { exact: true }).fill(password);
await target.getByRole('button', { name: '登录并同步', exact: true }).click(); await target.getByRole('button', { name: '清除本机保存', exact: true }).click();
await target.getByRole('button', { name: '立即同步', exact: true }).click(); await target.getByText('资源保险库已同步。', { exact: true }).waitFor();
const cleared = await stored(); if (cleared.sessions || cleared.credentials) throw new Error('Cleared login was restored by sync');
await reopen(); await target.getByLabel('密码', { exact: true }).waitFor();
if (await target.getByLabel('密码', { exact: true }).inputValue()) throw new Error('Password returned after clearing');
results.push('clearing local saves survives synchronization and page restart');
return { passed: results };
} finally { await context.close(); }
}
+10 -3
View File
@@ -1,4 +1,5 @@
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
const semver = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
@@ -8,6 +9,12 @@ const fail = (message) => {
process.exit(1);
};
const dtoSources = ['server/src/WebDrop.Api/Json/ApiJson.cs', 'server/src/WebDrop.Core/Channels/Dtos.cs', 'server/src/WebDrop.Core/Channels/Settings.cs', 'server/src/WebDrop.Core/Common/Enums.cs', 'server/src/WebDrop.Core/Spaces/Workspace.cs', 'server/src/WebDrop.Core/Accounts/AccountProfile.cs', 'server/src/WebDrop.Api/Passkeys/PasskeyDtos.cs', 'server/src/WebDrop.Core/Spaces/WorkspaceCrypto.cs', 'server/src/WebDrop.Api/Endpoints/Endpoints.WorkspaceCrypto.cs'];
const dtoDigest = createHash('sha256').update(dtoSources.map(path => `${path}\n${read(path).replace(/\r\n/g, '\n')}\n`).join('')).digest('hex');
const generatedDigest = /backend-contract-sha256: ([a-f0-9]{64})/.exec(read('web/src/api/generated.ts'))?.[1];
if (generatedDigest !== dtoDigest) fail('Backend DTOs changed. Regenerate with dotnet run --project tools/ContractGenerator -- .');
if (JSON.parse(read('contracts/api.schemas.json'))['x-backend-contract-sha256'] !== dtoDigest) fail('Generated API schemas are stale. Run ContractGenerator.');
if (typeof contract.version !== 'string' || !semver.test(contract.version)) {
fail('contracts/api-contract.json must contain a SemVer version.');
}
@@ -17,14 +24,14 @@ const openapi = read('docs/openapi.yaml');
const openapiVersion = /^info:\s*\r?\n(?:.*\r?\n)*?\s+version:\s*([^\s#]+)/m.exec(openapi)?.[1];
if (openapiVersion !== contract.version) fail(`OpenAPI version ${openapiVersion ?? '(missing)'} differs from ${contract.version}.`);
const backend = read('server/src/WebDrop.Api/ApiContract.cs');
const backend = read('server/src/WebDrop.Api/Hosting/ApiContract.cs');
const backendVersion = /const string Version = "([^"]+)"/.exec(backend)?.[1];
if (backendVersion !== contract.version) fail(`backend version ${backendVersion ?? '(missing)'} differs from ${contract.version}.`);
const frontend = read('web/src/core-bridge/contract.ts');
const frontend = read('web/src/api/contract.ts');
const supportedMajor = Number(/SUPPORTED_API_CONTRACT_MAJOR\s*=\s*(\d+)/.exec(frontend)?.[1]);
if (supportedMajor !== apiMajor) {
fail(`backend contract is ${contract.version}; frontend still supports API major ${supportedMajor || '(missing)'}. Update web/src/core-bridge/contract.ts.`);
fail(`backend contract is ${contract.version}; frontend still supports API major ${supportedMajor || '(missing)'}. Update web/src/api/contract.ts.`);
}
const tag = process.argv[2];
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
npm_cache="${NPM_CONFIG_CACHE:-$HOME/.cache/woodpecker/webdrop/npm}"
mkdir -p "$npm_cache"
cd "$repo_root/packages/core"
npm ci --cache "$npm_cache" --prefer-offline --no-audit
npm run typecheck
npm test
npm run vectors:check
npm run build
cd "$repo_root/web"
npm ci --cache "$npm_cache" --prefer-offline --no-audit
npm run typecheck
npm run lint:css
npm test
npm run build
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# 构建并推送一个镜像到 registry。开发镜像和发布镜像、主后端和文件服务都用它,构建和推送的细节只写在这里。
# 用法: ci-publish-image.sh <镜像名> <Containerfile> <版本标签> [额外标签...]
# 例: ci-publish-image.sh webdrop-api server/Containerfile dev-abc1234 dev
# 需要环境变量: ZOT_REGISTRY、ZOT_USERNAME、ZOT_SECRET(Woodpecker 的密钥)
set -euo pipefail
name="$1"; containerfile="$2"; version="$3"; shift 3
: "${ZOT_REGISTRY:?}" "${ZOT_USERNAME:?}" "${ZOT_SECRET:?}"
: "${WOODPECKER_BACKEND_LOCAL_TEMP_DIR:=$HOME/woodpecker/work}"
# 每个镜像一个构建缓存目录,两个镜像并行构建时互不干扰
store="$(dirname "$WOODPECKER_BACKEND_LOCAL_TEMP_DIR")/buildah-cache/$name"
mkdir -p "$store/storage" "$store/run"
buildah() { command buildah --root "$store/storage" --runroot "$store/run" "$@"; }
printf '%s' "$ZOT_SECRET" | buildah login --tls-verify=true --username "$ZOT_USERNAME" --password-stdin "$ZOT_REGISTRY"
trap 'buildah logout "$ZOT_REGISTRY" >/dev/null 2>&1 || true' EXIT
commit="${CI_COMMIT_SHA:-$(git rev-parse HEAD)}"
image="$ZOT_REGISTRY/justme/$name:$version"
buildah bud --layers --network host --format oci --platform linux/amd64 \
--build-arg "WEBDROP_FRONTEND_COMMIT=$commit" --build-arg "WEBDROP_FRONTEND_TAG=$version" \
--build-arg "WEBDROP_BACKEND_COMMIT=$commit" --build-arg "WEBDROP_BACKEND_TAG=$version" \
--build-arg "WEBDROP_BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--tag "$image" --file "$containerfile" .
buildah push "$image" "docker://$image"
for extra in "$@"; do
buildah tag "$image" "$ZOT_REGISTRY/justme/$name:$extra"
buildah push "$ZOT_REGISTRY/justme/$name:$extra" "docker://$ZOT_REGISTRY/justme/$name:$extra"
done
+39
View File
@@ -0,0 +1,39 @@
$ErrorActionPreference = 'Stop'
function ConvertTo-WslPath([string]$Path) {
$fullPath = [IO.Path]::GetFullPath($Path)
if ($fullPath -notmatch '^([A-Za-z]):\\(.*)$') { throw "Workspace must be on a Windows drive: $fullPath" }
$drive = $Matches[1].ToLowerInvariant()
$relative = $Matches[2].Replace('\', '/')
return "/mnt/$drive/$relative"
}
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$repoWsl = ConvertTo-WslPath $repoRoot
$unitPath = (Resolve-Path (Join-Path $PSScriptRoot '..\deploy\webdrop.container')).Path
$unitWsl = ConvertTo-WslPath $unitPath
$updatePath = (Resolve-Path (Join-Path $PSScriptRoot 'wsl-podman-update.sh')).Path
$updateWsl = ConvertTo-WslPath $updatePath
$commit = (& git -C $repoRoot rev-parse --short=12 HEAD).Trim()
if ($LASTEXITCODE -ne 0) { throw 'Could not read the current Git commit.' }
$tagOutput = & git -C $repoRoot describe --tags --exact-match HEAD 2>$null
$tag = if ($tagOutput) { "$tagOutput".Trim() } else { 'dev' }
$buildTime = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTimeOffset]::UtcNow, 'China Standard Time').ToString('yyyy-MM-dd HH:mm')
$deployHome = '/home/justme/podman/webdrop'
$configDir = '/home/justme/.config/containers/systemd'
& wsl.exe -d Debian -- mkdir -p $deployHome $configDir
if ($LASTEXITCODE -ne 0) { throw 'Could not create the WSL deployment directories.' }
& wsl.exe -d Debian -- install -D -m 755 $updateWsl "$deployHome/update.sh"
if ($LASTEXITCODE -ne 0) { throw 'Could not install the WSL update script.' }
& wsl.exe -d Debian -- install -D -m 644 $unitWsl "$deployHome/webdrop.container"
if ($LASTEXITCODE -ne 0) { throw 'Could not install the WebDrop Quadlet.' }
& wsl.exe -d Debian -- ln -sfn "$deployHome/webdrop.container" "$configDir/webdrop.container"
if ($LASTEXITCODE -ne 0) { throw 'Could not link the Quadlet into the user systemd directory.' }
& wsl.exe -d Debian -- bash "$deployHome/update.sh" $repoWsl $commit $tag $buildTime
if ($LASTEXITCODE -ne 0) { throw 'The WSL Podman build or service update failed.' }
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
set -Eeuo pipefail
source_dir="${1:?Pass the workspace path in WSL format}"
frontend_commit="${2:-unknown}"
frontend_tag="${3:-dev}"
build_time="${4:-unknown}"
deploy_dir="$HOME/podman/webdrop"
source_dir="$(realpath "$source_dir")"
# The WSL proxy exports lowercase names; containers/image reads the uppercase names.
export HTTP_PROXY="${HTTP_PROXY:-${http_proxy:-}}"
export HTTPS_PROXY="${HTTPS_PROXY:-${https_proxy:-}}"
export NO_PROXY="${NO_PROXY:-${no_proxy:-}}"
if [[ ! -f "$source_dir/server/Containerfile" ]]; then
echo "Not a WebDrop source tree: $source_dir" >&2
exit 2
fi
mkdir -p "$deploy_dir/src" "$deploy_dir/data"
# Seed the WSL data directory once; later updates leave runtime data untouched.
if [[ -d "$source_dir/data" ]] && [[ -z "$(find "$deploy_dir/data" -mindepth 1 -print -quit)" ]]; then
rsync -a "$source_dir/data/" "$deploy_dir/data/"
fi
# Keep build inputs on WSL's Linux filesystem, not the slower /mnt/c mount.
rsync -a --delete \
--exclude='/.git/' \
--exclude='/.env' --exclude='/.env.*' \
--exclude='/data/' \
--exclude='**/node_modules/' --exclude='**/dist/' \
--exclude='**/bin/' --exclude='**/obj/' \
--exclude='**/*.tsbuildinfo' \
"$source_dir/" "$deploy_dir/src/"
podman build \
--layers \
--platform linux/amd64 \
--format oci \
--file "$deploy_dir/src/server/Containerfile" \
--tag localhost/webdrop:local \
--build-arg "WEBDROP_FRONTEND_COMMIT=$frontend_commit" \
--build-arg "WEBDROP_FRONTEND_TAG=$frontend_tag" \
--build-arg "WEBDROP_BACKEND_COMMIT=$frontend_commit" \
--build-arg "WEBDROP_BACKEND_TAG=$frontend_tag" \
--build-arg "WEBDROP_BUILD_TIME=$build_time" \
"$deploy_dir/src"
systemctl --user daemon-reload
if systemctl --user is-active --quiet webdrop.service; then
systemctl --user restart webdrop.service
else
systemctl --user start webdrop.service
fi
for attempt in {1..30}; do
if curl --fail --silent http://127.0.0.1:5000/api/v1/info >/dev/null; then
echo "WebDrop updated and responding at http://127.0.0.1:5000"
podman ps --filter name=webdrop --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
exit 0
fi
sleep 1
done
systemctl --user status webdrop.service --no-pager || true
journalctl --user -u webdrop.service -n 80 --no-pager || true
exit 1
+13 -4
View File
@@ -5,18 +5,27 @@ COPY packages/core/package.json ./packages/core/package.json
RUN cd web && npm ci --no-audit --no-fund
COPY packages/core/ ./packages/core/
COPY web/ ./web/
ARG WEBDROP_FRONTEND_COMMIT=unknown
ARG WEBDROP_FRONTEND_TAG=unknown
ARG WEBDROP_BACKEND_COMMIT=unknown
ARG WEBDROP_BACKEND_TAG=unknown
ARG WEBDROP_BUILD_TIME=unknown
ENV WEBDROP_FRONTEND_COMMIT=$WEBDROP_FRONTEND_COMMIT \
WEBDROP_FRONTEND_TAG=$WEBDROP_FRONTEND_TAG \
WEBDROP_BACKEND_COMMIT=$WEBDROP_BACKEND_COMMIT \
WEBDROP_BACKEND_TAG=$WEBDROP_BACKEND_TAG \
WEBDROP_BUILD_TIME=$WEBDROP_BUILD_TIME
RUN npm run build --prefix web
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble-aot AS build
WORKDIR /src
RUN apt-get update \
&& apt-get install -y --no-install-recommends clang zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
COPY server/Directory.Build.props ./server/
COPY server/src/WebDrop.Api/WebDrop.Api.csproj ./server/src/WebDrop.Api/
COPY server/src/WebDrop.Core/WebDrop.Core.csproj ./server/src/WebDrop.Core/
COPY server/src/WebDrop.FileProtocol/WebDrop.FileProtocol.csproj ./server/src/WebDrop.FileProtocol/
COPY server/src/WebDrop.Frontend/WebDrop.Frontend.csproj ./server/src/WebDrop.Frontend/
COPY server/src/WebDrop.Storage/WebDrop.Storage.csproj ./server/src/WebDrop.Storage/
COPY server/src/WebDrop.Sync/WebDrop.Sync.csproj ./server/src/WebDrop.Sync/
RUN dotnet restore server/src/WebDrop.Api/WebDrop.Api.csproj \
--runtime linux-x64
COPY server/ ./server/
+37
View File
@@ -0,0 +1,37 @@
# 文件服务(fileAPI):单独部署,放在存储所在的机器(例如家里的 NAS)。只依赖 FileProtocol 和 Sync,不带前端和数据库
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble-aot AS build
WORKDIR /src
COPY server/Directory.Build.props ./server/
COPY server/src/WebDrop.FileApi/WebDrop.FileApi.csproj ./server/src/WebDrop.FileApi/
COPY server/src/WebDrop.FileProtocol/WebDrop.FileProtocol.csproj ./server/src/WebDrop.FileProtocol/
COPY server/src/WebDrop.Sync/WebDrop.Sync.csproj ./server/src/WebDrop.Sync/
RUN dotnet restore server/src/WebDrop.FileApi/WebDrop.FileApi.csproj \
--runtime linux-x64
COPY server/src/WebDrop.FileApi/ ./server/src/WebDrop.FileApi/
COPY server/src/WebDrop.FileProtocol/ ./server/src/WebDrop.FileProtocol/
COPY server/src/WebDrop.Sync/ ./server/src/WebDrop.Sync/
RUN dotnet publish server/src/WebDrop.FileApi/WebDrop.FileApi.csproj \
--configuration Release \
--runtime linux-x64 \
--self-contained true \
--no-restore \
--output /out
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0 AS runtime
ARG WEBDROP_BACKEND_COMMIT=unknown
ARG WEBDROP_BACKEND_TAG=unknown
ARG WEBDROP_BUILD_TIME=unknown
LABEL org.opencontainers.image.revision=$WEBDROP_BACKEND_COMMIT \
org.opencontainers.image.version=$WEBDROP_BACKEND_TAG \
org.opencontainers.image.created=$WEBDROP_BUILD_TIME
WORKDIR /app
COPY --from=build /out/webdrop-files /app/webdrop-files
RUN mkdir -p /data \
&& chown 10001:10001 /data \
&& chmod 0750 /data
ENV ASPNETCORE_URLS=http://+:5100 \
FileApi__DataDir=/data \
DOTNET_EnableDiagnostics=0
USER 10001:10001
EXPOSE 5100
ENTRYPOINT ["/app/webdrop-files"]
+2 -2
View File
@@ -42,14 +42,14 @@ python3 scripts/smoke.py out/linux-x64/webdrop # 跨平台版本,只用
| `Captcha:ApiEndpoint` | 空 | 浏览器里 Cap widget 连接的地址,形如 `https://cap.example.com/<site_key>/`,经 `/info` 下发 |
| `Captcha:SiteverifyUrl` | 空 | 本服务调用 Cap 校验 token 的地址,形如 `https://cap.example.com/<site_key>/siteverify`,可以是内网地址 |
| `Captcha:Secret` | 空 | Cap 站点密钥的 secret,只在服务端使用 |
| `Limits:*` | 见 `ServerLimits` | 负载、帧数、频道字节数、席位、TTL 上限等 |
| `Limits:*` | 见 `ServerLimits` | 负载、帧数、频道字节数、成员人数上限、TTL 上限等 |
## 约束
- 单实例部署:订阅枢纽在进程内,多实例需要换成外部发布订阅。
- 日志不记录请求头、请求体、完整 URL;Hosting 诊断日志已关闭,避免频道 id 进入日志。
- NativeAOT:不用 SignalR、EF Core、MVC、反射式 JSON;所有 AOT / 裁剪警告视为错误。
- `WebDrop.Frontend` 只负责嵌入静态资源与 SPA fallback。Dockerfile 自动构建 `web/`;单独发布 API 时,只有 `web/dist` 存在或指定 `WebDropFrontendDist` 后才包含前端。
- `WebDrop.Frontend` 只负责嵌入静态资源与 SPA fallback。`Containerfile` 自动构建 `web/`;单独发布 API 时,只有 `web/dist` 存在或指定 `WebDropFrontendDist` 后才包含前端。
## 验证码(Cap)
+12 -2
View File
@@ -1,9 +1,19 @@
<Solution>
<Folder Name="/src/">
<!-- 主后端(hostAPI):账号、空间、频道、聊天、实时推送,以及内嵌的前端 -->
<Folder Name="/src/host/">
<Project Path="src/WebDrop.Api/WebDrop.Api.csproj" />
<Project Path="src/WebDrop.Core/WebDrop.Core.csproj" />
<Project Path="src/WebDrop.Frontend/WebDrop.Frontend.csproj" />
</Folder>
<!-- 文件服务(fileAPI):另一个进程,只保存超过阈值的大文件 -->
<Folder Name="/src/files/">
<Project Path="src/WebDrop.FileApi/WebDrop.FileApi.csproj" />
<Project Path="src/WebDrop.FileProtocol/WebDrop.FileProtocol.csproj" />
</Folder>
<!-- 两边共用:业务规则、数据库、S3 备份 -->
<Folder Name="/src/shared/">
<Project Path="src/WebDrop.Core/WebDrop.Core.csproj" />
<Project Path="src/WebDrop.Storage/WebDrop.Storage.csproj" />
<Project Path="src/WebDrop.Sync/WebDrop.Sync.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/WebDrop.Api.Tests/WebDrop.Api.Tests.csproj" />
+3 -3
View File
@@ -46,12 +46,12 @@ try {
$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) '创建频道成功'
Check ($created.id -eq (B64 $id) -and $created.members_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) '成员加入'
$joined = Invoke-RestMethod "$base/api/v1/channels/$cid/join" -Method Post -Headers $memberH
Check ($joined.members_used -eq 2) '成员加入'
# enc 帧:负载 = 1024 档位 + 16 标签
$item = Rand 16
+2 -2
View File
@@ -71,8 +71,8 @@ try:
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, "成员加入")
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)
-7
View File
@@ -1,7 +0,0 @@
namespace WebDrop.Api;
public static class ApiContract
{
// Keep this aligned with contracts/api-contract.json and docs/openapi.yaml.
public const string Version = "1.0.0";
}
-66
View File
@@ -1,66 +0,0 @@
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("contract_version")] public string ContractVersion { get; init; } = ApiContract.Version;
[JsonPropertyName("captcha")] public required CaptchaBody Captcha { get; init; }
[JsonPropertyName("transports")] public required TransportsBody Transports { get; init; }
[JsonPropertyName("limits")] public required LimitsBody Limits { get; init; }
}
/// <summary>告诉前端要不要出示验证码、widget 该连哪里(Cap 的 data-cap-api-endpoint)。</summary>
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<SeatInfo>))]
[JsonSerializable(typeof(AppendResult))]
[JsonSerializable(typeof(SiteverifyRequest))]
[JsonSerializable(typeof(SiteverifyResponse))]
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
public sealed partial class ApiJson : JsonSerializerContext;
-104
View File
@@ -1,104 +0,0 @@
using WebDrop.Core;
namespace WebDrop.Api;
/// <summary>
/// 进程内的订阅枢纽。只传递「有变化」的信号,订阅者被唤醒后自己从日志里按游标读取,
/// 所以不存在回放与实时之间漏帧的竞态,也不需要为慢客户端缓冲数据。
/// 因此只支持单实例部署(见 architecture.md)。
/// </summary>
public sealed class ChannelHub : IChannelEvents
{
private readonly object _gate = new();
private readonly Dictionary<Id16, Signal> _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<Signal> 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);
}
/// <summary>
/// 用法:先取 <see cref="Changed"/>,再读状态,最后等待取到的那个任务。
/// 取任务之后发生的任何发布都会让它完成,不会丢通知。
/// </summary>
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);
}
}
}
@@ -0,0 +1,139 @@
using Microsoft.AspNetCore.Http.Features;
using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api;
public static partial class Endpoints
{
private static void MapAccounts(RouteGroupBuilder api, SqliteAccountStore accounts)
{
api.MapGet("/accounts/profile", (HttpContext ctx) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
return Json(accounts.Profile(username).WithoutAvatar(), ApiJson.Default.AccountProfile);
});
// 头像单独取:默认给有损的缩略图(列表、聊天用),size=full 才是原图(预览时)。带 ETag,没变化时只回 304。需要登录账号
api.MapGet("/accounts/users/{username}/avatar", (string username, HttpContext ctx, ChannelService svc) =>
{
if (!TryAccount(ctx, accounts, out var viewer)) throw new WebDropException(ErrorCode.AccountRequired);
// 只有和对方同在一个空间(或就是自己)才能看头像,不能靠猜用户名批量抓取
if (!svc.SharesSpace(viewer, NormalizeUsername(username))) throw new WebDropException(ErrorCode.NotFound);
return AvatarResult(ctx, accounts.ProfileAvatar(NormalizeUsername(username), ctx.Request.Query["size"] == "full"));
});
api.MapPatch("/accounts/profile", async (HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
// 动图头像比其他请求大得多,单独放宽(全局上限 2 MB)
if (ctx.Features.Get<IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } size) size.MaxRequestBodySize = 8 * 1024 * 1024;
var req = await ReadJsonCompressed(ctx.Request, ApiJson.Default.AccountProfileRequest, 12 * 1024 * 1024, ct);
var profile = accounts.UpdateProfile(username, req.DisplayName, req.Avatar, req.AvatarThumb, req.KeepAvatar);
svc.AccountProfileChanged(username);
return Json(profile.WithoutAvatar(), ApiJson.Default.AccountProfile);
});
api.MapPost("/accounts", async (HttpContext ctx, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountRegisterRequest, ct);
var username = NormalizeUsername(req.Username);
var salt = DecodeBase64(req.Salt, 16, 16);
var verifier = DecodeBase64(req.Verifier, 32, 32);
var vault = DecodeBase64(req.Vault, 28, 1_300_000);
ValidatePublicKey(req.PublicKey);
ValidatePublicKey(req.SigningPublicKey);
if (!accounts.Register(username, salt, verifier, vault, req.PublicKey, req.SigningPublicKey)) return Json(new ErrorBody { Error = "username_unavailable" }, ApiJson.Default.ErrorBody, StatusCodes.Status409Conflict);
return Results.NoContent();
}).RequireRateLimiting(CreatePolicy);
api.MapPost("/accounts/salt", async (HttpContext ctx, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountSaltRequest, ct);
var username = NormalizeUsername(req.Username);
var salt = accounts.LoginSalt(username);
return Json(new AccountSaltBody { Salt = Convert.ToBase64String(salt) }, ApiJson.Default.AccountSaltBody);
}).RequireRateLimiting(CreatePolicy);
api.MapPost("/accounts/session", async (HttpContext ctx, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountSessionRequest, ct);
var username = NormalizeUsername(req.Username);
var verifier = DecodeBase64(req.Verifier, 32, 32);
var token = accounts.Authenticate(username, verifier, req.DeviceId, req.DeviceName);
if (token is null) return Json(new ErrorBody { Error = "account_invalid" }, ApiJson.Default.ErrorBody, StatusCodes.Status401Unauthorized);
return Json(new AccountSessionBody { Token = token, Username = username }, ApiJson.Default.AccountSessionBody);
}).RequireRateLimiting(CreatePolicy);
api.MapGet("/accounts/vault", (HttpContext ctx) =>
{
if (!TryAccount(ctx, accounts, out var username)) return Json(new ErrorBody { Error = "account_invalid" }, ApiJson.Default.ErrorBody, StatusCodes.Status401Unauthorized);
var vault = accounts.GetVault(username);
return vault is null ? Results.NotFound() : Json(new AccountVaultBody { Revision = vault.Value.Revision, Vault = Convert.ToBase64String(vault.Value.Vault) }, ApiJson.Default.AccountVaultBody);
});
api.MapGet("/accounts/devices", (HttpContext ctx) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var current = accounts.SessionDevice(ctx.Request.Headers["X-WD-Account"].ToString());
var devices = accounts.Devices(username, current).Select(d => new AccountDeviceBody
{ Id = d.Id, Name = d.Name, CreatedAt = d.CreatedAt, LastSeen = d.LastSeen, Current = d.Current }).ToList();
return Json(devices, ApiJson.Default.ListAccountDeviceBody);
});
api.MapPatch("/accounts/devices/{id}", async (string id, HttpContext ctx, CancellationToken ct) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountDeviceNameRequest, ct);
if (req.Name is null || !accounts.RenameDevice(username, id, req.Name.Trim())) throw new WebDropException(ErrorCode.BadRequest);
return Results.NoContent();
});
api.MapPut("/accounts/devices/signing-key", async (HttpContext ctx, SqliteAccountStore accountStore, CancellationToken ct) =>
{
if (!TryAccount(ctx, accountStore, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountDeviceSigningRequest, ct);
var token = ctx.Request.Headers["X-WD-Account"].ToString();
var deviceId = accountStore.SessionDevice(token);
if (deviceId is null || !string.Equals(deviceId, req.DeviceId, StringComparison.OrdinalIgnoreCase)) throw new WebDropException(ErrorCode.Forbidden);
ValidatePublicKey(req.PublicKey);
var rootKey = accountStore.GetSigningPublicKey(username);
if (rootKey is null) throw new WebDropException(ErrorCode.BadRequest);
using var root = System.Text.Json.JsonDocument.Parse(rootKey);
var rootObject = root.RootElement;
if (!Codec.TryDecodeExact(rootObject.GetProperty("x").GetString(), 32, out var rootX)
|| !Codec.TryDecodeExact(rootObject.GetProperty("y").GetString(), 32, out var rootY)) throw new WebDropException(ErrorCode.BadRequest);
using var device = System.Text.Json.JsonDocument.Parse(req.PublicKey);
var deviceObject = device.RootElement;
if (!Codec.TryDecodeExact(deviceObject.GetProperty("x").GetString(), 32, out var deviceX)
|| !Codec.TryDecodeExact(deviceObject.GetProperty("y").GetString(), 32, out var deviceY)) throw new WebDropException(ErrorCode.BadRequest);
var payload = SignatureCodec.DeviceCertificatePayload(username, deviceId, deviceX, deviceY);
var signature = DecodeBase64(req.Signature, 64, 64);
if (!SignatureCodec.VerifyP256(rootX, rootY, "device.certificate", payload, signature)) throw new WebDropException(ErrorCode.Forbidden);
if (!accountStore.SaveDeviceSigningKey(username, deviceId, req.PublicKey, signature)) throw new WebDropException(ErrorCode.Forbidden);
return Results.NoContent();
});
api.MapDelete("/accounts/devices/{id}", (string id, HttpContext ctx, ChannelService svc) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
accounts.RevokeDevice(username, id);
return Results.NoContent();
});
api.MapPut("/accounts/vault", async (HttpContext ctx, CancellationToken ct) =>
{
if (!TryAccount(ctx, accounts, out var username)) return Json(new ErrorBody { Error = "account_invalid" }, ApiJson.Default.ErrorBody, StatusCodes.Status401Unauthorized);
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountVaultRequest, ct);
if (req.Revision < 0) return Results.BadRequest();
var vault = DecodeBase64(req.Vault, 28, 1_300_000);
var next = accounts.PutVault(username, vault, req.Revision);
return next is null ? Json(new ErrorBody { Error = "vault_conflict" }, ApiJson.Default.ErrorBody, StatusCodes.Status409Conflict) : Json(new AccountVaultBody { Revision = next.Value, Vault = req.Vault }, ApiJson.Default.AccountVaultBody);
});
api.MapDelete("/accounts/session", (HttpContext ctx) =>
{
var accountToken = ctx.Request.Headers["X-WD-Account"].ToString();
if (accountToken.Length > 0) { accounts.RevokeSession(accountToken); return Results.NoContent(); }
var header = ctx.Request.Headers["Authorization"].ToString();
if (!header.StartsWith("Bearer ", StringComparison.Ordinal)) return Results.NoContent();
accounts.RevokeSession(header[7..]);
return Results.NoContent();
});
}
}
@@ -0,0 +1,117 @@
using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api;
public static partial class Endpoints
{
private static void MapChannelAccess(RouteGroupBuilder api, SqliteAccountStore accounts)
{
api.MapGet("/accounts/spaces", (HttpContext ctx, ChannelService svc) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
return Json(svc.AccountSpaces(username).ToList(), ApiJson.Default.ListChannelInfo);
});
// 空间列表的实时提示:账号所在空间有新内容就推一条 {id,last_seq}。只含序号,不含内容
api.MapGet("/accounts/spaces/events", async (HttpContext ctx, ChannelService svc, ChannelHub hub) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var ct = ctx.RequestAborted;
ctx.Response.ContentType = "text/event-stream";
ctx.Response.Headers["X-Accel-Buffering"] = "no";
await ctx.Response.StartAsync(ct);
var sent = new Dictionary<Id16, long>();
try
{
while (!ct.IsCancellationRequested)
{
// 每次唤醒都重新核对登录:退出登录、被移除设备后,这条流立刻结束
if (!TryAccount(ctx, accounts, out var current) || current != username)
{
await ctx.Response.WriteAsync("event: error\ndata: {\"error\":\"account_required\"}\n\n", ct);
break;
}
var changed = hub.AnyAdvanced;
foreach (var id in svc.AccountSpaceIds(username))
{
var seq = hub.AdvancedSeq(id);
// 第一次只记下当前值:连接建立前的进展客户端已经从列表里拿到了
if (!sent.TryGetValue(id, out var known)) { sent[id] = seq; continue; }
if (seq <= known) continue;
sent[id] = seq;
await ctx.Response.WriteAsync($"event: space\ndata: {{\"id\":\"{id.ToBase64Url()}\",\"last_seq\":{seq}}}\n\n", ct);
}
await ctx.Response.Body.FlushAsync(ct);
var idle = Task.Delay(TimeSpan.FromSeconds(20), ct);
// 久无变化就发心跳,客户端据此判断连接还活着
if (await Task.WhenAny(changed, idle) == idle) { await ctx.Response.WriteAsync("event: ping\ndata: 1\n\n", ct); await ctx.Response.Body.FlushAsync(ct); }
}
}
catch (OperationCanceledException) { }
return Results.Empty;
});
api.MapGet("/accounts/channels", (HttpContext ctx, ChannelService svc) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var packages = svc.AccountChannels(username).Select(k => new AccountChannelBody
{
ChannelId = k.ChannelId.ToBase64Url(), Role = k.Role, Payload = k.Payload, CreatedAt = k.CreatedAt,
}).ToList();
return Json(packages, ApiJson.Default.ListAccountChannelBody);
});
api.MapPut("/channels/{id}/key-package", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.KeyPackageRequest, ct);
svc.SaveOwnerKeyPackage(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Payload);
return Results.NoContent();
});
api.MapGet("/accounts/users/{username}/key", (string username, HttpContext ctx) =>
{
if (!TryAccount(ctx, accounts, out _)) throw new WebDropException(ErrorCode.AccountRequired);
var key = accounts.GetPublicKey(NormalizeUsername(username));
if (key is null) throw new WebDropException(ErrorCode.NotFound);
return Json(new PublicKeyBody { PublicKey = key, SigningPublicKey = accounts.GetSigningPublicKey(NormalizeUsername(username)) }, ApiJson.Default.PublicKeyBody);
});
api.MapGet("/accounts/invitations", (HttpContext ctx, ChannelService svc) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
return Json(InvitationBodies(svc.UserInvitations(username)), ApiJson.Default.ListInvitationBody);
});
api.MapGet("/channels/{id}/invitations", (string id, HttpContext ctx, ChannelService svc) =>
Json(svc.InvitationsView(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false)).Select(view => new InvitationBody
{ ChannelId = view.Invitation.ChannelId.ToBase64Url(), Username = view.Invitation.Username, Payload = view.Invitation.Payload, CreatedAt = view.Invitation.CreatedAt, CanRevoke = view.CanRevoke }).ToList(), ApiJson.Default.ListInvitationBody));
api.MapGet("/channels/{id}/invitations/{username}/crypto-plan", (string id, string username, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore) =>
{
username = NormalizeUsername(username);
if (accountStore.GetPublicKey(username) is null) throw new WebDropException(ErrorCode.NotFound);
return Json(svc.InvitationPlan(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), username), ApiJson.Default.InvitationCryptoPlan);
});
api.MapGet("/channels/{id}/members", (string id, HttpContext ctx, ChannelService svc, ChannelHub hub) =>
{
var channelId = ParseId(id);
var members = svc.MembersView(channelId, RequestAuth.FromHeaders(ctx.Request, false)).Select(view =>
{
var member = view.Member;
var (online, onlineDevices) = hub.Presence(channelId, member.Username);
// 设备是账号的属性:总数取账号的登录设备,在线数取该账号在本频道有订阅的设备
var devices = accounts.Devices(member.Username, null);
return new MemberBody { Username = member.Username, Role = member.Role, JoinedAt = member.JoinedAt,
Online = online, Devices = Math.Max(devices.Count, onlineDevices), OnlineDevices = onlineDevices,
LastSeen = devices.Count == 0 ? 0 : devices.Max(device => device.LastSeen), CanRemove = view.CanRemove };
}).ToList();
return Json(members, ApiJson.Default.ListMemberBody);
});
api.MapPost("/channels/{id}/invitations", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.InvitationRequest, ct);
var username = NormalizeUsername(req.Username);
if (accounts.GetPublicKey(username) is null) throw new WebDropException(ErrorCode.NotFound);
svc.InviteUser(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), username, req.Payload, req.DomainEnvelopes ?? []); return Results.NoContent();
});
api.MapDelete("/channels/{id}/invitations/{username}", (string id, string username, HttpContext ctx, ChannelService svc) =>
{
svc.RevokeInvitation(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), NormalizeUsername(username)); return Results.NoContent();
});
}
}
@@ -0,0 +1,58 @@
using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api;
public static partial class Endpoints
{
/// <summary>
/// 文件服务的票据:权限由空间决定,通过后给客户端一张短期票据,客户端拿着它直接找文件服务。
/// create 是所有者/管理员创建文件域;put/get 是按频道权限上传、下载某个条目。
/// </summary>
private static void MapFiles(RouteGroupBuilder api)
{
// 一次登记整个文件:请求体 = 末帧的 26 字节头部 + 每一帧的 [负载长度 u32 大端][整帧 SHA-256 32 字节]。
// 字节早已存进文件域;逐帧登记要为每个 1 MiB 往返一次,这里一次搞定
api.MapPost("/channels/{id}/files", async (string id, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore, ServerLimits limits, CancellationToken ct) =>
{
var channelId = ParseId(id);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false);
long? maxDownloads = ParseMaxDownloads(ctx.Request.Headers["X-WD-Max-Downloads"].ToString());
const int EntrySize = 36;
int max = Envelope.HeaderSize + limits.MaxFramesPerItem * EntrySize;
if (ctx.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } size) size.MaxRequestBodySize = max;
byte[] body = await ReadBody(ctx.Request, max, ct);
if (body.Length < Envelope.HeaderSize + EntrySize || (body.Length - Envelope.HeaderSize) % EntrySize != 0) throw new WebDropException(ErrorCode.BadRequest);
var header = body.AsMemory(0, Envelope.HeaderSize);
var entries = new List<ChannelService.FrameEntry>((body.Length - Envelope.HeaderSize) / EntrySize);
for (int at = Envelope.HeaderSize; at < body.Length; at += EntrySize)
entries.Add(new ChannelService.FrameEntry((int)System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(body.AsSpan(at, 4)), body.AsSpan(at + 4, 32).ToArray()));
var signedEvent = ParseSignedContentEvent(ctx.Request, accountStore, channelId, creds, header.ToArray());
var result = svc.RegisterFileItem(channelId, creds, header, entries, maxDownloads, signedEvent);
return Json(result, ApiJson.Default.AppendResult, result.Created ? StatusCodes.Status201Created : StatusCodes.Status200OK);
});
api.MapPost("/spaces/{id}/file-grants", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var spaceId = ParseId(id);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false);
var req = await ReadJson(ctx.Request, ApiJson.Default.FileGrantRequest, ct);
FileGrant grant;
if (req.Op == "create") grant = svc.IssueCreateGrant(spaceId, creds);
else if (req.Op == "domain") grant = svc.IssueDomainGrant(spaceId, creds, req.Channel ?? throw new WebDropException(ErrorCode.BadRequest));
else
{
if (req.Item is null || req.From is < 0 or > uint.MaxValue) throw new WebDropException(ErrorCode.BadRequest);
var item = ParseId(req.Item);
grant = req.Op switch
{
"put" => svc.IssueUploadGrant(spaceId, creds, item),
"get" => svc.IssueDownloadGrant(spaceId, creds, item, (uint)req.From),
"delete" => svc.IssueDeleteGrant(spaceId, creds, item),
_ => throw new WebDropException(ErrorCode.BadRequest),
};
}
return Json(new FileGrantBody { Url = grant.Url, Domain = grant.Domain, Ticket = grant.Ticket, ExpiresAt = grant.ExpiresAt }, ApiJson.Default.FileGrantBody);
});
}
}
@@ -0,0 +1,93 @@
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.AspNetCore.Identity;
using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api;
public static partial class Endpoints
{
private static PasskeyInfoBody PasskeyInfo(StoredPasskey p) => new() { Id = p.Id, Name = p.Name, PrfSalt = p.PrfSalt, CanRecover = p.KeyEnvelope is not null, CreatedAt = p.CreatedAt };
private static void ValidateRecoveryEnvelope(string? value)
{
if (value is not null) _ = DecodeBase64(value, 28, 512);
}
private static void MapPasskeys(RouteGroupBuilder api, SqliteAccountStore accounts)
{
api.MapGet("/accounts/passkeys", (HttpContext ctx) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
return Json(accounts.Passkeys(username).Select(PasskeyInfo).ToList(), ApiJson.Default.ListPasskeyInfoBody);
});
api.MapDelete("/accounts/passkeys/{id}", (string id, HttpContext ctx) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
accounts.RemovePasskey(id, username); return Results.NoContent();
});
api.MapPost("/accounts/passkeys/register/options", async (HttpContext ctx, IPasskeyHandler<PasskeyAccount> handler, PasskeyCeremonies ceremonies, CancellationToken ct) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var req = await ReadJson(ctx.Request, ApiJson.Default.PasskeyBeginRequest, ct);
if (NormalizeUsername(req.Username) != username || req.Verifier is null || !accounts.VerifyLogin(username, DecodeBase64(req.Verifier, 32, 32))) throw new WebDropException(ErrorCode.Unauthorized);
var result = await handler.MakeCreationOptionsAsync(new() { Id = username, Name = username, DisplayName = accounts.Profile(username).DisplayName }, ctx);
var salt = Codec.Encode(RandomNumberGenerator.GetBytes(32));
var id = ceremonies.Put(new(username, "register", result.AttestationState ?? throw new WebDropException(ErrorCode.BadRequest), salt, PasskeyCeremonies.Hash(ctx.Request.Headers["X-WD-Account"].ToString()), DateTimeOffset.UtcNow.AddMinutes(2)));
return Json(new PasskeyOptionsBody { Id = id, Options = result.CreationOptionsJson, PrfSalt = salt }, ApiJson.Default.PasskeyOptionsBody);
}).RequireRateLimiting(CreatePolicy);
api.MapPost("/accounts/passkeys/register", async (HttpContext ctx, IPasskeyHandler<PasskeyAccount> handler, PasskeyCeremonies ceremonies, CancellationToken ct) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var req = await ReadJson(ctx.Request, ApiJson.Default.PasskeyFinishRequest, ct);
var state = ceremonies.Take(req.Id, username, "register", ctx.Request.Headers["X-WD-Account"].ToString());
if (NormalizeUsername(req.Username) != username || req.Credential.Length > 40_000 || req.Name is null || req.Name.Trim().Length is < 1 or > 80 || req.Name.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
ValidateRecoveryEnvelope(req.KeyEnvelope);
var result = await handler.PerformAttestationAsync(new() { HttpContext = ctx, CredentialJson = req.Credential, AttestationState = state.State });
if (!result.Succeeded || result.UserEntity?.Id != username || result.Passkey is null) throw new WebDropException(ErrorCode.BadRequest);
result.Passkey.Name = req.Name.Trim();
var row = new StoredPasskey(Codec.Encode(result.Passkey.CredentialId), username, result.Passkey.Name,
JsonSerializer.Serialize(result.Passkey, PasskeyStorageJson.Default.UserPasskeyInfo), state.PrfSalt, req.KeyEnvelope, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
accounts.AddPasskey(row); return Json(PasskeyInfo(row), ApiJson.Default.PasskeyInfoBody, 201);
}).RequireRateLimiting(CreatePolicy);
api.MapPost("/accounts/passkeys/login/options", async (HttpContext ctx, IPasskeyHandler<PasskeyAccount> handler, PasskeyCeremonies ceremonies, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountSaltRequest, ct); var username = NormalizeUsername(req.Username);
var options = await handler.MakeRequestOptionsAsync(new PasskeyAccount(username), ctx);
var id = ceremonies.Put(new(username, "login", options.AssertionState ?? throw new WebDropException(ErrorCode.BadRequest), "", null, DateTimeOffset.UtcNow.AddMinutes(2)));
return Json(new PasskeyOptionsBody { Id = id, Options = options.RequestOptionsJson, Credentials = accounts.Passkeys(username).Select(PasskeyInfo).ToList() }, ApiJson.Default.PasskeyOptionsBody);
}).RequireRateLimiting(CreatePolicy);
api.MapPost("/accounts/passkeys/login", async (HttpContext ctx, IPasskeyHandler<PasskeyAccount> handler, UserManager<PasskeyAccount> manager, PasskeyCeremonies ceremonies, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.PasskeyFinishRequest, ct); var username = NormalizeUsername(req.Username);
var state = ceremonies.Take(req.Id, username, "login", null);
if (req.Credential.Length > 40_000 || req.DeviceId is null || req.DeviceName is null) throw new WebDropException(ErrorCode.BadRequest);
var result = await handler.PerformAssertionAsync(new() { HttpContext = ctx, CredentialJson = req.Credential, AssertionState = state.State });
if (!result.Succeeded || result.User?.Username != username || result.Passkey is null) throw new WebDropException(ErrorCode.Unauthorized);
var id = Codec.Encode(result.Passkey.CredentialId); var row = accounts.Passkey(id) ?? throw new WebDropException(ErrorCode.Unauthorized);
var updated = await manager.AddOrUpdatePasskeyAsync(result.User, result.Passkey);
if (!updated.Succeeded) throw new WebDropException(ErrorCode.Unauthorized);
var token = accounts.CreateDeviceSession(username, req.DeviceId, req.DeviceName) ?? throw new WebDropException(ErrorCode.BadRequest);
return Json(new PasskeySessionBody { Username = username, Token = token, CredentialId = id, PrfSalt = row.PrfSalt, KeyEnvelope = row.KeyEnvelope }, ApiJson.Default.PasskeySessionBody);
}).RequireRateLimiting(CreatePolicy);
api.MapPost("/accounts/passkeys/recovery/options", async (HttpContext ctx, IPasskeyHandler<PasskeyAccount> handler, PasskeyCeremonies ceremonies) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var options = await handler.MakeRequestOptionsAsync(new PasskeyAccount(username), ctx);
var id = ceremonies.Put(new(username, "recovery", options.AssertionState ?? throw new WebDropException(ErrorCode.BadRequest), "", PasskeyCeremonies.Hash(ctx.Request.Headers["X-WD-Account"].ToString()), DateTimeOffset.UtcNow.AddMinutes(2)));
return Json(new PasskeyOptionsBody { Id = id, Options = options.RequestOptionsJson, Credentials = accounts.Passkeys(username).Select(PasskeyInfo).ToList() }, ApiJson.Default.PasskeyOptionsBody);
}).RequireRateLimiting(CreatePolicy);
api.MapPut("/accounts/passkeys/recovery", async (HttpContext ctx, IPasskeyHandler<PasskeyAccount> handler, UserManager<PasskeyAccount> manager, PasskeyCeremonies ceremonies, CancellationToken ct) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var req = await ReadJson(ctx.Request, ApiJson.Default.PasskeyEnvelopeRequest, ct);
var state = ceremonies.Take(req.Id, username, "recovery", ctx.Request.Headers["X-WD-Account"].ToString());
if (NormalizeUsername(req.Username) != username || req.Credential.Length > 40_000) throw new WebDropException(ErrorCode.BadRequest);
ValidateRecoveryEnvelope(req.KeyEnvelope);
var result = await handler.PerformAssertionAsync(new() { HttpContext = ctx, CredentialJson = req.Credential, AssertionState = state.State });
if (!result.Succeeded || result.User?.Username != username || result.Passkey is null) throw new WebDropException(ErrorCode.Unauthorized);
if (!(await manager.AddOrUpdatePasskeyAsync(result.User, result.Passkey)).Succeeded) throw new WebDropException(ErrorCode.Unauthorized);
accounts.UpdatePasskeyEnvelope(Codec.Encode(result.Passkey.CredentialId), username, req.KeyEnvelope);
return Results.NoContent();
}).RequireRateLimiting(CreatePolicy);
}
}
@@ -0,0 +1,89 @@
using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api;
public static partial class Endpoints
{
private static void MapWorkspace(RouteGroupBuilder api, SqliteAccountStore accounts)
{
// 成员资料:发过内容的作者加上空间里所有成员(没发过内容的成员也要能显示头像和名字)
WorkspaceBody View(ChannelService service, Id16 space, WorkspaceState state) => new()
{
Structure = state,
People = state.Locations.Select(l => l.AuthorAccount).Where(u => u is not null).Select(u => u!)
.Concat(service.MemberAccounts(space)).Distinct(StringComparer.OrdinalIgnoreCase)
.Select(u => accounts.Profile(u).WithoutAvatar()).ToList(),
};
api.MapGet("/spaces/{id}/structure", (string id, HttpContext ctx, ChannelService svc) =>
Json(View(svc, ParseId(id), svc.GetWorkspace(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false))), ApiJson.Default.WorkspaceBody));
api.MapPost("/spaces/{id}/channels", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceChannelRequest, ct);
var created = svc.CreateWorkspaceChannelResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Name, req.GroupId, req.FileDomain);
return Json(new WorkspaceChannelCreatedBody { Workspace = View(svc, ParseId(id), created.State), Channel = created.Channel }, ApiJson.Default.WorkspaceChannelCreatedBody, 201);
});
api.MapPatch("/spaces/{id}/channels/{channelId}", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
return Json(View(svc, ParseId(id), svc.RenameWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.Name)), ApiJson.Default.WorkspaceBody);
});
api.MapPut("/spaces/{id}/channels/{channelId}/file-domain", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceFileDomainRequest, ct);
return Json(View(svc, ParseId(id), svc.SetWorkspaceChannelFileDomain(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.FileDomain)), ApiJson.Default.WorkspaceBody);
});
api.MapPut("/spaces/{id}/channels/{channelId}/group", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceGroupRequest, ct);
return Json(View(svc, ParseId(id), svc.GroupWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.GroupId)), ApiJson.Default.WorkspaceBody);
});
api.MapPost("/spaces/{id}/groups", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
var created = svc.CreateWorkspaceGroupResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Name);
return Json(new WorkspaceGroupCreatedBody { Workspace = View(svc, ParseId(id), created.State), Group = created.Group }, ApiJson.Default.WorkspaceGroupCreatedBody, 201);
});
api.MapPatch("/spaces/{id}/groups/{groupId}", async (string id, string groupId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
return Json(View(svc, ParseId(id), svc.RenameWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), groupId, req.Name)), ApiJson.Default.WorkspaceBody);
});
api.MapDelete("/spaces/{id}/groups/{groupId}", (string id, string groupId, HttpContext ctx, ChannelService svc) =>
Json(View(svc, ParseId(id), svc.DeleteWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), groupId)), ApiJson.Default.WorkspaceBody));
api.MapPost("/spaces/{id}/roles", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceRoleRequest, ct);
var created = svc.CreateWorkspaceRoleResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Name, req.Prefix, req.Suffix, req.Color, req.Permissions);
return Json(new WorkspaceRoleCreatedBody { Workspace = View(svc, ParseId(id), created.State), Role = created.Role }, ApiJson.Default.WorkspaceRoleCreatedBody, 201);
});
api.MapPatch("/spaces/{id}/roles/{roleId}", async (string id, string roleId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceRoleRequest, ct);
return Json(View(svc, ParseId(id), svc.UpdateWorkspaceRole(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), roleId, req.Name, req.Prefix, req.Suffix, req.Color, req.Permissions)), ApiJson.Default.WorkspaceBody);
});
api.MapDelete("/spaces/{id}/roles/{roleId}", (string id, string roleId, HttpContext ctx, ChannelService svc) =>
Json(View(svc, ParseId(id), svc.DeleteWorkspaceRole(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), roleId)), ApiJson.Default.WorkspaceBody));
api.MapPut("/spaces/{id}/roles/{roleId}/members", async (string id, string roleId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceRoleMembersRequest, ct);
return Json(View(svc, ParseId(id), svc.SetWorkspaceRoleMembers(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), roleId, req.Members)), ApiJson.Default.WorkspaceBody);
});
api.MapPost("/spaces/{id}/folders", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceFolderRequest, ct);
return Json(View(svc, ParseId(id), svc.CreateWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.ThreadId, req.ParentId, req.Name)), ApiJson.Default.WorkspaceBody, 201);
});
api.MapPatch("/spaces/{id}/folders/{folderId}", async (string id, string folderId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
return Json(View(svc, ParseId(id), svc.RenameWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), folderId, req.Name)), ApiJson.Default.WorkspaceBody);
});
api.MapPut("/spaces/{id}/locations", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceMoveRequest, ct);
if (req.ItemIds is null) throw new WebDropException(ErrorCode.BadRequest);
return Json(View(svc, ParseId(id), svc.MoveWorkspaceItems(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.ItemIds, req.ThreadId, req.FolderId)), ApiJson.Default.WorkspaceBody);
});
}
}
@@ -0,0 +1,32 @@
using System.Text.Json.Serialization;
using WebDrop.Core;
namespace WebDrop.Api;
public sealed class ChannelHistoryRequest
{
[JsonPropertyName("chatHistory")] public bool ChatHistory { get; init; }
[JsonPropertyName("filesHistory")] public bool FilesHistory { get; init; }
}
public static partial class Endpoints
{
private static void MapWorkspaceCrypto(RouteGroupBuilder api)
{
api.MapGet("/spaces/{id}/crypto", (string id, HttpContext ctx, ChannelService svc) => Json(svc.ReadCrypto(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false)), ApiJson.Default.CryptoState));
api.MapPut("/spaces/{id}/crypto/domains", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.DomainPublishRequest, ct);
svc.PublishDomain(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req); return Results.NoContent();
});
api.MapPost("/spaces/{id}/crypto/objects", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.ObjectEnvelope, ct);
return Json(svc.DeclareObject(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req), ApiJson.Default.ObjectEnvelope, 201);
});
api.MapPatch("/spaces/{id}/channels/{channelId}/history", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.ChannelHistoryRequest, ct);
svc.SetChannelHistory(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.ChatHistory, req.FilesHistory); return Results.NoContent();
});
}
}
@@ -1,20 +1,23 @@
using System.Buffers;
using System.Diagnostics;
using System.IO.Compression;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api;
public static class Endpoints
public static partial 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)
public static void MapApi(this IEndpointRouteBuilder app, WebDropOptions options, ICaptchaVerifier captcha, SqliteAccountStore accounts, IFileGrants files)
{
var api = app.MapGroup("/api/v1");
@@ -27,25 +30,44 @@ public static class Endpoints
ApiEndpoint = captcha.Enabled ? options.Captcha.ApiEndpoint : null,
},
Transports = new TransportsBody { Http3 = options.EnableHttp3 },
Defaults = new DefaultsBody { Channel = CreationDefaults.Channel(limits), Share = CreationDefaults.Share(limits) },
Files = new FilesInfoBody { Enabled = files.Enabled, Url = files.Enabled ? files.PublicUrl : null },
Choices = new SettingChoicesBody
{
MaxMembers = new[] { 2, 3, 5, 10, limits.MaxMembers }.Where(n => n >= 1 && n <= limits.MaxMembers).Distinct().Order().ToArray(),
TtlSeconds = new[] { 3600, 86_400, 604_800, limits.MinTtlSeconds, limits.MaxTtlSeconds, CreationDefaults.Channel(limits).TtlSeconds }
.Where(n => n >= limits.MinTtlSeconds && n <= limits.MaxTtlSeconds).Distinct().Order().ToArray(),
MaxVisitors = new[] { 0, 2, 5, 10, 20, limits.MaxVisitors }.Where(n => n <= limits.MaxVisitors).Distinct().Order().ToArray(),
},
Limits = new LimitsBody
{
MaxPayloadBytes = limits.MaxPayloadBytes,
MaxFramesPerItem = limits.MaxFramesPerItem,
MaxChannelBytes = limits.MaxChannelBytes,
MaxSeats = limits.MaxSeats,
MaxWorkspaceBytes = limits.MaxWorkspaceBytes,
MaxMembers = limits.MaxMembers,
MaxTtlSeconds = limits.MaxTtlSeconds,
MinTtlSeconds = limits.MinTtlSeconds,
MaxItems = limits.MaxItemsLimit,
MaxVisitors = limits.MaxVisitors,
},
}, ApiJson.Default.ServerInfoBody));
MapAccounts(api, accounts);
MapPasskeys(api, accounts);
MapWorkspace(api, accounts);
MapWorkspaceCrypto(api);
MapFiles(api);
// ───────── 频道 ─────────
api.MapPost("/channels", async (HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var req = await ReadJson(ctx.Request, ApiJson.Default.CreateChannelRequest, ct);
var endpoint = RequestAuth.ParseEndpointHash(ctx.Request.Headers[RequestAuth.EndpointHeader]);
string token = ctx.Request.Headers[RequestAuth.CaptchaHeader].ToString();
await captcha.VerifyAsync(token.Length > 0 ? token : null, ct);
return Json(svc.CreateChannel(req, endpoint), ApiJson.Default.ChannelInfo, StatusCodes.Status201Created);
return Json(svc.CreateChannel(req, username), ApiJson.Default.ChannelInfo, StatusCodes.Status201Created);
}).RequireRateLimiting(CreatePolicy);
api.MapGet("/channels/{id}", (string id, HttpContext ctx, ChannelService svc) =>
@@ -53,61 +75,54 @@ public static class Endpoints
api.MapPatch("/channels/{id}/settings", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var patch = await ReadJson(ctx.Request, ApiJson.Default.ChannelSettingsPatch, ct);
// 空间头像(动图)比其他设置大得多,单独放宽请求体上限
if (ctx.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } size) size.MaxRequestBodySize = 4 * 1024 * 1024;
var patch = await ReadJsonCompressed(ctx.Request, ApiJson.Default.ChannelSettingsPatch, 6 * 1024 * 1024, ct);
var info = svc.UpdateSettings(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), patch);
return Json(info, ApiJson.Default.ChannelInfo);
});
// 空间头像(信息里只带版本):默认缩略图,size=full 原图。磁盘文件直接发送,带 ETag(内容哈希),没变时回 304
api.MapGet("/channels/{id}/avatar", (string id, HttpContext ctx, ChannelService svc) =>
{
bool thumb = ctx.Request.Query["size"] != "full";
return AvatarResult(ctx, svc.GetChannelAvatarBlob(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), thumb));
});
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) =>
api.MapPost("/channels/{id}/join", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var seats = svc.ListSeats(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false));
return Json([.. seats], ApiJson.Default.ListSeatInfo);
var request = ctx.Request.ContentType?.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) == true ? await ReadJson(ctx.Request, ApiJson.Default.JoinRequest, ct) : new JoinRequest();
return Json(svc.Join(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), request.Password), ApiJson.Default.JoinResult);
});
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();
});
MapChannelAccess(api, accounts);
// ───────── 帧与日志 ─────────
api.MapPost("/channels/{id}/frames", async (string id, HttpContext ctx, ChannelService svc, ServerLimits limits, CancellationToken ct) =>
api.MapPost("/channels/{id}/frames", async (string id, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore, ServerLimits limits, CancellationToken ct) =>
{
var channelId = ParseId(id);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false);
long? maxDownloads = ParseMaxDownloads(ctx.Request.Headers["X-WD-Max-Downloads"].ToString());
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);
var signedEvent = ParseSignedContentEvent(ctx.Request, accountStore, channelId, creds, body);
var result = await svc.AppendFrameAsync(channelId, creds, body, maxDownloads, ct, signedEvent);
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);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false);
long from = QueryLong(ctx.Request, "from", 0);
int limit = (int)Math.Clamp(QueryLong(ctx.Request, "limit", 200), 1, 1000);
var wait = TimeSpan.FromSeconds(Math.Clamp(QueryLong(ctx.Request, "wait", 0), 0, 25));
@@ -131,7 +146,7 @@ public static class Endpoints
throw new WebDropException(ErrorCode.BadRequest);
var channelId = ParseId(id);
var creds = RequestAuth.FromSubprotocols(ctx.WebSockets.WebSocketRequestedProtocols);
var creds = RequestAuth.FromSubprotocols(ctx.WebSockets.WebSocketRequestedProtocols, accounts);
long from = QueryLong(ctx.Request, "from", 0);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ctx.RequestAborted);
@@ -143,7 +158,7 @@ public static class Endpoints
ws = await ctx.WebSockets.AcceptWebSocketAsync("wd.v1");
_ = WatchClose(ws, cts);
return new WebSocketSink(ws);
}, cts.Token);
}, cts.Token, () => StreamAccountValid(ctx, accounts, creds));
}
finally
{
@@ -155,7 +170,7 @@ public static class Endpoints
api.MapGet("/channels/{id}/events", async (string id, HttpContext ctx, ChannelService svc, ChannelHub hub) =>
{
var channelId = ParseId(id);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false);
long from = QueryLong(ctx.Request, "from", -1);
if (from < 0)
from = long.TryParse(ctx.Request.Headers["Last-Event-ID"].ToString(), out long last) && last >= 0 ? last + 1 : 0;
@@ -166,7 +181,7 @@ public static class Endpoints
ctx.Response.Headers["X-Accel-Buffering"] = "no";
await ctx.Response.StartAsync();
return new SseSink(ctx.Response);
}, ctx.RequestAborted);
}, ctx.RequestAborted, () => StreamAccountValid(ctx, accounts, creds));
return Results.Empty;
});
@@ -176,7 +191,7 @@ public static class Endpoints
{
var channelId = ParseId(id);
var item = ParseId(itemId);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false);
long from = QueryLong(ctx.Request, "from", 0);
if (from is < 0 or > uint.MaxValue) throw new WebDropException(ErrorCode.BadRequest);
@@ -201,9 +216,12 @@ public static class Endpoints
return Results.Empty;
});
api.MapDelete("/channels/{id}/items/{itemId}", (string id, string itemId, HttpContext ctx, ChannelService svc) =>
api.MapDelete("/channels/{id}/items/{itemId}", (string id, string itemId, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore) =>
{
svc.DeleteItem(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true), ParseId(itemId));
var channelId = ParseId(id); var parsedItemId = ParseId(itemId);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false);
var proof = ParseSignedDeleteEvent(ctx.Request, accountStore, svc, channelId, parsedItemId, creds);
svc.DeleteItem(channelId, creds, parsedItemId, proof);
return Results.NoContent();
});
@@ -211,11 +229,12 @@ public static class Endpoints
api.MapPost("/shares", async (HttpContext ctx, ShareService svc, CancellationToken ct) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
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);
return Json(svc.Create(req, endpoint.Length == 0 ? null : endpoint, username), ApiJson.Default.ShareInfo, StatusCodes.Status201Created);
}).RequireRateLimiting(CreatePolicy);
api.MapPatch("/shares/{id}/settings", async (string id, HttpContext ctx, ShareService svc, CancellationToken ct) =>
@@ -287,6 +306,147 @@ public static class Endpoints
private static IResult Json<T>(T value, JsonTypeInfo<T> info, int? status = null) =>
Results.Json(value, info, statusCode: status);
private static bool TryAccount(HttpContext ctx, SqliteAccountStore accounts, out string username)
{
username = "";
var accountToken = ctx.Request.Headers["X-WD-Account"].ToString();
if (accountToken.Length > 0) return accounts.HasSession(accountToken, out username);
var header = ctx.Request.Headers["Authorization"].ToString();
return header.StartsWith("Bearer ", StringComparison.Ordinal) && accounts.HasSession(header[7..], out username);
}
private static bool StreamAccountValid(HttpContext ctx, SqliteAccountStore accounts, Credentials creds)
{
if (creds.AccountName is null) return true;
string token = ctx.Request.Headers["X-WD-Account"].ToString();
if (ctx.WebSockets.IsWebSocketRequest)
{
var protocol = ctx.WebSockets.WebSocketRequestedProtocols.FirstOrDefault(p => p.StartsWith("wd.account.", StringComparison.Ordinal));
if (protocol is null || !Codec.TryDecodeExact(protocol[11..], 32, out var raw)) return false;
token = Convert.ToBase64String(raw);
}
return accounts.HasSession(token, out var username) && username == creds.AccountName;
}
private static string NormalizeUsername(string? value)
{
if (value is null) throw new WebDropException(ErrorCode.BadRequest);
var name = value.Trim().ToLowerInvariant();
if (name.Length is < 3 or > 32 || name.Any(c => !(char.IsAsciiLetterOrDigit(c) || c is '_' or '-')))
throw new WebDropException(ErrorCode.BadRequest);
return name;
}
private static List<InvitationBody> InvitationBodies(IReadOnlyList<ChannelInvitation> invitations) => invitations.Select(i => new InvitationBody
{
ChannelId = i.ChannelId.ToBase64Url(), Username = i.Username, Payload = i.Payload, CreatedAt = i.CreatedAt,
}).ToList();
private static void ValidatePublicKey(string? value)
{
if (value is null) throw new WebDropException(ErrorCode.BadRequest);
if (value.Length > 1024) throw new WebDropException(ErrorCode.BadRequest);
try
{
using var doc = JsonDocument.Parse(value); var root = doc.RootElement;
if (root.GetProperty("kty").GetString() != "EC" || root.GetProperty("crv").GetString() != "P-256"
|| !Codec.TryDecodeExact(root.GetProperty("x").GetString(), 32, out _)
|| !Codec.TryDecodeExact(root.GetProperty("y").GetString(), 32, out _) || root.TryGetProperty("d", out _))
throw new WebDropException(ErrorCode.BadRequest);
}
catch (Exception e) when (e is JsonException or KeyNotFoundException or InvalidOperationException) { throw new WebDropException(ErrorCode.BadRequest); }
}
private static SignedContentEventProof? ParseSignedContentEvent(HttpRequest request, SqliteAccountStore accounts,
Id16 spaceId, Credentials creds, byte[] frame)
{
string[] names = ["X-WD-Event-Id", "X-WD-Event-Kind", "X-WD-Event-Channel", "X-WD-Event-Domain",
"X-WD-Event-Epoch", "X-WD-Event-Frames", "X-WD-Event-Manifest", "X-WD-Event-Signature", "X-WD-Event-Target"];
var values = names.Select(name => request.Headers[name].ToString()).ToArray();
bool any = values.Any(value => value.Length > 0);
if (!any)
{
if ((Envelope.TryParse(frame, out var legacyEnvelope) || Envelope.TryParseHeader(frame, out legacyEnvelope)) && legacyEnvelope.IsFinal && creds.AccountName is not null
&& creds.AccountDeviceId is not null && accounts.DeviceSigningKey(creds.AccountName, creds.AccountDeviceId) is not null)
throw new WebDropException(ErrorCode.Forbidden);
return null;
}
if (!(Envelope.TryParse(frame, out var envelope) || Envelope.TryParseHeader(frame, out envelope)) || !envelope.IsFinal || creds.AccountName is null || creds.AccountDeviceId is null
|| values.Take(8).Any(string.IsNullOrEmpty)) throw new WebDropException(ErrorCode.BadRequest);
if (!Guid.TryParseExact(values[2], "D", out _) || values[3] is not ("chat" or "files")
|| !int.TryParse(values[4], out var epoch) || epoch < 0
|| !int.TryParse(values[5], out var frameCount) || frameCount < 1 || frameCount > 65_536)
throw new WebDropException(ErrorCode.BadRequest);
var domain = values[3] == "chat" ? CryptoDomain.Chat : CryptoDomain.Files;
var eventKind = values[1];
if (domain == CryptoDomain.Chat && !eventKind.StartsWith("chat.", StringComparison.Ordinal)
|| domain == CryptoDomain.Files && !eventKind.StartsWith("file.", StringComparison.Ordinal)) throw new WebDropException(ErrorCode.BadRequest);
if (!Codec.TryDecodeExact(values[0], 16, out var eventId)
|| !Codec.TryDecodeExact(values[6], 32, out var manifestHash)
|| !Codec.TryDecodeExact(values[7], 64, out var signature)) throw new WebDropException(ErrorCode.BadRequest);
byte[] target = [];
if (values[8].Length > 0 && !Codec.TryDecodeExact(values[8], 16, out target)) throw new WebDropException(ErrorCode.BadRequest);
var deviceKey = accounts.DeviceSigningKey(creds.AccountName, creds.AccountDeviceId)
?? throw new WebDropException(ErrorCode.Forbidden);
var rootPublicKey = accounts.GetSigningPublicKey(creds.AccountName) ?? throw new WebDropException(ErrorCode.Forbidden);
using var publicKey = JsonDocument.Parse(deviceKey.PublicKey);
var jwk = publicKey.RootElement;
if (!Codec.TryDecodeExact(jwk.GetProperty("x").GetString(), 32, out var x)
|| !Codec.TryDecodeExact(jwk.GetProperty("y").GetString(), 32, out var y)) throw new WebDropException(ErrorCode.BadRequest);
var payload = SignatureCodec.ContentEventPayload(spaceId.ToArray(), values[2], creds.AccountName,
creds.AccountDeviceId, eventId, eventKind, domain, epoch, envelope.ItemId.ToArray(), frameCount, manifestHash, target);
if (!SignatureCodec.VerifyP256(x, y, "content.event", payload, signature)) throw new WebDropException(ErrorCode.Forbidden);
return new SignedContentEventProof(eventId, eventKind, creds.AccountName, values[2], domain, epoch, frameCount,
manifestHash, creds.AccountDeviceId, signature, values[8].Length == 0 ? null : values[8],
rootPublicKey, deviceKey.PublicKey, deviceKey.CertificateSignature);
}
private static SignedContentEventProof? ParseSignedDeleteEvent(HttpRequest request, SqliteAccountStore accounts,
ChannelService service, Id16 spaceId, Id16 itemId, Credentials creds)
{
string[] names = ["X-WD-Event-Id", "X-WD-Event-Kind", "X-WD-Event-Channel", "X-WD-Event-Domain",
"X-WD-Event-Epoch", "X-WD-Event-Frames", "X-WD-Event-Manifest", "X-WD-Event-Signature"];
var values = names.Select(name => request.Headers[name].ToString()).ToArray();
if (values.All(string.IsNullOrEmpty)) return null;
if (creds.AccountName is null || creds.AccountDeviceId is null || values.Any(string.IsNullOrEmpty)) throw new WebDropException(ErrorCode.BadRequest);
var obj = service.ObjectSigningContext(spaceId, creds, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
var createdEvent = obj.Event ?? throw new WebDropException(ErrorCode.BadRequest);
if (!Guid.TryParseExact(values[2], "D", out _) || values[3] is not ("chat" or "files")
|| !int.TryParse(values[4], out var epoch) || !int.TryParse(values[5], out var frameCount)) throw new WebDropException(ErrorCode.BadRequest);
var domain = values[3] == "chat" ? CryptoDomain.Chat : CryptoDomain.Files;
var kind = domain == CryptoDomain.Chat ? "chat.delete" : "file.delete";
if (values[1] != kind || obj.ChannelId != values[2] || obj.Domain != domain || obj.Epoch != epoch
|| createdEvent.FrameCount != frameCount
|| !Codec.TryDecodeExact(values[0], 16, out var eventId)
|| !Codec.TryDecodeExact(values[6], 32, out var manifest)
|| !Codec.TryDecodeExact(values[7], 64, out var signature)
|| !Codec.TryDecodeExact(createdEvent.ManifestHash, 32, out var objectManifest)
|| !CryptographicOperations.FixedTimeEquals(manifest, objectManifest)) throw new WebDropException(ErrorCode.BadRequest);
var deviceKey = accounts.DeviceSigningKey(creds.AccountName, creds.AccountDeviceId) ?? throw new WebDropException(ErrorCode.Forbidden);
using var publicKey = JsonDocument.Parse(deviceKey.PublicKey); var jwk = publicKey.RootElement;
if (!Codec.TryDecodeExact(jwk.GetProperty("x").GetString(), 32, out var x)
|| !Codec.TryDecodeExact(jwk.GetProperty("y").GetString(), 32, out var y)) throw new WebDropException(ErrorCode.BadRequest);
var payload = SignatureCodec.ContentEventPayload(spaceId.ToArray(), obj.ChannelId, creds.AccountName,
creds.AccountDeviceId, eventId, kind, domain, epoch, itemId.ToArray(), frameCount, manifest, []);
if (!SignatureCodec.VerifyP256(x, y, "content.event", payload, signature)) throw new WebDropException(ErrorCode.Forbidden);
return new SignedContentEventProof(eventId, kind, creds.AccountName, obj.ChannelId, domain, epoch, frameCount,
manifest, creds.AccountDeviceId, signature, null, accounts.GetSigningPublicKey(creds.AccountName)!,
deviceKey.PublicKey, deviceKey.CertificateSignature);
}
private static byte[] DecodeBase64(string? value, int minLength, int maxLength)
{
if (value is null || value.Length > ((maxLength + 2) / 3 * 4)) throw new WebDropException(ErrorCode.BadRequest);
try
{
var bytes = Convert.FromBase64String(value);
return bytes.Length >= minLength && bytes.Length <= maxLength ? bytes : throw new WebDropException(ErrorCode.BadRequest);
}
catch (FormatException) { throw new WebDropException(ErrorCode.BadRequest); }
}
private static Id16 ParseId(string text) =>
Id16.TryParse(text, out var id) ? id : throw new WebDropException(ErrorCode.BadRequest);
@@ -303,6 +463,40 @@ public static class Endpoints
return long.TryParse(header, out long n) && n >= 0 ? n : throw new WebDropException(ErrorCode.BadRequest);
}
/// <summary>
/// 读 JSON 请求体,支持 Content-Encoding: gzip(头像这类带 base64 的大请求,压缩后能省下 base64 多出来的约四分之一)。
/// 解压后的大小另外限制,防止小体积的压缩炸弹。
/// </summary>
private static async Task<T> ReadJsonCompressed<T>(HttpRequest req, JsonTypeInfo<T> info, int maxBytes, CancellationToken ct) where T : class
{
try
{
Stream body = req.Body;
if (string.Equals(req.Headers.ContentEncoding.ToString(), "gzip", StringComparison.OrdinalIgnoreCase)) body = new GZipStream(req.Body, CompressionMode.Decompress);
using var plain = new MemoryStream();
var buffer = new byte[81920]; int read;
while ((read = await body.ReadAsync(buffer, ct)) > 0)
{
if (plain.Length + read > maxBytes) throw new WebDropException(ErrorCode.BadRequest);
plain.Write(buffer, 0, read);
}
plain.Position = 0;
return await JsonSerializer.DeserializeAsync(plain, info, ct) ?? throw new WebDropException(ErrorCode.BadRequest);
}
catch (Exception error) when (error is JsonException or InvalidDataException) { throw new WebDropException(ErrorCode.BadRequest); }
}
/// <summary>头像文件(带 ETag = 内容哈希,没变时 304)。直接按路径发送,不读进内存</summary>
private static IResult AvatarResult(HttpContext ctx, AvatarBlob? blob)
{
if (blob is null) return Results.NotFound();
var etag = $"\"{blob.Hash}\"";
ctx.Response.Headers.ETag = etag;
ctx.Response.Headers.CacheControl = "private, no-cache";
if (ctx.Request.Headers.IfNoneMatch == etag) return Results.StatusCode(StatusCodes.Status304NotModified);
return Results.File(blob.Path, blob.Mime);
}
private static async Task<T> ReadJson<T>(HttpRequest req, JsonTypeInfo<T> info, CancellationToken ct) where T : class
{
try
@@ -320,7 +514,21 @@ public static class Endpoints
{
if (req.ContentLength is { } declared && declared > max) throw new WebDropException(ErrorCode.TooLarge);
using var ms = new MemoryStream(req.ContentLength is { } len ? (int)len : 1024);
// 长度已知:直接读进刚好大小的数组,少一份拷贝(帧最大几 MB,多一份就是一次大对象分配)
if (req.ContentLength is { } known)
{
var exact = new byte[(int)known];
int filled = 0;
while (filled < exact.Length)
{
int got = await req.Body.ReadAsync(exact.AsMemory(filled), ct);
if (got == 0) throw new WebDropException(ErrorCode.BadRequest);
filled += got;
}
return exact;
}
using var ms = new MemoryStream(1024);
byte[] buffer = ArrayPool<byte>.Shared.Rent(16 * 1024);
try
{
@@ -0,0 +1,24 @@
using System.Text;
using WebDrop.Core;
using WebDrop.FileProtocol;
namespace WebDrop.Api;
/// <summary>
/// 主后端这一侧和文件服务的全部关系:拿共用的密钥给客户端签短期票据。
/// 它不调用文件服务、不保存文件服务的任何状态;文件域怎么创建、多大、用了多少,都是客户端直接和文件服务之间的事。
/// </summary>
public sealed class FileGrants(FileApiOptions options) : IFileGrants
{
private readonly byte[] _secret = Encoding.UTF8.GetBytes(options.Secret);
private readonly string _publicUrl = options.PublicUrl.TrimEnd('/');
public bool Enabled => true;
public string PublicUrl => _publicUrl;
public FileGrant Issue(FileOp op, string space, string scope, string item, uint from, long nowMs)
{
long expires = nowMs + Math.Max(30, options.TicketSeconds) * 1000L;
return new FileGrant(_publicUrl, op == FileOp.Create ? "" : scope, FileTokens.Sign(_secret, new FileTicket(op, scope, item, from, expires, space)), expires);
}
}
@@ -0,0 +1,10 @@
namespace WebDrop.Api;
public static class ApiContract
{
// v5: 频道容量按账号成员计;v7: seats 改名 max_members/members_used/members_full,
// 去掉设备名与设备清单接口。设备只是账号的属性。
// v8: 频道强制登录,去掉未登录访客(allow_guests、/guests、终端席位);便捷分享不受影响。
// Keep this aligned with contracts/api-contract.json and docs/openapi.yaml.
public const string Version = "8.2.0";
}
@@ -0,0 +1,79 @@
using System.IO.Compression;
using System.Globalization;
using WebDrop.Storage;
using WebDrop.Sync;
namespace WebDrop.Api;
/// <summary>
/// 每天把数据库快照(聊天、头像、账号)压缩后上传到外部存储,只在本地留一个临时文件。
/// 外部存储里按日期留 RetainDays 份:用户删掉的聊天,最多再在备份里留这么久。
/// </summary>
public sealed class BackupWorker(WebDropOptions options, SqliteChannelStore store, ILogger<BackupWorker> logger) : BackgroundService
{
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(30) };
private const string Prefix = "webdrop-";
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var backup = options.Backup;
if (!backup.Enabled || !backup.S3.Configured) return;
while (!stoppingToken.IsCancellationRequested)
{
try { await Task.Delay(DailySchedule.Next(DateTime.Now, backup.TimeOfDay) - DateTime.Now, stoppingToken).ConfigureAwait(false); } catch (OperationCanceledException) { break; }
try { await RunOnceAsync(DateTime.Now, stoppingToken).ConfigureAwait(false); }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
catch (Exception e) { logger.LogWarning("数据库备份失败:{Type}", e.GetType().Name); }
}
}
public async Task<(string Name, int Purged)> RunOnceAsync(DateTime now, CancellationToken ct)
{
var backup = options.Backup;
var s3 = new S3Client(_http, backup.S3);
string tmp = Path.Combine(options.DataDir, "v2", "backup-tmp");
Directory.CreateDirectory(tmp);
string name = $"{Prefix}{now:yyyyMMdd-HHmm}.db.gz";
string db = Path.Combine(tmp, $"snapshot-{Guid.NewGuid():N}.db"), gz = db + ".gz";
try
{
store.Snapshot(db);
await using (var source = File.OpenRead(db))
await using (var target = File.Create(gz))
await using (var zip = new GZipStream(target, CompressionLevel.SmallestSize))
await source.CopyToAsync(zip, ct).ConfigureAwait(false);
await using (var upload = File.OpenRead(gz))
await s3.PutAsync($"chat/{name}", upload, ct).ConfigureAwait(false);
}
finally
{
foreach (var path in new[] { db, gz }) try { File.Delete(path); } catch (IOException) { }
}
// 头像是按内容哈希命名的磁盘文件(不在数据库里):内容不会变,已经上传过的跳过
foreach (var kind in new[] { "space", "account" })
{
string directory = Path.Combine(options.DataDir, "v2", "avatars", kind);
if (!Directory.Exists(directory)) continue;
var uploaded = (await s3.ListAsync($"chat/avatars/{kind}", ct).ConfigureAwait(false)).ToHashSet();
foreach (var file in Directory.EnumerateFiles(directory))
{
if (file.EndsWith(".tmp", StringComparison.Ordinal) || uploaded.Contains(Path.GetFileName(file))) continue;
await using var content = File.OpenRead(file);
await s3.PutAsync($"chat/avatars/{kind}/{Path.GetFileName(file)}", content, ct).ConfigureAwait(false);
}
}
// 保留期:按文件名里的日期清理更早的
int purged = 0;
var cutoff = now.Date.AddDays(-Math.Max(0, backup.RetainDays));
foreach (var existing in await s3.ListAsync("chat", ct).ConfigureAwait(false))
{
if (!existing.StartsWith(Prefix, StringComparison.Ordinal) || existing.Length < Prefix.Length + 8) continue;
if (!DateTime.TryParseExact(existing.AsSpan(Prefix.Length, 8), "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) || date >= cutoff) continue;
if (await s3.DeleteAsync($"chat/{existing}", ct).ConfigureAwait(false)) purged++;
}
if (purged > 0) logger.LogInformation("清理了 {Count} 份过期的数据库备份", purged);
return (name, purged);
}
}
@@ -16,6 +16,11 @@ public sealed class SiteverifyResponse
[JsonPropertyName("success")] public bool Success { get; init; }
}
// Backend-to-Cap DTOs are not part of the browser API contract.
[JsonSerializable(typeof(SiteverifyRequest))]
[JsonSerializable(typeof(SiteverifyResponse))]
public sealed partial class CaptchaJson : JsonSerializerContext;
/// <summary>
/// 用 Cap(https://github.com/tiagozip/cap)校验创建频道的验证码。
/// 工作量证明的出题、求解、验证全部由 Cap 完成,本项目不实现任何 PoW。
@@ -36,7 +41,7 @@ public sealed class CapCaptchaVerifier(HttpClient http, CaptchaOptions options,
throw new WebDropException(ErrorCode.CaptchaRequired);
var body = JsonSerializer.Serialize(
new SiteverifyRequest { Secret = options.Secret, Response = token }, ApiJson.Default.SiteverifyRequest);
new SiteverifyRequest { Secret = options.Secret, Response = token }, CaptchaJson.Default.SiteverifyRequest);
try
{
@@ -55,7 +60,7 @@ public sealed class CapCaptchaVerifier(HttpClient http, CaptchaOptions options,
var result = await JsonSerializer.DeserializeAsync(
await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false),
ApiJson.Default.SiteverifyResponse, ct).ConfigureAwait(false);
CaptchaJson.Default.SiteverifyResponse, ct).ConfigureAwait(false);
if (result is not { Success: true }) throw new WebDropException(ErrorCode.CaptchaRequired);
}
@@ -1,4 +1,5 @@
using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api;
@@ -23,28 +24,52 @@ public static class RequestAuth
throw new WebDropException(ErrorCode.BadRequest);
}
return new Credentials(role, token, ParseEndpoint(req.Headers[EndpointHeader].ToString(), requireEndpoint));
string? accountName = null;
string? deviceId = null;
var accountToken = req.Headers["X-WD-Account"].ToString();
if (accountToken.Length > 0)
{
var accounts = req.HttpContext.RequestServices.GetRequiredService<SqliteAccountStore>();
if (!accounts.HasSession(accountToken, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
accountName = username;
deviceId = accounts.SessionDevice(accountToken);
}
if (role == Role.Owner && accountName is null) throw new WebDropException(ErrorCode.AccountRequired);
// 账号以会话鉴权;终端密钥只用于便捷分享(未登录的创建者与读者)
return new Credentials(role, token, ParseEndpoint(req.Headers[EndpointHeader].ToString(), requireEndpoint && accountName is null), accountName, deviceId, accountName is not null);
}
/// <summary>
/// 浏览器的 WebSocket 不能设请求头,凭据放在子协议里:
/// wd.v1, wd.role.&lt;role&gt;, wd.auth.&lt;b64url&gt;, wd.ep.&lt;b64url&gt;
/// </summary>
public static Credentials FromSubprotocols(IEnumerable<string> protocols)
public static Credentials FromSubprotocols(IEnumerable<string> protocols, SqliteAccountStore? accounts = null)
{
string? role = null, auth = null, endpoint = null;
string? role = null, auth = null, endpoint = null, accountToken = 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..];
else if (p.StartsWith("wd.account.", StringComparison.Ordinal)) accountToken = p["wd.account.".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));
string? accountName = null;
string? deviceId = null;
if (accountToken is not null)
{
if (accounts is null || !Codec.TryDecodeExact(accountToken, 32, out var raw)
|| !accounts.HasSession(Convert.ToBase64String(raw), out var username)) throw new WebDropException(ErrorCode.AccountRequired);
accountName = username;
deviceId = accounts.SessionDevice(Convert.ToBase64String(raw));
}
var parsedRole = ParseRole(role);
if (parsedRole == Role.Owner && accountName is null) throw new WebDropException(ErrorCode.AccountRequired);
return new Credentials(parsedRole, token, ParseEndpoint(endpoint, requireEndpoint: false), accountName, deviceId, accountName is not null);
}
public static byte[] ParseEndpointHash(string? header) => ParseEndpoint(header, requireEndpoint: true);
@@ -1,4 +1,6 @@
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using WebDrop.Core;
using WebDrop.Frontend;
using WebDrop.Storage;
@@ -24,17 +26,45 @@ public static class WebDropApp
builder.Services.AddSingleton(options);
builder.Services.AddSingleton(options.Limits);
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddSingleton<IBlobStore>(_ => new DiskBlobStore(Path.Combine(options.DataDir, "blobs")));
builder.Services.AddSingleton<SqliteChannelStore>(_ => new SqliteChannelStore(Path.Combine(options.DataDir, "webdrop.db")));
builder.Services.AddSingleton<IBlobStore>(_ => new DiskBlobStore(Path.Combine(options.DataDir, "v2", "blobs")));
builder.Services.AddSingleton<SqliteChannelStore>(_ => new SqliteChannelStore(Path.Combine(options.DataDir, "v2", "webdrop.db")));
builder.Services.AddSingleton<SqliteAccountStore>(_ => new SqliteAccountStore(Path.Combine(options.DataDir, "v2", "webdrop.db")));
builder.Services.AddSingleton<PasskeyCeremonies>();
builder.Services.AddScoped<PasskeyUserStore>();
builder.Services.AddScoped<UserManager<PasskeyAccount>>(sp => new(
sp.GetRequiredService<PasskeyUserStore>(), Options.Create(new IdentityOptions()), new PasswordHasher<PasskeyAccount>(), [], [],
new UpperInvariantLookupNormalizer(), new IdentityErrorDescriber(), sp, sp.GetRequiredService<ILogger<UserManager<PasskeyAccount>>>()));
builder.Services.Configure<IdentityPasskeyOptions>(o =>
{
o.ServerDomain = options.PasskeyRpId;
o.UserVerificationRequirement = "required"; o.ResidentKeyRequirement = "required";
o.AttestationConveyancePreference = "none"; o.AuthenticatorTimeout = TimeSpan.FromMinutes(2);
o.IsAllowedAlgorithm = algorithm => algorithm == -7;
o.ValidateOrigin = context => ValueTask.FromResult(!context.CrossOrigin && context.TopOrigin is null
&& Uri.TryCreate(context.Origin, UriKind.Absolute, out var origin)
&& (options.CorsOrigins.Contains(origin.GetLeftPart(UriPartial.Authority), StringComparer.Ordinal)
|| origin.GetLeftPart(UriPartial.Authority) == $"{context.HttpContext.Request.Scheme}://{context.HttpContext.Request.Host}"));
});
builder.Services.AddScoped<IPasskeyHandler<PasskeyAccount>, PasskeyHandler<PasskeyAccount>>();
builder.Services.AddSingleton<IResourceStore>(sp => sp.GetRequiredService<SqliteChannelStore>());
builder.Services.AddSingleton<ICaptchaVerifier>(sp => CreateCaptchaVerifier(options.Captcha, sp));
builder.Services.AddSingleton<ChannelHub>();
builder.Services.AddSingleton<IChannelEvents>(sp => sp.GetRequiredService<ChannelHub>());
builder.Services.AddSingleton<ChannelService>();
options.Files.Validate();
builder.Services.AddSingleton<IFileGrants>(_ => options.Files.Enabled ? new FileGrants(options.Files) : NoFileGrants.Instance);
builder.Services.AddSingleton<BackupWorker>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<BackupWorker>());
builder.Services.AddSingleton<ChannelService>(sp => new ChannelService(
sp.GetRequiredService<IResourceStore>(),
sp.GetRequiredService<IBlobStore>(),
sp.GetRequiredService<IClock>(), options.Limits, sp.GetRequiredService<IChannelEvents>(), sp.GetRequiredService<IFileGrants>()));
builder.Services.AddSingleton<ShareService>(sp => new ShareService(
sp.GetRequiredService<IResourceStore>(), sp.GetRequiredService<IBlobStore>(), sp.GetRequiredService<IClock>(),
options.Limits, options.ShareDraftTtlSeconds));
builder.Services.AddHostedService<SweepWorker>();
// JSON(如 crypto 状态)与前端静态资源压缩后再传;认证走请求头而非 cookie,开启 HTTPS 压缩不会带来 BREACH 风险。
// 默认 MIME 列表不含 text/event-stream 与 octet-stream,日志流和密文不受影响
builder.Services.AddResponseCompression(o => o.EnableForHttps = true);
if (options.CorsOrigins.Length > 0)
{
@@ -42,8 +72,10 @@ public static class WebDropApp
.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")
"X-WD-Role", "X-WD-Endpoint", "X-WD-Cap", "X-WD-Max-Downloads", "X-WD-Account",
"X-WD-Event-Id", "X-WD-Event-Kind", "X-WD-Event-Channel", "X-WD-Event-Domain",
"X-WD-Event-Epoch", "X-WD-Event-Frames", "X-WD-Event-Manifest", "X-WD-Event-Signature", "X-WD-Event-Target", "X-WD-Receipt")
.WithExposedHeaders("X-WD-Last-Seq", "Retry-After")
.SetPreflightMaxAge(TimeSpan.FromHours(1))));
}
@@ -51,19 +83,24 @@ public static class WebDropApp
{
r.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
// 限流状态只在内存里,按 IP 分区,不落盘
r.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
RateLimitPartition.GetFixedWindowLimiter(ClientKey(ctx), _ => PerMinute(options.RequestsPerMinute)));
r.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx => IsFrameUpload(ctx.Request)
? RateLimitPartition.GetFixedWindowLimiter("frames:" + ClientKey(ctx), _ => PerMinute(options.FrameRequestsPerMinute))
: 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;
// 告诉客户端多久后窗口重置,弱网和上传重试据此等待,不再盲目重试
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
context.HttpContext.Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture);
await context.HttpContext.Response.WriteAsJsonAsync(
new ErrorBody { Error = ErrorCode.RateLimited.ToWire() }, ApiJson.Default.ErrorBody, cancellationToken: ct);
};
});
var app = builder.Build();
_ = app.Services.GetRequiredService<SqliteChannelStore>();
if (options.TrustForwardedHeaders)
{
@@ -76,13 +113,14 @@ public static class WebDropApp
});
}
app.UseResponseCompression();
app.Use(SecurityHeaders);
app.Use(HandleErrors);
if (options.CorsOrigins.Length > 0) app.UseCors();
app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = LogStreamer.Heartbeat });
app.UseRateLimiter();
EmbeddedFrontend.Use(app, options.Captcha.ApiEndpoint);
app.MapApi(options, app.Services.GetRequiredService<ICaptchaVerifier>());
app.MapApi(options, app.Services.GetRequiredService<ICaptchaVerifier>(), app.Services.GetRequiredService<SqliteAccountStore>(), app.Services.GetRequiredService<IFileGrants>());
return app;
}
@@ -107,6 +145,9 @@ public static class WebDropApp
}
}
private static bool IsFrameUpload(HttpRequest request) =>
HttpMethods.IsPost(request.Method) && request.Path.Value is { } path && path.EndsWith("/frames", StringComparison.Ordinal);
private static string ClientKey(HttpContext ctx) => ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown";
private static FixedWindowRateLimiterOptions PerMinute(int permits) => new()
@@ -1,4 +1,5 @@
using WebDrop.Core;
using WebDrop.Sync;
namespace WebDrop.Api;
@@ -18,8 +19,14 @@ public sealed class WebDropOptions
/// 服务端进程本身不内置 QUIC:CreateSlimBuilder 不含 HTTP/3,需要时再接入。
/// </summary>
public bool EnableHttp3 { get; set; }
public string? PasskeyRpId { get; set; }
public int RequestsPerMinute { get; set; } = 600;
/// <summary>
/// 上传帧(POST .../frames)单独计数。每帧最多约 1 MiB,和普通 API 请求放在一个 600/分钟的桶里,
/// 持续上传速度就被锁在约 10 MiB/s;帧的总量另有空间配额和单条上限保护。
/// </summary>
public int FrameRequestsPerMinute { get; set; } = 6000;
public int ChannelCreatesPerMinute { get; set; } = 30;
public int ShareDraftTtlSeconds { get; set; } = 86_400;
public int SweepIntervalSeconds { get; set; } = 30;
@@ -27,6 +34,38 @@ public sealed class WebDropOptions
public ServerLimits Limits { get; set; } = new();
public CaptchaOptions Captcha { get; set; } = new();
/// <summary>文件服务(fileAPI)。关闭时文件帧仍存在本进程的存储里。</summary>
public FileApiOptions Files { get; set; } = new();
/// <summary>每天把数据库(聊天、头像、账号)做个快照,压缩后上传到外部存储(S3 协议),保留 RetainDays 天</summary>
public DailyBackupOptions Backup { get; set; } = new() { Time = "04:15" };
}
/// <summary>
/// 文件的内容不放主后端,由另一台机器上的文件服务(fileAPI)保存。主后端只保存聊天、头像、空间结构和文件的元数据与摘要,
/// 并按空间里的权限给客户端签发访问文件服务的短期票据;客户端直接和文件服务通信,主后端不调用它。
/// </summary>
public sealed class FileApiOptions
{
public bool Enabled { get; set; }
/// <summary>浏览器访问文件服务的地址(经反向隧道对外的那个),形如 https://files.example.com</summary>
public string PublicUrl { get; set; } = "";
/// <summary>和文件服务共用的密钥,至少 32 个字符。用来给票据签名</summary>
public string Secret { get; set; } = "";
public int TicketSeconds { get; set; } = 600;
public string Validate()
{
if (!Enabled) return "";
if (!Uri.TryCreate(PublicUrl, UriKind.Absolute, out var pub) || pub.Scheme is not ("http" or "https")) throw new InvalidOperationException("WebDrop:Files:PublicUrl 必须是 http(s) 绝对地址");
if (Secret.Length < 32) throw new InvalidOperationException("WebDrop:Files:Secret 至少 32 个字符");
return pub.GetLeftPart(UriPartial.Authority).TrimEnd('/');
}
}
/// <summary>
+323
View File
@@ -0,0 +1,323 @@
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("contract_version")] public string ContractVersion { get; init; } = ApiContract.Version;
[JsonPropertyName("captcha")] public required CaptchaBody Captcha { get; init; }
[JsonPropertyName("transports")] public required TransportsBody Transports { get; init; }
[JsonPropertyName("limits")] public required LimitsBody Limits { get; init; }
[JsonPropertyName("defaults")] public required DefaultsBody Defaults { get; init; }
[JsonPropertyName("choices")] public required SettingChoicesBody Choices { get; init; }
[JsonPropertyName("files")] public required FilesInfoBody Files { get; init; }
}
public sealed class FilesInfoBody
{
/// <summary>true 时,超过 threshold_bytes 的文件由客户端直接传给文件服务,主后端只登记元数据</summary>
[JsonPropertyName("enabled")] public bool Enabled { get; init; }
/// <summary>超过这个大小的文件才存到文件服务;未开启时为 0</summary>
[JsonPropertyName("url")] public string? Url { get; init; }
}
public sealed class FileGrantRequest
{
/// <summary>create:创建文件域(空间所有者/管理员);put:上传某个条目;get:下载或播放某个条目;delete:删除某个条目的字节;domain:查询某个频道文件域的容量和用量</summary>
[JsonPropertyName("op")] public required string Op { get; init; }
/// <summary>put/get/delete 时的条目 id(base64url)</summary>
[JsonPropertyName("item")] public string? Item { get; init; }
/// <summary>domain 时的频道 id:查询这个频道文件域的容量和用量</summary>
[JsonPropertyName("channel")] public string? Channel { get; init; }
/// <summary>get 时从第几帧开始(0 才计一次下载)</summary>
[JsonPropertyName("from")] public long From { get; init; }
}
/// <summary>去文件服务用的凭证:地址、文件域 id(create 时为空)和一张短期票据</summary>
public sealed class FileGrantBody
{
[JsonPropertyName("url")] public required string Url { get; init; }
[JsonPropertyName("domain")] public required string Domain { get; init; }
[JsonPropertyName("ticket")] public required string Ticket { get; init; }
[JsonPropertyName("expires_at")] public long ExpiresAt { get; init; }
}
public sealed class DefaultsBody
{
[JsonPropertyName("channel_mode")] public ChannelMode ChannelMode { get; init; } = CreationDefaults.ChannelMode;
[JsonPropertyName("channel")] public required ChannelSettings Channel { get; init; }
[JsonPropertyName("share")] public required ShareSettings Share { get; init; }
}
public sealed class SettingChoicesBody
{
[JsonPropertyName("max_members")] public required int[] MaxMembers { get; init; }
[JsonPropertyName("ttl_seconds")] public required int[] TtlSeconds { get; init; }
[JsonPropertyName("file_max_downloads")] public int[] FileMaxDownloads { get; init; } = ServerSettingChoices.FileMaxDownloads;
[JsonPropertyName("max_visitors")] public required int[] MaxVisitors { get; init; }
}
/// <summary>告诉前端要不要出示验证码、widget 该连哪里(Cap 的 data-cap-api-endpoint)。</summary>
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_workspace_bytes")] public required long MaxWorkspaceBytes { get; init; }
[JsonPropertyName("max_members")] public required int MaxMembers { get; init; }
[JsonPropertyName("max_ttl_seconds")] public required int MaxTtlSeconds { get; init; }
[JsonPropertyName("min_ttl_seconds")] public required int MinTtlSeconds { get; init; }
[JsonPropertyName("max_items")] public required int MaxItems { get; init; }
[JsonPropertyName("max_visitors")] public required int MaxVisitors { get; init; }
}
public sealed class AccountRegisterRequest
{
[JsonPropertyName("public_key")] public required string PublicKey { get; init; }
[JsonPropertyName("signing_public_key")] public required string SigningPublicKey { get; init; }
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("salt")] public required string Salt { get; init; }
[JsonPropertyName("verifier")] public required string Verifier { get; init; }
[JsonPropertyName("vault")] public required string Vault { get; init; }
}
public sealed class AccountSaltRequest { [JsonPropertyName("username")] public required string Username { get; init; } }
public sealed class AccountSessionRequest
{
[JsonPropertyName("device_id")] public string? DeviceId { get; init; }
[JsonPropertyName("device_name")] public string? DeviceName { get; init; }
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("verifier")] public required string Verifier { get; init; }
}
public sealed class AccountVaultRequest
{
[JsonPropertyName("revision")] public required long Revision { get; init; }
[JsonPropertyName("vault")] public required string Vault { get; init; }
}
public sealed class AccountSaltBody
{
[JsonPropertyName("salt")] public required string Salt { get; init; }
}
public sealed class AccountSessionBody
{
[JsonPropertyName("token")] public required string Token { get; init; }
[JsonPropertyName("username")] public required string Username { get; init; }
}
public sealed class AccountVaultBody
{
[JsonPropertyName("revision")] public required long Revision { get; init; }
[JsonPropertyName("vault")] public required string Vault { get; init; }
}
public sealed class PublicKeyBody
{
[JsonPropertyName("public_key")] public required string PublicKey { get; init; }
[JsonPropertyName("signing_public_key")] public string? SigningPublicKey { get; init; }
}
public sealed class InvitationRequest
{
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("payload")] public required string Payload { get; init; }
[JsonPropertyName("domain_envelopes")] public List<DomainEnvelope>? DomainEnvelopes { get; init; }
}
public sealed class InvitationBody
{
[JsonPropertyName("channel_id")] public required string ChannelId { get; init; }
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("payload")] public required string Payload { get; init; }
[JsonPropertyName("created_at")] public required long CreatedAt { get; init; }
/// <summary>调用者能不能撤销这条邀请(后端按权限判断)</summary>
[JsonPropertyName("can_revoke")] public bool CanRevoke { get; init; }
}
public sealed class JoinRequest { [JsonPropertyName("password")] public string? Password { get; init; } }
public sealed class KeyPackageRequest { [JsonPropertyName("payload")] public required string Payload { get; init; } }
public sealed class AccountDeviceNameRequest { [JsonPropertyName("name")] public required string Name { get; init; } }
public sealed class AccountDeviceSigningRequest
{
[JsonPropertyName("device_id")] public required string DeviceId { get; init; }
[JsonPropertyName("public_key")] public required string PublicKey { get; init; }
[JsonPropertyName("signature")] public required string Signature { get; init; }
}
public sealed class AccountDeviceBody
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("name")] public required string Name { get; init; }
[JsonPropertyName("created_at")] public required long CreatedAt { get; init; }
[JsonPropertyName("last_seen")] public required long LastSeen { get; init; }
[JsonPropertyName("current")] public required bool Current { get; init; }
}
public sealed class AccountChannelBody
{
[JsonPropertyName("channel_id")] public required string ChannelId { get; init; }
[JsonPropertyName("role")] public required string Role { get; init; }
[JsonPropertyName("payload")] public required string Payload { get; init; }
[JsonPropertyName("created_at")] public required long CreatedAt { get; init; }
}
public sealed class MemberBody
{
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("role")] public required string Role { get; init; }
[JsonPropertyName("joined_at")] public required long JoinedAt { get; init; }
/// <summary>该账号此刻在本频道有至少一个活动订阅(WebSocket/SSE)。</summary>
[JsonPropertyName("online")] public required bool Online { get; init; }
/// <summary>该账号在本频道登记过的设备(终端)数;设备只是账号的属性,不是独立成员。</summary>
[JsonPropertyName("devices")] public required int Devices { get; init; }
[JsonPropertyName("online_devices")] public required int OnlineDevices { get; init; }
/// <summary>该账号任一设备最近一次活跃的 Unix 时间戳(登录时是秒,活跃续期后是毫秒,客户端按量级区分);没有登录过设备时为 0。</summary>
[JsonPropertyName("last_seen")] public required long LastSeen { get; init; }
/// <summary>调用者能不能移除这位成员(后端按权限判断)。</summary>
[JsonPropertyName("can_remove")] public bool CanRemove { get; init; }
}
public sealed class AccountProfileRequest
{
[JsonPropertyName("displayName")] public required string DisplayName { get; init; }
[JsonPropertyName("avatar")] public string? Avatar { get; init; }
/// <summary>头像的有损小图(data URL)。列表、聊天默认只分发它,原图在预览时才下载</summary>
[JsonPropertyName("avatarThumb")] public string? AvatarThumb { get; init; }
/// <summary>只改名字、不动头像:保留服务端现有的头像(不带 avatar 的请求默认表示移除头像)</summary>
[JsonPropertyName("keepAvatar")] public bool KeepAvatar { get; init; }
}
public sealed class WorkspaceNameRequest { [JsonPropertyName("name")] public required string Name { get; init; } }
public sealed class WorkspaceChannelRequest
{
[JsonPropertyName("name")] public required string Name { get; init; }
[JsonPropertyName("groupId")] public string? GroupId { get; init; }
/// <summary>文件服务里为这个频道创建的文件域 id(不透明字符串,主后端只存不解释)</summary>
[JsonPropertyName("fileDomain")] public string? FileDomain { get; init; }
}
public sealed class WorkspaceFileDomainRequest { [JsonPropertyName("fileDomain")] public required string FileDomain { get; init; } }
public sealed class WorkspaceRoleRequest
{
[JsonPropertyName("name")] public required string Name { get; init; }
[JsonPropertyName("prefix")] public string? Prefix { get; init; }
[JsonPropertyName("suffix")] public string? Suffix { get; init; }
[JsonPropertyName("color")] public string? Color { get; init; }
[JsonPropertyName("permissions")] public required List<string> Permissions { get; init; }
}
public sealed class WorkspaceRoleMembersRequest { [JsonPropertyName("members")] public required List<string> Members { get; init; } }
public sealed class WorkspaceRoleCreatedBody
{
[JsonPropertyName("workspace")] public required WorkspaceBody Workspace { get; init; }
[JsonPropertyName("role")] public required WorkspaceRole Role { get; init; }
}
public sealed class WorkspaceGroupRequest { [JsonPropertyName("groupId")] public string? GroupId { get; init; } }
public sealed class WorkspaceFolderRequest
{
[JsonPropertyName("name")] public required string Name { get; init; }
[JsonPropertyName("threadId")] public required string ThreadId { get; init; }
[JsonPropertyName("parentId")] public string? ParentId { get; init; }
}
public sealed class WorkspaceMoveRequest
{
[JsonPropertyName("itemIds")] public required string[] ItemIds { get; init; }
[JsonPropertyName("threadId")] public required string ThreadId { get; init; }
[JsonPropertyName("folderId")] public string? FolderId { get; init; }
}
public sealed class WorkspaceBody
{
[JsonPropertyName("structure")] public required WorkspaceState Structure { get; init; }
[JsonPropertyName("people")] public required List<AccountProfile> People { get; init; }
}
public sealed class WorkspaceChannelCreatedBody
{
[JsonPropertyName("workspace")] public required WorkspaceBody Workspace { get; init; }
[JsonPropertyName("channel")] public required WorkspaceChannel Channel { get; init; }
}
public sealed class WorkspaceGroupCreatedBody
{
[JsonPropertyName("workspace")] public required WorkspaceBody Workspace { get; init; }
[JsonPropertyName("group")] public required WorkspaceGroup Group { get; init; }
}
[JsonSerializable(typeof(ErrorBody))]
[JsonSerializable(typeof(CryptoState))]
[JsonSerializable(typeof(DomainPublishRequest))]
[JsonSerializable(typeof(ObjectEnvelope))]
[JsonSerializable(typeof(ChannelHistoryRequest))]
[JsonSerializable(typeof(PasskeyBeginRequest))]
[JsonSerializable(typeof(PasskeyOptionsBody))]
[JsonSerializable(typeof(PasskeyFinishRequest))]
[JsonSerializable(typeof(PasskeyInfoBody))]
[JsonSerializable(typeof(List<PasskeyInfoBody>))]
[JsonSerializable(typeof(PasskeySessionBody))]
[JsonSerializable(typeof(PasskeyEnvelopeRequest))]
[JsonSerializable(typeof(AccountProfile))]
[JsonSerializable(typeof(AccountProfileRequest))]
[JsonSerializable(typeof(WorkspaceState))]
[JsonSerializable(typeof(WorkspaceBody))]
[JsonSerializable(typeof(WorkspaceChannelCreatedBody))]
[JsonSerializable(typeof(WorkspaceGroupCreatedBody))]
[JsonSerializable(typeof(WorkspaceNameRequest))]
[JsonSerializable(typeof(WorkspaceChannelRequest))]
[JsonSerializable(typeof(WorkspaceFileDomainRequest))]
[JsonSerializable(typeof(WorkspaceGroupRequest))]
[JsonSerializable(typeof(WorkspaceRoleRequest))]
[JsonSerializable(typeof(WorkspaceRoleMembersRequest))]
[JsonSerializable(typeof(WorkspaceRoleCreatedBody))]
[JsonSerializable(typeof(WorkspaceFolderRequest))]
[JsonSerializable(typeof(WorkspaceMoveRequest))]
[JsonSerializable(typeof(GoneLogBody))]
[JsonSerializable(typeof(ServerInfoBody))]
[JsonSerializable(typeof(FileGrantRequest))]
[JsonSerializable(typeof(FileGrantBody))]
[JsonSerializable(typeof(ChannelInfo))]
[JsonSerializable(typeof(List<ChannelInfo>))]
[JsonSerializable(typeof(ShareInfo))]
[JsonSerializable(typeof(CreateShareRequest))]
[JsonSerializable(typeof(ShareSettingsPatch))]
[JsonSerializable(typeof(FinishShareRequest))]
[JsonSerializable(typeof(CreateChannelRequest))]
[JsonSerializable(typeof(ChannelSettingsPatch))]
[JsonSerializable(typeof(JoinResult))]
[JsonSerializable(typeof(AppendResult))]
[JsonSerializable(typeof(AccountRegisterRequest))]
[JsonSerializable(typeof(AccountDeviceSigningRequest))]
[JsonSerializable(typeof(AccountSaltRequest))]
[JsonSerializable(typeof(AccountSessionRequest))]
[JsonSerializable(typeof(AccountVaultRequest))]
[JsonSerializable(typeof(AccountSaltBody))]
[JsonSerializable(typeof(AccountSessionBody))]
[JsonSerializable(typeof(AccountVaultBody))]
[JsonSerializable(typeof(PublicKeyBody))]
[JsonSerializable(typeof(InvitationRequest))]
[JsonSerializable(typeof(InvitationCryptoPlan))]
[JsonSerializable(typeof(List<InvitationBody>))]
[JsonSerializable(typeof(JoinRequest))]
[JsonSerializable(typeof(List<MemberBody>))]
[JsonSerializable(typeof(KeyPackageRequest))]
[JsonSerializable(typeof(List<AccountChannelBody>))]
[JsonSerializable(typeof(AccountDeviceNameRequest))]
[JsonSerializable(typeof(List<AccountDeviceBody>))]
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
public sealed partial class ApiJson : JsonSerializerContext;
@@ -0,0 +1,25 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using WebDrop.Core;
namespace WebDrop.Api;
public sealed record PasskeyCeremony(string Username, string Purpose, string State, string PrfSalt, string? SessionHash, DateTimeOffset Expires);
public sealed class PasskeyCeremonies
{
private readonly ConcurrentDictionary<string, PasskeyCeremony> _pending = new();
public string Put(PasskeyCeremony ceremony)
{
foreach (var entry in _pending) if (entry.Value.Expires <= DateTimeOffset.UtcNow) _pending.TryRemove(entry.Key, out _);
if (_pending.Count >= 1000) throw new WebDropException(ErrorCode.RateLimited);
var id = Codec.Encode(RandomNumberGenerator.GetBytes(32));
_pending[id] = ceremony; return id;
}
public PasskeyCeremony Take(string id, string username, string purpose, string? token)
{
if (string.IsNullOrEmpty(id) || id.Length > 100 || !_pending.TryRemove(id, out var ceremony) || ceremony.Expires <= DateTimeOffset.UtcNow
|| ceremony.Username != username || ceremony.Purpose != purpose || ceremony.SessionHash != Hash(token)) throw new WebDropException(ErrorCode.BadRequest);
return ceremony;
}
public static string? Hash(string? token) => token is null ? null : Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token)));
}
@@ -0,0 +1,49 @@
using System.Text.Json.Serialization;
namespace WebDrop.Api;
public sealed class PasskeyBeginRequest
{
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("verifier")] public string? Verifier { get; init; }
}
public sealed class PasskeyOptionsBody
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("options")] public required string Options { get; init; }
[JsonPropertyName("credentials")] public List<PasskeyInfoBody> Credentials { get; init; } = [];
[JsonPropertyName("prf_salt")] public string? PrfSalt { get; init; }
}
public sealed class PasskeyFinishRequest
{
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("credential")] public required string Credential { get; init; }
[JsonPropertyName("name")] public string? Name { get; init; }
[JsonPropertyName("key_envelope")] public string? KeyEnvelope { get; init; }
[JsonPropertyName("device_id")] public string? DeviceId { get; init; }
[JsonPropertyName("device_name")] public string? DeviceName { get; init; }
}
public sealed class PasskeyInfoBody
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("name")] public required string Name { get; init; }
[JsonPropertyName("prf_salt")] public required string PrfSalt { get; init; }
[JsonPropertyName("can_recover")] public bool CanRecover { get; init; }
[JsonPropertyName("created_at")] public long CreatedAt { get; init; }
}
public sealed class PasskeySessionBody
{
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("token")] public required string Token { get; init; }
[JsonPropertyName("credential_id")] public required string CredentialId { get; init; }
[JsonPropertyName("prf_salt")] public required string PrfSalt { get; init; }
[JsonPropertyName("key_envelope")] public string? KeyEnvelope { get; init; }
}
public sealed class PasskeyEnvelopeRequest
{
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("credential")] public required string Credential { get; init; }
[JsonPropertyName("key_envelope")] public required string KeyEnvelope { get; init; }
}
@@ -0,0 +1,51 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Identity;
using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api;
public sealed record PasskeyAccount(string Username);
[JsonSerializable(typeof(UserPasskeyInfo))]
public sealed partial class PasskeyStorageJson : JsonSerializerContext;
/// <summary>Reuse the framework's WebAuthn verifier without replacing the existing account/session system.</summary>
public sealed class PasskeyUserStore(SqliteAccountStore accounts) : IUserPasskeyStore<PasskeyAccount>
{
private readonly Dictionary<string, string> _readVersions = [];
public void Dispose() { }
public Task<string> GetUserIdAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult(user.Username);
public Task<string?> GetUserNameAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult<string?>(user.Username);
public Task<string?> GetNormalizedUserNameAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult<string?>(user.Username.ToUpperInvariant());
public Task SetUserNameAsync(PasskeyAccount user, string? name, CancellationToken ct) => name == user.Username ? Task.CompletedTask : throw new NotSupportedException();
public Task SetNormalizedUserNameAsync(PasskeyAccount user, string? name, CancellationToken ct) => Task.CompletedTask;
public Task<IdentityResult> CreateAsync(PasskeyAccount user, CancellationToken ct) => throw new NotSupportedException();
public Task<IdentityResult> DeleteAsync(PasskeyAccount user, CancellationToken ct) => throw new NotSupportedException();
public Task<IdentityResult> UpdateAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult(IdentityResult.Success);
public Task<PasskeyAccount?> FindByIdAsync(string id, CancellationToken ct) => Task.FromResult(accounts.GetPublicKey(id) is null ? null : new PasskeyAccount(id.ToLowerInvariant()));
public Task<PasskeyAccount?> FindByNameAsync(string name, CancellationToken ct) => FindByIdAsync(name, ct);
public Task<PasskeyAccount?> FindByPasskeyIdAsync(byte[] id, CancellationToken ct) => Task.FromResult(accounts.Passkey(Codec.Encode(id)) is { } record ? new PasskeyAccount(record.Username) : null);
public Task<UserPasskeyInfo?> FindPasskeyAsync(PasskeyAccount user, byte[] id, CancellationToken ct)
{
var record = accounts.Passkey(Codec.Encode(id));
return Task.FromResult(record?.Username == user.Username ? Read(record) : null);
}
public Task<IList<UserPasskeyInfo>> GetPasskeysAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult<IList<UserPasskeyInfo>>(accounts.Passkeys(user.Username).Select(Read).ToList());
private UserPasskeyInfo Read(StoredPasskey record)
{
_readVersions[record.Id] = record.Data;
return JsonSerializer.Deserialize(record.Data, PasskeyStorageJson.Default.UserPasskeyInfo)!;
}
public Task AddOrUpdatePasskeyAsync(PasskeyAccount user, UserPasskeyInfo passkey, CancellationToken ct)
{
var id = Codec.Encode(passkey.CredentialId);
var latest = accounts.Passkey(id) ?? throw new WebDropException(ErrorCode.Unauthorized);
var previous = JsonSerializer.Deserialize(latest.Data, PasskeyStorageJson.Default.UserPasskeyInfo)!;
if ((previous.SignCount != 0 || passkey.SignCount != 0) && passkey.SignCount <= previous.SignCount) throw new WebDropException(ErrorCode.Unauthorized);
if (!_readVersions.TryGetValue(id, out var old) || !accounts.UpdatePasskeyData(id, user.Username, old, JsonSerializer.Serialize(passkey, PasskeyStorageJson.Default.UserPasskeyInfo))) throw new WebDropException(ErrorCode.Conflict);
return Task.CompletedTask;
}
public Task RemovePasskeyAsync(PasskeyAccount user, byte[] id, CancellationToken ct) { accounts.RemovePasskey(Codec.Encode(id), user.Username); return Task.CompletedTask; }
}
@@ -0,0 +1,169 @@
using WebDrop.Core;
namespace WebDrop.Api;
/// <summary>
/// 进程内的订阅枢纽。只传递「有变化」的信号,订阅者被唤醒后自己从日志里按游标读取,
/// 所以不存在回放与实时之间漏帧的竞态,也不需要为慢客户端缓冲数据。
/// 因此只支持单实例部署(见 architecture.md)。
/// </summary>
public sealed class ChannelHub : IChannelEvents
{
private readonly object _gate = new();
private readonly Dictionary<Id16, Signal> _signals = [];
// 账号级的「有频道前进了」信号:不管有没有人订阅这个频道都会记录,供空间列表上的未读提示用
private readonly Dictionary<Id16, long> _advanced = [];
private TaskCompletionSource _anyAdvanced = new(TaskCreationOptions.RunContinuationsAsynchronously);
// 在线状态:频道 -> 账号 -> 账号设备 -> 活动订阅数。只记账号。
private readonly Dictionary<Id16, Dictionary<string, Dictionary<string, int>>> _presence = [];
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);
}
}
/// <summary>登记一个活动订阅,释放返回值即下线。只供成员接口读取,不向订阅流推送,以免改变流的线格式。</summary>
public IDisposable Connected(Id16 id, string account, string deviceKey)
{
string endpoint = deviceKey;
lock (_gate)
{
if (!_presence.TryGetValue(id, out var accounts)) _presence[id] = accounts = [];
if (!accounts.TryGetValue(account, out var endpoints)) accounts[account] = endpoints = [];
endpoints[endpoint] = endpoints.GetValueOrDefault(endpoint) + 1;
}
return new Connection(() => Disconnected(id, account, endpoint));
}
private void Disconnected(Id16 id, string account, string endpoint)
{
lock (_gate)
{
if (_presence.TryGetValue(id, out var accounts) && accounts.TryGetValue(account, out var endpoints) && endpoints.TryGetValue(endpoint, out int count))
{
if (count <= 1) endpoints.Remove(endpoint); else endpoints[endpoint] = count - 1;
if (endpoints.Count == 0) accounts.Remove(account);
if (accounts.Count == 0) _presence.Remove(id);
}
}
}
public (bool Online, int Devices) Presence(Id16 id, string account)
{
lock (_gate)
{
return _presence.TryGetValue(id, out var accounts) && accounts.TryGetValue(account, out var endpoints)
? (endpoints.Count > 0, endpoints.Count) : (false, 0);
}
}
private sealed class Connection(Action release) : IDisposable
{
private Action? _release = release;
public void Dispose() => Interlocked.Exchange(ref _release, null)?.Invoke();
}
public void Advanced(Id16 channelId, long seq)
{
TaskCompletionSource old;
lock (_gate)
{
_advanced[channelId] = Math.Max(_advanced.GetValueOrDefault(channelId), seq);
old = _anyAdvanced;
_anyAdvanced = new(TaskCreationOptions.RunContinuationsAsynchronously);
}
old.TrySetResult();
Publish(channelId, s => s.Seq = Math.Max(s.Seq, seq));
}
/// <summary>先取这个任务、再读 <see cref="AdvancedSeq"/>,最后等待任务:不会漏掉中间的前进。</summary>
public Task AnyAdvanced { get { lock (_gate) return _anyAdvanced.Task; } }
/// <summary>本进程启动以来该频道见到的最大序号;没有见到过为 0。</summary>
public long AdvancedSeq(Id16 channelId) { lock (_gate) return _advanced.GetValueOrDefault(channelId); }
public void SettingsChanged(Id16 channelId) => Publish(channelId, s => s.SettingsVersion++);
public void MembersChanged(Id16 channelId) => Publish(channelId, s => s.MembersVersion++);
public void ChannelDeleted(Id16 channelId) => Publish(channelId, s => s.Deleted = true);
private void Publish(Id16 id, Action<Signal> 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 MembersVersion;
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);
}
/// <summary>
/// 用法:先取 <see cref="Changed"/>,再读状态,最后等待取到的那个任务。
/// 取任务之后发生的任何发布都会让它完成,不会丢通知。
/// </summary>
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 MaxMembers, bool Deleted) State
{
get { lock (_hub._gate) return (_signal.SettingsVersion, _signal.MembersVersion, _signal.Deleted); }
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 0) _hub.Release(_id, _signal);
}
}
}
@@ -25,20 +25,24 @@ public static class LogStreamer
/// </summary>
public static async Task RunAsync(
ChannelService svc, ChannelHub hub, Id16 id, Credentials creds, long from,
Func<Task<ILogSink>> open, CancellationToken ct)
Func<Task<ILogSink>> open, CancellationToken ct, Func<bool>? validateAccount = null)
{
using var sub = hub.Subscribe(id);
ILogSink? sink = null;
IDisposable? presence = null;
long cursor = from;
var (settingsV, seatsV, _) = sub.State;
var (settingsV, membersV, _) = sub.State;
try
{
while (!ct.IsCancellationRequested)
{
if (validateAccount is not null && !validateAccount()) throw new WebDropException(ErrorCode.AccountRequired);
var changed = sub.Changed; // 先取任务再读状态:读取期间的发布不会丢
var page = await svc.ReadLogAsync(id, creds, cursor, PageSize, ct).ConfigureAwait(false);
sink ??= await open().ConfigureAwait(false);
// 鉴权与首次读取都成功之后才算在线
presence ??= hub.Connected(id, creds.AccountName!, creds.AccountDeviceId ?? "");
foreach (var record in page.Records)
{
@@ -46,16 +50,16 @@ public static class LogStreamer
cursor = record.Seq + 1;
}
var (settingsNow, seatsNow, deleted) = sub.State;
var (settingsNow, membersNow, deleted) = sub.State;
if (settingsNow != settingsV)
{
settingsV = settingsNow;
await sink.SendControlAsync("""{"t":"settings"}""", ct).ConfigureAwait(false);
}
if (seatsNow != seatsV)
if (membersNow != membersV)
{
seatsV = seatsNow;
await sink.SendControlAsync("""{"t":"seats"}""", ct).ConfigureAwait(false);
membersV = membersNow;
await sink.SendControlAsync("""{"t":"members"}""", ct).ConfigureAwait(false);
}
if (deleted) throw new WebDropException(ErrorCode.NotFound);
@@ -79,6 +83,10 @@ public static class LogStreamer
{
await sink.FailAsync(e.Code, CancellationToken.None).ConfigureAwait(false);
}
finally
{
presence?.Dispose();
}
}
}
@@ -13,6 +13,7 @@
<ProjectReference Include="..\WebDrop.Core\WebDrop.Core.csproj" />
<ProjectReference Include="..\WebDrop.Frontend\WebDrop.Frontend.csproj" />
<ProjectReference Include="..\WebDrop.Storage\WebDrop.Storage.csproj" />
<ProjectReference Include="..\WebDrop.Sync\WebDrop.Sync.csproj" />
</ItemGroup>
</Project>
@@ -42,7 +42,7 @@ public interface IChannelEvents
{
void Advanced(Id16 channelId, long seq);
void SettingsChanged(Id16 channelId);
void SeatsChanged(Id16 channelId);
void MembersChanged(Id16 channelId);
void ChannelDeleted(Id16 channelId);
}
@@ -50,7 +50,7 @@ public sealed class NullChannelEvents : IChannelEvents
{
public void Advanced(Id16 channelId, long seq) { }
public void SettingsChanged(Id16 channelId) { }
public void SeatsChanged(Id16 channelId) { }
public void MembersChanged(Id16 channelId) { }
public void ChannelDeleted(Id16 channelId) { }
}
@@ -91,21 +91,43 @@ public interface IResourceTx
// 频道
Channel? GetChannel(Id16 id);
void InsertChannel(Channel channel);
WorkspaceState? GetWorkspace(Id16 id);
CryptoState GetWorkspaceCrypto(Id16 id);
void PutWorkspaceCrypto(Id16 id, CryptoState state);
IReadOnlyList<Id16> ListAccountSpaces(string username, long now);
void PutWorkspace(Id16 id, WorkspaceState state);
void UpdateChannel(Id16 id, ChannelSettings settings, long expiresAt);
void SetJoinPassword(Id16 id, byte[]? hash, byte[]? salt);
bool HasInvitation(Id16 id, string username);
void PutInvitation(ChannelInvitation invitation);
IReadOnlyList<ChannelInvitation> ListInvitations(Id16 id);
IReadOnlyList<ChannelInvitation> ListUserInvitations(string username);
void DeleteInvitation(Id16 id, string username);
void PutKeyPackage(Id16 id, string username, string payload, long createdAt);
void DeleteKeyPackage(Id16 id, string username);
IReadOnlyList<ChannelKeyPackage> ListUserKeyPackages(string username, long now);
ChannelMember? GetChannelMember(Id16 id, string username);
IReadOnlyList<ChannelMember> ListChannelMembers(Id16 id);
void PutChannelMember(Id16 id, ChannelMember member);
void DeleteChannelMember(Id16 id, string username);
bool IsJoinDenied(Id16 id, string username);
void DenyAccount(Id16 id, string username);
void AllowAccount(Id16 id, string username);
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);
/// <summary>空间头像(data URL)单独存放,不放进每次请求都要读的设置 JSON</summary>
/// <summary>thumb 为 true 取缩略图(没有就用原图),否则取原图</summary>
AvatarBlob? GetChannelAvatar(Id16 id, bool thumb);
/// <summary>头像的版本标记(头像文件名,内容变了才会变);没有头像为 null</summary>
string? GetChannelAvatarVersion(Id16 id);
void SetChannelAvatar(Id16 id, string? avatar, string? thumb);
IReadOnlyList<Id16> ListExpiredChannels(long now, int limit);
// 席位
Seat? GetSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash);
int CountSeats(Id16 channelId);
void InsertSeat(Id16 channelId, Seat seat);
bool DeleteSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash);
void SetSeatLabel(Id16 channelId, ReadOnlySpan<byte> endpointHash, byte[] label);
IReadOnlyList<Seat> ListSeats(Id16 channelId);
// 成员
int CountMembers(Id16 channelId);
// 条目
Item? GetItem(Id16 channelId, Id16 itemId);
@@ -113,6 +135,7 @@ public interface IResourceTx
void UpdateItem(Item item);
void DeleteItemRows(Id16 channelId, Id16 itemId);
int CountItems(Id16 channelId);
int CountControlItems(Id16 channelId);
IReadOnlyList<Item> ListOldestItems(Id16 channelId, int limit);
Item? FindSnapshotItem(Id16 channelId, Id16 excludeItemId);
IReadOnlyList<Item> ListExpiredItems(long now, int limit);
@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace WebDrop.Core;
public sealed class AccountProfile
{
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("displayName")] public required string DisplayName { get; init; }
/// <summary>头像文件名(服务端存的是磁盘文件)。资料列表、空间成员等接口不再内联头像(动图可能有几 MB),只告诉有没有,头像另用 /accounts/users/{用户名}/avatar 取</summary>
[JsonPropertyName("avatar")] public string? Avatar { get; init; }
[JsonPropertyName("hasAvatar")] public bool HasAvatar { get; init; }
[JsonPropertyName("modifiedAt")] public required long ModifiedAt { get; init; }
/// <summary>有损的小图(列表、聊天里默认分发);没有单独上传时用原图。不随 JSON 输出</summary>
[JsonIgnore] public string? AvatarThumb { get; init; }
public AccountProfile WithoutAvatar() => new() { Username = Username, DisplayName = DisplayName, HasAvatar = HasAvatar, ModifiedAt = ModifiedAt };
}
@@ -0,0 +1,35 @@
namespace WebDrop.Core;
/// <summary>头像(账号头像、空间头像)的 data URL 校验。浏览器里已经裁剪好并重新编码,服务端只做格式、大小和文件头检查</summary>
public static class AvatarData
{
/// <summary>静态头像(256×256 WebP/PNG/JPEG)的长度上限(字符数)</summary>
public const int MaxStaticChars = 350_000;
/// <summary>账号动图头像保留全部帧,约 4.5 MB</summary>
public const int MaxAccountGifChars = 6_000_000;
/// <summary>空间头像会跟着空间信息发给所有成员和空间列表,动图上限比账号头像小,约 1.5 MB</summary>
public const int MaxSpaceGifChars = 2_000_000;
/// <summary>缩略图(列表、聊天里默认分发的有损小图)的上限:静态约 75 KB,动图约 370 KB</summary>
public const int MaxThumbStaticChars = 100_000;
public const int MaxThumbGifChars = 800_000;
private static readonly string[] Prefixes = ["data:image/png;base64,", "data:image/jpeg;base64,", "data:image/webp;base64,", "data:image/gif;base64,"];
public static void ValidateThumb(string thumb) => Validate(thumb, MaxThumbGifChars, MaxThumbStaticChars);
/// <summary>不合法时抛 BadRequest</summary>
public static void Validate(string avatar, int maxGifChars, int maxStaticChars = MaxStaticChars)
{
var prefix = Prefixes.FirstOrDefault(p => avatar.StartsWith(p, StringComparison.Ordinal));
var gif = prefix?.Contains("gif", StringComparison.Ordinal) == true;
if (prefix is null || avatar.Length > (gif ? maxGifChars : maxStaticChars) || avatar.Length <= prefix.Length) throw new WebDropException(ErrorCode.BadRequest);
byte[] bytes;
try { bytes = Convert.FromBase64String(avatar[prefix.Length..]); }
catch (FormatException) { throw new WebDropException(ErrorCode.BadRequest); }
if (gif && !(bytes.Length > 6 && bytes[0] == (byte)'G' && bytes[1] == (byte)'I' && bytes[2] == (byte)'F')) throw new WebDropException(ErrorCode.BadRequest);
}
}
/// <summary>落在磁盘上的头像文件:下载时直接按路径发送,不读进内存。Hash 是内容的 SHA-256,也用作 ETag</summary>
public sealed record AvatarBlob(string Path, string Mime, string Hash);
-26
View File
@@ -1,26 +0,0 @@
namespace WebDrop.Core;
/// <summary>谁能做什么。规则只写在这里,服务和测试都调用它。</summary>
public static class ChannelPolicy
{
public static bool IsOwner(Principal p) => p.Role == Role.Owner;
public static bool CanRead(Principal p) => p.Role >= Role.Read;
/// <summary>成员写入需要频道允许上传,且服务端持有写令牌哈希。</summary>
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,
};
}
}
-584
View File
@@ -1,584 +0,0 @@
using System.Security.Cryptography;
namespace WebDrop.Core;
/// <summary>
/// 频道的全部业务逻辑。依赖抽象(存储、blob、时钟、事件),不含 HTTP、SQL、文件细节。
/// 约定:所有失败以 <see cref="WebDropException"/> 表达;提交成功后才发事件;blob 先写后提交。
/// </summary>
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<SeatInfo> ListSeats(Id16 id, Credentials creds) => store.Read(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
return (IReadOnlyList<SeatInfo>)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<AppendResult> AppendFrameAsync(
Id16 channelId, Credentials creds, ReadOnlyMemory<byte> 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<string> prefixes)
{
public AppendResult Result { get; } = result;
public long LastSeq { get; } = lastSeq;
public List<string> BlobPrefixesToDelete { get; } = prefixes;
}
private AppendOutcome AppendInTx(
IResourceTx tx, Id16 channelId, Credentials creds, Envelope env, ReadOnlyMemory<byte> 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<string>();
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);
}
private static bool IsItemReadable(Item item, long now) =>
item.ExpiresAt > now && (item.CloseDeadline is null || item.CloseDeadline > now);
// ───────────────────────── 读取 ─────────────────────────
public async Task<LogPage> 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);
long now = clock.NowMs;
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.GetItem(id, e.ItemId) is { } frameItem && IsItemReadable(frameItem, now)
&& 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 && IsItemReadable(it, now))
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<LogRecord>(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);
}
/// <summary>开始拉取条目:鉴权,并按规则计一次下载。<paramref name="fromIndex"/> 为 0 才计数,大于 0 视为续传。</summary>
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.ExpiresAt <= now) throw new WebDropException(ErrorCode.Gone);
if (item.CloseDeadline is { } deadline && (fromIndex == 0 || now >= deadline))
throw new WebDropException(ErrorCode.Gone);
if (item.IsGated && fromIndex > 0 && !tx.HasDownloadGrant(id, itemId, principal.EndpointHash))
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 });
tx.AddDownloadGrant(id, itemId, principal.EndpointHash);
}
return 0;
});
}
/// <summary>读取自 <paramref name="fromIndex"/> 起连续的帧。条目未完成时可能返回空批次,调用方等待后重试。</summary>
public async Task<ItemChunk> 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.ExpiresAt <= clock.NowMs) throw new WebDropException(ErrorCode.Gone);
if (item.IsGated && !tx.HasDownloadGrant(id, itemId, principal.EndpointHash))
throw new WebDropException(ErrorCode.Gone);
if (item.CloseDeadline is { } d && clock.NowMs >= d) throw new WebDropException(ErrorCode.Gone);
var taken = new List<StoredFrame>();
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<byte[]>(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<byte[]?> 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<string>();
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<string> 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;
}
// ───────────────────────── 后台清理 ─────────────────────────
/// <summary>清理过期条目、下载达上限的条目、过期频道;可选清理孤立 blob。返回删除的条目和频道总数。</summary>
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<string>();
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;
}
/// <summary>删除没有任何帧引用、且超过 10 分钟的 blob(崩溃发生在写 blob 与提交事务之间会留下它们)。</summary>
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,
};
}
@@ -0,0 +1,111 @@
using System.Security.Cryptography;
namespace WebDrop.Core;
public sealed partial class ChannelService
{
private static (byte[]? Hash, byte[]? Salt) HashJoinPassword(string? password)
{
if (string.IsNullOrEmpty(password)) return (null, null);
if (password.Length is < 8 or > 256) throw new WebDropException(ErrorCode.BadRequest);
var salt = RandomNumberGenerator.GetBytes(16);
return (Rfc2898DeriveBytes.Pbkdf2(password, salt, 210_000, HashAlgorithmName.SHA256, 32), salt);
}
private static void CheckAdmission(IResourceTx tx, Channel channel, Credentials creds, string? password)
{
string account = creds.AccountName ?? throw new WebDropException(ErrorCode.AccountRequired);
if (account == channel.CreatorAccount) return;
if (tx.IsJoinDenied(channel.Id, account)) throw new WebDropException(ErrorCode.Forbidden);
if (tx.HasInvitation(channel.Id, account)) return;
if (tx.GetChannelMember(channel.Id, account) is not null) return;
if (channel.Settings.JoinPolicy == JoinPolicy.Invite) throw new WebDropException(ErrorCode.InvitationRequired);
if (channel.JoinPasswordHash is null) return;
if (string.IsNullOrEmpty(password)) throw new WebDropException(ErrorCode.ChannelPasswordRequired);
if (password.Length > 256) throw new WebDropException(ErrorCode.ChannelPasswordInvalid);
var actual = Rfc2898DeriveBytes.Pbkdf2(password, channel.JoinPasswordSalt!, 210_000, HashAlgorithmName.SHA256, 32);
if (!CryptographicOperations.FixedTimeEquals(actual, channel.JoinPasswordHash)) throw new WebDropException(ErrorCode.ChannelPasswordInvalid);
}
public IReadOnlyList<ChannelInvitation> Invitations(Id16 id, Credentials creds) => store.Read(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
if (!ChannelPolicy.IsOwner(principal)) RequireMemberPermission(tx, id, creds, WorkspacePermission.RemoveMembers);
return tx.ListInvitations(id);
});
/// <summary>成员列表,附带调用者能不能移除每一位(后端判断,前端只显示按钮)</summary>
public IReadOnlyList<MemberView> MembersView(Id16 id, Credentials creds) => store.Read(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
if (!ChannelPolicy.IsOwner(principal)) _ = Authenticate(tx, id, creds, Membership.Required);
var state = tx.GetWorkspace(id);
return tx.ListChannelMembers(id).Select(m => new MemberView(m, ChannelPolicy.CanRemoveMember(state, principal, m.Username, m.Role == "owner"))).ToList();
});
/// <summary>邀请列表,附带调用者能不能撤销每一条</summary>
public IReadOnlyList<InvitationView> InvitationsView(Id16 id, Credentials creds) => store.Read(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
if (!ChannelPolicy.IsOwner(principal)) principal = RequireMemberPermission(tx, id, creds, WorkspacePermission.RemoveMembers);
var state = tx.GetWorkspace(id);
return tx.ListInvitations(id).Select(i => new InvitationView(i, ChannelPolicy.CanRemoveMember(state, principal, i.Username, false))).ToList();
});
/// <summary>非空间主的调用方:必须是已加入的成员,且所在身份组被授予该权限</summary>
private Principal RequireMemberPermission(IResourceTx tx, Id16 id, Credentials creds, string permission)
{
var (_, principal) = Authenticate(tx, id, creds, Membership.Required);
if (!ChannelPolicy.HasPermission(tx.GetWorkspace(id), principal, permission)) throw new WebDropException(ErrorCode.Forbidden);
return principal;
}
public IReadOnlyList<ChannelInvitation> UserInvitations(string username) => store.Read(tx => tx.ListUserInvitations(username));
public void RevokeInvitation(Id16 id, Credentials creds, string username)
{
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
bool targetIsOwner = tx.GetChannelMember(id, username)?.Role == "owner";
if (!ChannelPolicy.IsOwner(principal)) principal = RequireMemberPermission(tx, id, creds, WorkspacePermission.RemoveMembers);
if (!ChannelPolicy.CanRemoveMember(tx.GetWorkspace(id), principal, username, targetIsOwner)) throw new WebDropException(ErrorCode.Forbidden);
tx.DeleteInvitation(id, username);
tx.DeleteKeyPackage(id, username);
tx.DeleteChannelMember(id, username);
var crypto = tx.GetWorkspaceCrypto(id);
crypto.Access.RemoveAll(a => a.Username == username);
crypto.Envelopes.RemoveAll(e => e.Username == username);
crypto.Revision++;
tx.PutWorkspaceCrypto(id, crypto);
tx.DenyAccount(id, username);
return 0;
});
events.MembersChanged(id);
}
/// <summary>空间里所有成员的账号(不做鉴权,供已经通过鉴权的接口补全成员资料)</summary>
public IReadOnlyList<string> MemberAccounts(Id16 id) => store.Read(tx => tx.ListChannelMembers(id).Select(m => m.Username).ToList());
public IReadOnlyList<ChannelMember> Members(Id16 id, Credentials creds) => store.Read(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
if (!ChannelPolicy.IsOwner(principal)) _ = Authenticate(tx, id, creds, Membership.Required);
return tx.ListChannelMembers(id);
});
public IReadOnlyList<ChannelKeyPackage> AccountChannels(string username) =>
store.Read(tx => tx.ListUserKeyPackages(username, clock.NowMs));
public void SaveOwnerKeyPackage(Id16 id, Credentials creds, string payload)
{
if (payload is null || payload.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest);
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
RequireOwner(principal);
tx.PutKeyPackage(id, principal.AccountName!, payload, clock.NowMs);
return 0;
});
}
}
@@ -0,0 +1,103 @@
namespace WebDrop.Core;
/// <summary>谁能做什么。规则只写在这里,服务和测试都调用它。</summary>
public static class ChannelPolicy
{
public static bool IsOwner(Principal p) => p.Role == Role.Owner;
public static bool CanRead(Principal p) => p.Role >= Role.Read;
/// <summary>成员写入需要频道允许上传,且服务端持有写令牌哈希。</summary>
public static bool CanWrite(Channel channel, Principal p) =>
p.Role == Role.Owner
|| (p.Role == Role.Write && channel.Settings.MembersCanUpload && channel.WriteHash is not null);
// ───── 空间所有者与频道访问授权(ChannelAccess):谁能读、写哪个频道的哪个数据域。其他地方只调用这里,不再各写一遍
/// <summary>空间的所有者:持有所有者令牌的人,或者空间的创建账号</summary>
public static bool IsSpaceOwner(Channel space, Principal p) => p.Role == Role.Owner || p.AccountName == space.CreatorAccount;
/// <summary>某个账号在某个频道上的访问授权;没有记录返回 null</summary>
public static ChannelAccess? AccessOf(CryptoState crypto, string? account, string channelId) =>
crypto.Access.Find(a => a.ChannelId == channelId && a.Username == account);
/// <summary>这条授权允许读这个数据域</summary>
public static bool CanReadDomain(ChannelAccess? access, CryptoDomain domain) =>
access is not null && (domain == CryptoDomain.Chat ? access.ChatRead : access.FilesRead);
/// <summary>这条授权允许写这个数据域(聊天域写消息,文件域上传)</summary>
public static bool CanWriteDomain(ChannelAccess? access, CryptoDomain domain) =>
access is not null && (domain == CryptoDomain.Chat ? access.ChatWrite : access.FilesUpload);
/// <summary>读一个对象:能读它所在的数据域,并且对象不早于授权生效的版本(关闭历史可读时,新成员看不到加入前的内容)</summary>
public static bool CanReadObject(ChannelAccess? access, CryptoDomain domain, long objectRevision) =>
CanReadDomain(access, domain) && objectRevision >= (domain == CryptoDomain.Chat ? access!.ChatFromRevision : access!.FilesFromRevision);
/// <summary>频道对这个账号可见:聊天或文件任一域能读</summary>
public static bool CanSeeChannel(ChannelAccess access) => access.ChatRead || access.FilesRead;
/// <summary>能碰这个频道的文件域:能读或能上传</summary>
public static bool CanUseFiles(ChannelAccess access) => access.FilesRead || access.FilesUpload;
/// <summary>能往频道里放东西:聊天或文件任一域能写</summary>
public static bool CanWriteAnyDomain(ChannelAccess? access) => access is not null && (access.ChatWrite || access.FilesUpload);
/// <summary>这个频道里能读某个数据域的账号</summary>
public static IEnumerable<ChannelAccess> Readers(CryptoState crypto, string channelId, CryptoDomain domain) =>
crypto.Access.Where(a => a.ChannelId == channelId && CanReadDomain(a, domain));
/// <summary>
/// 这个空间是否已经按授权记录来约束成员:加密空间有了密钥信封,明文空间有了授权记录或对象登记之后才约束。
/// 之前(刚建好、还没初始化)所有成员的行为和旧版一致。
/// </summary>
public static bool GrantsEnforced(Channel space, CryptoState crypto) =>
space.Mode == ChannelMode.Enc ? crypto.Envelopes.Count > 0 : crypto.Access.Count > 0 || crypto.Objects.Count > 0;
// ───── 空间权限(身份组授予)与结构管理
/// <summary>有没有这项空间权限:所有者拥有全部,其余看身份组授予</summary>
public static bool HasPermission(WorkspaceState? state, Principal principal, string permission) =>
principal.Role == Role.Owner
|| principal.AccountName is { } account && state?.Roles.Any(r => r.Members.Contains(account) && r.Permissions.Contains(permission)) == true;
/// <summary>重命名频道、调整频道所属分组、管理分组(所有者,或被授予「管理频道和分组」)</summary>
public static bool CanManageStructure(WorkspaceState? state, Principal p) => HasPermission(state, p, WorkspacePermission.ManageStructure);
/// <summary>移除成员、撤销邀请的权限(所有者,或被授予「移除成员」)。具体能移除谁见 <see cref="CanRemoveMember"/></summary>
public static bool CanRemoveMembers(WorkspaceState? state, Principal p) => HasPermission(state, p, WorkspacePermission.RemoveMembers);
/// <summary>
/// 谁能移除谁:所有者能移除除自己外的任何人;被授权的成员不能移除自己、空间主,也不能移除同样有移除权限的人。
/// 执行(RevokeInvitation)和给前端的可移除标记(MembersView)都用这一处。
/// </summary>
public static bool CanRemoveMember(WorkspaceState? state, Principal acting, string target, bool targetIsOwner)
{
if (targetIsOwner) return false;
if (acting.Role == Role.Owner) return true;
return acting.AccountName is { } actor && actor != target
&& CanRemoveMembers(state, acting)
&& !CanRemoveMembers(state, new Principal(Role.Write, target));
}
// 下面这几项目前都只有所有者:新建频道(涉及密钥)、文件夹、邀请、改空间设置。要放开时只改这里
public static bool CanCreateChannel(Principal p) => IsOwner(p);
public static bool CanManageFolders(Principal p) => IsOwner(p);
public static bool CanInvite(Principal p) => IsOwner(p);
public static bool CanEditSettings(Principal p) => IsOwner(p);
public static bool CanManageRoles(Principal p) => IsOwner(p);
// ───── 删除条目
/// <summary>能删任何人发的条目:所有者,或空间的删除策略是 anyone</summary>
public static bool CanDeleteAnyItem(Channel channel, Principal p) =>
IsOwner(p) || p.Role >= Role.Read && channel.Settings.DeletePolicy == DeletePolicy.Anyone;
/// <summary>能删自己发的条目:所有者,或删除策略是 own / anyone</summary>
public static bool CanDeleteOwnItem(Channel channel, Principal p) =>
IsOwner(p) || p.Role >= Role.Read && channel.Settings.DeletePolicy is DeletePolicy.Own or DeletePolicy.Anyone;
/// <summary>某个具体条目能不能删:能删任何人的,或者是自己的并且能删自己的</summary>
public static bool CanDeleteItem(Channel channel, Item item, Principal p) =>
CanDeleteAnyItem(channel, p) || item.AuthorAccount == p.AccountName && CanDeleteOwnItem(channel, p);
}
@@ -0,0 +1,875 @@
using System.Security.Cryptography;
using WebDrop.FileProtocol;
namespace WebDrop.Core;
/// <summary>
/// 频道的全部业务逻辑。依赖抽象(存储、blob、时钟、事件),不含 HTTP、SQL、文件细节。
/// 约定:所有失败以 <see cref="WebDropException"/> 表达;提交成功后才发事件;blob 先写后提交。
/// </summary>
public sealed partial class ChannelService(
IResourceStore store,
IBlobStore blobs,
IClock clock,
ServerLimits limits,
IChannelEvents events,
IFileGrants? fileGrants = null)
{
private IFileGrants Files => fileGrants ?? NoFileGrants.Instance;
/// <summary>帧内容在文件域里的帧,BlobKey 以它开头;主后端只有头部、长度和摘要</summary>
public const string FilesPrefix = "files:";
/// <summary>这个条目是文件域的对象(不含目录控制帧)。所在频道没有分配文件域(文件服务没开、小文件)时,文件和聊天一样存在主后端</summary>
private static bool IsFileObject(IResourceTx tx, Id16 channelId, Envelope env) =>
!env.IsControl && FileDomainOf(tx, channelId, env.ItemId) is not null;
private static bool IsRemote(string? blobKey) => blobKey is not null && blobKey.StartsWith(FilesPrefix, StringComparison.Ordinal);
/// <summary>Required:必须已是频道成员;None:只验证身份(加入、查看信息等)。</summary>
private enum Membership { None, Required }
// ───────────────────────── 频道 ─────────────────────────
public ChannelInfo CreateChannel(CreateChannelRequest req, string creatorAccount)
{
if (string.IsNullOrWhiteSpace(creatorAccount)) throw new WebDropException(ErrorCode.AccountRequired);
if (req.KeyPackage is not null && req.KeyPackage.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest);
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 ?? CreationDefaults.Channel(limits);
if (string.IsNullOrWhiteSpace(settings.Name)) throw new WebDropException(ErrorCode.SpaceNameRequired);
settings.Name = settings.Name.Trim();
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 (passwordHash, passwordSalt) = HashJoinPassword(req.JoinPassword);
var channel = new Channel(id, mode, settings, readHash, writeHash, ownerHash,
now, now + settings.TtlSeconds * 1000L, 0, 0, 0, creatorAccount, passwordHash, passwordSalt);
store.Write(tx =>
{
if (tx.HasResourceId(id)) throw new WebDropException(ErrorCode.Conflict);
tx.InsertChannel(channel);
tx.PutWorkspace(id, NewWorkspace(creatorAccount, now));
tx.PutChannelMember(id, new ChannelMember(creatorAccount, "owner", now));
if (req.KeyPackage is not null) tx.PutKeyPackage(id, creatorAccount, req.KeyPackage, now);
return 0;
});
return ToInfo(channel, 1);
}
public ChannelInfo GetChannel(Id16 id, Credentials creds) => store.Read(tx =>
{
var (channel, principal) = Authenticate(tx, id, creds, Membership.None);
return ToInfo(channel, tx.CountMembers(id), principal, tx.GetChannelAvatarVersion(id), tx.GetWorkspace(id));
});
/// <summary>空间头像(thumb 为缩略图,否则原图)。需要能读这个空间</summary>
public AvatarBlob? GetChannelAvatarBlob(Id16 id, Credentials creds, bool thumb) => store.Read(tx =>
{
_ = Authenticate(tx, id, creds, Membership.None);
return tx.GetChannelAvatar(id, thumb);
});
public ChannelInfo UpdateSettings(Id16 id, Credentials creds, ChannelSettingsPatch patch)
{
var info = store.Write(tx =>
{
var (channel, principal) = Authenticate(tx, id, creds, Membership.None);
RequireOwner(principal);
var updated = patch.ApplyTo(channel.Settings);
updated.Validate(limits);
if (patch.Avatar is { Length: > 0 } newAvatar) AvatarData.Validate(newAvatar, AvatarData.MaxSpaceGifChars);
if (patch.AvatarThumb is { Length: > 0 } newThumb) { if (patch.Avatar is not { Length: > 0 }) throw new WebDropException(ErrorCode.BadRequest); AvatarData.ValidateThumb(newThumb); }
if (patch.Avatar is not null) tx.SetChannelAvatar(id, patch.Avatar.Length == 0 ? null : patch.Avatar, patch.Avatar.Length == 0 ? null : patch.AvatarThumb is { Length: > 0 } ? patch.AvatarThumb : null);
long expiresAt = updated.TtlSeconds != channel.Settings.TtlSeconds
? clock.NowMs + updated.TtlSeconds * 1000L
: channel.ExpiresAt;
tx.UpdateChannel(id, updated, expiresAt);
if (patch.JoinPassword is not null)
{
var (hash, salt) = HashJoinPassword(patch.JoinPassword);
tx.SetJoinPassword(id, hash, salt);
channel = channel with { JoinPasswordHash = hash, JoinPasswordSalt = salt };
}
return ToInfo(channel with { Settings = updated, ExpiresAt = expiresAt }, tx.CountMembers(id), avatarVersion: tx.GetChannelAvatarVersion(id));
});
events.SettingsChanged(id);
return info;
}
public void DeleteChannel(Id16 id, Credentials creds)
{
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
RequireOwner(principal);
tx.DeleteChannelRows(id);
return 0;
});
blobs.DeletePrefix(id.ToHex());
events.ChannelDeleted(id);
}
// ───────────────────────── 席位 ─────────────────────────
/// <summary>加入频道。只有已登录的账号能加入:登记成员关系,同一账号的多台设备不增加人数。</summary>
public JoinResult Join(Id16 id, Credentials creds, string? password = null)
{
var (joined, isNew) = store.Write(tx =>
{
var (channel, _) = Authenticate(tx, id, creds, Membership.None);
string account = creds.AccountName!;
int membersUsed = tx.CountMembers(id);
JoinResult Result(long joinedAt, int used) => new() { JoinedAt = joinedAt, MembersUsed = used, MembersMax = channel.Settings.MaxMembers };
if (tx.GetChannelMember(id, account) is { } existingMember) return (Result(existingMember.JoinedAt, membersUsed), false);
CheckAdmission(tx, channel, creds, password);
if (membersUsed >= channel.Settings.MaxMembers) throw new WebDropException(ErrorCode.MembersFull);
long memberAt = clock.NowMs;
tx.PutChannelMember(id, new ChannelMember(account, "member", memberAt));
return (Result(memberAt, membersUsed + 1), true);
});
if (isNew) events.MembersChanged(id);
return joined;
}
// ───────────────────────── 追加帧 ─────────────────────────
public async Task<AppendResult> AppendFrameAsync(
Id16 channelId, Credentials creds, ReadOnlyMemory<byte> body,
long? maxDownloads, CancellationToken ct = default, SignedContentEventProof? signedEvent = null)
{
if (!Envelope.TryParse(body.Span, out var env)) throw new WebDropException(ErrorCode.BadRequest);
if (env.PayloadLength > limits.MaxPayloadBytes) throw new WebDropException(ErrorCode.TooLarge);
ValidateShape(env);
byte[] digest = SHA256.HashData(body.Span);
// 1. 预检:鉴权并处理幂等重发,避免为重复帧白写 blob
var existing = store.Read(tx =>
{
var (channel, principal) = Authenticate(tx, channelId, creds, Membership.Required);
CheckWritable(channel, principal, env);
CheckObjectWrite(tx, channel, principal, env.ItemId);
// 文件域的对象不存主后端:字节在文件域里,主后端只在整个文件传完后一次登记头部和摘要(见 RegisterFileItem)
if (IsFileObject(tx, channelId, env)) throw new WebDropException(ErrorCode.UseFileApi);
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, signedEvent));
}
catch
{
DeleteBlobIfUnreferenced(blobKey);
throw;
}
if (!outcome.Result.Created) DeleteBlobIfUnreferenced(blobKey);
foreach (string prefix in outcome.BlobPrefixesToDelete) blobs.DeletePrefix(prefix);
events.Advanced(channelId, outcome.LastSeq);
if (outcome.NotifySettings) events.SettingsChanged(channelId);
return outcome.Result;
}
/// <summary>登记整个文件时,每一帧的负载长度和摘要(摘要是整帧封装的 SHA-256)</summary>
public readonly record struct FrameEntry(int PayloadLength, byte[] Digest);
/// <summary>
/// 一次登记整个文件:字节都在文件域里,逐帧登记要为每个 1 MiB 往返一次主后端,大文件的上传速度被它拖死。
/// 请求带的是末帧的头部和每一帧的(长度,摘要)。规则和逐帧登记完全一样:第 0 帧创建条目(含下载次数、日志里的公布),
/// 中间帧只落库,末帧验签名事件(清单摘要要和这里登记的所有摘要对得上)。整个登记在一个事务里,要么全成要么全不成。
/// 重复登记同一个文件(摘要全一致)幂等。
/// </summary>
public AppendResult RegisterFileItem(
Id16 channelId, Credentials creds, ReadOnlyMemory<byte> finalHeader, IReadOnlyList<FrameEntry> frames,
long? maxDownloads, SignedContentEventProof? signedEvent = null)
{
if (!Envelope.TryParseHeader(finalHeader.Span, out var last) || !last.IsFinal || frames.Count < 1
|| last.FrameIndex != frames.Count - 1 || frames[^1].PayloadLength != last.PayloadLength) throw new WebDropException(ErrorCode.BadRequest);
if (frames.Count > limits.MaxFramesPerItem) throw new WebDropException(ErrorCode.TooLarge);
if (frames.Any(f => f.Digest.Length != 32 || f.PayloadLength < 0 || f.PayloadLength > limits.MaxPayloadBytes)) throw new WebDropException(ErrorCode.BadRequest);
// 每一帧的头部由末帧头部推出:条目级标志一致,只有末帧带 FINAL
FrameFlags itemFlags = last.Flags & ~FrameFlags.Final;
Envelope EnvelopeOf(int index) => new(index == frames.Count - 1 ? last.Flags : itemFlags, last.ItemId, (uint)index, frames[index].PayloadLength);
ValidateShape(EnvelopeOf(0)); ValidateShape(last);
var outcomes = store.Write<(AppendOutcome? First, AppendOutcome? Last, long Seq)>(tx =>
{
var (channel, principal) = Authenticate(tx, channelId, creds, Membership.Required);
for (int i = 0; i < frames.Count; i++) CheckWritable(channel, principal, EnvelopeOf(i));
CheckObjectWrite(tx, channel, principal, last.ItemId);
if (!IsFileObject(tx, channelId, last)) throw new WebDropException(ErrorCode.BadRequest);
// 已经登记过同一个文件:摘要全部一致就是重复请求,否则冲突
if (tx.GetItem(channelId, last.ItemId) is not null)
{
var stored = tx.ListFrames(channelId, last.ItemId, 0, limits.MaxFramesPerItem);
if (stored.Count != frames.Count || stored.Select((f, i) => f.Index == i && f.Digest.AsSpan().SequenceEqual(frames[i].Digest)).Any(same => !same))
throw new WebDropException(ErrorCode.Conflict);
return (First: (AppendOutcome?)null, Last: (AppendOutcome?)null, Seq: stored[^1].Seq);
}
long middleBytes = 0;
for (int i = 1; i < frames.Count - 1; i++) middleBytes += EnvelopeOf(i).TotalLength;
if (channel.Bytes + middleBytes > limits.MaxWorkspaceBytes) throw new WebDropException(ErrorCode.TooLarge);
string BlobKeyOf(int i) => $"{FilesPrefix}{channelId.ToHex()}/{last.ItemId.ToHex()}/{i}";
var first = AppendInTx(tx, channelId, creds, EnvelopeOf(0), ReadOnlyMemory<byte>.Empty, frames[0].Digest, BlobKeyOf(0), maxDownloads,
frames.Count == 1 ? signedEvent : null);
if (frames.Count == 1) return (First: (AppendOutcome?)first, Last: null, Seq: first.LastSeq);
// 中间帧(受限条目的中间帧不进日志):只落库,条目计数一次性补上
for (int i = 1; i < frames.Count - 1; i++)
{
var env = EnvelopeOf(i);
tx.InsertFrame(new StoredFrame(channelId, last.ItemId, (uint)i, tx.NextSeq(channelId), env.TotalLength, frames[i].Digest, null, BlobKeyOf(i)));
}
if (frames.Count > 2)
{
var item = tx.GetItem(channelId, last.ItemId)!;
tx.UpdateItem(item with { FrameCount = item.FrameCount + frames.Count - 2, MaxIndex = Math.Max(item.MaxIndex, frames.Count - 2), Bytes = item.Bytes + middleBytes });
tx.AddChannelBytes(channelId, middleBytes);
}
var final = AppendInTx(tx, channelId, creds, last, ReadOnlyMemory<byte>.Empty, frames[^1].Digest, BlobKeyOf(frames.Count - 1), null, signedEvent);
return (First: (AppendOutcome?)first, Last: final, Seq: final.Result.Seq);
});
foreach (var outcome in new[] { outcomes.First, outcomes.Last })
{
if (outcome is null) continue;
foreach (string prefix in outcome.BlobPrefixesToDelete) blobs.DeletePrefix(prefix);
events.Advanced(channelId, outcome.LastSeq);
if (outcome.NotifySettings) events.SettingsChanged(channelId);
}
return new AppendResult { Seq = outcomes.Seq, Created = outcomes.First is not null };
}
/// <summary>条目所属频道的文件域(文件域的对象才有)</summary>
private static string? FileDomainOf(IResourceTx tx, Id16 spaceId, Id16 itemId)
{
var obj = tx.GetWorkspaceCrypto(spaceId).Objects.Find(o => o.ItemId == itemId.ToBase64Url() && o.Domain == CryptoDomain.Files);
return obj is null ? null : tx.GetWorkspace(spaceId)?.Channels.Find(c => c.Id == obj.ChannelId)?.FileDomain;
}
/// <summary>创建文件域的票据:只有空间所有者和有「管理频道和分组」权限的人能要。票据绑定空间 id</summary>
public FileGrant IssueCreateGrant(Id16 spaceId, Credentials creds)
{
if (!Files.Enabled) throw new WebDropException(ErrorCode.NotFound);
store.Read(tx =>
{
var (space, principal) = Authenticate(tx, spaceId, creds, Membership.Required);
if (!ChannelPolicy.CanManageStructure(tx.GetWorkspace(spaceId), principal)) throw new WebDropException(ErrorCode.Forbidden);
return 0;
});
return Files.Issue(FileOp.Create, spaceId.ToHex(), spaceId.ToHex(), "*", 0, clock.NowMs);
}
/// <summary>上传票据:确认能写这个条目所在的频道,然后给客户端文件域的访问凭证。</summary>
public FileGrant IssueUploadGrant(Id16 id, Credentials creds, Id16 itemId)
{
if (!Files.Enabled) throw new WebDropException(ErrorCode.NotFound);
string domain = store.Read(tx =>
{
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
if (!ChannelPolicy.CanWrite(space, principal)) throw new WebDropException(ErrorCode.Forbidden);
CheckObjectWrite(tx, space, principal, itemId);
return FileDomainOf(tx, id, itemId) ?? throw new WebDropException(ErrorCode.Conflict);
});
return Files.Issue(FileOp.Put, id.ToHex(), domain, itemId.ToHex(), 0, clock.NowMs);
}
/// <summary>
/// 文件域的查询票据(看容量和用量):能读这个频道的文件就行。票据的条目是 "*",只能查域,读不了任何文件。
/// </summary>
public FileGrant IssueDomainGrant(Id16 id, Credentials creds, string threadId)
{
if (!Files.Enabled) throw new WebDropException(ErrorCode.NotFound);
string domain = store.Read(tx =>
{
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
var thread = workspace.Channels.Find(c => c.Id == threadId) ?? throw new WebDropException(ErrorCode.NotFound);
var crypto = tx.GetWorkspaceCrypto(id);
bool allowed = ChannelPolicy.IsSpaceOwner(space, principal)
|| crypto.Access.Count == 0 && space.Mode == ChannelMode.Raw
|| ChannelPolicy.AccessOf(crypto, principal.AccountName, threadId) is { } access && ChannelPolicy.CanUseFiles(access);
if (!allowed) throw new WebDropException(ErrorCode.Forbidden);
return thread.FileDomain ?? throw new WebDropException(ErrorCode.NotFound);
});
return Files.Issue(FileOp.Get, id.ToHex(), domain, "*", 0, clock.NowMs);
}
/// <summary>删除票据:确认调用者按原来的规则能删这个条目,再签。客户端先拿票据,再删主后端的条目,最后用票据清文件域里的字节</summary>
public FileGrant IssueDeleteGrant(Id16 id, Credentials creds, Id16 itemId)
{
if (!Files.Enabled) throw new WebDropException(ErrorCode.NotFound);
string domain = store.Read(tx =>
{
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
if (!ChannelPolicy.CanDeleteItem(space, item, principal)) throw new WebDropException(ErrorCode.Forbidden);
return FileDomainOf(tx, id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
});
return Files.Issue(FileOp.Delete, id.ToHex(), domain, itemId.ToHex(), 0, clock.NowMs);
}
/// <summary>
/// 下载票据:按原来的规则鉴权并计次(只有 from=0 才计),再签票据。条目没传完时返回冲突,让客户端稍后再来。
/// </summary>
public FileGrant IssueDownloadGrant(Id16 id, Credentials creds, Id16 itemId, uint fromIndex)
{
if (!Files.Enabled) throw new WebDropException(ErrorCode.NotFound);
string domain = store.Read(tx =>
{
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden);
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
if (!CanReadObject(tx, space, principal, itemId)) throw new WebDropException(ErrorCode.Forbidden);
if (item.FinalIndex is null) throw new WebDropException(ErrorCode.Conflict);
return FileDomainOf(tx, id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
});
StartItemDownload(id, creds, itemId, fromIndex, allowRemote: true);
return Files.Issue(FileOp.Get, id.ToHex(), domain, itemId.ToHex(), fromIndex, clock.NowMs);
}
private sealed class AppendOutcome(AppendResult result, long lastSeq, List<string> prefixes, bool notifySettings = false)
{
public AppendResult Result { get; } = result;
public long LastSeq { get; } = lastSeq;
public List<string> BlobPrefixesToDelete { get; } = prefixes;
public bool NotifySettings { get; } = notifySettings;
}
private AppendOutcome AppendInTx(
IResourceTx tx, Id16 channelId, Credentials creds, Envelope env, ReadOnlyMemory<byte> body,
byte[] digest, string? blobKey, long? maxDownloads, SignedContentEventProof? signedEvent)
{
var (channel, principal) = Authenticate(tx, channelId, creds, Membership.Required);
CheckWritable(channel, principal, env);
CheckObjectWrite(tx, channel, principal, env.ItemId);
int bodyLength = IsRemote(blobKey) ? env.TotalLength : body.Length;
// 并发下预检之后可能已有同一帧
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);
var crypto = tx.GetWorkspaceCrypto(channelId);
var objectEnvelope = crypto.Objects.Find(o => o.ItemId == env.ItemId.ToBase64Url());
var workspaceState = objectEnvelope is null ? null : tx.GetWorkspace(channelId);
var workspaceChannel = objectEnvelope is null ? null : workspaceState?.Channels.Find(c => c.Id == objectEnvelope.ChannelId);
if (objectEnvelope is not null && workspaceChannel is null) throw new WebDropException(ErrorCode.BadRequest);
if (signedEvent is not null && (objectEnvelope is null || !env.IsFinal)) throw new WebDropException(ErrorCode.BadRequest);
if (objectEnvelope is not null && env.IsFinal)
{
if (signedEvent is null)
{
if (creds.RequireSignedContent) throw new WebDropException(ErrorCode.Forbidden);
}
else
{
if (objectEnvelope.AuthorAccount != principal.AccountName || signedEvent.Username != principal.AccountName || creds.AccountDeviceId != signedEvent.DeviceId
|| objectEnvelope.ChannelId != signedEvent.ChannelId || objectEnvelope.Domain != signedEvent.Domain
|| objectEnvelope.Epoch != signedEvent.Epoch || signedEvent.FrameCount != env.FrameIndex + 1
|| signedEvent.EventIdBytes.Length != 16 || signedEvent.ManifestHash.Length != 32 || signedEvent.Signature.Length != 64
|| objectEnvelope.Event is not null
|| signedEvent.Domain == CryptoDomain.Chat && signedEvent.EventKind is not ("chat.create" or "chat.edit" or "chat.snapshot")
|| signedEvent.Domain == CryptoDomain.Files && signedEvent.EventKind is not ("file.publish" or "file.catalog"))
throw new WebDropException(ErrorCode.Forbidden);
if (signedEvent.EventKind == "chat.edit")
{
if (signedEvent.TargetItemId is null || !Codec.TryDecodeExact(signedEvent.TargetItemId, 16, out var targetId)) throw new WebDropException(ErrorCode.BadRequest);
var targetItem = tx.GetItem(channelId, Id16.FromBytes(targetId));
var targetObject = crypto.Objects.Find(o => o.ItemId == signedEvent.TargetItemId);
if (targetItem is null || targetObject?.Domain != CryptoDomain.Chat || !ChannelPolicy.CanDeleteItem(channel, targetItem, principal))
throw new WebDropException(ErrorCode.Forbidden);
}
else if (signedEvent.TargetItemId is not null) throw new WebDropException(ErrorCode.BadRequest);
var manifestFrames = tx.ListFrames(channelId, env.ItemId, 0, limits.MaxFramesPerItem);
if (manifestFrames.Count != env.FrameIndex) throw new WebDropException(ErrorCode.Conflict);
var digests = new List<byte[]>(manifestFrames.Count + 1);
for (int i = 0; i < manifestFrames.Count; i++)
{
if (manifestFrames[i].Index != i) throw new WebDropException(ErrorCode.Conflict);
digests.Add(manifestFrames[i].Digest);
}
digests.Add(digest);
if (!CryptographicOperations.FixedTimeEquals(SignatureCodec.FrameManifestHash(digests), signedEvent.ManifestHash))
throw new WebDropException(ErrorCode.Conflict);
}
}
if (env.FrameIndex == 0)
{
if (env.IsControl && tx.CountControlItems(channelId) >= 4096) throw new WebDropException(ErrorCode.TooLarge);
if (item is not null) throw new WebDropException(ErrorCode.BadRequest);
long max = env.IsGated ? maxDownloads ?? channel.Settings.FileMaxDownloads : 0;
if (env.IsGated && !ServerSettingChoices.IsFileMaxDownloadsAllowed(max, channel.Settings.FileMaxDownloads)) throw new WebDropException(ErrorCode.BadRequest);
item = new Item(channelId, env.ItemId, principal.GrantKey, env.ItemFlags, max, 0, 0, -1, null, 0,
now, env.IsControl ? long.MaxValue : now + ttlMs, null, principal.AccountName);
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 (objectEnvelope is null && channel.Bytes + bodyLength > limits.MaxChannelBytes
|| objectEnvelope is not null && channel.Bytes + bodyLength > limits.MaxWorkspaceBytes)
throw new WebDropException(ErrorCode.TooLarge);
long seq = tx.NextSeq(channelId);
tx.InsertFrame(new StoredFrame(channelId, env.ItemId, env.FrameIndex, seq, bodyLength, digest,
blobKey is null ? body.ToArray() : null, blobKey));
if (signedEvent is not null && objectEnvelope is not null)
{
objectEnvelope.Event = ToSignedContentEventRecord(signedEvent);
crypto.Revision++;
tx.PutWorkspaceCrypto(channelId, crypto);
}
// 受限帧和放在文件服务上的帧不进日志(日志里的帧自带内容),只在第 0 帧公布一次
if (!env.IsGated && !IsRemote(blobKey))
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 + bodyLength,
});
tx.AddChannelBytes(channelId, bodyLength);
tx.TouchChannel(channelId, Math.Max(channel.ExpiresAt, now + ttlMs));
long lastSeq = seq;
var prefixes = new List<string>();
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)
{
if (env.IsControl && (env.IsGated || env.IsSnapshot || env.FrameIndex != 0 || !env.IsFinal || env.PayloadLength > 16_400)) throw new WebDropException(ErrorCode.BadRequest);
// 非 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);
}
private static bool IsItemReadable(Item item, long now) =>
item.ExpiresAt > now && (item.CloseDeadline is null || item.CloseDeadline > now);
// ───────────────────────── 读取 ─────────────────────────
public async Task<LogPage> 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, Membership.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);
long now = clock.NowMs;
var list = new List<(long Seq, byte[]? Bytes, StoredFrame? Frame)>();
foreach (var e in tx.ReadLog(id, from, limit))
{
if (e.ItemId != default && !CanReadObject(tx, channel, principal, e.ItemId)) continue;
switch (e.Kind)
{
case LogKind.Frame:
if (tx.GetItem(id, e.ItemId) is { } frameItem && IsItemReadable(frameItem, now)
&& 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 && IsItemReadable(it, now))
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<LogRecord>(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);
}
/// <summary>开始拉取条目:鉴权,并按规则计一次下载。<paramref name="fromIndex"/> 为 0 才计数,大于 0 视为续传。</summary>
public void StartItemDownload(Id16 id, Credentials creds, Id16 itemId, uint fromIndex, bool allowRemote = false)
{
store.Write(tx =>
{
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden);
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
if (!CanReadObject(tx, space, principal, itemId)) throw new WebDropException(ErrorCode.Forbidden);
if (!allowRemote && IsRemote(tx.GetFrame(id, itemId, 0)?.BlobKey)) throw new WebDropException(ErrorCode.UseFileApi);
long now = clock.NowMs;
if (item.ExpiresAt <= now) throw new WebDropException(ErrorCode.Gone);
if (item.CloseDeadline is { } deadline && (fromIndex == 0 || now >= deadline))
throw new WebDropException(ErrorCode.Gone);
if (item.IsGated && fromIndex > 0 && !tx.HasDownloadGrant(id, itemId, principal.GrantKey))
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 });
tx.AddDownloadGrant(id, itemId, principal.GrantKey);
}
return 0;
});
}
/// <summary>读取自 <paramref name="fromIndex"/> 起连续的帧。条目未完成时可能返回空批次,调用方等待后重试。</summary>
public async Task<ItemChunk> 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 (space, principal) = Authenticate(tx, id, creds, Membership.Required);
if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden);
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
if (!CanReadObject(tx, space, principal, itemId)) throw new WebDropException(ErrorCode.Forbidden);
if (item.ExpiresAt <= clock.NowMs) throw new WebDropException(ErrorCode.Gone);
if (item.IsGated && !tx.HasDownloadGrant(id, itemId, principal.GrantKey))
throw new WebDropException(ErrorCode.Gone);
if (item.CloseDeadline is { } d && clock.NowMs >= d) throw new WebDropException(ErrorCode.Gone);
var taken = new List<StoredFrame>();
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<byte[]>(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<byte[]?> LoadFrameBytesAsync(StoredFrame frame, CancellationToken ct)
{
if (IsRemote(frame.BlobKey)) throw new WebDropException(ErrorCode.UseFileApi);
return frame.Inline ?? (frame.BlobKey is null ? null : await blobs.ReadAsync(frame.BlobKey, ct).ConfigureAwait(false));
}
// ───────────────────────── 删除 ─────────────────────────
public ObjectEnvelope? ObjectSigningContext(Id16 id, Credentials creds, Id16 itemId) => store.Read(tx =>
{
_ = Authenticate(tx, id, creds, Membership.Required);
return tx.GetWorkspaceCrypto(id).Objects.Find(o => o.ItemId == itemId.ToBase64Url());
});
public void DeleteItem(Id16 id, Credentials creds, Id16 itemId, SignedContentEventProof? signedEvent = null)
{
var (seq, prefixes) = store.Write(tx =>
{
var (channel, principal) = Authenticate(tx, id, creds, Membership.Required);
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
if (!ChannelPolicy.CanDeleteItem(channel, item, principal)) throw new WebDropException(ErrorCode.Forbidden);
var crypto = tx.GetWorkspaceCrypto(id);
var obj = crypto.Objects.Find(o => o.ItemId == itemId.ToBase64Url());
// 没传完的上传(末帧从未入库)没有已验签的清单,也就不可能有删除事件:作者或空间主可以直接清理,否则它会永久占着空间配额
bool unfinished = obj is not null && obj.Event is null;
if (creds.RequireSignedContent && obj is not null && !unfinished)
{
if (signedEvent is null || obj?.Event is null || signedEvent.Username != principal.AccountName
|| signedEvent.DeviceId != creds.AccountDeviceId || signedEvent.ChannelId != obj.ChannelId
|| signedEvent.Domain != obj.Domain || signedEvent.Epoch != obj.Epoch
|| signedEvent.FrameCount != obj.Event.FrameCount || signedEvent.ManifestHash.Length != 32
|| !Codec.TryDecodeExact(obj.Event.ManifestHash, 32, out var oldManifest)
|| !CryptographicOperations.FixedTimeEquals(signedEvent.ManifestHash, oldManifest)
|| signedEvent.EventKind != (obj.Domain == CryptoDomain.Chat ? "chat.delete" : "file.delete")
|| signedEvent.TargetItemId is not null || signedEvent.EventIdBytes.Length != 16 || signedEvent.Signature.Length != 64
|| obj.DeleteEvent is not null)
throw new WebDropException(ErrorCode.Forbidden);
obj.DeleteEvent = ToSignedContentEventRecord(signedEvent);
crypto.Revision++;
tx.PutWorkspaceCrypto(id, crypto);
}
else if (signedEvent is not null) throw new WebDropException(ErrorCode.BadRequest);
var list = new List<string>();
long s = DeleteItemCore(tx, id, item, TombstoneReason.Deleted, list);
return (s, list);
});
foreach (string prefix in prefixes) blobs.DeletePrefix(prefix);
events.Advanced(id, seq);
events.SettingsChanged(id);
}
private long DeleteItemCore(IResourceTx tx, Id16 channelId, Item item, TombstoneReason reason, List<string> 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;
}
private static SignedContentEventRecord ToSignedContentEventRecord(SignedContentEventProof signedEvent) => new()
{
EventId = Codec.Encode(signedEvent.EventIdBytes), Kind = signedEvent.EventKind, FrameCount = signedEvent.FrameCount,
ManifestHash = Convert.ToBase64String(signedEvent.ManifestHash).TrimEnd('=').Replace('+', '-').Replace('/', '_'),
SignerDeviceId = signedEvent.DeviceId, SignerAccount = signedEvent.Username,
Signature = Convert.ToBase64String(signedEvent.Signature).TrimEnd('=').Replace('+', '-').Replace('/', '_'),
TargetItemId = signedEvent.TargetItemId,
SignerRootPublicKey = signedEvent.RootPublicKey, SignerDevicePublicKey = signedEvent.DevicePublicKey,
DeviceCertificateSignature = Convert.ToBase64String(signedEvent.DeviceCertificateSignature).TrimEnd('=').Replace('+', '-').Replace('/', '_'),
};
// ───────────────────────── 后台清理 ─────────────────────────
/// <summary>清理过期条目、下载达上限的条目、过期频道;可选清理孤立 blob。返回删除的条目和频道总数。</summary>
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<string>();
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;
}
/// <summary>删除没有任何帧引用、且超过 10 分钟的 blob(崩溃发生在写 blob 与提交事务之间会留下它们)。</summary>
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, Membership seat)
{
// 频道强制登录:没有账号会话的请求一律拒绝(便捷分享不受影响)
string account = creds.AccountName ?? throw new WebDropException(ErrorCode.AccountRequired);
var channel = tx.GetChannel(id);
if (channel is null || channel.ExpiresAt <= clock.NowMs) throw new WebDropException(ErrorCode.NotFound);
var role = VerifyRole(channel, creds);
var member = tx.GetChannelMember(id, account);
if (role == Role.Owner && member?.Role != "owner") throw new WebDropException(ErrorCode.AccountRequired);
if (seat == Membership.Required && member is null) throw new WebDropException(ErrorCode.NotJoined);
return (channel, new Principal(role, account));
}
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 membersUsed, Principal? principal = null, string? avatarVersion = null, WorkspaceState? workspace = null)
{
// 没有调用者(创建、改设置时)就是所有者本人
var who = principal ?? new Principal(Role.Owner, channel.CreatorAccount);
return new()
{
AvatarVersion = avatarVersion,
IsOwner = ChannelPolicy.IsOwner(who),
CanUpload = ChannelPolicy.CanWrite(channel, who),
CanManageStructure = ChannelPolicy.CanManageStructure(workspace, who),
CanCreateChannel = ChannelPolicy.CanCreateChannel(who),
CanManageFolders = ChannelPolicy.CanManageFolders(who),
CanInvite = ChannelPolicy.CanInvite(who),
CanEditSettings = ChannelPolicy.CanEditSettings(who),
CanManageRoles = ChannelPolicy.CanManageRoles(who),
CanRemoveMembers = ChannelPolicy.CanRemoveMembers(workspace, who),
CanDeleteAny = ChannelPolicy.CanDeleteAnyItem(channel, who),
CanDeleteOwn = ChannelPolicy.CanDeleteOwnItem(channel, who),
Id = channel.Id.ToBase64Url(),
CreatorAccount = channel.CreatorAccount,
PasswordRequired = channel.JoinPasswordHash is not null,
Mode = channel.Mode,
Settings = channel.Settings,
MembersUsed = membersUsed,
OldestSeq = channel.CompactedThrough + 1,
LastSeq = channel.LastSeq,
CreatedAt = channel.CreatedAt,
ExpiresAt = channel.ExpiresAt,
};
}
}
@@ -5,6 +5,8 @@ namespace WebDrop.Core;
public sealed class CreateChannelRequest
{
[JsonPropertyName("key_package")] public string? KeyPackage { get; init; }
[JsonPropertyName("join_password")] public string? JoinPassword { get; init; }
[JsonPropertyName("id")] public string? Id { get; init; }
[JsonPropertyName("mode")] public ChannelMode? Mode { get; init; }
[JsonPropertyName("settings")] public ChannelSettings? Settings { get; init; }
@@ -40,6 +42,7 @@ public sealed class ShareInfo
public sealed class ShareItemInfo
{
[JsonPropertyName("complete")] public bool Complete { get; init; }
[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; }
@@ -49,10 +52,32 @@ public sealed class ShareItemInfo
public sealed class ChannelInfo
{
// ───── 调用者在这个空间里的能力。前端只读不算:规则只在后端一处
/// <summary>调用者是空间所有者</summary>
[JsonPropertyName("is_owner")] public bool IsOwner { get; init; }
[JsonPropertyName("can_upload")] public bool CanUpload { get; init; }
/// <summary>能重命名频道、管理分组(所有者,或被授予「管理频道和分组」)</summary>
[JsonPropertyName("can_manage_structure")] public bool CanManageStructure { get; init; }
[JsonPropertyName("can_create_channel")] public bool CanCreateChannel { get; init; }
[JsonPropertyName("can_manage_folders")] public bool CanManageFolders { get; init; }
[JsonPropertyName("can_invite")] public bool CanInvite { get; init; }
[JsonPropertyName("can_edit_settings")] public bool CanEditSettings { get; init; }
[JsonPropertyName("can_manage_roles")] public bool CanManageRoles { get; init; }
/// <summary>能移除成员、撤销邀请;具体能移除谁见成员列表里每一项的 can_remove</summary>
[JsonPropertyName("can_remove_members")] public bool CanRemoveMembers { get; init; }
/// <summary>能删除任何人的消息和文件(所有者,或删除策略为 anyone)</summary>
[JsonPropertyName("can_delete_any")] public bool CanDeleteAny { get; init; }
/// <summary>能删除自己发的消息和文件</summary>
[JsonPropertyName("can_delete_own")] public bool CanDeleteOwn { get; init; }
[JsonPropertyName("password_required")] public bool PasswordRequired { get; init; }
[JsonPropertyName("creator_account")] public string? CreatorAccount { get; init; }
/// <summary>头像版本标记,变了就要重新取头像(GET /channels/{id}/avatar);没有头像为 null。头像本体不随空间信息下发。</summary>
[JsonPropertyName("avatar_version")] public string? AvatarVersion { get; init; }
[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; }
/// <summary>频道成员数(账号),不统计账号设备数。</summary>
[JsonPropertyName("members_used")] public required int MembersUsed { get; init; }
/// <summary>从该 seq 起的续传有效;0 &lt; from &lt; oldest_seq 的续传会得到 410,需从 0 全量同步。</summary>
[JsonPropertyName("oldest_seq")] public required long OldestSeq { get; init; }
@@ -62,19 +87,12 @@ public sealed class ChannelInfo
[JsonPropertyName("expires_at")] public required long ExpiresAt { get; init; }
}
public sealed class SeatJoined
public sealed class JoinResult
{
[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; }
/// <summary>加入后的成员人数,同一账号的其他终端不增加此值。</summary>
[JsonPropertyName("members_used")] public required int MembersUsed { get; init; }
[JsonPropertyName("members_max")] public required int MembersMax { get; init; }
}
public sealed class AppendResult
@@ -8,30 +8,43 @@ 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 long MaxWorkspaceBytes { get; set; } = 1L << 40;
/// <summary>最大账号成员数。属性名和 info 字段保持旧线格式兼容。</summary>
public int MaxMembers { get; set; } = 64;
public int MaxVisitors { get; set; } = 10_000;
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 static class ServerSettingChoices
{
public static readonly int[] FileMaxDownloads = [0, 1, 3, 5, 10, 20];
public static bool IsFileMaxDownloadsAllowed(long value, int currentDefault) => value == currentDefault
|| value is >= int.MinValue and <= int.MaxValue && Array.IndexOf(FileMaxDownloads, (int)value) >= 0;
}
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; }
[JsonPropertyName("join_policy")] public JoinPolicy JoinPolicy { get; set; } = JoinPolicy.Open;
[JsonPropertyName("name")] public string? Name { get; set; }
/// <summary>成员人数上限;线字段名保留为 seats 以兼容已有频道设置。</summary>
[JsonPropertyName("max_members")] public int MaxMembers { get; set; } = 2;
[JsonPropertyName("members_can_upload")] public bool MembersCanUpload { get; set; } = true;
[JsonPropertyName("delete_policy")] public DeletePolicy DeletePolicy { get; set; } = DeletePolicy.Own;
[JsonPropertyName("ttl_seconds")] public int TtlSeconds { get; set; } = 86_400;
[JsonPropertyName("max_items")] public int MaxItems { get; set; } = 1000;
[JsonPropertyName("file_max_downloads")] public int FileMaxDownloads { get; set; } = 10;
[JsonPropertyName("ui_restrict_copy_download")] public bool UiRestrictCopyDownload { get; set; }
public void Validate(ServerLimits limits)
{
if (Seats < 1 || Seats > limits.MaxSeats) throw new WebDropException(ErrorCode.BadRequest);
if (!Enum.IsDefined(JoinPolicy)) throw new WebDropException(ErrorCode.BadRequest);
if (Name is { } name && (name.Length > 48 || name.Any(char.IsControl))) throw new WebDropException(ErrorCode.BadRequest);
if (MaxMembers < 1 || MaxMembers > limits.MaxMembers) 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);
@@ -40,17 +53,26 @@ public sealed class ChannelSettings
public sealed class ChannelSettingsPatch
{
[JsonPropertyName("seats")] public int? Seats { get; init; }
[JsonPropertyName("join_policy")] public JoinPolicy? JoinPolicy { get; init; }
[JsonPropertyName("join_password")] public string? JoinPassword { get; init; }
[JsonPropertyName("name")] public string? Name { get; init; }
[JsonPropertyName("max_members")] public int? MaxMembers { 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; }
/// <summary>空间头像 data URL;不传表示不改,空字符串表示移除。只有空间主能改。</summary>
[JsonPropertyName("avatar")] public string? Avatar { get; init; }
/// <summary>空间头像的有损小图(data URL):空间列表、标题默认只分发它,原图在预览时才下载</summary>
[JsonPropertyName("avatar_thumb")] public string? AvatarThumb { get; init; }
public ChannelSettings ApplyTo(ChannelSettings s) => new()
{
Seats = Seats ?? s.Seats,
JoinPolicy = JoinPolicy ?? s.JoinPolicy,
Name = Name is null ? s.Name : string.IsNullOrWhiteSpace(Name) ? null : Name.Trim(),
MaxMembers = MaxMembers ?? s.MaxMembers,
MembersCanUpload = MembersCanUpload ?? s.MembersCanUpload,
DeletePolicy = DeletePolicy ?? s.DeletePolicy,
TtlSeconds = TtlSeconds ?? s.TtlSeconds,
@@ -62,14 +84,14 @@ public sealed class ChannelSettingsPatch
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;
[JsonPropertyName("ttl_seconds")] public int TtlSeconds { get; set; } = 86_400;
[JsonPropertyName("max_visitors")] public int MaxVisitors { get; set; }
[JsonPropertyName("file_max_downloads")] public int FileMaxDownloads { get; set; } = 10;
public void Validate(ServerLimits limits)
{
if (TtlSeconds < limits.MinTtlSeconds || TtlSeconds > limits.MaxTtlSeconds
|| MaxVisitors < 0 || MaxVisitors > 10_000
|| MaxVisitors < 0 || MaxVisitors > limits.MaxVisitors
|| FileMaxDownloads < 0)
throw new WebDropException(ErrorCode.BadRequest);
}
@@ -88,3 +110,22 @@ public sealed class ShareSettingsPatch
FileMaxDownloads = FileMaxDownloads ?? s.FileMaxDownloads,
};
}
/// <summary>创建接口与 /info 使用同一个配置来源;旧存储对象的字段默认值不作迁移。</summary>
public static class CreationDefaults
{
public const ChannelMode ChannelMode = WebDrop.Core.ChannelMode.Enc;
public static ChannelSettings Channel(ServerLimits limits) => new()
{
JoinPolicy = JoinPolicy.Invite,
MaxMembers = Math.Min(10, limits.MaxMembers),
TtlSeconds = Math.Clamp(86_400, limits.MinTtlSeconds, limits.MaxTtlSeconds),
MaxItems = Math.Min(1000, limits.MaxItemsLimit),
};
public static ShareSettings Share(ServerLimits limits) => new()
{
TtlSeconds = Math.Clamp(86_400, limits.MinTtlSeconds, limits.MaxTtlSeconds),
MaxVisitors = Math.Min(10, limits.MaxVisitors),
};
}
File renamed without changes.
@@ -9,6 +9,20 @@ public enum ChannelMode : byte
[JsonStringEnumMemberName("raw")] Raw = 2,
}
[JsonConverter(typeof(JsonStringEnumConverter<CryptoDomain>))]
public enum CryptoDomain : byte
{
[JsonStringEnumMemberName("chat")] Chat = 1,
[JsonStringEnumMemberName("files")] Files = 2,
}
[JsonConverter(typeof(JsonStringEnumConverter<JoinPolicy>))]
public enum JoinPolicy : byte
{
[JsonStringEnumMemberName("open")] Open = 1,
[JsonStringEnumMemberName("invite")] Invite = 2,
}
[JsonConverter(typeof(JsonStringEnumConverter<DeletePolicy>))]
public enum DeletePolicy : byte
{
@@ -34,6 +48,7 @@ public enum FrameFlags : byte
Snapshot = 2,
Gated = 4,
Enc = 8,
Control = 16,
}
public enum LogKind : byte
@@ -51,3 +66,10 @@ public enum TombstoneReason : byte
DownloadLimit = 3,
Superseded = 4,
}
/// <summary>便捷分享的状态(线格式的取值)。前端从生成的契约里取,不手写字符串</summary>
public static class ShareState
{
public const string Draft = "draft";
public const string Sealed = "sealed";
}
@@ -17,7 +17,7 @@ public readonly record struct Envelope(FrameFlags Flags, Id16 ItemId, uint Frame
if (data.Length < HeaderSize || data[0] != Version) return false;
byte flags = data[1];
if ((flags & 0xF0) != 0) return false;
if ((flags & 0xE0) != 0) return false;
uint payloadLen = BinaryPrimitives.ReadUInt32BigEndian(data[22..]);
if (payloadLen > int.MaxValue || data.Length != HeaderSize + (int)payloadLen) return false;
@@ -30,13 +30,26 @@ public readonly record struct Envelope(FrameFlags Flags, Id16 ItemId, uint Frame
return true;
}
/// <summary>只有 26 字节头部(不带负载)的帧:文件服务存负载,主后端只收头部和回执。长度字段仍要合法。</summary>
public static bool TryParseHeader(ReadOnlySpan<byte> data, out Envelope envelope)
{
envelope = default;
if (data.Length != HeaderSize || data[0] != Version) return false;
if ((data[1] & 0xE0) != 0) return false;
uint payloadLen = BinaryPrimitives.ReadUInt32BigEndian(data[22..]);
if (payloadLen > int.MaxValue) return false;
envelope = new Envelope((FrameFlags)data[1], 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 bool IsControl => (Flags & FrameFlags.Control) != 0;
/// <summary>条目级标志:同一条目的所有帧必须一致。</summary>
public FrameFlags ItemFlags => Flags & (FrameFlags.Snapshot | FrameFlags.Gated);
public FrameFlags ItemFlags => Flags & (FrameFlags.Snapshot | FrameFlags.Gated | FrameFlags.Control);
}
public static class PayloadTiers
@@ -6,7 +6,7 @@ public enum ErrorCode
Unauthorized,
Forbidden,
NotJoined,
SeatsFull,
MembersFull,
ModeMismatch,
NotFound,
Conflict,
@@ -17,6 +17,15 @@ public enum ErrorCode
RateLimited,
VisitorsFull,
Sealed,
AccountRequired,
InvitationRequired,
ChannelPasswordRequired,
ChannelPasswordInvalid,
SpaceNameRequired,
/// <summary>这个条目的内容在文件服务上:去要一张票据,直接找文件服务</summary>
UseFileApi,
/// <summary>文件服务暂时连不上或已满</summary>
FilesUnavailable,
}
/// <summary>预期内的业务失败。消息只含错误码,绝不回显用户提交的内容。</summary>
@@ -42,7 +51,7 @@ public static class ErrorCodes
ErrorCode.Unauthorized => "unauthorized",
ErrorCode.Forbidden => "forbidden",
ErrorCode.NotJoined => "not_joined",
ErrorCode.SeatsFull => "seats_full",
ErrorCode.MembersFull => "members_full",
ErrorCode.ModeMismatch => "mode_mismatch",
ErrorCode.NotFound => "not_found",
ErrorCode.Conflict => "conflict",
@@ -53,6 +62,13 @@ public static class ErrorCodes
ErrorCode.RateLimited => "rate_limited",
ErrorCode.VisitorsFull => "visitors_full",
ErrorCode.Sealed => "sealed",
ErrorCode.AccountRequired => "account_required",
ErrorCode.InvitationRequired => "invitation_required",
ErrorCode.ChannelPasswordRequired => "channel_password_required",
ErrorCode.ChannelPasswordInvalid => "channel_password_invalid",
ErrorCode.SpaceNameRequired => "space_name_required",
ErrorCode.UseFileApi => "use_file_api",
ErrorCode.FilesUnavailable => "files_unavailable",
_ => "bad_request",
};
@@ -60,16 +76,21 @@ public static class ErrorCodes
{
ErrorCode.BadRequest => 400,
ErrorCode.Unauthorized => 401,
ErrorCode.Forbidden or ErrorCode.NotJoined or ErrorCode.SeatsFull or ErrorCode.ModeMismatch => 403,
ErrorCode.Forbidden or ErrorCode.NotJoined or ErrorCode.MembersFull or ErrorCode.ModeMismatch => 403,
ErrorCode.NotFound => 404,
ErrorCode.Conflict => 409,
ErrorCode.Sealed => 409,
ErrorCode.SpaceNameRequired => 400,
ErrorCode.Gone => 410,
ErrorCode.TooLarge => 413,
ErrorCode.CaptchaRequired => 428,
ErrorCode.CaptchaUnavailable => 503,
ErrorCode.FilesUnavailable => 503,
ErrorCode.UseFileApi => 409,
ErrorCode.RateLimited => 429,
ErrorCode.VisitorsFull => 403,
ErrorCode.AccountRequired => 401,
ErrorCode.InvitationRequired or ErrorCode.ChannelPasswordRequired or ErrorCode.ChannelPasswordInvalid => 403,
_ => 400,
};
}
Loaded 100 of 478 files, more files were not shown because too many files have changed in this diff. Show more