52 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
410 changed files with 12237 additions and 3020 deletions

No files matched your search

+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
-51
View File
@@ -1,51 +0,0 @@
when:
- event: push
branch: master
evaluate: 'CI_COMMIT_MESSAGE contains "[CI]"'
- event: push
branch: main
evaluate: 'CI_COMMIT_MESSAGE contains "[CI]"'
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:
- set -euo pipefail
- ': "${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_SECRET" | buildah login --tls-verify=true --username "$ZOT_USERNAME" --password-stdin "$ZOT_REGISTRY"
- trap 'buildah logout "$ZOT_REGISTRY" >/dev/null 2>&1 || true' EXIT
- sha="${CI_COMMIT_SHA:0:7}"
- build_tag="dev-$sha"
- image="$ZOT_REGISTRY/justme/webdrop-api:dev-$sha"
- dev_image="$ZOT_REGISTRY/justme/webdrop-api:dev"
- build_time="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
- buildah bud --layers --network host --format oci --platform linux/amd64 --build-arg "WEBDROP_FRONTEND_COMMIT=$CI_COMMIT_SHA" --build-arg "WEBDROP_FRONTEND_TAG=$build_tag" --build-arg "WEBDROP_BACKEND_COMMIT=$CI_COMMIT_SHA" --build-arg "WEBDROP_BACKEND_TAG=$build_tag" --build-arg "WEBDROP_BUILD_TIME=$build_time" --tag "$image" --file server/Containerfile .
- buildah tag "$image" "$dev_image"
- buildah push "$image" "docker://$image"
- buildah push "$dev_image" "docker://$dev_image"
@@ -1,3 +1,6 @@
# 发布:打 vMAJOR.MINOR.PATCH tag(或手动运行并传 RELEASE_TAG)。
# 一个 tag 同时发布两个镜像:webdrop-api:<tag> / :latest 和 webdrop-files:<tag> / :latest。
# 两边共用票据格式和文件协议,版本号一致最不容易配错;各机器只会拉自己用的那个镜像。
when:
- event: tag
ref: refs/tags/v*
@@ -37,29 +40,26 @@ steps:
- 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:
publish-api:
image: bash
depends_on: [release-checks]
environment:
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
ZOT_REGISTRY: registry.oeo.one
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_SECRET" | buildah login --tls-verify=true --username "$ZOT_USERNAME" --password-stdin "$ZOT_REGISTRY"
- trap 'buildah logout "$ZOT_REGISTRY" >/dev/null 2>&1 || true' EXIT
- image="$ZOT_REGISTRY/justme/webdrop-api:$release_tag"
- latest_image="$ZOT_REGISTRY/justme/webdrop-api:latest"
- build_commit="$(git rev-parse HEAD)"
- build_time="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
- buildah bud --layers --network host --format oci --platform linux/amd64 --build-arg "WEBDROP_FRONTEND_COMMIT=$build_commit" --build-arg "WEBDROP_FRONTEND_TAG=$release_tag" --build-arg "WEBDROP_BACKEND_COMMIT=$build_commit" --build-arg "WEBDROP_BACKEND_TAG=$release_tag" --build-arg "WEBDROP_BUILD_TIME=$build_time" --tag "$image" --file server/Containerfile .
- buildah tag "$image" "$latest_image"
- buildah push "$image" "docker://$image"
- buildah push "$latest_image" "docker://$latest_image"
- 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
+1 -1
View File
@@ -35,7 +35,7 @@
- **频道**:唯一的顶层概念,一条只追加的帧流,有自己的 id、密钥、设置和账号成员人数上限。
- **条目**:频道里的一项内容(文本、JSON、文件),由若干帧组成,可复制、下载、删除。
- **成员**:频道里的人是用户(账号);设备只是账号的属性,同一账号的多设备只算一人,请求凭账号会话鉴权。未登录的**访客**以终端密钥 `S` 登记,每个访客终端各算一人。
- **成员**:频道里的人是用户(账号);设备只是账号的属性,同一账号的多设备只算一人,请求凭账号会话鉴权。频道强制登录,没有未登录访客(便捷分享不受影响)。
- **所有者 / 成员**:所有者持有管理密钥,成员通过频道链接加入。
- **模式**:`enc`(默认,浏览器端 AES-256-GCM)和 `raw`(负载原样,服务器可见,用于公开或用户自行加密的内容)。
+1 -1
View File
@@ -1,3 +1,3 @@
{
"version": "7.0.0"
"version": "8.2.0"
}
+365 -31
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"x-backend-contract-sha256": "54ea3f00fa672e0241efbb9fc8e7ad9e8fc68415ec124e08f70657c2d57d9ff9",
"x-backend-contract-sha256": "89c6298b865197835486f01891acc597802367a7ae1b89f21ba882a52935df3b",
"$defs": {
"AccountChannelBody": {
"type": "object",
@@ -98,6 +98,9 @@
"displayName": {
"type": "string"
},
"hasAvatar": {
"type": "boolean"
},
"modifiedAt": {
"type": "integer"
},
@@ -107,6 +110,7 @@
},
"required": [
"displayName",
"hasAvatar",
"modifiedAt",
"username"
]
@@ -124,12 +128,26 @@
}
]
},
"avatarThumb": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"displayName": {
"type": "string"
},
"keepAvatar": {
"type": "boolean"
}
},
"required": [
"displayName"
"displayName",
"keepAvatar"
]
},
"AccountRegisterRequest": {
@@ -294,9 +312,43 @@
"ChannelInfo": {
"type": "object",
"properties": {
"avatar_version": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"can_create_channel": {
"type": "boolean"
},
"can_delete_any": {
"type": "boolean"
},
"can_delete_own": {
"type": "boolean"
},
"can_edit_settings": {
"type": "boolean"
},
"can_invite": {
"type": "boolean"
},
"can_manage_folders": {
"type": "boolean"
},
"can_manage_roles": {
"type": "boolean"
},
"can_manage_structure": {
"type": "boolean"
},
"can_remove_members": {
"type": "boolean"
},
"can_upload": {
"type": "boolean"
},
@@ -319,6 +371,9 @@
"id": {
"type": "string"
},
"is_owner": {
"type": "boolean"
},
"last_seq": {
"type": "integer"
},
@@ -339,11 +394,20 @@
}
},
"required": [
"can_create_channel",
"can_delete_any",
"can_delete_own",
"can_edit_settings",
"can_invite",
"can_manage_folders",
"can_manage_roles",
"can_manage_structure",
"can_remove_members",
"can_upload",
"created_at",
"expires_at",
"id",
"is_owner",
"last_seq",
"members_used",
"mode",
@@ -362,9 +426,6 @@
"ChannelSettings": {
"type": "object",
"properties": {
"allow_guests": {
"type": "boolean"
},
"delete_policy": {
"$ref": "#/$defs/DeletePolicy"
},
@@ -401,7 +462,6 @@
}
},
"required": [
"allow_guests",
"delete_policy",
"file_max_downloads",
"join_policy",
@@ -415,10 +475,20 @@
"ChannelSettingsPatch": {
"type": "object",
"properties": {
"allow_guests": {
"avatar": {
"anyOf": [
{
"type": "boolean"
"type": "string"
},
{
"type": "null"
}
]
},
"avatar_thumb": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
@@ -810,6 +880,64 @@
"error"
]
},
"FileGrantBody": {
"type": "object",
"properties": {
"domain": {
"type": "string"
},
"expires_at": {
"type": "integer"
},
"ticket": {
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"domain",
"expires_at",
"ticket",
"url"
]
},
"FileGrantRequest": {
"type": "object",
"properties": {
"channel": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"from": {
"type": "integer"
},
"item": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"op": {
"type": "string"
}
},
"required": [
"from",
"op"
]
},
"FinishShareRequest": {
"type": "object",
"properties": {
@@ -841,24 +969,12 @@
"oldest_seq"
]
},
"GuestInfo": {
"type": "object",
"properties": {
"endpoint_hash": {
"type": "string"
},
"joined_at": {
"type": "integer"
}
},
"required": [
"endpoint_hash",
"joined_at"
]
},
"InvitationBody": {
"type": "object",
"properties": {
"can_revoke": {
"type": "boolean"
},
"channel_id": {
"type": "string"
},
@@ -873,6 +989,7 @@
}
},
"required": [
"can_revoke",
"channel_id",
"created_at",
"payload",
@@ -1011,12 +1128,18 @@
"MemberBody": {
"type": "object",
"properties": {
"can_remove": {
"type": "boolean"
},
"devices": {
"type": "integer"
},
"joined_at": {
"type": "integer"
},
"last_seen": {
"type": "integer"
},
"online": {
"type": "boolean"
},
@@ -1031,8 +1154,10 @@
}
},
"required": [
"can_remove",
"devices",
"joined_at",
"last_seen",
"online",
"online_devices",
"role",
@@ -1331,6 +1456,9 @@
"defaults": {
"$ref": "#/$defs/DefaultsBody"
},
"files": {
"$ref": "#/$defs/FilesInfoBody"
},
"limits": {
"$ref": "#/$defs/LimitsBody"
},
@@ -1346,6 +1474,7 @@
"choices",
"contract_version",
"defaults",
"files",
"limits",
"protocol",
"transports"
@@ -1395,6 +1524,27 @@
"share"
]
},
"FilesInfoBody": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"required": [
"enabled"
]
},
"LimitsBody": {
"type": "object",
"properties": {
@@ -1422,9 +1572,6 @@
"max_workspace_bytes": {
"type": "integer"
},
"max_workspace_channel_bytes": {
"type": "integer"
},
"min_ttl_seconds": {
"type": "integer"
}
@@ -1438,7 +1585,6 @@
"max_ttl_seconds",
"max_visitors",
"max_workspace_bytes",
"max_workspace_channel_bytes",
"min_ttl_seconds"
]
},
@@ -1645,6 +1791,16 @@
"signature": {
"type": "string"
},
"signerAccount": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"signerDeviceId": {
"type": "string"
},
@@ -1744,8 +1900,15 @@
"createdBy": {
"type": "string"
},
"fileBytes": {
"type": "integer"
"fileDomain": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"filesEpoch": {
"type": "integer"
@@ -1775,7 +1938,6 @@
"chatHistory",
"createdAt",
"createdBy",
"fileBytes",
"filesEpoch",
"filesHistory",
"id",
@@ -1785,6 +1947,16 @@
"WorkspaceChannelRequest": {
"type": "object",
"properties": {
"fileDomain": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"groupId": {
"anyOf": [
{
@@ -1803,6 +1975,17 @@
"name"
]
},
"WorkspaceFileDomainRequest": {
"type": "object",
"properties": {
"fileDomain": {
"type": "string"
}
},
"required": [
"fileDomain"
]
},
"WorkspaceFolderRequest": {
"type": "object",
"properties": {
@@ -1917,6 +2100,146 @@
"name"
]
},
"WorkspaceRoleCreatedBody": {
"type": "object",
"properties": {
"role": {
"$ref": "#/$defs/WorkspaceRole"
},
"workspace": {
"$ref": "#/$defs/WorkspaceBody"
}
},
"required": [
"role",
"workspace"
]
},
"WorkspaceRole": {
"type": "object",
"properties": {
"color": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"createdAt": {
"type": "integer"
},
"id": {
"type": "string"
},
"members": {
"type": "array",
"items": {
"type": "string"
}
},
"name": {
"type": "string"
},
"permissions": {
"type": "array",
"items": {
"type": "string"
}
},
"prefix": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"suffix": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"required": [
"createdAt",
"id",
"members",
"name",
"permissions"
]
},
"WorkspaceRoleMembersRequest": {
"type": "object",
"properties": {
"members": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"members"
]
},
"WorkspaceRoleRequest": {
"type": "object",
"properties": {
"color": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"name": {
"type": "string"
},
"permissions": {
"type": "array",
"items": {
"type": "string"
}
},
"prefix": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"suffix": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"required": [
"name",
"permissions"
]
},
"WorkspaceState": {
"type": "object",
"properties": {
@@ -1956,6 +2279,15 @@
},
"revision": {
"type": "integer"
},
"roles": {
"type": "array",
"items": {
"$ref": "#/$defs/WorkspaceRole"
}
},
"rolesSeeded": {
"type": "boolean"
}
},
"required": [
@@ -1963,7 +2295,9 @@
"folders",
"groups",
"locations",
"revision"
"revision",
"roles",
"rolesSeeded"
]
},
"WorkspaceFolder": {
+1
View File
@@ -10,6 +10,7 @@ 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
+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=
+9 -3
View File
@@ -1,15 +1,21 @@
[Unit]
Description=WebDrop API with embedded frontend
Wants=network-online.target
After=network-online.target
[Container]
ContainerName=webdrop
Image=localhost/webdrop:local
Pull=never
# 正式环境:跟踪打 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
Volume=/home/justme/podman/webdrop/data:/data:Z,U
# 机器上的配置(文件服务的地址和共用密钥等),不进仓库。例子见 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
+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=
+21 -21
View File
@@ -2,9 +2,9 @@
账户资料使用 `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` 为 `7.0.0`。v7 把「设备」收束为账号的属性:成员只按用户管理,`seats` 系列命名改为 `max_members` / `members_used` / `members_full`,去掉设备名与设备清单接口,访客改走 `/guests`,登录账号不再需要终端密钥。v6 引入账号签名根密钥、设备证书和频道对象签名事件;不兼容的旧主版本客户端会被契约预检拒绝。账号资料与空间结构由服务端管理。创建频道/分享以及所有 owner 管理操作必须携带有效账号 session;公开链接仍允许访客按频道加入规则访问。
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` 在启动时迁移)。其他选项按部署限制收束,前端只格式化选项文案,不提供替代业务默认值。
@@ -16,24 +16,24 @@ URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `7.0.0`。v7 把
|---|---|
| `X-WD-Role` | `read` / `write` / `owner`,指明 `Authorization` 里的令牌属于哪种角色 |
| `Authorization` | `Bearer <base64url(令牌)>` |
| `X-WD-Endpoint` | `base64url(S)`,终端密钥。**只有未登录访客必填**;登录账号以 `X-WD-Account` 会话鉴权,可省略 |
| `X-WD-Endpoint` | `base64url(S)`,终端密钥。**只用于便捷分享**(未登录的创建者与读者);频道与空间不使用,可省略 |
| `X-WD-Cap` | 创建频道时的 Cap 验证码 token(服务端启用验证码时必填,见 protocol §11) |
| `X-WD-Max-Downloads` | 第 0 帧上带,条目的下载上限,0 为不限 |
| `X-WD-Account` | 账号 session token。与频道/分享的能力令牌分开;创建、管理和账号成员请求必填,访客省略 |
| `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. 接口
@@ -77,16 +77,16 @@ WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, w
raw 模式省略 `read_hash`;`write_hash` 仅在成员可上传时提供。创建者自动成为频道的所有者成员。返回 `201`,响应体与「读取频道信息」相同(`ChannelInfo`)。
### `GET /api/v1/channels/{id}`
返回 `ChannelInfo`:设置(包含所有成员共享的频道名)、模式、成员名额占用(`members_used`)、`oldest_seq`、`last_seq`、时间。`members_used` 统计唯一账号成员和匿名访客终端,不统计账号的设备数。**不占成员名额**,任意角色可调用,用于加入前预览和核对模式。频道名是普通设置,服务端可见。
返回 `ChannelInfo`:设置(包含所有成员共享的频道名)、模式、成员名额占用(`members_used`)、`oldest_seq`、`last_seq`、时间。`members_used` 统计唯一账号成员,不统计账号的设备数。**不占成员名额**,需要账号会话,用于加入前预览和核对模式。频道名是普通设置,服务端可见。
### `POST /api/v1/channels/{id}/join`
加入频道。登录账号只登记成员关系:账号第一次加入时占一个成员名额,同一账号的更多设备不增加名额、也不登记终端,此后仅凭账号会话鉴权(`X-WD-Endpoint` 可省略)。未登录访客登记一个终端(`X-WD-Endpoint` 必填),每个访客终端各计一个成员名额。同一账号或同一访客终端重复调用幂等。成员名额已满时返回 `403 members_full`。读取频道信息和 owner 管理接口不占成员名额;访客访问频道内容前必须已加入,否则返回 `403 not_joined`。响应 `JoinResult`:`{joined_at, members_used, members_max}`,不再返回终端标识。
加入频道,必须带账号会话(没有则 `401 account_required`)。只登记成员关系:账号第一次加入时占一个成员名额,同一账号的更多设备不增加名额,此后仅凭账号会话鉴权。同一账号重复调用幂等。成员名额已满时返回 `403 members_full`。读取频道信息和 owner 管理接口不占成员名额;访问频道内容前必须已加入,否则返回 `403 not_joined`。响应 `JoinResult`:`{joined_at, members_used, members_max}`,不再返回终端标识。
可发送 JSON `{password}`。默认 `join_policy=open,allow_guests=true`,允许持链接的陌生人设备加入。`allow_guests=false` 要求账号登录;`join_policy=invite` 要求该账号已受邀。频道主、已加入的账号成员和受邀账号无需再次输入频道密码。密码由服务端保存加盐 PBKDF2 哈希,从不通过频道信息返回。设备是账号的属性:同一账号可在任意多台设备登录,只占一个成员名额;账号成员的读写请求出示有效账号 session 即可,不再绑定到某个终端。
可发送 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}`,同样阻止该终端重新加入。
`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)>` 子协议传递,并在推送循环中重新检查有效性。
频道设置、成员权限、邀请、加入密码与加密模式统一作用于同一 Channel。每次上传、发言、线程/目录控制条目写入均经服务器校验账号会话、频道成员关系与写入权限,不能靠客户端隐藏按钮绕过。移除账号成员才会释放成员名额;撤销账号的某台登录设备只使该设备的会话失效,不影响成员身份。WebSocket 账号 session 经 `wd.account.<base64url(token)>` 子协议传递,并在推送循环中重新检查有效性。
### `PATCH /api/v1/channels/{id}/settings`
所有者修改设置。可通过 `name` 更新共享频道名,传空字符串清除名称;`max_members` 调整成员人数上限,不能改变已有成员身份;`ttl_seconds` 的修改从现在起算。
@@ -112,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":"members"}` 成员/访客变更。
- 文本消息(JSON 控制消息):`{"t":"hb","seq":N}` 心跳,`{"t":"settings"}` 设置变更,`{"t":"members"}` 成员变更。
上传仍走 HTTP `POST`,便于幂等重试。
@@ -123,15 +123,14 @@ 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。已登记签名设备还必须提交对应 `chat.delete`/`file.delete` 事件头;服务端验证其绑定的设备证书、权限和原对象帧 manifest 后保存删除签名,浏览器验签后才应用 tombstone。
### 访客
- `GET /api/v1/channels/{id}/guests`:所有者查看未登录访客的终端(`endpoint_hash`、`joined_at`)。账号成员按用户管理(`/members`、`/invitations`),其设备不对外列出。
- `DELETE /api/v1/channels/{id}/guests/{endpoint_hash}`:所有者移除该访客终端并禁止它再加入,其活动订阅随后被拒绝(`not_joined`);对账号终端返回 `404`。
- 已移除:`PUT /channels/{id}/seats/me`、`PUT /channels/{id}/seats/{endpoint_hash}/label`、`GET/DELETE /channels/{id}/seats`。设备名只在账号设备管理里存在(`/accounts/devices`)。
### 已移除的接口
- 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. 便捷分享接口
@@ -210,8 +209,9 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20
|---|---|---|
| `bad_request` | 400 | 封装格式或参数错误 |
| `unauthorized` | 401 | 令牌缺失或错误 |
| `account_required` | 401 | 需要账号会话:频道与空间只接受已登录账号,账号会话缺失或已失效 |
| `forbidden` | 403 | 角色不够 |
| `not_joined` | 403 | 访客终端未加入或已被所有者移除,或账号不是该频道成员 |
| `not_joined` | 403 | 账号不是该频道成员(未加入或已被所有者移除) |
| `visitors_full` | 403 | 便捷分享的访客名额已满,且这个终端未登记 |
| `sealed` | 409 | 便捷分享已封存,不能再修改;封存后再用所有者令牌发请求也是这个错误 |
| `members_full` | 403 | 成员人数已满 |
+4 -4
View File
@@ -77,7 +77,7 @@ SQLite 存元数据和小帧,磁盘存大帧。
| `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`, `joined_at`;PK(`channel_id`, `endpoint_hash`)。账号成员只在 `channel_members`,不写此表(旧库里的 `label` 列不再使用) |
| `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` | 分享条目的帧状态和下载次数,独立于频道条目 |
@@ -114,8 +114,8 @@ SQLite 存元数据和小帧,磁盘存大帧。
- 发送队列落在 IndexedDB,恢复网络后自动补发,依赖协议的幂等追加。
- 条目预览在沙箱 iframe 中渲染;raw 内容只当文本展示或下载。
- 下载大文件优先使用 OPFS 流式写入临时文件,认证完成后提供文件;不支持 OPFS 时回退 Blob,限制 64 MiB。
- 首页先注册/登录;公开频道/分享链接可按规则由访客访问。创建与管理在服务端绑定账号,成员人数按账号去重,设备只是账号的属性(账号设备与登录 session 在 `/accounts/devices` 管理);访客是未登录终端,单独登记。所有写入都检查账号会话或访客终端、频道角色、成员名额和能力权限。
- IndexedDB 按账号与访客分库;切换时中止后台订阅并清空内存。保险库同步私钥、资源凭据和移除墓碑;设备 S、设备标签、草稿内容、缓存、原始文件和 outbox 保留本机。新设备生成独立 S 后重新加入。
- 首页先注册/登录;频道链接只对登录账号有效(未登录先登录),便捷分享链接任何人可读。创建与管理在服务端绑定账号,成员人数按账号去重,设备只是账号的属性(账号设备与登录 session 在 `/accounts/devices` 管理)。所有频道写入都检查账号会话、频道角色、成员名额和能力权限。
- IndexedDB 按账号分库;切换时中止后台订阅并清空内存。保险库同步私钥、资源凭据和移除墓碑;设备 S、设备标签、草稿内容、缓存、原始文件和 outbox 保留本机。新设备生成独立 S 后重新加入。
- 空间统一管理成员、权限与密钥,后端管理频道、分组和目录。资源日志仅保存内容及资源元数据;CONTROL 资源元数据记录随空间删除。
## 7. 阶段划分
@@ -124,7 +124,7 @@ 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 级文件:并行上传和断点续传 | 中途断网后续传成功,内存占用平稳 |
+14
View File
@@ -28,6 +28,20 @@ systemctl --user start webdrop.service
本机开发前端仍可运行 `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 镜像:
+201 -66
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: WebDrop API
version: 7.0.0
version: 8.2.0
servers:
- url: /api/v1
@@ -9,7 +9,6 @@ tags:
- name: server
- name: channel
- name: join
- name: guest
- name: frame
- name: item
- name: share
@@ -94,7 +93,7 @@ paths:
required: false
schema: { type: string }
- $ref: '#/components/parameters/Role'
- $ref: '#/components/parameters/Endpoint'
- $ref: '#/components/parameters/Account'
- name: Authorization
in: header
schema: { type: string }
@@ -123,7 +122,7 @@ paths:
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Endpoint'
- $ref: '#/components/parameters/Account'
- name: Authorization
in: header
schema: { type: string }
@@ -161,7 +160,7 @@ paths:
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Endpoint'
- $ref: '#/components/parameters/Account'
- name: Authorization
in: header
schema: { type: string }
@@ -199,7 +198,7 @@ paths:
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Endpoint'
- $ref: '#/components/parameters/Account'
- name: Authorization
in: header
schema: { type: string }
@@ -233,7 +232,7 @@ paths:
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Endpoint'
- $ref: '#/components/parameters/Account'
- name: Authorization
in: header
schema: { type: string }
@@ -271,7 +270,7 @@ paths:
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Endpoint'
- $ref: '#/components/parameters/Account'
- name: Authorization
in: header
schema: { type: string }
@@ -292,6 +291,178 @@ paths:
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
delete:
tags: [channel]
summary: 删除分组;组内频道回到未分组,频道与内容保留
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: groupId
in: path
required: true
schema: { type: string, format: uuid }
- name: X-WD-Account
in: header
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Account'
- name: Authorization
in: header
schema: { type: string }
responses:
'200':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody'
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
/spaces/{id}/roles:
post:
tags: [channel]
summary: 新建身份组(只有空间主)
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: X-WD-Account
in: header
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Account'
- name: Authorization
in: header
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceRoleRequest' }
responses:
'201':
description: 完整结构快照和新建的身份组
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/WorkspaceRoleCreatedBody'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
/spaces/{id}/roles/{roleId}:
patch:
tags: [channel]
summary: 修改身份组的名称、前缀后缀、颜色和权限(只有空间主)
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: roleId
in: path
required: true
schema: { type: string, format: uuid }
- name: X-WD-Account
in: header
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Account'
- name: Authorization
in: header
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceRoleRequest' }
responses:
'200':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
delete:
tags: [channel]
summary: 删除身份组(只有空间主);其成员失去它授予的权限
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: roleId
in: path
required: true
schema: { type: string, format: uuid }
- name: X-WD-Account
in: header
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Account'
- name: Authorization
in: header
schema: { type: string }
responses:
'200':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
/spaces/{id}/roles/{roleId}/members:
put:
tags: [channel]
summary: 整体替换身份组的成员(只有空间主);成员必须已加入空间
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: roleId
in: path
required: true
schema: { type: string, format: uuid }
- name: X-WD-Account
in: header
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Account'
- name: Authorization
in: header
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceRoleMembersRequest' }
responses:
'200':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
/spaces/{id}/folders:
post:
tags: [channel]
@@ -305,7 +476,7 @@ paths:
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Endpoint'
- $ref: '#/components/parameters/Account'
- name: Authorization
in: header
schema: { type: string }
@@ -343,7 +514,7 @@ paths:
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Endpoint'
- $ref: '#/components/parameters/Account'
- name: Authorization
in: header
schema: { type: string }
@@ -377,7 +548,7 @@ paths:
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Endpoint'
- $ref: '#/components/parameters/Account'
- name: Authorization
in: header
schema: { type: string }
@@ -603,7 +774,7 @@ paths:
parameters:
- { $ref: '#/components/parameters/ChannelId' }
- { $ref: '#/components/parameters/Role' }
- { $ref: '#/components/parameters/Endpoint' }
- { $ref: '#/components/parameters/Account' }
responses:
'200':
description: 成员列表
@@ -679,7 +850,7 @@ paths:
创建者的账号自动成为该频道 owner,其终端自动入席。
parameters:
- { $ref: '#/components/parameters/Captcha' }
- { $ref: '#/components/parameters/Endpoint' }
- { $ref: '#/components/parameters/Account' }
requestBody:
required: true
content:
@@ -703,7 +874,7 @@ paths:
get:
tags: [channel]
summary: 读取频道信息(不占成员名额)
description: 任意角色可调用。`members_used` 表示唯一账号成员数加匿名访客终端数,不是设备数。
description: 需要账号会话。`members_used` 表示唯一账号成员数,不是设备数。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/Role' }
@@ -760,13 +931,12 @@ paths:
tags: [join]
summary: 加入频道
description: |
幂等。登录账号只登记成员关系:同一账号的多台设备不增加人数,也不登记终端,此后仅凭账号会话鉴权(`X-WD-Endpoint` 可省略)。
未登录访客登记一个终端(`X-WD-Endpoint` 必填),每个终端单独计一个成员名额。成员名额已满返回 403 `members_full`。
读取频道信息和 owner 管理接口不占名额;访客访问频道内容前必须已加入,否则 403 `not_joined`。
幂等。必须带账号会话(没有则 401 `account_required`)。只登记成员关系:同一账号的多台设备不增加人数,此后仅凭账号会话鉴权。
成员名额已满返回 403 `members_full`。读取频道信息和 owner 管理接口不占名额;访问频道内容前必须已加入,否则 403 `not_joined`。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/Role' }
- { $ref: '#/components/parameters/Endpoint' }
- { $ref: '#/components/parameters/Account' }
requestBody:
required: false
content:
@@ -785,44 +955,6 @@ paths:
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
/channels/{id}/guests:
get:
tags: [guest]
summary: 查看访客(所有者)
description: 只列未登录访客的终端。账号成员按用户管理(`/members`、`/invitations`),其设备不对外列出。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/ChannelId' }
- { $ref: '#/components/parameters/RoleOwner' }
responses:
'200':
description: OK
content:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/Guest' }
'403': { $ref: '#/components/responses/Error' }
/channels/{id}/guests/{endpointHash}:
delete:
tags: [guest]
summary: 移除访客(所有者)
description: 移除该访客终端并禁止它再加入;被移除终端后续请求返回 `not_joined`。账号成员改用 `DELETE /invitations/{username}`。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/ChannelId' }
- name: endpointHash
in: path
required: true
description: 访客终端标识,即 `base64url(SHA-256(S))`
schema: { $ref: '#/components/schemas/Base64Url' }
- { $ref: '#/components/parameters/RoleOwner' }
responses:
'204': { description: 已移除 }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
/channels/{id}/frames:
post:
tags: [frame]
@@ -835,7 +967,7 @@ paths:
parameters:
- { $ref: '#/components/parameters/ChannelId' }
- { $ref: '#/components/parameters/RoleWriter' }
- { $ref: '#/components/parameters/Endpoint' }
- { $ref: '#/components/parameters/Account' }
- name: X-WD-Max-Downloads
in: header
required: false
@@ -875,7 +1007,7 @@ paths:
parameters:
- { $ref: '#/components/parameters/ChannelId' }
- { $ref: '#/components/parameters/Role' }
- { $ref: '#/components/parameters/Endpoint' }
- { $ref: '#/components/parameters/Account' }
- name: from
in: query
required: false
@@ -918,7 +1050,7 @@ paths:
**服务端 → 客户端**
- 二进制消息:一条日志记录(`seq ‖ kind ‖ body`)。
- 文本消息(JSON):`{"t":"hb","seq":N}` 心跳(每 20 秒);`{"t":"settings"}` 设置变更;`{"t":"members"}` 成员/访客变更。
- 文本消息(JSON):`{"t":"hb","seq":N}` 心跳(每 20 秒);`{"t":"settings"}` 设置变更;`{"t":"members"}` 成员变更。
客户端 45 秒收不到任何消息应判定死链,以指数退避(1~30 秒)加抖动重连,并带 `from=last_seq+1`。
上传不走 WebSocket,仍用 `POST /frames`。
@@ -950,7 +1082,7 @@ paths:
parameters:
- { $ref: '#/components/parameters/ChannelId' }
- { $ref: '#/components/parameters/Role' }
- { $ref: '#/components/parameters/Endpoint' }
- { $ref: '#/components/parameters/Account' }
- name: from
in: query
required: false
@@ -981,7 +1113,7 @@ paths:
- { $ref: '#/components/parameters/ChannelId' }
- { $ref: '#/components/parameters/ItemId' }
- { $ref: '#/components/parameters/Role' }
- { $ref: '#/components/parameters/Endpoint' }
- { $ref: '#/components/parameters/Account' }
- name: from
in: query
required: false
@@ -1009,7 +1141,7 @@ paths:
- { $ref: '#/components/parameters/ChannelId' }
- { $ref: '#/components/parameters/ItemId' }
- { $ref: '#/components/parameters/Role' }
- { $ref: '#/components/parameters/Endpoint' }
- { $ref: '#/components/parameters/Account' }
responses:
'204': { description: 已删除 }
'403': { $ref: '#/components/responses/Error' }
@@ -1243,6 +1375,12 @@ components:
required: true
description: 条目 id,16 字节 base64url
schema: { $ref: '#/components/schemas/Base64Url' }
Account:
name: X-WD-Account
in: header
required: true
description: 账号会话令牌。频道与空间只接受已登录账号,缺少或失效返回 401 `account_required`。
schema: { type: string }
Role:
name: X-WD-Role
in: header
@@ -1263,7 +1401,7 @@ components:
name: X-WD-Endpoint
in: header
required: false
description: 终端密钥 `S` 的 base64url,仅存本地。只有未登录访客必填;登录账号以 `X-WD-Account` 会话鉴权,可省略。
description: 终端密钥 `S` 的 base64url,仅存本地。只用于便捷分享(未登录的创建者与读者);频道与空间使用 `X-WD-Account` 会话,不需要它。
schema: { $ref: '#/components/schemas/Base64Url' }
ShareEndpoint:
name: X-WD-Endpoint
@@ -1410,8 +1548,5 @@ components:
JoinResult:
$ref: '../contracts/api.schemas.json#/$defs/JoinResult'
Guest:
$ref: '../contracts/api.schemas.json#/$defs/GuestInfo'
AppendResult:
$ref: '../contracts/api.schemas.json#/$defs/AppendResult'
+6 -7
View File
@@ -25,7 +25,7 @@
| `K` | 32 B | 空间/临时频道访问凭据;新工作区内容加密不使用它,可选能力链接使用 fragment |
| `A` | 32 B | 管理密钥,独立随机,只存在于所有者链接 |
| `W` | 32 B | 写密钥,仅 raw 模式且成员可上传时 |
| `S` | 32 B | 访客终端密钥,每个频道每个访客终端各生成一个,仅存本地;登录账号不需要 |
| `S` | 32 B | 便捷分享的终端密钥(创建者与读者),仅存本地;频道与空间不使用,只凭账号会话鉴权 |
## 2. 模式与链接
@@ -161,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 条目的帧允许乱序到达(并行上传),条目拉取只返回从起点开始连续的帧。
@@ -178,10 +178,9 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
## 10. 用户、设备与成员人数
- **成员是用户(账号)**。设备只是账号的属性:同一账号可在任意多台设备登录,只占一个成员名额;账号成员的请求凭有效的账号 session 鉴权,不再需要终端密钥,也不登记终端。
- **访客**是未登录的终端:本地为每个频道生成随机 `S`,请求中带 `S`,服务端保存 `SHA-256(S)` 作为访客标识,并通过显式的加入请求(`POST .../join`)登记。未加入的访客访问频道返回 `not_joined`;所有者可以列出(`GET .../guests`)并移除(`DELETE .../guests/{endpoint_hash}`)访客。
- 频道设置字段 `max_members`(v7 前叫 `seats`)是**最大成员人数**。owner 和每个唯一账号各计一人,同一账号的额外设备不增加人数;访客没有可跨设备识别的账号身份,每个访客终端按一个访客成员计入上限,因此访客在多台设备加入会占多个名额。成员人数达到上限时,新的成员加入返回 `members_full`。若希望严格按自然人去重,应要求加入者登录账号。
- 不同频道使用不同的 `S`(仅访客),避免服务端把同一访客终端关联起来。
- 受限下载(GATED)的计数单位是「下载方」:登录账号按用户,访客按终端;同一下载方对同一条目重复发起 `from=0` 才算续传。
- **频道强制登录**(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,为已发布的测试向量保持稳定,但不再被使用。
@@ -245,7 +244,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
| 模式 | enc 或 raw | **只有 enc** |
| 谁能写 | 所有者和成员 | 只有创建者,且只在草稿阶段 |
| 寿命 | 长期(TTL 滚动续期) | 短,封存后到期即清 |
| 访客 | 作为访客成员计入人数上限 | 只读访客,按终端计数,不展示人数 |
| 谁能读 | 已登录的成员(账号) | 持链接的任何人,无需账号;访客按终端计数,不展示人数 |
分享与频道**共用同一个 16 字节 id 空间**,服务端保证全局唯一,所以同一个 id 不可能既是频道又是分享。分享 id 在频道接口上返回 `not_found`,反之亦然。
+3 -3
View File
@@ -54,14 +54,14 @@
| 模式降级(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(访客为终端登记)、成员人数;加密选项不会绕开权限 |
| 账号混用/设备身份混用 | 按账号隔离 IndexedDB,切换时中止后台任务;保险库不包含设备 S,每台设备单独生成 |
| 绕过频道加入规则 | 服务端检查 owner/member 角色、邀请、密码、账号 session(没有则一律拒绝)、成员人数;加密选项不会绕开权限 |
| 匿名滥用与 DoS | 创建时的 Cap 验证码(工作量证明由 Cap 提供)、限流、频道配额、条目数和字节数上限 |
## 5. 前端信任根(残余风险)
+6 -4
View File
@@ -80,6 +80,8 @@ WebDrop 是**暂存区**,频道工作区:聊天按 thread 展示,文件按 thr
进入频道就是纯聊天界面:单行顶栏(≡、# 频道名、加密图标、搜索、⋯)加底部输入框,没有页签。空间切换、频道列表、本空间的「文件 / 成员 / 邀请 / 分享」、账号与外观都在左侧抽屉(≡ 打开,返回键关闭);点「文件」「成员」整页打开,左上角 ‹ 回到聊天。⋯ 里是空间设置、分享、邀请。触屏没有悬停,消息上不常驻删除按钮,点一下消息弹出菜单(复制、删除)。新建频道自动创建“常规”thread。文件页与桌面共用目录路径、上传、文件夹、排序、移动和预览组件。
频道加载中先画出页面框架(骨架屏:顶栏、频道列表、消息、输入框的灰色占位),不留白屏;等待不足约 300ms 时不显示,避免闪烁;占位轻微呼吸闪动,尊重「减少动效」。不用假进度条。加载失败时在骨架屏上给出错误与「重试」「回到首页」。
### 6.2 电脑:频道与 threads | 聊天 | 线程文件
左侧固定频道导航与 thread 列表,可直接切换已加入频道、新建频道、新建/重命名 thread,并打开账号与设备设置。中间完整高度展示当前 thread 的聊天,底部输入支持文字和文件附件。右侧是资源管理器式文件区:路径面包屑、上传文件、新建文件夹、按名称/大小/时间排序、多选下载、移动、双击预览。
@@ -111,14 +113,14 @@ WebDrop 是**暂存区**,频道工作区:聊天按 thread 展示,文件按 thr
| 快速分享(输入文本、拖入、粘贴、选择、相册、拍照) | 先在首页编辑临时文本并收集文件;点「创建临时分享」后建立加密草稿并带入内容;点「完成分享」后生成链接与二维码 |
| 新建频道 | 登录后创建,自动成为该频道所有者;可选择是否端到端加密,默认启用;成员人数上限、TTL、受限下载次数和上传权限仍用同一频道模型 |
| 加入频道 | 扫码或粘贴链接 → 加入预览 → 加入 |
| 我的频道 | 点一行进入;垃圾桶从当前账号/访客资源清单移除,**不会删除频道**,需二次确认;账号清单移除会同步到其他设备 |
| 我的频道 | 点一行进入;垃圾桶从当前账号资源清单移除,**不会删除频道**,需二次确认;账号清单移除会同步到其他设备 |
| 账号与同步 | 用户名密码注册或登录;本地频道/分享凭据在 Worker 加密后同步,另一台设备登录后恢复;退出时撤销会话 |
登录/注册页是居中的单列卡片(标题与标语在上,卡片在下);创建账号需要再输入一次密码确认,两次不一致时就地提示并禁用提交。账号设置是左侧导航 + 右侧单页(个人资料 / 我的设备 / 安全 / 空间邀请 / 同步与登录;手机上导航变为顶部横向页签),结果统一用 toast 反馈,设备重命名就地编辑,移除设备与退出登录需确认。
账号密码不提供找回流程。忘记密码会导致账号保险库无法解密;界面需在注册区域明确提示。本机记住登录通过 IndexedDB 保存 session 和不可导出的保险库 CryptoKey,刷新后恢复身份;退出或设备被撤销后清除。
未登录访问首页时先显示注册/登录页;收到的频道或分享链接保持访客入口。频道加入页按当前规则显示密码输入或登录提示。右上角频道设置集中管理加入方式、陌生人开关、频道密码、成员角色、邀请与撤销,不另设权限入口。
未登录访问首页时先显示注册/登录页;收到的分享链接任何人可读;收到的频道链接要求登录,未登录先显示登录页,登录后继续加入。频道加入页按当前规则显示密码输入。右上角频道设置集中管理加入方式(登录后持链接加入 / 仅受邀账号)、频道密码、成员角色、邀请与撤销,不另设权限入口。
| 路由 | 页面 |
|---|---|
@@ -232,8 +234,8 @@ Share 是独立的只读发布资源,不创建频道成员或 threads。创建
### 7.5 频道文件预览与下载限制
- 新上传文件默认允许单帧内容直接随频道同步。加入频道的访客可直接看到文件名、类型、大小;PNG 等图片和 TXT 等文本可直接预览,不消耗下载次数。文件名与类型仍只存在于加密帧中。
- 超出单帧容量的文件保持 GATED,列表不自动读取内容。访客主动打开预览时才取回文件,并消耗一次下载次数;取回后显示解密出的文件名、类型和大小。EXE 等不支持内联预览的类型仍提供下载。
- 新上传文件默认允许单帧内容直接随频道同步。加入频道的成员可直接看到文件名、类型、大小;PNG 等图片和 TXT 等文本可直接预览,不消耗下载次数。文件名与类型仍只存在于加密帧中。
- 超出单帧容量的文件保持 GATED,列表不自动读取内容。成员主动打开预览时才取回文件,并消耗一次下载次数;取回后显示解密出的文件名、类型和大小。EXE 等不支持内联预览的类型仍提供下载。
- 上传者可关闭直接预览并为文件设置下载次数上限。
- 设备标签优先显示浏览器提供的机型和系统版本,例如「Pixel 9 (Android 16) · A1B2C3D4」「Windows 11 · 1234ABCD」。浏览器没有暴露具体机型时显示能识别的平台名称;保留端点短标识以区分同型号设备。
+1 -1
View File
@@ -134,7 +134,7 @@ const { complete, nextIndex, meta } = await result;
## 必须注意的几点
**1. 访客终端密钥必须持久化。** `session.endpointSecret`(32 字节)标识「这个访客终端在这个频道里」,服务端按它登记访客以便认证和移除;登录账号以账号会话鉴权,不需要它,同一账号的多台设备也不重复占名额。访客不持久化的话,每次打开链接都会登记一个新访客终端。用 IndexedDB 保存,下次 `open(link, { endpointSecret })` 传回来。
**1. 终端密钥只用于便捷分享。** `session.endpointSecret`(32 字节)标识「这个终端」,便捷分享的创建者与读者靠它登记和续传;频道与空间只凭账号会话鉴权,不使用它。分享读者不持久化的话,每次打开链接都会登记一个新访客终端,用 IndexedDB 保存。
**2. Worker。** `ChannelSession` 实例不能跨线程传递(它持有 `CryptoKey` 和闭包)。在 Worker 里用链接和 `endpointSecret` 重建它,主线程只传链接、帧字节和事件。
-1
View File
@@ -13,7 +13,6 @@ export type AuthRole = 'read' | 'write' | 'owner';
/** 频道设置,字段名与 API 一致(snake_case)。 */
export interface ChannelSettings {
join_policy?: 'open' | 'invite';
allow_guests?: boolean;
name?: string;
max_members?: number;
members_can_upload?: boolean;
+4 -3
View File
@@ -45,7 +45,8 @@ let dataDir = '';
let base = '';
let accountToken = '';
function headersFor(session: ChannelSession, role: AuthRole): Record<string, string> {
return { ...session.authHeaders(role), ...(session.role === 'owner' ? { 'X-WD-Account': accountToken } : {}) };
// 频道只接受登录账号:测试里所有角色共用同一个账号,权限差异由角色令牌体现
return { ...session.authHeaders(role), 'X-WD-Account': accountToken };
}
beforeAll(async () => {
@@ -145,7 +146,7 @@ function subscribe(session: ChannelSession, role: AuthRole, until: (events: Item
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 ', '')}`);
if (session.role === 'owner') protocols.push(`wd.account.${accountToken.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`);
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';
@@ -416,7 +417,7 @@ suite('端到端(真实服务端)', () => {
const member = await ChannelSession.open(owner.link(ORIGIN));
await joinChannel(member, 'read');
const res = await fetch(api(`/channels/${owner.id}/guests`), { headers: { ...headersFor(member, '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(() => headersFor(member, 'owner')).toThrow();
});
+5 -5
View File
@@ -9,9 +9,9 @@ const fail = (message) => {
process.exit(1);
};
const dtoSources = ['server/src/WebDrop.Api/ApiJson.cs', 'server/src/WebDrop.Core/Dtos.cs', 'server/src/WebDrop.Core/Settings.cs', 'server/src/WebDrop.Core/Enums.cs', 'server/src/WebDrop.Core/Workspace.cs', 'server/src/WebDrop.Core/AccountProfile.cs', 'server/src/WebDrop.Api/PasskeyDtos.cs', 'server/src/WebDrop.Core/WorkspaceCrypto.cs', 'server/src/WebDrop.Api/Endpoints.WorkspaceCrypto.cs'];
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/core-bridge/generated/api.ts'))?.[1];
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.');
@@ -24,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];
+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
+2
View File
@@ -22,8 +22,10 @@ WORKDIR /src
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"]
+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" />
@@ -1,62 +0,0 @@
using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api;
public static partial class Endpoints
{
private static void MapWorkspace(RouteGroupBuilder api, SqliteAccountStore accounts)
{
WorkspaceBody View(WorkspaceState state) => new()
{
Structure = state,
People = state.Locations.Select(l => l.AuthorAccount).Where(u => u is not null).Distinct()
.Select(u => accounts.Profile(u!)).ToList(),
};
api.MapGet("/spaces/{id}/structure", (string id, HttpContext ctx, ChannelService svc) =>
Json(View(svc.GetWorkspace(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true))), ApiJson.Default.WorkspaceBody));
api.MapPost("/spaces/{id}/channels", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceChannelRequest, ct);
var created = svc.CreateWorkspaceChannelResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.Name, req.GroupId);
return Json(new WorkspaceChannelCreatedBody { Workspace = View(created.State), Channel = created.Channel }, ApiJson.Default.WorkspaceChannelCreatedBody, 201);
});
api.MapPatch("/spaces/{id}/channels/{channelId}", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
return Json(View(svc.RenameWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), channelId, req.Name)), ApiJson.Default.WorkspaceBody);
});
api.MapPut("/spaces/{id}/channels/{channelId}/group", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceGroupRequest, ct);
return Json(View(svc.GroupWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), channelId, req.GroupId)), ApiJson.Default.WorkspaceBody);
});
api.MapPost("/spaces/{id}/groups", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
var created = svc.CreateWorkspaceGroupResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.Name);
return Json(new WorkspaceGroupCreatedBody { Workspace = View(created.State), Group = created.Group }, ApiJson.Default.WorkspaceGroupCreatedBody, 201);
});
api.MapPatch("/spaces/{id}/groups/{groupId}", async (string id, string groupId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
return Json(View(svc.RenameWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), groupId, req.Name)), ApiJson.Default.WorkspaceBody);
});
api.MapPost("/spaces/{id}/folders", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceFolderRequest, ct);
return Json(View(svc.CreateWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.ThreadId, req.ParentId, req.Name)), ApiJson.Default.WorkspaceBody, 201);
});
api.MapPatch("/spaces/{id}/folders/{folderId}", async (string id, string folderId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
return Json(View(svc.RenameWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), folderId, req.Name)), ApiJson.Default.WorkspaceBody);
});
api.MapPut("/spaces/{id}/locations", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceMoveRequest, ct);
if (req.ItemIds is null) throw new WebDropException(ErrorCode.BadRequest);
return Json(View(svc.MoveWorkspaceItems(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.ItemIds, req.ThreadId, req.FolderId)), ApiJson.Default.WorkspaceBody);
});
}
}
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Http.Features;
using WebDrop.Core;
using WebDrop.Storage;
@@ -10,15 +11,25 @@ public static partial class Endpoints
api.MapGet("/accounts/profile", (HttpContext ctx) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
return Json(accounts.Profile(username), ApiJson.Default.AccountProfile);
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);
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountProfileRequest, ct);
var profile = accounts.UpdateProfile(username, req.DisplayName, req.Avatar);
// 动图头像比其他请求大得多,单独放宽(全局上限 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, ApiJson.Default.AccountProfile);
return Json(profile.WithoutAvatar(), ApiJson.Default.AccountProfile);
});
api.MapPost("/accounts", async (HttpContext ctx, CancellationToken ct) =>
{
@@ -101,7 +112,6 @@ public static partial class Endpoints
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
accounts.RevokeDevice(username, id);
svc.ReleaseAccountDevice(username, id);
return Results.NoContent();
});
@@ -12,6 +12,44 @@ public static partial class Endpoints
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);
@@ -40,7 +78,8 @@ public static partial class Endpoints
return Json(InvitationBodies(svc.UserInvitations(username)), ApiJson.Default.ListInvitationBody);
});
api.MapGet("/channels/{id}/invitations", (string id, HttpContext ctx, ChannelService svc) =>
Json(InvitationBodies(svc.Invitations(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false))), ApiJson.Default.ListInvitationBody));
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);
@@ -50,12 +89,15 @@ public static partial class Endpoints
api.MapGet("/channels/{id}/members", (string id, HttpContext ctx, ChannelService svc, ChannelHub hub) =>
{
var channelId = ParseId(id);
var members = svc.Members(channelId, RequestAuth.FromHeaders(ctx.Request, true)).Select(member =>
var members = svc.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(accounts.Devices(member.Username, null).Count, onlineDevices), OnlineDevices = onlineDevices };
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);
});
@@ -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,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);
});
}
}
@@ -12,21 +12,21 @@ public static partial class Endpoints
{
private static void MapWorkspaceCrypto(RouteGroupBuilder api)
{
api.MapGet("/spaces/{id}/crypto", (string id, HttpContext ctx, ChannelService svc) => Json(svc.ReadCrypto(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true)), ApiJson.Default.CryptoState));
api.MapGet("/spaces/{id}/crypto", (string id, HttpContext ctx, ChannelService svc) => Json(svc.ReadCrypto(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false)), ApiJson.Default.CryptoState));
api.MapPut("/spaces/{id}/crypto/domains", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.DomainPublishRequest, ct);
svc.PublishDomain(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req); return Results.NoContent();
svc.PublishDomain(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req); return Results.NoContent();
});
api.MapPost("/spaces/{id}/crypto/objects", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.ObjectEnvelope, ct);
return Json(svc.DeclareObject(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req), ApiJson.Default.ObjectEnvelope, 201);
return Json(svc.DeclareObject(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req), ApiJson.Default.ObjectEnvelope, 201);
});
api.MapPatch("/spaces/{id}/channels/{channelId}/history", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.ChannelHistoryRequest, ct);
svc.SetChannelHistory(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), channelId, req.ChatHistory, req.FilesHistory); return Results.NoContent();
svc.SetChannelHistory(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.ChatHistory, req.FilesHistory); return Results.NoContent();
});
}
}
@@ -1,5 +1,6 @@
using System.Buffers;
using System.Diagnostics;
using System.IO.Compression;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text.Json;
@@ -16,7 +17,7 @@ public static partial class Endpoints
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, SqliteAccountStore accounts)
public static void MapApi(this IEndpointRouteBuilder app, WebDropOptions options, ICaptchaVerifier captcha, SqliteAccountStore accounts, IFileGrants files)
{
var api = app.MapGroup("/api/v1");
@@ -30,6 +31,7 @@ public static partial class Endpoints
},
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(),
@@ -43,7 +45,6 @@ public static partial class Endpoints
MaxFramesPerItem = limits.MaxFramesPerItem,
MaxChannelBytes = limits.MaxChannelBytes,
MaxWorkspaceBytes = limits.MaxWorkspaceBytes,
MaxWorkspaceChannelBytes = limits.MaxWorkspaceChannelBytes,
MaxMembers = limits.MaxMembers,
MaxTtlSeconds = limits.MaxTtlSeconds,
MinTtlSeconds = limits.MinTtlSeconds,
@@ -56,6 +57,7 @@ public static partial class Endpoints
MapPasskeys(api, accounts);
MapWorkspace(api, accounts);
MapWorkspaceCrypto(api);
MapFiles(api);
// ───────── 频道 ─────────
@@ -63,10 +65,9 @@ public static partial class Endpoints
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var req = await ReadJson(ctx.Request, ApiJson.Default.CreateChannelRequest, ct);
var endpoint = RequestAuth.ParseEndpointHash(ctx.Request.Headers[RequestAuth.EndpointHeader]);
string token = ctx.Request.Headers[RequestAuth.CaptchaHeader].ToString();
await captcha.VerifyAsync(token.Length > 0 ? token : null, ct);
return Json(svc.CreateChannel(req, endpoint, username, accounts.SessionDevice(ctx.Request.Headers["X-WD-Account"].ToString())), ApiJson.Default.ChannelInfo, StatusCodes.Status201Created);
return Json(svc.CreateChannel(req, username), ApiJson.Default.ChannelInfo, StatusCodes.Status201Created);
}).RequireRateLimiting(CreatePolicy);
api.MapGet("/channels/{id}", (string id, HttpContext ctx, ChannelService svc) =>
@@ -74,11 +75,20 @@ public static partial 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));
@@ -90,28 +100,17 @@ public static partial class Endpoints
api.MapPost("/channels/{id}/join", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var request = ctx.Request.ContentType?.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) == true ? await ReadJson(ctx.Request, ApiJson.Default.JoinRequest, ct) : new JoinRequest();
return Json(svc.Join(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true), request.Password), ApiJson.Default.JoinResult);
return Json(svc.Join(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), request.Password), ApiJson.Default.JoinResult);
});
MapChannelAccess(api, accounts);
// 访客是未登录的终端;账号成员只按用户管理(members / invitations),设备不对外列出
api.MapGet("/channels/{id}/guests", (string id, HttpContext ctx, ChannelService svc) =>
Json([.. svc.ListGuests(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false))], ApiJson.Default.ListGuestInfo));
api.MapDelete("/channels/{id}/guests/{endpointHash}", (string id, string endpointHash, HttpContext ctx, ChannelService svc) =>
{
if (!Codec.TryDecodeExact(endpointHash, TokenHash.Size, out var hash)) throw new WebDropException(ErrorCode.BadRequest);
svc.ReleaseGuest(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), hash);
return Results.NoContent();
});
// ───────── 帧与日志 ─────────
api.MapPost("/channels/{id}/frames", async (string id, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore, ServerLimits limits, CancellationToken ct) =>
{
var channelId = ParseId(id);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false);
long? maxDownloads = ParseMaxDownloads(ctx.Request.Headers["X-WD-Max-Downloads"].ToString());
byte[] body = await ReadBody(ctx.Request, Envelope.HeaderSize + limits.MaxPayloadBytes, ct);
@@ -123,7 +122,7 @@ public static partial class Endpoints
api.MapGet("/channels/{id}/log", async (string id, HttpContext ctx, ChannelService svc, ChannelHub hub, CancellationToken ct) =>
{
var channelId = ParseId(id);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false);
long from = QueryLong(ctx.Request, "from", 0);
int limit = (int)Math.Clamp(QueryLong(ctx.Request, "limit", 200), 1, 1000);
var wait = TimeSpan.FromSeconds(Math.Clamp(QueryLong(ctx.Request, "wait", 0), 0, 25));
@@ -171,7 +170,7 @@ public static partial class Endpoints
api.MapGet("/channels/{id}/events", async (string id, HttpContext ctx, ChannelService svc, ChannelHub hub) =>
{
var channelId = ParseId(id);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false);
long from = QueryLong(ctx.Request, "from", -1);
if (from < 0)
from = long.TryParse(ctx.Request.Headers["Last-Event-ID"].ToString(), out long last) && last >= 0 ? last + 1 : 0;
@@ -192,7 +191,7 @@ public static partial class Endpoints
{
var channelId = ParseId(id);
var item = ParseId(itemId);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false);
long from = QueryLong(ctx.Request, "from", 0);
if (from is < 0 or > uint.MaxValue) throw new WebDropException(ErrorCode.BadRequest);
@@ -220,7 +219,7 @@ public static partial class Endpoints
api.MapDelete("/channels/{id}/items/{itemId}", (string id, string itemId, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore) =>
{
var channelId = ParseId(id); var parsedItemId = ParseId(itemId);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false);
var proof = ParseSignedDeleteEvent(ctx.Request, accountStore, svc, channelId, parsedItemId, creds);
svc.DeleteItem(channelId, creds, parsedItemId, proof);
return Results.NoContent();
@@ -367,12 +366,12 @@ public static partial class Endpoints
bool any = values.Any(value => value.Length > 0);
if (!any)
{
if (Envelope.TryParse(frame, out var legacyEnvelope) && legacyEnvelope.IsFinal && creds.AccountName is not null
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.IsFinal || creds.AccountName is null || creds.AccountDeviceId is 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
@@ -464,6 +463,40 @@ public static partial 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
@@ -481,7 +514,21 @@ public static partial 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);
}
}
@@ -3,7 +3,8 @@ namespace WebDrop.Api;
public static class ApiContract
{
// v5: 频道容量按账号成员计;v7: seats 改名 max_members/members_used/members_full,
// 去掉设备名与设备清单接口,访客改走 /guests。设备只是账号的属性。
// 去掉设备名与设备清单接口。设备只是账号的属性。
// v8: 频道强制登录,去掉未登录访客(allow_guests、/guests、终端席位);便捷分享不受影响。
// Keep this aligned with contracts/api-contract.json and docs/openapi.yaml.
public const string Version = "7.0.0";
public const string Version = "8.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);
}
}
@@ -35,7 +35,7 @@ public static class RequestAuth
deviceId = accounts.SessionDevice(accountToken);
}
if (role == Role.Owner && accountName is null) throw new WebDropException(ErrorCode.AccountRequired);
// 账号以会话鉴权,终端密钥只对未登录访客是必需的
// 账号以会话鉴权;终端密钥只用于便捷分享(未登录的创建者与读者)
return new Credentials(role, token, ParseEndpoint(req.Headers[EndpointHeader].ToString(), requireEndpoint && accountName is null), accountName, deviceId, accountName is not null);
}
@@ -69,7 +69,7 @@ public static class RequestAuth
}
var parsedRole = ParseRole(role);
if (parsedRole == Role.Owner && accountName is null) throw new WebDropException(ErrorCode.AccountRequired);
return new Credentials(parsedRole, token, ParseEndpoint(endpoint, requireEndpoint: accountName is null), accountName, deviceId, accountName is not null);
return new Credentials(parsedRole, token, ParseEndpoint(endpoint, requireEndpoint: false), accountName, deviceId, accountName is not null);
}
public static byte[] ParseEndpointHash(string? header) => ParseEndpoint(header, requireEndpoint: true);
@@ -50,11 +50,21 @@ public static class WebDropApp
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)
{
@@ -64,8 +74,8 @@ public static class WebDropApp
.WithHeaders("Authorization", "Content-Type", "Last-Event-ID",
"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")
.WithExposedHeaders("X-WD-Last-Seq")
"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))));
}
@@ -73,13 +83,17 @@ 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);
};
@@ -99,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.Services.GetRequiredService<SqliteAccountStore>());
app.MapApi(options, app.Services.GetRequiredService<ICaptchaVerifier>(), app.Services.GetRequiredService<SqliteAccountStore>(), app.Services.GetRequiredService<IFileGrants>());
return app;
}
@@ -130,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;
@@ -21,6 +22,11 @@ public sealed class WebDropOptions
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;
@@ -28,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>
@@ -23,6 +23,36 @@ public sealed class ServerInfoBody
[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
@@ -60,7 +90,6 @@ public sealed class LimitsBody
[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_workspace_channel_bytes")] public required long MaxWorkspaceChannelBytes { 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; }
@@ -122,6 +151,8 @@ public sealed class InvitationBody
[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; } }
@@ -138,6 +169,7 @@ public sealed class AccountDeviceBody
[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
@@ -159,18 +191,45 @@ public sealed class MemberBody
/// <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
@@ -221,11 +280,17 @@ public sealed class WorkspaceGroupCreatedBody
[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))]
@@ -235,7 +300,6 @@ public sealed class WorkspaceGroupCreatedBody
[JsonSerializable(typeof(CreateChannelRequest))]
[JsonSerializable(typeof(ChannelSettingsPatch))]
[JsonSerializable(typeof(JoinResult))]
[JsonSerializable(typeof(List<GuestInfo>))]
[JsonSerializable(typeof(AppendResult))]
[JsonSerializable(typeof(AccountRegisterRequest))]
[JsonSerializable(typeof(AccountDeviceSigningRequest))]
@@ -12,7 +12,11 @@ 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)
@@ -26,9 +30,8 @@ public sealed class ChannelHub : IChannelEvents
}
/// <summary>登记一个活动订阅,释放返回值即下线。只供成员接口读取,不向订阅流推送,以免改变流的线格式。</summary>
public IDisposable Connected(Id16 id, string? account, string deviceKey)
public IDisposable Connected(Id16 id, string account, string deviceKey)
{
if (account is null) return NoPresence.Instance;
string endpoint = deviceKey;
lock (_gate)
{
@@ -67,13 +70,24 @@ public sealed class ChannelHub : IChannelEvents
public void Dispose() => Interlocked.Exchange(ref _release, null)?.Invoke();
}
private sealed class NoPresence : IDisposable
public void Advanced(Id16 channelId, long seq)
{
public static readonly NoPresence Instance = new();
public void Dispose() { }
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));
}
public void Advanced(Id16 channelId, long seq) => 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);
@@ -31,7 +31,7 @@ public static class LogStreamer
ILogSink? sink = null;
IDisposable? presence = null;
long cursor = from;
var (settingsV, seatsV, _) = sub.State;
var (settingsV, membersV, _) = sub.State;
try
{
@@ -42,7 +42,7 @@ public static class LogStreamer
var page = await svc.ReadLogAsync(id, creds, cursor, PageSize, ct).ConfigureAwait(false);
sink ??= await open().ConfigureAwait(false);
// 鉴权与首次读取都成功之后才算在线
presence ??= hub.Connected(id, creds.AccountName, creds.AccountDeviceId ?? Convert.ToHexString(creds.EndpointHash));
presence ??= hub.Connected(id, creds.AccountName!, creds.AccountDeviceId ?? "");
foreach (var record in page.Records)
{
@@ -50,15 +50,15 @@ public static class LogStreamer
cursor = record.Seq + 1;
}
var (settingsNow, seatsNow, deleted) = sub.State;
var (settingsNow, membersNow, deleted) = sub.State;
if (settingsNow != settingsV)
{
settingsV = settingsNow;
await sink.SendControlAsync("""{"t":"settings"}""", ct).ConfigureAwait(false);
}
if (seatsNow != seatsV)
if (membersNow != membersV)
{
seatsV = seatsNow;
membersV = membersNow;
await sink.SendControlAsync("""{"t":"members"}""", ct).ConfigureAwait(false);
}
if (deleted) throw new WebDropException(ErrorCode.NotFound);
@@ -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>
@@ -106,31 +106,28 @@ public interface IResourceTx
void PutKeyPackage(Id16 id, string username, string payload, long createdAt);
void DeleteKeyPackage(Id16 id, string username);
IReadOnlyList<ChannelKeyPackage> ListUserKeyPackages(string username, long now);
IReadOnlyList<Id16> DeleteDeviceSeats(string username, string deviceId);
void BindSeatDevice(Id16 id, byte[] endpointHash, string deviceId);
void DeleteAccountSeats(Id16 id, string username);
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, byte[] endpointHash);
bool IsJoinDenied(Id16 id, string username);
void DenyAccount(Id16 id, string username);
void AllowAccount(Id16 id, string username);
void DenyEndpoint(Id16 id, byte[] endpointHash);
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);
// 成员
int CountMembers(Id16 channelId);
void InsertSeat(Id16 channelId, Seat seat);
bool DeleteSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash);
IReadOnlyList<Seat> ListSeats(Id16 channelId);
// 条目
Item? GetItem(Id16 channelId, Id16 itemId);
-11
View File
@@ -1,11 +0,0 @@
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; }
[JsonPropertyName("avatar")] public string? Avatar { get; init; }
[JsonPropertyName("modifiedAt")] public required long ModifiedAt { get; init; }
}
@@ -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);
@@ -1,89 +0,0 @@
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)
{
if (creds.AccountName is not null && creds.AccountName == channel.CreatorAccount) return;
if (tx.IsJoinDenied(channel.Id, creds.AccountName, creds.EndpointHash)) throw new WebDropException(ErrorCode.Forbidden);
bool invited = creds.AccountName is not null && tx.HasInvitation(channel.Id, creds.AccountName);
if (invited) return;
if (creds.AccountName is not null && tx.GetChannelMember(channel.Id, creds.AccountName) is not null) return;
if (channel.Settings.JoinPolicy == JoinPolicy.Invite) throw new WebDropException(ErrorCode.InvitationRequired);
if (!channel.Settings.AllowGuests && creds.AccountName is null) throw new WebDropException(ErrorCode.AccountRequired);
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, SeatRule.None);
RequireOwner(principal);
return tx.ListInvitations(id);
});
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, SeatRule.None);
RequireOwner(principal);
if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden);
tx.DeleteInvitation(id, username);
tx.DeleteKeyPackage(id, username);
tx.DeleteAccountSeats(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);
}
public IReadOnlyList<ChannelMember> Members(Id16 id, Credentials creds) => store.Read(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
if (principal.Role != Role.Owner) _ = Authenticate(tx, id, creds, SeatRule.Required);
return tx.ListChannelMembers(id);
});
public IReadOnlyList<ChannelKeyPackage> AccountChannels(string username) =>
store.Read(tx => tx.ListUserKeyPackages(username, clock.NowMs));
public void ReleaseAccountDevice(string username, string deviceId)
{
var channels = store.Write(tx => tx.DeleteDeviceSeats(username, deviceId));
foreach (var id in channels) events.MembersChanged(id);
}
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, SeatRule.None);
RequireOwner(principal);
tx.PutKeyPackage(id, principal.AccountName!, payload, clock.NowMs);
return 0;
});
}
}
-28
View File
@@ -1,28 +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.AuthorAccount is not null
? item.AuthorAccount == p.AccountName
: item.AuthorHash.AsSpan().SequenceEqual(p.EndpointHash),
_ => false,
};
}
}
-144
View File
@@ -1,144 +0,0 @@
namespace WebDrop.Core;
public sealed partial class ChannelService
{
public void AccountProfileChanged(string username)
{
foreach (var id in store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs))) events.SettingsChanged(id);
}
public IReadOnlyList<ChannelInfo> AccountSpaces(string username) => store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs).Select(id =>
{
var channel = tx.GetChannel(id)!;
var role = tx.GetChannelMember(id, username)?.Role == "owner" ? Role.Owner : channel.WriteHash is not null ? Role.Write : Role.Read;
return ToInfo(channel, tx.CountMembers(id), new Principal(role, [], username));
}).ToList());
private static WorkspaceState NewWorkspace(string username, long now)
{
var id = Guid.NewGuid().ToString();
return new() { Revision = 1, DefaultChannelId = id, Channels = [new WorkspaceChannel { Id = id, Name = "常规", CreatedAt = now, CreatedBy = username }] };
}
public WorkspaceState GetWorkspace(Id16 id, Credentials creds) => store.Read(tx =>
{
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
var state = tx.GetWorkspace(id) ?? new WorkspaceState();
if (principal.AccountName != space.CreatorAccount)
{
var crypto = tx.GetWorkspaceCrypto(id);
var grants = crypto.Access.Where(a => a.Username == principal.AccountName).ToList();
var ids = grants.Where(a => a.ChatRead || a.FilesRead).Select(a => a.ChannelId).ToHashSet();
state.Channels.RemoveAll(c => !ids.Contains(c.Id));
foreach (var workspaceChannel in state.Channels)
{
var grant = grants.Find(a => a.ChannelId == workspaceChannel.Id);
if (grant is null || !grant.FilesRead && !grant.FilesUpload) workspaceChannel.FileBytes = 0;
}
state.Folders.RemoveAll(f => !ids.Contains(f.ThreadId));
state.Locations.RemoveAll(l => !Id16.TryParse(l.ItemId, out var itemId) || !CanReadObject(tx, space, principal, itemId));
}
state.Locations = state.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(id, itemId) is { } item && item.ExpiresAt > clock.NowMs).ToList();
return state;
});
private WorkspaceState ChangeWorkspace(Id16 id, Credentials creds, Action<IResourceTx, WorkspaceState, Principal> change, bool requireAccount = true)
{
var state = store.Write(tx =>
{
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.Required);
if (requireAccount && principal.AccountName is null) throw new WebDropException(ErrorCode.AccountRequired);
if (requireAccount && principal.Role != Role.Owner) throw new WebDropException(ErrorCode.Forbidden);
if (!ChannelPolicy.CanWrite(channel, principal)) throw new WebDropException(ErrorCode.Forbidden);
var current = tx.GetWorkspace(id) ?? new WorkspaceState();
current.Locations = current.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(id, itemId) is not null).ToList();
change(tx, current, principal);
ValidateWorkspace(current);
current.Revision++;
tx.PutWorkspace(id, current);
return current;
});
events.SettingsChanged(id);
return state;
}
public (WorkspaceState State, WorkspaceChannel Channel) CreateWorkspaceChannelResult(Id16 id, Credentials creds, string name, string? groupId)
{
WorkspaceChannel? created = null;
var state = ChangeWorkspace(id, creds, (_, workspace, principal) =>
{
created = new WorkspaceChannel { Id = Guid.NewGuid().ToString(), Name = WorkspaceName(name), GroupId = groupId, CreatedAt = clock.NowMs, CreatedBy = principal.AccountName! };
workspace.Channels.Add(created);
});
return (state, created!);
}
public WorkspaceState CreateWorkspaceChannel(Id16 id, Credentials creds, string name, string? groupId) => CreateWorkspaceChannelResult(id, creds, name, groupId).State;
public WorkspaceState RenameWorkspaceChannel(Id16 id, Credentials creds, string channelId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
FindChannel(state, channelId).Name = WorkspaceName(name));
public WorkspaceState GroupWorkspaceChannel(Id16 id, Credentials creds, string channelId, string? groupId) => ChangeWorkspace(id, creds, (_, state, _) =>
FindChannel(state, channelId).GroupId = groupId);
public (WorkspaceState State, WorkspaceGroup Group) CreateWorkspaceGroupResult(Id16 id, Credentials creds, string name)
{
WorkspaceGroup? created = null;
var state = ChangeWorkspace(id, creds, (_, workspace, _) =>
{
created = new WorkspaceGroup { Id = Guid.NewGuid().ToString(), Name = WorkspaceName(name), CreatedAt = clock.NowMs };
workspace.Groups.Add(created);
});
return (state, created!);
}
public WorkspaceState CreateWorkspaceGroup(Id16 id, Credentials creds, string name) => CreateWorkspaceGroupResult(id, creds, name).State;
public WorkspaceState RenameWorkspaceGroup(Id16 id, Credentials creds, string groupId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
(state.Groups.Find(g => g.Id == groupId) ?? throw new WebDropException(ErrorCode.NotFound)).Name = WorkspaceName(name));
public WorkspaceState CreateWorkspaceFolder(Id16 id, Credentials creds, string threadId, string? parentId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
state.Folders.Add(new WorkspaceFolder { Id = Guid.NewGuid().ToString(), ThreadId = threadId, ParentId = parentId, Name = WorkspaceName(name) }));
public WorkspaceState RenameWorkspaceFolder(Id16 id, Credentials creds, string folderId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
(state.Folders.Find(f => f.Id == folderId) ?? throw new WebDropException(ErrorCode.NotFound)).Name = WorkspaceName(name));
public WorkspaceState MoveWorkspaceItems(Id16 id, Credentials creds, string[] itemIds, string threadId, string? folderId) => ChangeWorkspace(id, creds, (tx, state, principal) =>
{
if (itemIds.Length is < 1 or > 100 || itemIds.Distinct().Count() != itemIds.Length) throw new WebDropException(ErrorCode.BadRequest);
foreach (var itemId in itemIds)
{
if (!Id16.TryParse(itemId, out var parsed)) throw new WebDropException(ErrorCode.BadRequest);
var item = tx.GetItem(id, parsed) ?? throw new WebDropException(ErrorCode.NotFound);
if ((item.Flags & (FrameFlags.Control | FrameFlags.Snapshot)) != 0) throw new WebDropException(ErrorCode.BadRequest);
if (principal.Role != Role.Owner && (item.AuthorAccount is not null ? item.AuthorAccount != principal.AccountName : !item.AuthorHash.AsSpan().SequenceEqual(principal.EndpointHash))) throw new WebDropException(ErrorCode.Forbidden);
state.Locations.RemoveAll(l => l.ItemId == itemId);
state.Locations.Add(new WorkspaceLocation { ItemId = itemId, ThreadId = threadId, FolderId = folderId, AuthorAccount = item.AuthorAccount });
}
}, requireAccount: false);
private static WorkspaceChannel FindChannel(WorkspaceState state, string id) => state.Channels.Find(c => c.Id == id) ?? throw new WebDropException(ErrorCode.NotFound);
private static string WorkspaceName(string name)
{
if (string.IsNullOrWhiteSpace(name) || name.Trim().Length > 80 || name.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
return name.Trim();
}
private static void ValidateWorkspace(WorkspaceState state)
{
if (state.Channels is null || state.Groups is null || state.Folders is null || state.Locations is null
|| state.Channels.Count > 256 || state.Groups.Count > 128 || state.Folders.Count > 2048 || state.Locations.Count > 10_000)
throw new WebDropException(ErrorCode.BadRequest);
if (state.Channels.Any(c => c is null) || state.Groups.Any(g => g is null) || state.Folders.Any(f => f is null) || state.Locations.Any(l => l is null)) throw new WebDropException(ErrorCode.BadRequest);
var allIds = state.Channels.Select(c => c.Id).Concat(state.Groups.Select(g => g.Id)).Concat(state.Folders.Select(f => f.Id)).ToArray();
if (allIds.Any(id => !Guid.TryParseExact(id, "D", out _)) || allIds.Distinct().Count() != allIds.Length) throw new WebDropException(ErrorCode.BadRequest);
foreach (var group in state.Groups) group.Name = WorkspaceName(group.Name);
foreach (var channel in state.Channels)
{
channel.Name = WorkspaceName(channel.Name);
if (channel.GroupId is not null && !state.Groups.Any(g => g.Id == channel.GroupId)) throw new WebDropException(ErrorCode.BadRequest);
}
foreach (var folder in state.Folders)
{
folder.Name = WorkspaceName(folder.Name);
_ = FindChannel(state, folder.ThreadId);
var visited = new HashSet<string> { folder.Id };
for (var parentId = folder.ParentId; parentId is not null;)
{
var parent = state.Folders.Find(f => f.Id == parentId && f.ThreadId == folder.ThreadId);
if (parent is null || !visited.Add(parent.Id)) throw new WebDropException(ErrorCode.BadRequest);
parentId = parent.ParentId;
}
}
if (state.Locations.Select(l => l.ItemId).Distinct().Count() != state.Locations.Count) throw new WebDropException(ErrorCode.BadRequest);
foreach (var location in state.Locations)
{
_ = FindChannel(state, location.ThreadId);
if (location.FolderId is not null && !state.Folders.Any(f => f.Id == location.FolderId && f.ThreadId == location.ThreadId)) throw new WebDropException(ErrorCode.BadRequest);
}
}
}
@@ -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);
}
@@ -1,4 +1,5 @@
using System.Security.Cryptography;
using WebDrop.FileProtocol;
namespace WebDrop.Core;
@@ -11,13 +12,24 @@ public sealed partial class ChannelService(
IBlobStore blobs,
IClock clock,
ServerLimits limits,
IChannelEvents events)
IChannelEvents events,
IFileGrants? fileGrants = null)
{
private enum SeatRule { None, Required }
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, byte[] endpointHash, string creatorAccount, string? accountDeviceId = null)
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);
@@ -66,19 +78,29 @@ public sealed partial class ChannelService(
public ChannelInfo GetChannel(Id16 id, Credentials creds) => store.Read(tx =>
{
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.None);
return ToInfo(channel, tx.CountMembers(id), principal);
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, SeatRule.None);
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
@@ -90,7 +112,7 @@ public sealed partial class ChannelService(
tx.SetJoinPassword(id, hash, salt);
channel = channel with { JoinPasswordHash = hash, JoinPasswordSalt = salt };
}
return ToInfo(channel with { Settings = updated, ExpiresAt = expiresAt }, tx.CountMembers(id));
return ToInfo(channel with { Settings = updated, ExpiresAt = expiresAt }, tx.CountMembers(id), avatarVersion: tx.GetChannelAvatarVersion(id));
});
events.SettingsChanged(id);
return info;
@@ -100,7 +122,7 @@ public sealed partial class ChannelService(
{
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
RequireOwner(principal);
tx.DeleteChannelRows(id);
return 0;
@@ -111,62 +133,27 @@ public sealed partial class ChannelService(
// ───────────────────────── 席位 ─────────────────────────
/// <summary>
/// 加入频道。账号成员只登记成员关系(同一账号的多台设备不增加人数,也不登记终端);
/// 未登录访客登记终端,每个终端按一个访客成员计入上限。
/// </summary>
/// <summary>加入频道。只有已登录的账号能加入:登记成员关系,同一账号的多台设备不增加人数。</summary>
public JoinResult Join(Id16 id, Credentials creds, string? password = null)
{
var (joined, isNew) = store.Write(tx =>
{
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.None);
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 (creds.AccountName is not null)
{
if (tx.GetChannelMember(id, creds.AccountName) 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(creds.AccountName, "member", memberAt));
return (Result(memberAt, membersUsed + 1), true);
}
if (tx.GetSeat(id, principal.EndpointHash) is { } existing) return (Result(existing.JoinedAt, membersUsed), false);
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 now = clock.NowMs;
tx.InsertSeat(id, new Seat(principal.EndpointHash, now));
return (Result(now, membersUsed + 1), true);
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;
}
/// <summary>所有者查看访客(未登录终端)。账号成员按用户管理,其设备不对外列出。</summary>
public IReadOnlyList<GuestInfo> ListGuests(Id16 id, Credentials creds) => store.Read(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
return (IReadOnlyList<GuestInfo>)tx.ListSeats(id).Where(s => s.AccountName is null)
.Select(s => new GuestInfo { EndpointHash = Codec.Encode(s.EndpointHash), JoinedAt = s.JoinedAt }).ToList();
});
/// <summary>所有者移除一个访客终端并禁止它再加入。账号成员改用撤销邀请(RevokeInvitation)。</summary>
public void ReleaseGuest(Id16 id, Credentials creds, byte[] endpointHash)
{
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
if (tx.GetSeat(id, endpointHash) is not { AccountName: null } || !tx.DeleteSeat(id, endpointHash)) throw new WebDropException(ErrorCode.NotFound);
tx.DenyEndpoint(id, endpointHash);
return 0;
});
events.MembersChanged(id);
}
// ───────────────────────── 追加帧 ─────────────────────────
public async Task<AppendResult> AppendFrameAsync(
@@ -182,9 +169,11 @@ public sealed partial class ChannelService(
// 1. 预检:鉴权并处理幂等重发,避免为重复帧白写 blob
var existing = store.Read(tx =>
{
var (channel, principal) = Authenticate(tx, channelId, creds, SeatRule.Required);
var (channel, principal) = Authenticate(tx, channelId, creds, Membership.Required);
CheckWritable(channel, principal, env);
CheckObjectWrite(tx, 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);
@@ -216,6 +205,166 @@ public sealed partial class ChannelService(
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;
@@ -228,9 +377,10 @@ public sealed partial class ChannelService(
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, SeatRule.Required);
var (channel, principal) = Authenticate(tx, channelId, creds, Membership.Required);
CheckWritable(channel, principal, env);
CheckObjectWrite(tx, 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)
@@ -244,7 +394,6 @@ public sealed partial class ChannelService(
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);
bool countsTowardFileQuota = objectEnvelope?.Domain == CryptoDomain.Files && !env.IsControl;
if (signedEvent is not null && (objectEnvelope is null || !env.IsFinal)) throw new WebDropException(ErrorCode.BadRequest);
if (objectEnvelope is not null && env.IsFinal)
{
@@ -291,7 +440,7 @@ public sealed partial class ChannelService(
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.EndpointHash, env.ItemFlags, max, 0, 0, -1, null, 0,
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);
}
@@ -309,14 +458,12 @@ public sealed partial class ChannelService(
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 + body.Length > limits.MaxChannelBytes
|| objectEnvelope is not null && channel.Bytes + body.Length > limits.MaxWorkspaceBytes)
throw new WebDropException(ErrorCode.TooLarge);
if (countsTowardFileQuota && workspaceChannel!.FileBytes + body.Length > limits.MaxWorkspaceChannelBytes)
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, body.Length, digest,
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)
@@ -326,7 +473,8 @@ public sealed partial class ChannelService(
tx.PutWorkspaceCrypto(channelId, crypto);
}
if (!env.IsGated)
// 受限帧和放在文件服务上的帧不进日志(日志里的帧自带内容),只在第 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));
@@ -336,9 +484,9 @@ public sealed partial class ChannelService(
FrameCount = item.FrameCount + 1,
MaxIndex = Math.Max(item.MaxIndex, env.FrameIndex),
FinalIndex = env.IsFinal ? env.FrameIndex : item.FinalIndex,
Bytes = item.Bytes + body.Length,
Bytes = item.Bytes + bodyLength,
});
tx.AddChannelBytes(channelId, body.Length);
tx.AddChannelBytes(channelId, bodyLength);
tx.TouchChannel(channelId, Math.Max(channel.ExpiresAt, now + ttlMs));
long lastSeq = seq;
@@ -368,16 +516,7 @@ public sealed partial class ChannelService(
if (pruned > channel.CompactedThrough) tx.SetCompactedThrough(channelId, pruned);
}
if (countsTowardFileQuota)
{
var latestWorkspace = tx.GetWorkspace(channelId) ?? throw new WebDropException(ErrorCode.NotFound);
var latestChannel = latestWorkspace.Channels.Find(c => c.Id == objectEnvelope!.ChannelId) ?? throw new WebDropException(ErrorCode.NotFound);
latestChannel.FileBytes += body.Length;
tx.PutWorkspace(channelId, latestWorkspace);
}
return new AppendOutcome(new AppendResult { Seq = seq, Created = true }, lastSeq, prefixes,
countsTowardFileQuota && env.IsFinal);
return new AppendOutcome(new AppendResult { Seq = seq, Created = true }, lastSeq, prefixes);
}
private static AppendResult Resolve(StoredFrame existing, byte[] digest) =>
@@ -417,7 +556,7 @@ public sealed partial class ChannelService(
limit = Math.Clamp(limit, 1, 1000);
var (pending, lastSeq) = store.Read(tx =>
{
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.Required);
var (channel, principal) = Authenticate(tx, id, creds, Membership.Required);
if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden);
// 续传点早于已被清理的墓碑:客户端可能漏掉删除事件,必须从 0 全量同步
@@ -459,14 +598,15 @@ public sealed partial class ChannelService(
}
/// <summary>开始拉取条目:鉴权,并按规则计一次下载。<paramref name="fromIndex"/> 为 0 才计数,大于 0 视为续传。</summary>
public void StartItemDownload(Id16 id, Credentials creds, Id16 itemId, uint fromIndex)
public void StartItemDownload(Id16 id, Credentials creds, Id16 itemId, uint fromIndex, bool allowRemote = false)
{
store.Write(tx =>
{
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
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);
@@ -498,7 +638,7 @@ public sealed partial class ChannelService(
maxFrames = Math.Clamp(maxFrames, 1, 64);
var (frames, next, complete) = store.Read(tx =>
{
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
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);
@@ -528,14 +668,17 @@ public sealed partial class ChannelService(
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));
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, SeatRule.Required);
_ = Authenticate(tx, id, creds, Membership.Required);
return tx.GetWorkspaceCrypto(id).Objects.Find(o => o.ItemId == itemId.ToBase64Url());
});
@@ -543,13 +686,15 @@ public sealed partial class ChannelService(
{
var (seq, prefixes) = store.Write(tx =>
{
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.Required);
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());
if (creds.RequireSignedContent && obj is not null)
// 没传完的上传(末帧从未入库)没有已验签的清单,也就不可能有删除事件:作者或空间主可以直接清理,否则它会永久占着空间配额
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
@@ -578,18 +723,6 @@ public sealed partial class ChannelService(
private long DeleteItemCore(IResourceTx tx, Id16 channelId, Item item, TombstoneReason reason, List<string> prefixes)
{
var crypto = tx.GetWorkspaceCrypto(channelId);
var objectEnvelope = crypto.Objects.Find(o => o.ItemId == item.ItemId.ToBase64Url());
if (objectEnvelope?.Domain == CryptoDomain.Files && (item.Flags & FrameFlags.Control) == 0)
{
var workspace = tx.GetWorkspace(channelId);
var workspaceChannel = workspace?.Channels.Find(c => c.Id == objectEnvelope.ChannelId);
if (workspace is not null && workspaceChannel is not null)
{
workspaceChannel.FileBytes = Math.Max(0, workspaceChannel.FileBytes - item.Bytes);
tx.PutWorkspace(channelId, workspace);
}
}
tx.DeleteItemRows(channelId, item.ItemId);
tx.AddChannelBytes(channelId, -item.Bytes);
long seq = tx.NextSeq(channelId);
@@ -602,7 +735,7 @@ public sealed partial class ChannelService(
{
EventId = Codec.Encode(signedEvent.EventIdBytes), Kind = signedEvent.EventKind, FrameCount = signedEvent.FrameCount,
ManifestHash = Convert.ToBase64String(signedEvent.ManifestHash).TrimEnd('=').Replace('+', '-').Replace('/', '_'),
SignerDeviceId = signedEvent.DeviceId,
SignerDeviceId = signedEvent.DeviceId, SignerAccount = signedEvent.Username,
Signature = Convert.ToBase64String(signedEvent.Signature).TrimEnd('=').Replace('+', '-').Replace('/', '_'),
TargetItemId = signedEvent.TargetItemId,
SignerRootPublicKey = signedEvent.RootPublicKey, SignerDevicePublicKey = signedEvent.DevicePublicKey,
@@ -671,22 +804,18 @@ public sealed partial class ChannelService(
// ───────────────────────── 鉴权 ─────────────────────────
private (Channel Channel, Principal Principal) Authenticate(IResourceTx tx, Id16 id, Credentials creds, SeatRule seat)
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 = creds.AccountName is null ? null : tx.GetChannelMember(id, creds.AccountName);
var member = tx.GetChannelMember(id, account);
if (role == Role.Owner && member?.Role != "owner") throw new WebDropException(ErrorCode.AccountRequired);
if (seat == SeatRule.Required && member is null)
{
// 账号成员只凭账号会话鉴权,不再需要终端登记;未登录访客必须先有终端登记
var endpoint = tx.GetSeat(id, creds.EndpointHash);
if (endpoint is null) throw new WebDropException(ErrorCode.NotJoined);
if (endpoint.AccountName is not null) throw new WebDropException(ErrorCode.AccountRequired);
}
return (channel, new Principal(role, creds.EndpointHash, creds.AccountName));
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)
@@ -713,10 +842,24 @@ public sealed partial class ChannelService(
if (!ChannelPolicy.IsOwner(principal)) throw new WebDropException(ErrorCode.Forbidden);
}
private static ChannelInfo ToInfo(Channel channel, int membersUsed, Principal? principal = null) => new()
private static ChannelInfo ToInfo(Channel channel, int membersUsed, Principal? principal = null, string? avatarVersion = null, WorkspaceState? workspace = null)
{
CanUpload = ChannelPolicy.CanWrite(channel, principal ?? new Principal(Role.Owner, [], channel.CreatorAccount)),
CanManageStructure = principal is null || principal.Role == Role.Owner,
// 没有调用者(创建、改设置时)就是所有者本人
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,
@@ -727,5 +870,6 @@ public sealed partial class ChannelService(
LastSeq = channel.LastSeq,
CreatedAt = channel.CreatedAt,
ExpiresAt = channel.ExpiresAt,
};
};
}
}
@@ -52,14 +52,31 @@ 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; }
/// <summary>v5: 账号成员数加匿名访客终端数,不统计账号设备数。</summary>
/// <summary>频道成员数(账号),不统计账号设备数。</summary>
[JsonPropertyName("members_used")] public required int MembersUsed { get; init; }
/// <summary>从该 seq 起的续传有效;0 &lt; from &lt; oldest_seq 的续传会得到 410,需从 0 全量同步。</summary>
@@ -78,13 +95,6 @@ public sealed class JoinResult
[JsonPropertyName("members_max")] public required int MembersMax { get; init; }
}
/// <summary>未登录访客的一次加入(终端)。账号成员按用户管理,不在这里出现。</summary>
public sealed class GuestInfo
{
[JsonPropertyName("endpoint_hash")] public required string EndpointHash { get; init; }
[JsonPropertyName("joined_at")] public required long JoinedAt { get; init; }
}
public sealed class AppendResult
{
[JsonPropertyName("seq")] public required long Seq { get; init; }
@@ -9,7 +9,6 @@ public sealed class ServerLimits
public int MaxFramesPerItem { get; set; } = 65_536;
public long MaxChannelBytes { get; set; } = 1L << 30;
public long MaxWorkspaceBytes { get; set; } = 1L << 40;
public long MaxWorkspaceChannelBytes { get; set; } = 10L << 30;
/// <summary>最大账号成员数。属性名和 info 字段保持旧线格式兼容。</summary>
public int MaxMembers { get; set; } = 64;
public int MaxVisitors { get; set; } = 10_000;
@@ -31,7 +30,6 @@ public static class ServerSettingChoices
public sealed class ChannelSettings
{
[JsonPropertyName("join_policy")] public JoinPolicy JoinPolicy { get; set; } = JoinPolicy.Open;
[JsonPropertyName("allow_guests")] public bool AllowGuests { get; set; } = true;
[JsonPropertyName("name")] public string? Name { get; set; }
/// <summary>成员人数上限;线字段名保留为 seats 以兼容已有频道设置。</summary>
[JsonPropertyName("max_members")] public int MaxMembers { get; set; } = 2;
@@ -56,7 +54,6 @@ public sealed class ChannelSettings
public sealed class ChannelSettingsPatch
{
[JsonPropertyName("join_policy")] public JoinPolicy? JoinPolicy { get; init; }
[JsonPropertyName("allow_guests")] public bool? AllowGuests { 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; }
@@ -66,11 +63,14 @@ public sealed class ChannelSettingsPatch
[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()
{
JoinPolicy = JoinPolicy ?? s.JoinPolicy,
AllowGuests = AllowGuests ?? s.AllowGuests,
Name = Name is null ? s.Name : string.IsNullOrWhiteSpace(Name) ? null : Name.Trim(),
MaxMembers = MaxMembers ?? s.MaxMembers,
MembersCanUpload = MembersCanUpload ?? s.MembersCanUpload,
@@ -118,7 +118,7 @@ public static class CreationDefaults
public static ChannelSettings Channel(ServerLimits limits) => new()
{
JoinPolicy = JoinPolicy.Invite, AllowGuests = false,
JoinPolicy = JoinPolicy.Invite,
MaxMembers = Math.Min(10, limits.MaxMembers),
TtlSeconds = Math.Clamp(86_400, limits.MinTtlSeconds, limits.MaxTtlSeconds),
MaxItems = Math.Min(1000, limits.MaxItemsLimit),
File renamed without changes.
@@ -66,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";
}
@@ -30,6 +30,18 @@ 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;
@@ -22,6 +22,10 @@ public enum ErrorCode
ChannelPasswordRequired,
ChannelPasswordInvalid,
SpaceNameRequired,
/// <summary>这个条目的内容在文件服务上:去要一张票据,直接找文件服务</summary>
UseFileApi,
/// <summary>文件服务暂时连不上或已满</summary>
FilesUnavailable,
}
/// <summary>预期内的业务失败。消息只含错误码,绝不回显用户提交的内容。</summary>
@@ -63,6 +67,8 @@ public static class ErrorCodes
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",
};
@@ -79,6 +85,8 @@ public static class ErrorCodes
ErrorCode.TooLarge => 413,
ErrorCode.CaptchaRequired => 428,
ErrorCode.CaptchaUnavailable => 503,
ErrorCode.FilesUnavailable => 503,
ErrorCode.UseFileApi => 409,
ErrorCode.RateLimited => 429,
ErrorCode.VisitorsFull => 403,
ErrorCode.AccountRequired => 401,
@@ -25,6 +25,15 @@ public readonly struct Id16 : IEquatable<Id16>
}
/// <summary>只接受规范的 22 字符 base64url,拒绝同一值的非规范编码。</summary>
/// <summary>32 个十六进制字符(文件服务和 blob 键里的写法)</summary>
public static bool TryParseHex(string? hex, out Id16 id)
{
id = default;
if (hex is not { Length: 32 }) return false;
try { id = FromBytes(Convert.FromHexString(hex)); return true; }
catch (FormatException) { return false; }
}
public static bool TryParse(ReadOnlySpan<char> text, out Id16 id)
{
id = default;
@@ -82,10 +82,10 @@ public sealed record Item(
public bool IsComplete => FinalIndex is { } f && FrameCount == f + 1;
}
/// <summary>频道内的终端登记(账号设备或访客终端)。设备只是账号的属性,不是成员;不再有单独的设备名。</summary>
public sealed record Seat(byte[] EndpointHash, long JoinedAt, string? AccountName = null, string? AccountDeviceId = null);
public sealed record ChannelInvitation(Id16 ChannelId, string Username, string Payload, long CreatedAt);
public sealed record ChannelMember(string Username, string Role, long JoinedAt);
public sealed record MemberView(ChannelMember Member, bool CanRemove);
public sealed record InvitationView(ChannelInvitation Invitation, bool CanRevoke);
public sealed record ChannelKeyPackage(Id16 ChannelId, string Username, string Role, string Payload, long CreatedAt);
/// <summary>已存储的一帧(完整封装)。Inline 与 BlobKey 二选一。</summary>
@@ -101,13 +101,11 @@ public sealed record StoredFrame(
public sealed record LogEntry(long Seq, LogKind Kind, Id16 ItemId, uint FrameIndex, byte Aux, long CreatedAt);
/// <summary>一次请求验证通过后的身份。</summary>
public sealed record Principal(Role Role, byte[] EndpointHash, string? AccountName = null)
/// <summary>一次请求验证通过后的身份。频道只接受已登录账号。</summary>
public sealed record Principal(Role Role, string AccountName)
{
/// <summary>受限下载的计数单位:账号按用户计(设备只是账号的属性),访客按终端计。</summary>
public byte[] GrantKey => AccountName is null
? EndpointHash
: System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes("webdrop/grant/v1/" + AccountName));
/// <summary>受限下载的计数单位:按用户计(设备只是账号的属性)。</summary>
public byte[] GrantKey => System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes("webdrop/grant/v1/" + AccountName));
}
/// <summary>请求携带的凭据,尚未验证。</summary>
@@ -0,0 +1,27 @@
using WebDrop.FileProtocol;
namespace WebDrop.Core;
/// <summary>给客户端的访问凭证:文件服务地址、文件域 id 和一张短期票据</summary>
public sealed record FileGrant(string Url, string Domain, string Ticket, long ExpiresAt);
/// <summary>
/// 主后端只负责「权限」:判断某人在某个空间里能不能读写某个频道的文件,然后给文件服务(fileAPI)签一张短期票据。
/// 文件域的创建、容量、用量、字节的存取都是文件服务自己的事,主后端既不调用它,也不保存它的任何状态。
/// </summary>
public interface IFileGrants
{
bool Enabled { get; }
/// <summary>文件服务对外的地址,告诉客户端去哪里</summary>
string PublicUrl { get; }
/// <summary>space:签发票据的空间 id(十六进制)。scope:Create 时是空间 id;其余是文件域 id。item 是条目 id(十六进制)或 "*"</summary>
FileGrant Issue(FileOp op, string space, string scope, string item, uint from, long nowMs);
}
public sealed class NoFileGrants : IFileGrants
{
public static readonly NoFileGrants Instance = new();
public bool Enabled => false;
public string PublicUrl => "";
public FileGrant Issue(FileOp op, string space, string scope, string item, uint from, long nowMs) => throw new WebDropException(ErrorCode.NotFound);
}
@@ -355,7 +355,7 @@ public sealed class ShareService(
private static ShareInfo ToInfo(Share share, IReadOnlyList<ShareItem> items) => new()
{
Id = share.Id.ToBase64Url(),
State = share.IsSealed ? "sealed" : "draft",
State = share.IsSealed ? ShareState.Sealed : ShareState.Draft,
ExpiresAt = share.ExpiresAt,
CreatedAt = share.CreatedAt,
SealedAt = share.SealedAt,
@@ -4,7 +4,7 @@ public sealed partial class ChannelService
{
public InvitationCryptoPlan InvitationPlan(Id16 id, Credentials creds, string username) => store.Read(tx =>
{
var (space, principal) = Authenticate(tx, id, creds, SeatRule.None); RequireOwner(principal);
var (space, principal) = Authenticate(tx, id, creds, Membership.None); RequireOwner(principal);
if (tx.GetChannelMember(id, username) is not null || tx.HasInvitation(id, username)) throw new WebDropException(ErrorCode.Conflict);
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
var channelId = workspace.DefaultChannelId ?? throw new WebDropException(ErrorCode.Conflict);
@@ -27,8 +27,7 @@ public sealed partial class ChannelService
}
else
{
var recipients = crypto.Access.Where(a => a.ChannelId == channelId && (domain == CryptoDomain.Chat ? a.ChatRead : a.FilesRead)
&& (a.Username == space.CreatorAccount || tx.GetChannelMember(id, a.Username) is not null || tx.HasInvitation(id, a.Username)))
var recipients = ChannelPolicy.Readers(crypto, channelId, domain).Where(a => (a.Username == space.CreatorAccount || tx.GetChannelMember(id, a.Username) is not null || tx.HasInvitation(id, a.Username)))
.Select(a => a.Username).Append(space.CreatorAccount!).Append(username).Distinct().Order(StringComparer.Ordinal).ToList();
if (recipients.Count > 256) throw new WebDropException(ErrorCode.TooLarge);
batches.Add(new CryptoGrantBatch { Domain = domain, Epoch = currentEpoch + 1, Recipients = recipients, Rotate = true });
@@ -43,7 +42,7 @@ public sealed partial class ChannelService
if (payload is null || payload.Length is < 1 or > 16_384 || envelopes is null) throw new WebDropException(ErrorCode.BadRequest);
store.Write(tx =>
{
var (space, principal) = Authenticate(tx, id, creds, SeatRule.None); RequireOwner(principal);
var (space, principal) = Authenticate(tx, id, creds, Membership.None); RequireOwner(principal);
if (tx.GetChannelMember(id, username) is not null || tx.HasInvitation(id, username)) throw new WebDropException(ErrorCode.Conflict);
if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden);
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
@@ -67,8 +66,7 @@ public sealed partial class ChannelService
}
else
{
var recipients = state.Access.Where(a => a.ChannelId == channelId && (domain == CryptoDomain.Chat ? a.ChatRead : a.FilesRead)
&& (a.Username == space.CreatorAccount || tx.GetChannelMember(id, a.Username) is not null || tx.HasInvitation(id, a.Username)))
var recipients = ChannelPolicy.Readers(state, channelId, domain).Where(a => (a.Username == space.CreatorAccount || tx.GetChannelMember(id, a.Username) is not null || tx.HasInvitation(id, a.Username)))
.Select(a => a.Username).Append(space.CreatorAccount!).Append(username).Distinct().ToHashSet(StringComparer.Ordinal);
if (recipients.Count > 256) throw new WebDropException(ErrorCode.TooLarge);
expected.AddRange(recipients.Select(recipient => (domain, currentEpoch + 1, recipient)));
@@ -111,38 +109,34 @@ public sealed partial class ChannelService
if (crypto.Envelopes.Count == 0 && crypto.Objects.Count == 0 && space.Mode == ChannelMode.Enc) return true;
var item = crypto.Objects.Find(o => o.ItemId == itemId.ToBase64Url());
if (item is null) return false;
if (principal.Role == Role.Owner || principal.AccountName == space.CreatorAccount) return true;
var access = crypto.Access.Find(a => a.ChannelId == item.ChannelId && a.Username == principal.AccountName);
if (access is null || (item.Domain == CryptoDomain.Chat ? !access.ChatRead || item.CreatedRevision < access.ChatFromRevision : !access.FilesRead || item.CreatedRevision < access.FilesFromRevision)) return false;
if (ChannelPolicy.IsSpaceOwner(space, principal)) return true;
if (!ChannelPolicy.CanReadObject(ChannelPolicy.AccessOf(crypto, principal.AccountName, item.ChannelId), item.Domain, item.CreatedRevision)) return false;
if (space.Mode == ChannelMode.Raw) return true;
return crypto.Envelopes.Exists(e => e.ChannelId == item.ChannelId && e.Domain == item.Domain && e.Epoch == item.Epoch && e.Username == principal.AccountName);
}
private static void CheckObjectWrite(IResourceTx tx, Channel space, Principal principal, Envelope envelope)
private static void CheckObjectWrite(IResourceTx tx, Channel space, Principal principal, Id16 itemId)
{
var crypto = tx.GetWorkspaceCrypto(space.Id);
if (space.Mode == ChannelMode.Enc && crypto.Envelopes.Count == 0) return;
if (space.Mode == ChannelMode.Raw && crypto.Access.Count == 0 && crypto.Objects.Count == 0) return;
var item = crypto.Objects.Find(o => o.ItemId == envelope.ItemId.ToBase64Url()) ?? throw new WebDropException(ErrorCode.BadRequest);
if (!ChannelPolicy.GrantsEnforced(space, crypto)) return;
var item = crypto.Objects.Find(o => o.ItemId == itemId.ToBase64Url()) ?? throw new WebDropException(ErrorCode.BadRequest);
if (item.AuthorAccount != principal.AccountName) throw new WebDropException(ErrorCode.Forbidden);
var channel = tx.GetWorkspace(space.Id)?.Channels.Find(c => c.Id == item.ChannelId);
if (space.Mode == ChannelMode.Enc && channel is not null && item.Epoch != (item.Domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch)) throw new WebDropException(ErrorCode.Conflict);
if (principal.Role != Role.Owner)
if (!ChannelPolicy.IsOwner(principal))
{
var access = crypto.Access.Find(a => a.ChannelId == item.ChannelId && a.Username == principal.AccountName);
if (access is null && (space.Mode == ChannelMode.Enc || crypto.Access.Count > 0)
|| access is not null && (item.Domain == CryptoDomain.Chat ? !access.ChatWrite : !access.FilesUpload)) throw new WebDropException(ErrorCode.Forbidden);
var access = ChannelPolicy.AccessOf(crypto, principal.AccountName, item.ChannelId);
if (access is null ? space.Mode == ChannelMode.Enc || crypto.Access.Count > 0 : !ChannelPolicy.CanWriteDomain(access, item.Domain)) throw new WebDropException(ErrorCode.Forbidden);
}
}
public CryptoState ReadCrypto(Id16 id, Credentials creds) => store.Read(tx =>
{
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
var state = tx.GetWorkspaceCrypto(id);
if (principal.Role == Role.Owner || principal.AccountName == space.CreatorAccount) return state;
if (ChannelPolicy.IsSpaceOwner(space, principal)) return state;
var access = state.Access.Where(a => a.Username == principal.AccountName).ToList();
var allowed = access.ToDictionary(a => a.ChannelId);
bool CanRead(string channelId, CryptoDomain domain) => allowed.TryGetValue(channelId, out var grant)
&& (domain == CryptoDomain.Chat ? grant.ChatRead : grant.FilesRead);
bool CanRead(string channelId, CryptoDomain domain) => ChannelPolicy.CanReadDomain(allowed.GetValueOrDefault(channelId), domain);
var envelopes = state.Envelopes.Where(e => e.Username == principal.AccountName && CanRead(e.ChannelId, e.Domain)).ToList();
var epochs = envelopes.Select(e => (e.ChannelId, e.Domain, e.Epoch)).ToHashSet();
return new CryptoState { Envelopes = envelopes,
@@ -154,7 +148,7 @@ public sealed partial class ChannelService
{
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required); RequireOwner(principal);
var (_, principal) = Authenticate(tx, id, creds, Membership.Required); RequireOwner(principal);
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
var channel = workspace.Channels.Find(c => c.Id == req.ChannelId) ?? throw new WebDropException(ErrorCode.NotFound);
if (!Enum.IsDefined(req.Domain) || req.Envelopes is null || req.Envelopes.Count != 1 || req.Epoch != 1) throw new WebDropException(ErrorCode.BadRequest);
@@ -177,16 +171,15 @@ public sealed partial class ChannelService
}
public ObjectEnvelope DeclareObject(Id16 id, Credentials creds, ObjectEnvelope req) => store.Write(tx =>
{
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
if (!ChannelPolicy.CanWrite(space, principal)) throw new WebDropException(ErrorCode.Forbidden);
if (!Id16.TryParse(req.ItemId, out _) || !Enum.IsDefined(req.Domain) || req.KeyEnvelope is null || req.KeyEnvelope.Length > 1024) throw new WebDropException(ErrorCode.BadRequest);
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
var channel = workspace.Channels.Find(c => c.Id == req.ChannelId) ?? throw new WebDropException(ErrorCode.NotFound);
var state = tx.GetWorkspaceCrypto(id);
if (principal.Role != Role.Owner && (space.Mode == ChannelMode.Enc || state.Access.Count > 0))
if (!ChannelPolicy.IsOwner(principal) && (space.Mode == ChannelMode.Enc || state.Access.Count > 0))
{
var access = state.Access.Find(a => a.ChannelId == req.ChannelId && a.Username == principal.AccountName);
if (access is null || req.Domain == CryptoDomain.Chat && !access.ChatWrite || req.Domain == CryptoDomain.Files && !access.FilesUpload) throw new WebDropException(ErrorCode.Forbidden);
if (!ChannelPolicy.CanWriteDomain(ChannelPolicy.AccessOf(state, principal.AccountName, req.ChannelId), req.Domain)) throw new WebDropException(ErrorCode.Forbidden);
}
if (space.Mode == ChannelMode.Enc && req.Epoch != (req.Domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch)) throw new WebDropException(ErrorCode.Conflict);
var existing = state.Objects.Find(o => o.ItemId == req.ItemId);
@@ -202,7 +195,7 @@ public sealed partial class ChannelService
{
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required); RequireOwner(principal);
var (_, principal) = Authenticate(tx, id, creds, Membership.Required); RequireOwner(principal);
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
var channel = workspace.Channels.Find(c => c.Id == channelId) ?? throw new WebDropException(ErrorCode.NotFound);
channel.ChatHistory = chat; channel.FilesHistory = files; workspace.Revision++; tx.PutWorkspace(id, workspace); return 0;
@@ -0,0 +1,256 @@
namespace WebDrop.Core;
public sealed partial class ChannelService
{
public void AccountProfileChanged(string username)
{
foreach (var id in store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs))) events.SettingsChanged(id);
}
/// <summary>两个账号是否在同一个空间里(自己总是可以)。成员资料只对有交集的账号开放</summary>
public bool SharesSpace(string username, string other) => username == other
|| store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs).Any(id => tx.GetChannelMember(id, other) is not null));
public IReadOnlyList<Id16> AccountSpaceIds(string username) => store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs));
public IReadOnlyList<ChannelInfo> AccountSpaces(string username) => store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs).Select(id =>
{
var channel = tx.GetChannel(id)!;
var role = tx.GetChannelMember(id, username)?.Role == "owner" ? Role.Owner : channel.WriteHash is not null ? Role.Write : Role.Read;
return ToInfo(channel, tx.CountMembers(id), new Principal(role, username), tx.GetChannelAvatarVersion(id), tx.GetWorkspace(id));
}).ToList());
private static WorkspaceState NewWorkspace(string username, long now)
{
var id = Guid.NewGuid().ToString();
var state = new WorkspaceState { Revision = 1, DefaultChannelId = id, Channels = [new WorkspaceChannel { Id = id, Name = "常规", CreatedAt = now, CreatedBy = username }] };
EnsureDefaultRole(state, now);
return state;
}
/// <summary>补上默认身份组(只补一次)。读取时只在内存里补,下一次写入才落库</summary>
private static void EnsureDefaultRole(WorkspaceState state, long now)
{
if (!state.RolesSeeded)
{
if (!state.Roles.Any(r => r.Id == WorkspaceRole.DefaultId)) state.Roles.Insert(0, WorkspaceRole.CreateDefault(now));
state.RolesSeeded = true;
}
// Owner 组永远排在最前面
if (!state.Roles.Any(r => r.Id == WorkspaceRole.OwnerId)) state.Roles.Insert(0, WorkspaceRole.CreateOwner(now));
}
public WorkspaceState GetWorkspace(Id16 id, Credentials creds) => store.Read(tx =>
{
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
var state = tx.GetWorkspace(id) ?? new WorkspaceState();
EnsureDefaultRole(state, clock.NowMs);
return ViewFor(tx, space, principal, state);
});
/// <summary>
/// 空间结构按调用者的授权裁剪:没授权的频道、目录和条目位置一律不下发。
/// 读取和所有修改接口的返回值都必须经过它,否则任何一个成员的一次修改(比如发消息后登记条目位置)
/// 都会把整份结构返回给他。
/// </summary>
private WorkspaceState ViewFor(IResourceTx tx, Channel space, Principal principal, WorkspaceState state)
{
if (principal.AccountName != space.CreatorAccount)
{
var crypto = tx.GetWorkspaceCrypto(space.Id);
var grants = crypto.Access.Where(a => a.Username == principal.AccountName).ToList();
var ids = grants.Where(ChannelPolicy.CanSeeChannel).Select(a => a.ChannelId).ToHashSet();
state.Channels.RemoveAll(c => !ids.Contains(c.Id));
state.Folders.RemoveAll(f => !ids.Contains(f.ThreadId));
state.Locations.RemoveAll(l => !Id16.TryParse(l.ItemId, out var itemId) || !CanReadObject(tx, space, principal, itemId));
}
state.Locations = state.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(space.Id, itemId) is { } item && item.ExpiresAt > clock.NowMs).ToList();
return state;
}
private WorkspaceState ChangeWorkspace(Id16 id, Credentials creds, Action<IResourceTx, WorkspaceState, Principal> change, bool requireAccount = true, string? permission = null)
{
var state = store.Write(tx =>
{
var (channel, principal) = Authenticate(tx, id, creds, Membership.Required);
var current = tx.GetWorkspace(id) ?? new WorkspaceState();
EnsureDefaultRole(current, clock.NowMs);
// 被授予对应权限的身份组成员可以代行,不要求频道开放成员上传
bool delegated = permission is not null && !ChannelPolicy.IsOwner(principal) && ChannelPolicy.HasPermission(current, principal, permission);
if (requireAccount && !ChannelPolicy.IsOwner(principal) && !delegated) throw new WebDropException(ErrorCode.Forbidden);
if (!delegated && !ChannelPolicy.CanWrite(channel, principal)) throw new WebDropException(ErrorCode.Forbidden);
current.Locations = current.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(id, itemId) is not null).ToList();
change(tx, current, principal);
ValidateWorkspace(current);
current.Revision++;
tx.PutWorkspace(id, current);
// 先落库完整的结构,再按调用者裁剪后返回
return ViewFor(tx, channel, principal, current);
});
events.SettingsChanged(id);
return state;
}
/// <summary>文件域的引用只是个不透明的字符串(文件服务给的域 id),主后端不解释它,只存下来、原样给成员</summary>
private static string? FileDomainRef(string? value)
{
if (string.IsNullOrEmpty(value)) return null;
if (value.Length > 64 || !value.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_')) throw new WebDropException(ErrorCode.BadRequest);
return value;
}
public (WorkspaceState State, WorkspaceChannel Channel) CreateWorkspaceChannelResult(Id16 id, Credentials creds, string name, string? groupId, string? fileDomain = null)
{
string? domain = FileDomainRef(fileDomain);
WorkspaceChannel? created = null;
var state = ChangeWorkspace(id, creds, (_, workspace, principal) =>
{
created = new WorkspaceChannel { Id = Guid.NewGuid().ToString(), Name = WorkspaceName(name), GroupId = groupId, CreatedAt = clock.NowMs, CreatedBy = principal.AccountName!, FileDomain = domain };
workspace.Channels.Add(created);
});
return (state, created!);
}
public WorkspaceState CreateWorkspaceChannel(Id16 id, Credentials creds, string name, string? groupId) => CreateWorkspaceChannelResult(id, creds, name, groupId).State;
/// <summary>给还没有文件域的频道指定一个(创建频道时没带,或旧频道)。指定后不能改:文件已经存在那里了</summary>
public WorkspaceState SetWorkspaceChannelFileDomain(Id16 id, Credentials creds, string channelId, string fileDomain) => ChangeWorkspace(id, creds, (_, state, _) =>
{
var channel = FindChannel(state, channelId);
string domain = FileDomainRef(fileDomain) ?? throw new WebDropException(ErrorCode.BadRequest);
if (channel.FileDomain is not null && channel.FileDomain != domain) throw new WebDropException(ErrorCode.Conflict);
channel.FileDomain = domain;
}, permission: WorkspacePermission.ManageStructure);
public WorkspaceState RenameWorkspaceChannel(Id16 id, Credentials creds, string channelId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
FindChannel(state, channelId).Name = WorkspaceName(name), permission: WorkspacePermission.ManageStructure);
public WorkspaceState GroupWorkspaceChannel(Id16 id, Credentials creds, string channelId, string? groupId) => ChangeWorkspace(id, creds, (_, state, _) =>
FindChannel(state, channelId).GroupId = groupId, permission: WorkspacePermission.ManageStructure);
public (WorkspaceState State, WorkspaceGroup Group) CreateWorkspaceGroupResult(Id16 id, Credentials creds, string name)
{
WorkspaceGroup? created = null;
var state = ChangeWorkspace(id, creds, (_, workspace, _) =>
{
created = new WorkspaceGroup { Id = Guid.NewGuid().ToString(), Name = WorkspaceName(name), CreatedAt = clock.NowMs };
workspace.Groups.Add(created);
}, permission: WorkspacePermission.ManageStructure);
return (state, created!);
}
public WorkspaceState CreateWorkspaceGroup(Id16 id, Credentials creds, string name) => CreateWorkspaceGroupResult(id, creds, name).State;
public WorkspaceState RenameWorkspaceGroup(Id16 id, Credentials creds, string groupId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
(state.Groups.Find(g => g.Id == groupId) ?? throw new WebDropException(ErrorCode.NotFound)).Name = WorkspaceName(name), permission: WorkspacePermission.ManageStructure);
/// <summary>删除分组:组里的频道回到未分组,频道本身和内容都不动</summary>
public WorkspaceState DeleteWorkspaceGroup(Id16 id, Credentials creds, string groupId) => ChangeWorkspace(id, creds, (_, state, _) =>
{
var group = state.Groups.Find(g => g.Id == groupId) ?? throw new WebDropException(ErrorCode.NotFound);
foreach (var channel in state.Channels.Where(c => c.GroupId == groupId)) channel.GroupId = null;
state.Groups.Remove(group);
}, permission: WorkspacePermission.ManageStructure);
// ───── 身份组(只有空间主能增删改、分配成员) ─────
private static string? RoleLabel(string? value)
{
value = value?.Trim();
if (string.IsNullOrEmpty(value)) return null;
if (value.Length > WorkspacePermission.MaxLabelLength || value.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
return value;
}
private static void ApplyRole(WorkspaceRole role, string name, string? prefix, string? suffix, string? color, IReadOnlyList<string> permissions)
{
if (color is not null && !WorkspacePermission.IsValidColor(color)) throw new WebDropException(ErrorCode.BadRequest);
if (permissions.Any(p => !WorkspacePermission.All.Contains(p))) throw new WebDropException(ErrorCode.BadRequest);
role.Name = WorkspaceName(name); role.Prefix = RoleLabel(prefix); role.Suffix = RoleLabel(suffix); role.Color = color;
role.Permissions = permissions.Distinct().ToList();
}
public (WorkspaceState State, WorkspaceRole Role) CreateWorkspaceRoleResult(Id16 id, Credentials creds, string name, string? prefix, string? suffix, string? color, IReadOnlyList<string> permissions)
{
WorkspaceRole? created = null;
var state = ChangeWorkspace(id, creds, (_, workspace, _) =>
{
created = new WorkspaceRole { Id = Guid.NewGuid().ToString(), Name = "x", CreatedAt = clock.NowMs };
ApplyRole(created, name, prefix, suffix, color, permissions);
workspace.Roles.Add(created);
});
return (state, created!);
}
public WorkspaceState UpdateWorkspaceRole(Id16 id, Credentials creds, string roleId, string name, string? prefix, string? suffix, string? color, IReadOnlyList<string> permissions) =>
ChangeWorkspace(id, creds, (_, state, _) =>
{
var role = FindRole(state, roleId);
// Owner 组的名称和权限固定,只有前后缀和颜色可改
if (role.Id == WorkspaceRole.OwnerId) ApplyRole(role, role.Name, prefix, suffix, color, WorkspacePermission.All);
else ApplyRole(role, name, prefix, suffix, color, permissions);
});
public WorkspaceState DeleteWorkspaceRole(Id16 id, Credentials creds, string roleId) =>
ChangeWorkspace(id, creds, (_, state, _) =>
{
var role = FindRole(state, roleId);
if (role.Id == WorkspaceRole.OwnerId) throw new WebDropException(ErrorCode.BadRequest);
state.Roles.Remove(role);
});
public WorkspaceState SetWorkspaceRoleMembers(Id16 id, Credentials creds, string roleId, IReadOnlyList<string> members) =>
ChangeWorkspace(id, creds, (tx, state, _) =>
{
var role = FindRole(state, roleId);
if (role.Id == WorkspaceRole.OwnerId) throw new WebDropException(ErrorCode.BadRequest);
var distinct = members.Select(m => m.Trim().ToLowerInvariant()).Distinct().ToList();
if (distinct.Count > 256) throw new WebDropException(ErrorCode.BadRequest);
// 只能把已加入空间的账号放进身份组;空间主本来就有全部权限,不必加入
foreach (var member in distinct)
if (tx.GetChannelMember(id, member) is not { } row || row.Role == "owner") throw new WebDropException(ErrorCode.BadRequest);
role.Members = distinct;
});
private static WorkspaceRole FindRole(WorkspaceState state, string id) => state.Roles.Find(r => r.Id == id) ?? throw new WebDropException(ErrorCode.NotFound);
public WorkspaceState CreateWorkspaceFolder(Id16 id, Credentials creds, string threadId, string? parentId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
{
FindChannel(state, threadId);
state.Folders.Add(new WorkspaceFolder { Id = Guid.NewGuid().ToString(), ThreadId = threadId, ParentId = parentId, Name = WorkspaceName(name) });
});
public WorkspaceState RenameWorkspaceFolder(Id16 id, Credentials creds, string folderId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
(state.Folders.Find(f => f.Id == folderId) ?? throw new WebDropException(ErrorCode.NotFound)).Name = WorkspaceName(name));
public WorkspaceState MoveWorkspaceItems(Id16 id, Credentials creds, string[] itemIds, string threadId, string? folderId) => ChangeWorkspace(id, creds, (tx, state, principal) =>
{
if (itemIds.Length is < 1 or > 100 || itemIds.Distinct().Count() != itemIds.Length) throw new WebDropException(ErrorCode.BadRequest);
// 目的频道必须存在,而且成员只能往自己有写入授权的频道放条目
if (!state.Channels.Exists(c => c.Id == threadId)) throw new WebDropException(ErrorCode.NotFound);
var space = tx.GetChannel(id)!; var crypto = tx.GetWorkspaceCrypto(id);
// 与 CheckObjectWrite 同一口径:加密空间或已有授权记录时,必须有目的频道的写入授权
if (!ChannelPolicy.IsSpaceOwner(space, principal) && ChannelPolicy.GrantsEnforced(space, crypto)
&& !ChannelPolicy.CanWriteAnyDomain(ChannelPolicy.AccessOf(crypto, principal.AccountName, threadId))) throw new WebDropException(ErrorCode.Forbidden);
foreach (var itemId in itemIds)
{
if (!Id16.TryParse(itemId, out var parsed)) throw new WebDropException(ErrorCode.BadRequest);
var item = tx.GetItem(id, parsed) ?? throw new WebDropException(ErrorCode.NotFound);
if ((item.Flags & (FrameFlags.Control | FrameFlags.Snapshot)) != 0) throw new WebDropException(ErrorCode.BadRequest);
if (!ChannelPolicy.IsOwner(principal) && item.AuthorAccount != principal.AccountName) throw new WebDropException(ErrorCode.Forbidden);
state.Locations.RemoveAll(l => l.ItemId == itemId);
state.Locations.Add(new WorkspaceLocation { ItemId = itemId, ThreadId = threadId, FolderId = folderId, AuthorAccount = item.AuthorAccount });
}
}, requireAccount: false);
private static WorkspaceChannel FindChannel(WorkspaceState state, string id) => state.Channels.Find(c => c.Id == id) ?? throw new WebDropException(ErrorCode.NotFound);
private static string WorkspaceName(string name)
{
if (string.IsNullOrWhiteSpace(name) || name.Trim().Length > 80 || name.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
return name.Trim();
}
private static void ValidateWorkspace(WorkspaceState state)
{
if (state.Roles is null || state.Roles.Count > 32 || state.Roles.Any(r => r is null || r.Members is null || r.Permissions is null)) throw new WebDropException(ErrorCode.BadRequest);
if (state.Channels is null || state.Groups is null || state.Folders is null || state.Locations is null
|| state.Channels.Count > 256 || state.Groups.Count > 128 || state.Folders.Count > 2048 || state.Locations.Count > 10_000)
throw new WebDropException(ErrorCode.BadRequest);
if (state.Channels.Any(c => c is null) || state.Groups.Any(g => g is null) || state.Folders.Any(f => f is null) || state.Locations.Any(l => l is null)) throw new WebDropException(ErrorCode.BadRequest);
var allIds = state.Channels.Select(c => c.Id).Concat(state.Groups.Select(g => g.Id)).Concat(state.Folders.Select(f => f.Id)).Concat(state.Roles.Select(r => r.Id)).ToArray();
if (allIds.Any(id => !Guid.TryParseExact(id, "D", out _)) || allIds.Distinct().Count() != allIds.Length) throw new WebDropException(ErrorCode.BadRequest);
foreach (var group in state.Groups) group.Name = WorkspaceName(group.Name);
foreach (var channel in state.Channels)
{
channel.Name = WorkspaceName(channel.Name);
if (channel.GroupId is not null && !state.Groups.Any(g => g.Id == channel.GroupId)) throw new WebDropException(ErrorCode.BadRequest);
}
foreach (var folder in state.Folders)
{
folder.Name = WorkspaceName(folder.Name);
_ = FindChannel(state, folder.ThreadId);
var visited = new HashSet<string> { folder.Id };
for (var parentId = folder.ParentId; parentId is not null;)
{
var parent = state.Folders.Find(f => f.Id == parentId && f.ThreadId == folder.ThreadId);
if (parent is null || !visited.Add(parent.Id)) throw new WebDropException(ErrorCode.BadRequest);
parentId = parent.ParentId;
}
}
if (state.Locations.Select(l => l.ItemId).Distinct().Count() != state.Locations.Count) throw new WebDropException(ErrorCode.BadRequest);
foreach (var location in state.Locations)
{
_ = FindChannel(state, location.ThreadId);
if (location.FolderId is not null && !state.Folders.Any(f => f.Id == location.FolderId && f.ThreadId == location.ThreadId)) throw new WebDropException(ErrorCode.BadRequest);
}
}
}
+101
View File
@@ -0,0 +1,101 @@
using System.Text.Json.Serialization;
namespace WebDrop.Core;
public sealed class WorkspaceChannel
{
[JsonPropertyName("chatEpoch")] public int ChatEpoch { get; set; }
[JsonPropertyName("filesEpoch")] public int FilesEpoch { get; set; }
[JsonPropertyName("chatHistory")] public bool ChatHistory { get; set; } = true;
[JsonPropertyName("filesHistory")] public bool FilesHistory { get; set; } = true;
/// <summary>这个频道的文件域在文件服务里的 id。不透明字符串:创建、容量、用量都归文件服务管,这里只是引用。null 表示还没分配</summary>
[JsonPropertyName("fileDomain")] public string? FileDomain { get; set; }
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("name")] public required string Name { get; set; }
[JsonPropertyName("createdAt")] public required long CreatedAt { get; init; }
[JsonPropertyName("createdBy")] public required string CreatedBy { get; init; }
[JsonPropertyName("groupId")] public string? GroupId { get; set; }
}
public sealed class WorkspaceGroup
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("name")] public required string Name { get; set; }
[JsonPropertyName("createdAt")] public required long CreatedAt { get; init; }
}
public sealed class WorkspaceFolder
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("threadId")] public required string ThreadId { get; init; }
[JsonPropertyName("parentId")] public string? ParentId { get; init; }
[JsonPropertyName("name")] public required string Name { get; set; }
}
public sealed class WorkspaceLocation
{
[JsonPropertyName("itemId")] public required string ItemId { get; init; }
[JsonPropertyName("threadId")] public required string ThreadId { get; init; }
[JsonPropertyName("folderId")] public string? FolderId { get; init; }
[JsonPropertyName("authorAccount")] public string? AuthorAccount { get; init; }
}
/// <summary>身份组可授予的权限。空间主拥有全部权限,不属于任何身份组</summary>
public static partial class WorkspacePermission
{
/// <summary>移除成员、撤销邀请</summary>
public const string RemoveMembers = "members.remove";
/// <summary>重命名频道、调整频道所属分组、管理分组(新建频道涉及密钥,仍只有空间主)</summary>
public const string ManageStructure = "structure.manage";
public static readonly IReadOnlyList<string> All = [RemoveMembers, ManageStructure];
/// <summary>前后缀(支持行内 Markdown)的长度上限:界面不限制,这里只防止空间数据被撑大</summary>
public const int MaxLabelLength = 2000;
/// <summary>预设名,或自定义:纯色 "#rrggbb"、渐变 "g:#rrggbb,#rrggbb[,#rrggbb]"(2~5 个色标,从左到右);后面可以接动画效果 "|breathe"(呼吸) "|blink"(闪烁) "|marquee"(跑马灯,仅渐变) "|cycle"(色相循环)</summary>
public static bool IsValidColor(string color) => Colors.Contains(color) || CustomColor().IsMatch(color);
[System.Text.RegularExpressions.GeneratedRegex("^(#[0-9a-fA-F]{6}|g:#[0-9a-fA-F]{6}(,#[0-9a-fA-F]{6}){1,4})(\\|(breathe|blink|marquee|cycle))?$")]
private static partial System.Text.RegularExpressions.Regex CustomColor();
/// <summary>早期版本的预设色名,老数据里可能还有;新的界面只写自定义颜色</summary>
public static readonly IReadOnlyList<string> Colors = ["gray", "blue", "green", "amber", "red", "purple", "sunset", "ocean", "aurora", "candy", "gold", "rainbow"];
}
public sealed class WorkspaceRole
{
/// <summary>空间主所在的「Owner 组」,每个空间都有,id 固定。成员就是空间主本人,权限固定为全部,只能改前后缀和颜色</summary>
public const string OwnerId = "00000000-0000-4000-8000-000000000000";
public static WorkspaceRole CreateOwner(long now) => new()
{
Id = OwnerId, Name = "Owner 组", Suffix = "Owner", Color = "g:#d99a1c,#f2c94c,#c27a0a", CreatedAt = now,
Permissions = [.. WorkspacePermission.All],
};
/// <summary>每个空间都有的默认「管理组」,id 固定,老空间第一次读写时补上</summary>
public const string DefaultId = "00000000-0000-4000-8000-000000000001";
public static WorkspaceRole CreateDefault(long now) => new()
{
Id = DefaultId, Name = "管理组", Suffix = "管理员", Color = "#3b6ea8", CreatedAt = now,
Permissions = [.. WorkspacePermission.All],
};
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("name")] public required string Name { get; set; }
/// <summary>聊天里显示在名字前、后的文字(如「频道主」「管理员」),可只填其一</summary>
[JsonPropertyName("prefix")] public string? Prefix { get; set; }
[JsonPropertyName("suffix")] public string? Suffix { get; set; }
[JsonPropertyName("color")] public string? Color { get; set; }
[JsonPropertyName("permissions")] public List<string> Permissions { get; set; } = [];
[JsonPropertyName("members")] public List<string> Members { get; set; } = [];
[JsonPropertyName("createdAt")] public required long CreatedAt { get; init; }
}
public sealed class WorkspaceState
{
[JsonPropertyName("defaultChannelId")] public string? DefaultChannelId { get; init; }
[JsonPropertyName("revision")] public long Revision { get; set; }
[JsonPropertyName("channels")] public List<WorkspaceChannel> Channels { get; init; } = [];
[JsonPropertyName("groups")] public List<WorkspaceGroup> Groups { get; init; } = [];
/// <summary>旧版本存下来的结构里没有 roles,反序列化后是 null,这里统一当作空列表</summary>
[JsonPropertyName("roles")] public List<WorkspaceRole> Roles { get => _roles ??= []; init => _roles = value; }
private List<WorkspaceRole>? _roles;
/// <summary>默认身份组已经放进来过。之后空间主删掉它不会再自动出现</summary>
[JsonPropertyName("rolesSeeded")] public bool RolesSeeded { get; set; }
[JsonPropertyName("folders")] public List<WorkspaceFolder> Folders { get; init; } = [];
[JsonPropertyName("locations")] public List<WorkspaceLocation> Locations { get; set; } = [];
}
[JsonSerializable(typeof(WorkspaceState))]
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
public sealed partial class WorkspaceJson : JsonSerializerContext;
@@ -30,6 +30,8 @@ public sealed class SignedContentEventRecord
[JsonPropertyName("frameCount")] public required int FrameCount { get; init; }
[JsonPropertyName("manifestHash")] public required string ManifestHash { get; init; }
[JsonPropertyName("signerDeviceId")] public required string SignerDeviceId { get; init; }
/// <summary>签名者的账号。空间主撤回别人的消息时,签名者不是作者;旧记录没有这个字段,按作者处理</summary>
[JsonPropertyName("signerAccount")] public string? SignerAccount { get; init; }
[JsonPropertyName("signature")] public required string Signature { get; init; }
[JsonPropertyName("targetItemId")] public string? TargetItemId { get; init; }
[JsonPropertyName("signerRootPublicKey")] public required string SignerRootPublicKey { get; init; }
@@ -6,4 +6,8 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\WebDrop.FileProtocol\WebDrop.FileProtocol.csproj" />
</ItemGroup>
</Project>
-50
View File
@@ -1,50 +0,0 @@
using System.Text.Json.Serialization;
namespace WebDrop.Core;
public sealed class WorkspaceChannel
{
[JsonPropertyName("chatEpoch")] public int ChatEpoch { get; set; }
[JsonPropertyName("filesEpoch")] public int FilesEpoch { get; set; }
[JsonPropertyName("chatHistory")] public bool ChatHistory { get; set; } = true;
[JsonPropertyName("filesHistory")] public bool FilesHistory { get; set; } = true;
[JsonPropertyName("fileBytes")] public long FileBytes { get; set; }
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("name")] public required string Name { get; set; }
[JsonPropertyName("createdAt")] public required long CreatedAt { get; init; }
[JsonPropertyName("createdBy")] public required string CreatedBy { get; init; }
[JsonPropertyName("groupId")] public string? GroupId { get; set; }
}
public sealed class WorkspaceGroup
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("name")] public required string Name { get; set; }
[JsonPropertyName("createdAt")] public required long CreatedAt { get; init; }
}
public sealed class WorkspaceFolder
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("threadId")] public required string ThreadId { get; init; }
[JsonPropertyName("parentId")] public string? ParentId { get; init; }
[JsonPropertyName("name")] public required string Name { get; set; }
}
public sealed class WorkspaceLocation
{
[JsonPropertyName("itemId")] public required string ItemId { get; init; }
[JsonPropertyName("threadId")] public required string ThreadId { get; init; }
[JsonPropertyName("folderId")] public string? FolderId { get; init; }
[JsonPropertyName("authorAccount")] public string? AuthorAccount { get; init; }
}
public sealed class WorkspaceState
{
[JsonPropertyName("defaultChannelId")] public string? DefaultChannelId { get; init; }
[JsonPropertyName("revision")] public long Revision { get; set; }
[JsonPropertyName("channels")] public List<WorkspaceChannel> Channels { get; init; } = [];
[JsonPropertyName("groups")] public List<WorkspaceGroup> Groups { get; init; } = [];
[JsonPropertyName("folders")] public List<WorkspaceFolder> Folders { get; init; } = [];
[JsonPropertyName("locations")] public List<WorkspaceLocation> Locations { get; set; } = [];
}
[JsonSerializable(typeof(WorkspaceState))]
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
public sealed partial class WorkspaceJson : JsonSerializerContext;
+45
View File
@@ -0,0 +1,45 @@
using System.Security.Cryptography;
using System.Text.Json;
using WebDrop.FileProtocol;
namespace WebDrop.FileApi;
/// <summary>
/// 文件域的登记:一个域一个小 JSON 文件({数据目录}/domains/{id}.json),记录它属于哪个空间、分配了多大。
/// 域 id 是文件服务随机生成的,和主后端的任何 id 无关;用了多少字节由 <see cref="FrameStore"/> 按顶层目录统计。
/// </summary>
public sealed class DomainStore
{
private readonly string _root;
public DomainStore(string root)
{
_root = Path.GetFullPath(root);
Directory.CreateDirectory(_root);
}
private string PathOf(string id) => Path.Combine(_root, id + ".json");
public FileDomainBody Create(string space, long quota, long nowMs)
{
var domain = new FileDomainBody(Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16)), space, quota, 0, nowMs);
string tmp = PathOf(domain.Id) + "." + Guid.NewGuid().ToString("N") + ".tmp";
File.WriteAllText(tmp, JsonSerializer.Serialize(domain, FileProtocolJson.Default.FileDomainBody));
File.Move(tmp, PathOf(domain.Id), overwrite: false);
return domain;
}
public FileDomainBody? Get(string id)
{
if (!FileWire.IsHex32(id)) return null;
try { return JsonSerializer.Deserialize(File.ReadAllText(PathOf(id)), FileProtocolJson.Default.FileDomainBody); }
catch (Exception e) when (e is IOException or JsonException) { return null; }
}
public bool Delete(string id)
{
if (!FileWire.IsHex32(id) || !File.Exists(PathOf(id))) return false;
File.Delete(PathOf(id));
return true;
}
}
+177
View File
@@ -0,0 +1,177 @@
using System.Security.Cryptography;
using System.Text;
using WebDrop.FileProtocol;
namespace WebDrop.FileApi;
public static class FileApiApp
{
public static WebApplication Build(string[] args, Action<WebApplicationBuilder>? configure = null)
{
var builder = WebApplication.CreateSlimBuilder(args);
configure?.Invoke(builder);
var options = builder.Configuration.GetSection("FileApi").Get<FileApiOptions>() ?? new FileApiOptions();
if (options.Secret.Length < 32) throw new InvalidOperationException("FileApi:Secret 至少 32 个字符(和主后端的 WebDrop:Files:Secret 一致)");
if (Array.IndexOf(options.EffectiveChoices, options.DefaultQuota) < 0) throw new InvalidOperationException("FileApi:DefaultQuota 必须是 FileApi:QuotaChoices 里的一档");
builder.WebHost.ConfigureKestrel(k =>
{
k.AddServerHeader = false;
k.Limits.MaxRequestBodySize = options.MaxFrameBytes;
});
builder.Services.AddSingleton(options);
builder.Services.AddSingleton(new FrameStore(Path.Combine(options.DataDir, "frames"), options.MaxStoreBytes, Path.Combine(options.DataDir, "sync")));
var domains = new DomainStore(Path.Combine(options.DataDir, "domains"));
builder.Services.AddSingleton(domains);
builder.Services.AddHostedService<PendingSweeper>();
builder.Services.AddSingleton<SyncWorker>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<SyncWorker>());
if (options.CorsOrigins.Length > 0)
{
builder.Services.AddCors(c => c.AddDefaultPolicy(p => p
.WithOrigins(options.CorsOrigins)
.WithMethods("GET", "PUT", "POST", "DELETE", "OPTIONS")
.WithHeaders(FileWire.TicketHeader, "Content-Type")
.WithExposedHeaders("Retry-After")
.SetPreflightMaxAge(TimeSpan.FromHours(1))));
}
var app = builder.Build();
app.Use(async (ctx, next) =>
{
var h = ctx.Response.Headers;
h["Referrer-Policy"] = "no-referrer";
h["X-Content-Type-Options"] = "nosniff";
h["Cache-Control"] = "no-store";
h["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'";
try { await next(ctx); }
catch (OperationCanceledException) when (ctx.RequestAborted.IsCancellationRequested) { }
catch (BadHttpRequestException e)
{
if (!ctx.Response.HasStarted) { ctx.Response.StatusCode = e.StatusCode; await ctx.Response.WriteAsync("{\"error\":\"bad_request\"}"); }
}
});
if (options.CorsOrigins.Length > 0) app.UseCors();
Map(app, options, app.Services.GetRequiredService<FrameStore>(), domains);
return app;
}
private static void Map(WebApplication app, FileApiOptions options, FrameStore store, DomainStore domains)
{
byte[] secret = Encoding.UTF8.GetBytes(options.Secret);
// 校验票据,规则只在这里:文件域、条目必须和路径一致;票据必须是这个域所属的空间签发的;
// 读取、删除的票据只能用一次(下载次数在主后端签票据时计,重放同一张票据等于绕过计次);管理票据(运维用)对任何条目有效
var usedTickets = new UsedTickets();
FileTicket? Authorize(HttpContext ctx, FileOp op, string channel, string? item)
{
if (!FileTokens.TryVerify(secret, ctx.Request.Headers[FileWire.TicketHeader].ToString(), DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), out FileTicket ticket)) return null;
if (ticket.Op == FileOp.Admin) return ticket;
if (!(ticket.Op == op && ticket.Channel == channel && (item is null || ticket.Item == item))) return null;
// 票据是哪个空间签的,必须就是域所属的那个空间(别的空间即使拿到域 id 也用不了)
if (domains.Get(channel) is not { } domain || domain.Space != ticket.Space) return null;
if (op is FileOp.Get or FileOp.Delete && !usedTickets.TryUse(ticket.Jti, ticket.ExpiresMs)) return null;
return ticket;
}
IResult Error(int status, string code) => Results.Text($"{{\"error\":\"{code}\"}}", "application/json", Encoding.UTF8, status);
app.MapGet("/healthz", () => Results.Text("ok"));
// 文件域的选项(创建时可选的容量档位):不含任何机密,谁都能读,前端创建文件域前来看
app.MapGet("/file/v1/options", () => Results.Text(System.Text.Json.JsonSerializer.Serialize(options.Domain, FileProtocolJson.Default.FileDomainOptions), "application/json"));
app.MapGet("/file/v1/info", (HttpContext ctx) =>
FileTokens.TryVerify(secret, ctx.Request.Headers[FileWire.TicketHeader].ToString(), DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), out FileTicket t) && t.Op == FileOp.Admin
? Results.Text($"{{\"used_bytes\":{store.UsedBytes},\"free_bytes\":{store.FreeBytes}}}", "application/json")
: Error(401, "unauthorized"));
// 存一帧:请求体是完整的帧封装。域必须存在,写入受域的容量限制。返回这一帧的摘要,客户端拿它去主后端登记
app.MapPut("/file/v1/domains/{id}/items/{item}/frames/{index:long}", async (string id, string item, long index, HttpContext ctx, CancellationToken ct) =>
{
if (!FileWire.IsHex32(id) || !FileWire.IsHex32(item) || index is < 0 or > uint.MaxValue) return Error(400, "bad_request");
if (Authorize(ctx, FileOp.Put, id, item) is null) return Error(401, "unauthorized");
if (domains.Get(id) is not { } domain) return Error(404, "not_found");
if (ctx.Request.ContentLength is not { } length || length < FrameHeader.Size || length > options.MaxFrameBytes) return Error(413, "too_large");
byte[] frame = new byte[length];
await ctx.Request.Body.ReadExactlyAsync(frame, ct).ConfigureAwait(false);
if (!FrameHeader.TryParse(frame, out var header) || header.TotalLength != frame.Length || header.ItemHex != item || header.Index != index)
return Error(400, "bad_request");
byte[] digest = SHA256.HashData(frame);
switch (await store.PutAsync(id, item, (uint)index, frame, digest, ct, domain.Quota).ConfigureAwait(false))
{
case PutResult.Conflict: return Error(409, "conflict");
case PutResult.Full: return Error(507, "store_full");
case PutResult.OverQuota: return Error(507, "quota_exceeded");
}
return Results.Text($"{{\"digest\":\"{Convert.ToHexStringLower(digest)}\",\"length\":{frame.Length}}}", "application/json", Encoding.UTF8, 200);
});
// 读取:自 from 起把连续的帧原样顺序写出
app.MapGet("/file/v1/domains/{id}/items/{item}/frames", async (string id, string item, HttpContext ctx, CancellationToken ct) =>
{
if (!FileWire.IsHex32(id) || !FileWire.IsHex32(item)) return Error(400, "bad_request");
var ticket = Authorize(ctx, FileOp.Get, id, item);
if (ticket is null) return Error(401, "unauthorized");
if (!uint.TryParse(ctx.Request.Query["from"], out uint from)) from = ticket.From;
if (ticket.Op != FileOp.Admin && from < ticket.From) return Error(403, "forbidden");
ctx.Response.ContentType = "application/octet-stream";
bool any = false;
foreach (var path in store.FramesFrom(id, item, from))
{
any = true;
await ctx.Response.SendFileAsync(path, ct).ConfigureAwait(false);
}
return any ? Results.Empty : Error(404, "not_found");
});
// 删一个条目的所有帧:删除票据(主后端确认调用者有权删除之后签发),或者上传这个条目的票据(取消上传时清掉自己传的字节)
app.MapDelete("/file/v1/domains/{id}/items/{item}", (string id, string item, HttpContext ctx) =>
{
if (!FileWire.IsHex32(id) || !FileWire.IsHex32(item)) return Error(400, "bad_request");
if (Authorize(ctx, FileOp.Delete, id, item) is null && Authorize(ctx, FileOp.Put, id, item) is null) return Error(401, "unauthorized");
return store.DeletePrefix($"{id}/{item}") ? Results.NoContent() : Error(400, "bad_request");
});
// 确认一个条目:整个文件已经登记到主后端。持上传这个条目的票据
app.MapPost("/file/v1/domains/{id}/items/{item}/commit", (string id, string item, HttpContext ctx) =>
{
if (!FileWire.IsHex32(id) || !FileWire.IsHex32(item)) return Error(400, "bad_request");
if (Authorize(ctx, FileOp.Put, id, item) is null) return Error(401, "unauthorized");
return store.Commit(id, item) ? Results.NoContent() : Error(404, "not_found");
});
// ───── 文件域:创建、查询、删除。前端带着空间签发的票据直接来这里
// 创建:票据必须是 Create(空间的所有者/管理员向主后端申请的),Channel 字段是空间 id
app.MapPost("/file/v1/domains", async (HttpContext ctx, CancellationToken ct) =>
{
if (!FileTokens.TryVerify(secret, ctx.Request.Headers[FileWire.TicketHeader].ToString(), DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), out FileTicket ticket)
|| ticket.Op is not (FileOp.Create or FileOp.Admin)) return Error(401, "unauthorized");
if (!FileWire.IsHex32(ticket.Channel)) return Error(400, "bad_request");
var request = ctx.Request.ContentLength is 0 ? new FileDomainCreate() : await ctx.Request.ReadFromJsonAsync(FileProtocolJson.Default.FileDomainCreate, ct).ConfigureAwait(false) ?? new FileDomainCreate();
long quota = request.Quota == 0 ? options.DefaultQuota : request.Quota;
if (Array.IndexOf(options.EffectiveChoices, quota) < 0) return Error(400, "bad_quota");
var created = domains.Create(ticket.Channel, quota, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
return Results.Text(System.Text.Json.JsonSerializer.Serialize(created, FileProtocolJson.Default.FileDomainBody), "application/json", Encoding.UTF8, 201);
});
// 查询:持有这个域的读、写或管理票据都可以
app.MapGet("/file/v1/domains/{id}", (string id, HttpContext ctx) =>
{
if (!FileWire.IsHex32(id)) return Error(400, "bad_request");
if (Authorize(ctx, FileOp.Get, id, null) is null && Authorize(ctx, FileOp.Put, id, null) is null) return Error(401, "unauthorized");
if (domains.Get(id) is not { } domain) return Error(404, "not_found");
return Results.Text(System.Text.Json.JsonSerializer.Serialize(domain with { Used = store.UsedBy(id) }, FileProtocolJson.Default.FileDomainBody), "application/json");
});
// 删除域和里面所有文件:只有管理票据(运维用)
app.MapDelete("/file/v1/domains/{id}", (string id, HttpContext ctx) =>
{
if (!FileWire.IsHex32(id)) return Error(400, "bad_request");
if (Authorize(ctx, FileOp.Admin, "*", null) is null) return Error(401, "unauthorized");
if (!domains.Delete(id)) return Error(404, "not_found");
store.DeletePrefix(id);
return Results.NoContent();
});
}
}
@@ -0,0 +1,38 @@
using WebDrop.FileProtocol;
using WebDrop.Sync;
namespace WebDrop.FileApi;
/// <summary>配置节 "FileApi"。环境变量形式:FileApi__DataDir、FileApi__Secret ……</summary>
public sealed class FileApiOptions
{
public string DataDir { get; set; } = "files-data";
/// <summary>和主后端共用的密钥(主后端的 WebDrop:Files:Secret),至少 32 个字符</summary>
public string Secret { get; set; } = "";
/// <summary>前端所在的源;浏览器直接把文件传给这里,必须列出,否则不发 CORS 头</summary>
public string[] CorsOrigins { get; set; } = [];
/// <summary>创建频道时可以分配的文件空间档位(字节);不配置用内置的 1/5/10/50/100 GiB。默认值不放在数组里:配置绑定会把数组按下标合并进默认值</summary>
public long[] QuotaChoices { get; set; } = [];
/// <summary>创建频道时不选择的默认文件空间,必须是档位之一</summary>
public long DefaultQuota { get; set; } = 10L << 30;
public FileDomainOptions Domain => new(EffectiveChoices, DefaultQuota);
public long[] EffectiveChoices => QuotaChoices.Length == 0 ? FileDomainOptions.Fallback.QuotaChoices : QuotaChoices.Where(n => n > 0).Distinct().Order().ToArray();
/// <summary>上传了字节、却一直没有被确认(没有登记到主后端)的条目,过多少小时删掉。默认 24</summary>
public int PendingRetentionHours { get; set; } = 24;
/// <summary>磁盘上最多存这么多字节,0 表示不限。满了拒绝新的帧(507),已有的不受影响</summary>
public long MaxStoreBytes { get; set; }
/// <summary>一帧的最大字节数:26 字节头部 + 最大负载(1 MiB + 16 字节标签)</summary>
public int MaxFrameBytes { get; set; } = 26 + (1 << 20) + 16;
/// <summary>每天把新增的文件帧同步到外部存储(S3 协议)。已删除的内容在备份里再留 RetainDays 天</summary>
public DailyBackupOptions Backup { get; set; } = new() { Time = "04:30" };
}
+233
View File
@@ -0,0 +1,233 @@
using System.Security.Cryptography;
using WebDrop.FileProtocol;
namespace WebDrop.FileApi;
public enum PutResult { Created, Existing, Conflict, Full, OverQuota }
/// <summary>
/// 文件帧的磁盘存储:{根}/{频道}/{条目}/{帧序号}。一帧一个文件,先写临时文件再改名,读者看不到半个文件。
/// 同一个序号只能存一份内容:再传同样的内容是幂等的,不同的内容拒绝(帧一旦存下就不可变)。
/// </summary>
public sealed class FrameStore
{
private readonly string _root;
private readonly string _syncRoot;
private readonly long _maxBytes;
private long _used;
/// <param name="syncRoot">同步状态:每个已经同步到外部存储的帧在这里有一个空的标记文件,不放在帧目录里(那里的内容就是帧本身)</param>
public FrameStore(string root, long maxBytes, string? syncRoot = null)
{
_root = Path.GetFullPath(root);
_syncRoot = Path.GetFullPath(syncRoot ?? _root + ".sync");
_maxBytes = maxBytes;
Directory.CreateDirectory(_root);
Directory.CreateDirectory(_syncRoot);
_used = Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories).Where(p => !p.EndsWith(".tmp", StringComparison.Ordinal)).Sum(p => new FileInfo(p).Length);
}
public long UsedBytes => Interlocked.Read(ref _used);
// 每个顶层目录(文件域)已用的字节数,第一次用到时扫一遍磁盘,之后随写入、删除增减
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long[]> _usedBy = new();
private long[] CounterOf(string channelHex) => _usedBy.GetOrAdd(channelHex, key =>
{
string dir = Path.Combine(_root, key);
return [Directory.Exists(dir) ? Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories).Where(p => !p.EndsWith(".tmp", StringComparison.Ordinal)).Sum(p => new FileInfo(p).Length) : 0];
});
/// <summary>一个文件域(顶层目录)已经存了多少字节</summary>
public long UsedBy(string channelHex) => Interlocked.Read(ref CounterOf(channelHex)[0]);
public long FreeBytes
{
get
{
try { return new DriveInfo(Path.GetPathRoot(_root)!).AvailableFreeSpace; }
catch (Exception) { return -1; }
}
}
/// <summary>
/// 新条目第一帧落盘时,在条目目录里放一个「待确认」标记。客户端把整个文件登记到主后端之后再来确认(Commit),标记才消失;
/// 一直没确认的(上传到一半放弃、浏览器关了)过了保留期由清理任务删掉,不会永远占着文件域的容量。
/// 在这个机制之前存下的条目没有标记,永远不会被清理。
/// </summary>
private const string PendingMarker = ".pending";
private string PathOf(string channelHex, string itemHex, uint index) => Path.Combine(_root, channelHex, itemHex, index.ToString("D10"));
/// <param name="quota">这个顶层目录(文件域)最多存多少字节,0 表示不限</param>
public async Task<PutResult> PutAsync(string channelHex, string itemHex, uint index, ReadOnlyMemory<byte> frame, byte[] digest, CancellationToken ct, long quota = 0)
{
string path = PathOf(channelHex, itemHex, index);
if (File.Exists(path)) return SameContent(path, digest) ? PutResult.Existing : PutResult.Conflict;
if (_maxBytes > 0 && UsedBytes + frame.Length > _maxBytes) return PutResult.Full;
if (quota > 0 && UsedBy(channelHex) + frame.Length > quota) return PutResult.OverQuota;
string itemDir = Path.GetDirectoryName(path)!;
bool fresh = !Directory.Exists(itemDir);
Directory.CreateDirectory(itemDir);
if (fresh) File.WriteAllBytes(Path.Combine(itemDir, PendingMarker), []);
string tmp = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
await using (var fs = new FileStream(tmp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.Asynchronous))
await fs.WriteAsync(frame, ct).ConfigureAwait(false);
try
{
File.Move(tmp, path, overwrite: false);
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException && File.Exists(path))
{
File.Delete(tmp); // 并发写入同一帧,对方先到了
return SameContent(path, digest) ? PutResult.Existing : PutResult.Conflict;
}
Interlocked.Add(ref _used, frame.Length);
Interlocked.Add(ref CounterOf(channelHex)[0], frame.Length);
return PutResult.Created;
}
private static bool SameContent(string path, byte[] digest)
{
using var fs = File.OpenRead(path);
return CryptographicOperations.FixedTimeEquals(SHA256.HashData(fs), digest);
}
/// <summary>确认一个条目:已经登记到主后端,不再是待确认。条目不存在返回 false;没有标记(旧条目)也算成功</summary>
public bool Commit(string channelHex, string itemHex)
{
string dir = Path.Combine(_root, channelHex, itemHex);
if (!Directory.Exists(dir)) return false;
File.Delete(Path.Combine(dir, PendingMarker));
return true;
}
/// <summary>一直待确认、最后一次写入早于 olderThan 的条目(频道/条目)</summary>
public IEnumerable<string> StalePending(TimeSpan olderThan)
{
var cutoff = DateTime.UtcNow - olderThan;
foreach (var channelDir in Directory.EnumerateDirectories(_root))
{
foreach (var itemDir in Directory.EnumerateDirectories(channelDir))
{
if (!File.Exists(Path.Combine(itemDir, PendingMarker))) continue;
var newest = Directory.EnumerateFiles(itemDir).Select(File.GetLastWriteTimeUtc).DefaultIfEmpty(DateTime.MinValue).Max();
if (newest < cutoff) yield return $"{Path.GetFileName(channelDir)}/{Path.GetFileName(itemDir)}";
}
}
}
/// <summary>自 from 起连续存在的帧(遇到缺口就停)。调用方负责按需读取,这里只给文件路径。</summary>
public IEnumerable<string> FramesFrom(string channelHex, string itemHex, uint from)
{
for (uint index = from; ; index++)
{
string path = PathOf(channelHex, itemHex, index);
if (!File.Exists(path)) yield break;
yield return path;
if (index == uint.MaxValue) yield break;
}
}
/// <summary>前缀是 {频道} 或 {频道}/{条目}</summary>
public bool DeletePrefix(string prefix)
{
var parts = prefix.Split('/');
if (parts.Length is < 1 or > 2 || parts.Any(p => !FileWire.IsHex32(p))) return false;
string dir = Path.Combine(_root, Path.Combine(parts));
if (!Directory.Exists(dir)) return true;
// 这个域的用量计数第一次用到时要扫磁盘:必须在删除之前扫,否则删掉的字节会被先排除、再被减一次
var counter = CounterOf(parts[0]);
long size = Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories).Where(p => !p.EndsWith(".tmp", StringComparison.Ordinal)).Sum(p => new FileInfo(p).Length);
Directory.Delete(dir, recursive: true);
Interlocked.Add(ref _used, -size);
if (parts.Length == 1) _usedBy.TryRemove(parts[0], out _); else Interlocked.Add(ref counter[0], -size);
ForgetSynced(prefix, parts);
if (parts.Length == 2) TryRemoveEmpty(Path.Combine(_root, parts[0]));
return true;
}
private string TombstoneLog => Path.Combine(_syncRoot, "deleted.log");
private readonly object _syncGate = new();
/// <summary>删除了的内容,如果已经同步到外部存储,记下删除时间,过了保留期再从外部存储清理</summary>
private void ForgetSynced(string prefix, string[] parts)
{
string markers = Path.Combine(_syncRoot, Path.Combine(parts));
if (!Directory.Exists(markers)) return;
lock (_syncGate)
{
File.AppendAllText(TombstoneLog, $"{prefix}\t{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}\n");
Directory.Delete(markers, recursive: true);
}
}
/// <summary>还没同步到外部存储的帧:(外部存储里的相对路径, 本地文件路径)</summary>
public IEnumerable<(string Key, string Path)> Unsynced()
{
foreach (var channelDir in Directory.EnumerateDirectories(_root))
{
string channel = Path.GetFileName(channelDir);
foreach (var itemDir in Directory.EnumerateDirectories(channelDir))
{
string item = Path.GetFileName(itemDir);
if (File.Exists(Path.Combine(itemDir, PendingMarker))) continue; // 还没确认的不同步
foreach (var file in Directory.EnumerateFiles(itemDir).Order(StringComparer.Ordinal))
{
if (file.EndsWith(".tmp", StringComparison.Ordinal) || Path.GetFileName(file).StartsWith('.')) continue;
string key = $"{channel}/{item}/{Path.GetFileName(file)}";
if (!File.Exists(Path.Combine(_syncRoot, channel, item, Path.GetFileName(file)))) yield return (key, file);
}
}
}
}
public void MarkSynced(string key)
{
string marker = Path.Combine(_syncRoot, key.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(marker)!);
File.WriteAllBytes(marker, []);
}
/// <summary>保留期已过的删除记录。返回前缀;处理完调用 <see cref="RemoveTombstones"/></summary>
public IReadOnlyList<string> ExpiredTombstones(TimeSpan retain)
{
lock (_syncGate)
{
if (!File.Exists(TombstoneLog)) return [];
long cutoff = DateTimeOffset.UtcNow.Add(-retain).ToUnixTimeMilliseconds();
return File.ReadAllLines(TombstoneLog).Select(l => l.Split('\t')).Where(p => p.Length == 2 && long.TryParse(p[1], out var at) && at <= cutoff).Select(p => p[0]).ToList();
}
}
public void RemoveTombstones(IEnumerable<string> prefixes)
{
var done = prefixes.ToHashSet();
lock (_syncGate)
{
if (!File.Exists(TombstoneLog)) return;
File.WriteAllLines(TombstoneLog, File.ReadAllLines(TombstoneLog).Where(l => !done.Contains(l.Split('\t')[0])));
}
}
private static void TryRemoveEmpty(string dir)
{
try { if (Directory.Exists(dir) && !Directory.EnumerateFileSystemEntries(dir).Any()) Directory.Delete(dir); }
catch (IOException) { /* 并发写入时目录又有内容了 */ }
}
/// <summary>最后一次写入早于 olderThan 的条目(频道/条目)。对账用,正在上传的条目不会出现在里面</summary>
public IEnumerable<string> ItemsOlderThan(TimeSpan olderThan)
{
var cutoff = DateTime.UtcNow - olderThan;
foreach (var channelDir in Directory.EnumerateDirectories(_root))
{
foreach (var itemDir in Directory.EnumerateDirectories(channelDir))
{
var newest = Directory.EnumerateFiles(itemDir).Select(File.GetLastWriteTimeUtc).DefaultIfEmpty(DateTime.MinValue).Max();
if (newest < cutoff) yield return $"{Path.GetFileName(channelDir)}/{Path.GetFileName(itemDir)}";
}
}
}
}
@@ -0,0 +1,28 @@
namespace WebDrop.FileApi;
/// <summary>
/// 清理一直没被确认的条目:字节传进来了,却没有登记到主后端(上传到一半放弃、关掉了浏览器)。
/// 它们会占着文件域的容量,过了保留期就删。已确认的条目、以及没有待确认标记的旧条目不受影响。
/// </summary>
public sealed class PendingSweeper(FileApiOptions options, FrameStore store, ILogger<PendingSweeper> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(30));
do
{
try { SweepOnce(); }
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { logger.LogWarning("清理待确认条目失败:{Type}", e.GetType().Name); }
}
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
}
public int SweepOnce()
{
int removed = 0;
foreach (var prefix in store.StalePending(TimeSpan.FromHours(Math.Max(1, options.PendingRetentionHours))).ToList())
if (store.DeletePrefix(prefix)) removed++;
if (removed > 0) logger.LogInformation("清理了 {Count} 个一直没有确认的上传", removed);
return removed;
}
}
+4
View File
@@ -0,0 +1,4 @@
using WebDrop.FileApi;
var app = FileApiApp.Build(args);
app.Run();
+70
View File
@@ -0,0 +1,70 @@
using WebDrop.FileProtocol;
using WebDrop.Sync;
namespace WebDrop.FileApi;
public sealed record SyncSummary(int Uploaded, long UploadedBytes, int Failed, int Purged);
/// <summary>
/// 每天定时把新增的文件帧同步到外部存储(S3 协议);删除了的内容在外部存储里再留 RetainDays 天才清理。
/// 帧的文件名里就是 频道/条目/序号,一帧一个远端文件,重复上传同样内容是幂等的,中途失败下次接着传。
/// </summary>
public sealed class SyncWorker(FileApiOptions options, FrameStore store, ILogger<SyncWorker> logger) : BackgroundService
{
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(30) };
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var backup = options.Backup;
if (!backup.Enabled || !backup.S3.Configured) return;
while (!stoppingToken.IsCancellationRequested)
{
var wait = DailySchedule.Next(DateTime.Now, backup.TimeOfDay) - DateTime.Now;
try { await Task.Delay(wait, stoppingToken).ConfigureAwait(false); } catch (OperationCanceledException) { break; }
try { await RunOnceAsync(stoppingToken).ConfigureAwait(false); }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
catch (Exception e) { logger.LogWarning("文件同步失败:{Type}", e.GetType().Name); }
}
}
public async Task<SyncSummary> RunOnceAsync(CancellationToken ct)
{
var backup = options.Backup;
var s3 = new S3Client(_http, backup.S3);
var throttle = new Throttle(backup.MaxBytesPerSecond);
int uploaded = 0, failed = 0, purged = 0; long bytes = 0;
foreach (var (key, path) in store.Unsynced())
{
ct.ThrowIfCancellationRequested();
var started = DateTime.UtcNow;
try
{
await using var file = File.OpenRead(path);
long length = file.Length; // 上传结束后流已经被释放
await s3.PutAsync($"files/{key}", file, ct).ConfigureAwait(false);
store.MarkSynced(key);
uploaded++; bytes += length;
await throttle.AfterAsync(length, DateTime.UtcNow - started, ct).ConfigureAwait(false);
}
catch (Exception e) when (e is HttpRequestException or IOException)
{
// 外部存储连不上或这一帧读不了(正好被删):记一笔,连续失败几次就等下一次再来
if (++failed >= 5) break;
}
}
// 保留期已过的删除记录:从外部存储清掉
var expired = store.ExpiredTombstones(TimeSpan.FromDays(Math.Max(0, backup.RetainDays)));
var cleaned = new List<string>();
foreach (var prefix in expired)
{
try { await s3.DeleteAsync($"files/{prefix}", ct).ConfigureAwait(false); cleaned.Add(prefix); purged++; }
catch (HttpRequestException) { break; }
}
store.RemoveTombstones(cleaned);
if (uploaded + purged + failed > 0) logger.LogInformation("文件同步:上传 {Uploaded} 帧({Bytes} 字节),失败 {Failed},清理 {Purged}", uploaded, bytes, failed, purged);
return new SyncSummary(uploaded, bytes, failed, purged);
}
}
+26
View File
@@ -0,0 +1,26 @@
using System.Collections.Concurrent;
namespace WebDrop.FileApi;
/// <summary>
/// 记下用过的一次性票据(按票据编号),过期后随清理丢掉。放在内存里:重启后在票据有效期内理论上能重放一次,
/// 但票据有效期只有几分钟,而且下载次数的计数在主后端,代价可以接受。
/// </summary>
public sealed class UsedTickets
{
private readonly ConcurrentDictionary<string, long> _used = new();
private long _lastPurge;
/// <summary>第一次用返回 true;同一个编号再用返回 false。编号为空的票据(旧格式)不被接受</summary>
public bool TryUse(string jti, long expiresMs)
{
if (jti.Length == 0) return false;
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (now - Interlocked.Read(ref _lastPurge) > 60_000)
{
Interlocked.Exchange(ref _lastPurge, now);
foreach (var (key, expiry) in _used) if (expiry < now) _used.TryRemove(key, out _);
}
return _used.TryAdd(jti, expiresMs);
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<!-- 文件服务:只保存文件帧的密文,按票据收发。部署在家宽,和主后端(WebDrop.Api)分开,只共用 WebDrop.FileProtocol -->
<PropertyGroup>
<PublishAot>true</PublishAot>
<StripSymbols>true</StripSymbols>
<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<AssemblyName>webdrop-files</AssemblyName>
<RootNamespace>WebDrop.FileApi</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\WebDrop.FileProtocol\WebDrop.FileProtocol.csproj" />
<ProjectReference Include="..\WebDrop.Sync\WebDrop.Sync.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,16 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.Hosting.Diagnostics": "None"
}
},
"FileApi": {
"DataDir": "files-data",
"Secret": "",
"CorsOrigins": [],
"MaxStoreBytes": 0,
"MaxFrameBytes": 1048642
}
}
@@ -0,0 +1,29 @@
using System.Text.Json.Serialization;
namespace WebDrop.FileProtocol;
/// <summary>
/// 文件域的选项,由文件服务(fileAPI)决定并对外公布:创建频道时能分配多大的文件空间。
/// </summary>
public sealed record FileDomainOptions(
[property: JsonPropertyName("quota_choices")] long[] QuotaChoices,
[property: JsonPropertyName("default_quota")] long DefaultQuota)
{
/// <summary>没有配置时的内置档位(频道默认 10 GiB)</summary>
public static readonly FileDomainOptions Fallback = new([1L << 30, 5L << 30, 10L << 30, 50L << 30, 100L << 30], 10L << 30);
}
/// <summary>文件域:创建频道时分配的文件空间。由文件服务创建和管理,quota 是容量,used 是已用字节</summary>
public sealed record FileDomainBody(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("space")] string Space,
[property: JsonPropertyName("quota")] long Quota,
[property: JsonPropertyName("used")] long Used,
[property: JsonPropertyName("created_at")] long CreatedAt);
public sealed record FileDomainCreate([property: JsonPropertyName("quota")] long Quota = 0);
[JsonSerializable(typeof(FileDomainOptions))]
[JsonSerializable(typeof(FileDomainBody))]
[JsonSerializable(typeof(FileDomainCreate))]
public sealed partial class FileProtocolJson : JsonSerializerContext;
@@ -0,0 +1,76 @@
using System.Security.Cryptography;
using System.Text;
namespace WebDrop.FileProtocol;
/// <summary>文件服务(fileAPI)的三种凭据用途</summary>
public enum FileOp : byte
{
/// <summary>上传某个条目的帧</summary>
Put = 1,
/// <summary>读取某个条目自某一帧起的内容</summary>
Get = 2,
/// <summary>主后端对文件服务的管理调用(删除、对账、复制)</summary>
Admin = 3,
/// <summary>在某个空间名下创建文件域(频道的文件空间)。由空间的所有者或管理员向主后端申请</summary>
Create = 4,
/// <summary>删除某个条目的所有帧。主后端确认调用者有权删除之后签发</summary>
Delete = 5,
}
/// <summary>
/// 主后端签发、文件服务校验的短期票据:允许对哪个文件域(Channel)的哪个条目做什么,过期即失效。
/// Space 是签发这张票据的空间:文件服务只认域所属的空间签发的票据,别的空间即使知道域 id 也用不了。
/// Jti 是票据的唯一编号:读取和删除的票据只能用一次,重放无效(每次读取都向空间要新的,计次才有意义)。
/// </summary>
public sealed record FileTicket(FileOp Op, string Channel, string Item, uint From, long ExpiresMs, string Space = "", string Jti = "");
/// <summary>
/// 票据:ASCII 载荷 + HMAC-SHA256,双方共享同一个密钥。
/// 文件服务本来就保管密文,密钥泄露的后果也只限于文件服务这一侧(不涉及账号、频道密钥),所以不需要非对称签名。
/// </summary>
public static class FileTokens
{
public const int MinSecretBytes = 16;
public static string Sign(ReadOnlySpan<byte> secret, FileTicket ticket) =>
Seal(secret, $"t|{(byte)ticket.Op}|{ticket.Channel}|{ticket.Item}|{ticket.From}|{ticket.ExpiresMs}|{ticket.Space}|{(ticket.Jti.Length > 0 ? ticket.Jti : Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(12)))}");
public static bool TryVerify(ReadOnlySpan<byte> secret, string? token, long nowMs, out FileTicket ticket)
{
ticket = null!;
if (!Open(secret, token, out var parts) || parts.Length != 8 || parts[0] != "t") return false;
if (!byte.TryParse(parts[1], out var op) || !Enum.IsDefined((FileOp)op) || !uint.TryParse(parts[4], out var from)
|| !long.TryParse(parts[5], out var expires) || expires < nowMs) return false;
ticket = new FileTicket((FileOp)op, parts[2], parts[3], from, expires, parts[6], parts[7]);
return true;
}
private static string Seal(ReadOnlySpan<byte> secret, string payload)
{
var bytes = Encoding.ASCII.GetBytes(payload);
var mac = HMACSHA256.HashData(secret, bytes);
return $"{Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')}.{Convert.ToBase64String(mac).TrimEnd('=').Replace('+', '-').Replace('/', '_')}";
}
private static bool Open(ReadOnlySpan<byte> secret, string? token, out string[] parts)
{
parts = [];
if (string.IsNullOrEmpty(token) || token.Length > 1024) return false;
int dot = token.IndexOf('.');
if (dot <= 0) return false;
if (!TryFromBase64Url(token[..dot], out var payload) || !TryFromBase64Url(token[(dot + 1)..], out var mac)) return false;
if (!CryptographicOperations.FixedTimeEquals(HMACSHA256.HashData(secret, payload), mac)) return false;
parts = Encoding.ASCII.GetString(payload).Split('|');
return true;
}
private static bool TryFromBase64Url(string text, out byte[] bytes)
{
bytes = [];
var padded = text.Replace('-', '+').Replace('_', '/');
padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '=');
try { bytes = Convert.FromBase64String(padded); return true; }
catch (FormatException) { return false; }
}
}
@@ -0,0 +1,38 @@
using System.Buffers.Binary;
namespace WebDrop.FileProtocol;
/// <summary>主后端、文件服务、浏览器三方约定的名字和格式</summary>
public static class FileWire
{
/// <summary>上传、读取时带的票据(主后端签发)</summary>
public const string TicketHeader = "X-WD-Ticket";
/// <summary>存储键:频道/条目/帧序号。帧序号补零到 10 位,按名字排序就是按帧序排序</summary>
public static string FrameKey(string channelHex, string itemHex, uint index) => $"{channelHex}/{itemHex}/{index:D10}";
public static bool IsHex32(string value)
{
if (value.Length != 32) return false;
foreach (char c in value) if (!(c is >= '0' and <= '9' or >= 'a' and <= 'f')) return false;
return true;
}
}
/// <summary>帧封装的 26 字节明文头部(protocol.md §4),文件服务只看这几个字段,负载原样保存</summary>
public readonly record struct FrameHeader(byte Flags, string ItemHex, uint Index, int PayloadLength)
{
public const int Size = 26;
public const byte Version = 1;
public const byte FinalFlag = 0x01;
public int TotalLength => Size + PayloadLength;
public static bool TryParse(ReadOnlySpan<byte> data, out FrameHeader header)
{
header = default;
if (data.Length < Size || data[0] != Version || (data[1] & 0xE0) != 0) return false;
uint payload = BinaryPrimitives.ReadUInt32BigEndian(data[22..]);
if (payload > int.MaxValue) return false;
header = new FrameHeader(data[1], Convert.ToHexStringLower(data.Slice(2, 16)), BinaryPrimitives.ReadUInt32BigEndian(data[18..]), (int)payload);
return true;
}
}
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- 主后端和文件服务(fileAPI)共用的协议:票据、回执、帧头部。两边分开部署,所以只放这一点点,不依赖任何业务代码 -->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+70
View File
@@ -0,0 +1,70 @@
using System.Security.Cryptography;
using WebDrop.Core;
namespace WebDrop.Storage;
/// <summary>
/// 头像按内容哈希存成磁盘文件,数据库里只记文件名(引用,形如 3f…a1.webp)。
/// 下载时直接把文件交给服务器发送,不用把几 MB 的 base64 读进托管内存再解码。
/// </summary>
public sealed class AvatarFiles
{
private static readonly Dictionary<string, string> MimeByExtension = new()
{
["png"] = "image/png", ["jpg"] = "image/jpeg", ["webp"] = "image/webp", ["gif"] = "image/gif",
};
private readonly string _dir;
public AvatarFiles(string dir)
{
_dir = Path.GetFullPath(dir);
Directory.CreateDirectory(_dir);
}
public string Dir => _dir;
/// <summary>旧数据把整段 data URL 存在数据库里;新数据是文件名</summary>
public static bool IsLegacy(string? value) => value is not null && value.StartsWith("data:", StringComparison.Ordinal);
/// <summary>把已校验的 data URL 解码写成文件(同样内容只写一份),返回引用</summary>
public string Save(string dataUrl)
{
var chars = dataUrl.AsSpan();
int semicolon = chars.IndexOf(';'), comma = chars.IndexOf(',');
if (!dataUrl.StartsWith("data:image/", StringComparison.Ordinal) || semicolon < 0 || comma < semicolon) throw new WebDropException(ErrorCode.BadRequest);
string extension = chars[11..semicolon].ToString() switch { "jpeg" => "jpg", var other => other };
if (!MimeByExtension.ContainsKey(extension)) throw new WebDropException(ErrorCode.BadRequest);
var payload = chars[(comma + 1)..];
var bytes = new byte[payload.Length / 4 * 3 + 3];
if (!Convert.TryFromBase64Chars(payload, bytes, out int written)) throw new WebDropException(ErrorCode.BadRequest);
string name = Convert.ToHexStringLower(SHA256.HashData(bytes.AsSpan(0, written))) + "." + extension;
string path = Path.Combine(_dir, name);
if (!File.Exists(path))
{
string temp = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
File.WriteAllBytes(temp, bytes.AsSpan(0, written));
File.Move(temp, path, overwrite: true);
}
return name;
}
public AvatarBlob? Resolve(string? reference)
{
if (reference is null || IsLegacy(reference)) return null;
int dot = reference.IndexOf('.');
if (dot != 64 || !MimeByExtension.TryGetValue(reference[(dot + 1)..], out var mime)) return null;
foreach (char c in reference.AsSpan(0, 64)) if (!char.IsAsciiHexDigitLower(c)) return null;
string path = Path.Combine(_dir, reference);
return File.Exists(path) ? new AvatarBlob(path, mime, reference[..64]) : null;
}
/// <summary>启动时调用:删掉没有任何记录引用的文件(替换、删除头像后留下的)</summary>
public void Sweep(IReadOnlySet<string> referenced)
{
foreach (var path in System.IO.Directory.EnumerateFiles(_dir))
{
if (referenced.Contains(Path.GetFileName(path))) continue;
try { File.Delete(path); } catch (IOException) { }
}
}
}
@@ -0,0 +1,47 @@
using WebDrop.Core;
namespace WebDrop.Storage;
public sealed partial class SqliteAccountStore
{
/// <summary>头像文件(full 为原图,否则缩略图,没有缩略图就用原图)。没有头像为 null</summary>
public AvatarBlob? ProfileAvatar(string username, bool full)
{
var profile = Profile(username);
return _avatars.Resolve(full ? profile.Avatar : profile.AvatarThumb ?? profile.Avatar);
}
/// <summary>Avatar / AvatarThumb 是磁盘上的头像文件名,不是图片内容</summary>
public AccountProfile Profile(string username)
{
lock (_gate)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT display_name,avatar,modified_at,avatar_thumb FROM account_profiles WHERE username=$u";
cmd.Parameters.AddWithValue("$u", username);
using var reader = cmd.ExecuteReader();
return reader.Read() ? new AccountProfile { Username = username, DisplayName = reader.GetString(0), Avatar = reader.IsDBNull(1) ? null : reader.GetString(1), HasAvatar = !reader.IsDBNull(1), ModifiedAt = reader.GetInt64(2), AvatarThumb = reader.IsDBNull(3) ? null : reader.GetString(3) }
: new AccountProfile { Username = username, DisplayName = username, ModifiedAt = 0 };
}
}
public AccountProfile UpdateProfile(string username, string displayName, string? avatar, string? avatarThumb = null, bool keepAvatar = false)
{
if (displayName is null || displayName.Trim().Length > 48 || displayName.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
if (keepAvatar) { var current = Profile(username); avatar = current.Avatar; avatarThumb = current.AvatarThumb; }
else if (avatar is not null) AvatarData.Validate(avatar, AvatarData.MaxAccountGifChars);
if (!keepAvatar && avatarThumb is not null) { if (avatar is null) throw new WebDropException(ErrorCode.BadRequest); AvatarData.ValidateThumb(avatarThumb); }
if (!keepAvatar) { avatar = avatar is null ? null : _avatars.Save(avatar); avatarThumb = avatarThumb is null ? null : _avatars.Save(avatarThumb); }
lock (_gate)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "INSERT INTO account_profiles(username,display_name,avatar,avatar_thumb,modified_at) VALUES($u,$n,$a,$b,$t) ON CONFLICT(username) DO UPDATE SET display_name=excluded.display_name,avatar=excluded.avatar,avatar_thumb=excluded.avatar_thumb,modified_at=MAX(account_profiles.modified_at+1,excluded.modified_at)";
cmd.Parameters.AddWithValue("$u", username);
cmd.Parameters.AddWithValue("$n", displayName.Trim());
cmd.Parameters.AddWithValue("$a", (object?)avatar ?? DBNull.Value);
cmd.Parameters.AddWithValue("$b", (object?)avatarThumb ?? DBNull.Value);
cmd.Parameters.AddWithValue("$t", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
cmd.ExecuteNonQuery();
return Profile(username);
}
}
}
@@ -8,10 +8,13 @@ public sealed partial class SqliteAccountStore : IDisposable
{
private readonly SqliteConnection _connection;
private readonly object _gate = new();
private readonly AvatarFiles _avatars;
public SqliteAccountStore(string path)
public SqliteAccountStore(string path, AvatarFiles? avatars = null)
{
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
string directory = Path.GetDirectoryName(Path.GetFullPath(path))!;
Directory.CreateDirectory(directory);
_avatars = avatars ?? new AvatarFiles(Path.Combine(directory, "avatars", "account"));
_connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = true }.ToString());
_connection.Open();
using var cmd = _connection.CreateCommand();
@@ -27,6 +30,19 @@ public sealed partial class SqliteAccountStore : IDisposable
cmd.ExecuteNonQuery();
using (var columns = _connection.CreateCommand())
{
columns.CommandText = "PRAGMA table_info(account_profiles)";
using (var profileColumns = columns.ExecuteReader())
{
bool hasThumb = false;
while (profileColumns.Read()) if (profileColumns.GetString(1) == "avatar_thumb") hasThumb = true;
profileColumns.Close();
if (!hasThumb)
{
using var alterProfiles = _connection.CreateCommand();
alterProfiles.CommandText = "ALTER TABLE account_profiles ADD COLUMN avatar_thumb TEXT";
alterProfiles.ExecuteNonQuery();
}
}
columns.CommandText = "PRAGMA table_info(accounts)";
using var reader = columns.ExecuteReader();
bool hasSigningKey = false;
@@ -39,6 +55,45 @@ public sealed partial class SqliteAccountStore : IDisposable
alter.ExecuteNonQuery();
}
}
MigrateAvatars();
}
/// <summary>旧版把头像的 data URL 整段存在数据库里:搬成磁盘文件(幂等,一个账号一个账号处理),再删掉没人引用的文件</summary>
private void MigrateAvatars()
{
var legacy = new List<string>();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "SELECT username FROM account_profiles WHERE avatar LIKE 'data:%' OR avatar_thumb LIKE 'data:%'";
using var reader = cmd.ExecuteReader();
while (reader.Read()) legacy.Add(reader.GetString(0));
}
foreach (var username in legacy)
{
string? avatar, thumb;
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "SELECT avatar, avatar_thumb FROM account_profiles WHERE username=$u";
cmd.Parameters.AddWithValue("$u", username);
using var reader = cmd.ExecuteReader();
reader.Read();
avatar = reader.IsDBNull(0) ? null : reader.GetString(0); thumb = reader.IsDBNull(1) ? null : reader.GetString(1);
}
using var update = _connection.CreateCommand();
update.CommandText = "UPDATE account_profiles SET avatar=$a, avatar_thumb=$t WHERE username=$u";
update.Parameters.AddWithValue("$u", username);
update.Parameters.AddWithValue("$a", (object?)(AvatarFiles.IsLegacy(avatar) ? _avatars.Save(avatar!) : avatar) ?? DBNull.Value);
update.Parameters.AddWithValue("$t", (object?)(AvatarFiles.IsLegacy(thumb) ? _avatars.Save(thumb!) : thumb) ?? DBNull.Value);
update.ExecuteNonQuery();
}
var referenced = new HashSet<string>();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "SELECT avatar, avatar_thumb FROM account_profiles";
using var reader = cmd.ExecuteReader();
while (reader.Read()) { if (!reader.IsDBNull(0)) referenced.Add(reader.GetString(0)); if (!reader.IsDBNull(1)) referenced.Add(reader.GetString(1)); }
}
_avatars.Sweep(referenced);
}
private static byte[] HashVerifier(byte[] verifier, byte[] salt) => Rfc2898DeriveBytes.Pbkdf2(verifier, salt, 210_000, HashAlgorithmName.SHA256, 32);
@@ -15,11 +15,13 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
private readonly string _connectionString;
private readonly SqliteConnection _writeConn;
private readonly object _writeLock = new();
private readonly AvatarFiles _avatars;
public SqliteChannelStore(string dbPath)
public SqliteChannelStore(string dbPath, AvatarFiles? avatars = null)
{
string? dir = Path.GetDirectoryName(Path.GetFullPath(dbPath));
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
_avatars = avatars ?? new AvatarFiles(Path.Combine(dir ?? ".", "avatars", "space"));
_connectionString = new SqliteConnectionStringBuilder
{
@@ -32,13 +34,12 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
_writeConn = Open();
Exec(_writeConn, "PRAGMA journal_mode=WAL;");
InitializeSchema();
using (var columns = _writeConn.CreateCommand())
{
columns.CommandText = "SELECT COUNT(*) FROM pragma_table_info('seats') WHERE name='account_device_id'";
if ((long)columns.ExecuteScalar()! == 0) Exec(_writeConn, "ALTER TABLE seats ADD COLUMN account_device_id TEXT;");
}
// v8: 频道只接受登录账号,不再有访客席位;丢弃旧的席位表和针对终端的拒绝记录(幂等)
Exec(_writeConn, "DROP TABLE IF EXISTS seats;");
Exec(_writeConn, "DELETE FROM channel_join_denials WHERE target LIKE 'endpoint:%';");
// v7: 设置里的成员人数上限由 seats 改名为 max_members,迁移已有频道的设置 JSON(幂等)
Exec(_writeConn, "UPDATE channels SET settings = json_remove(json_set(settings, '$.max_members', json_extract(settings, '$.seats')), '$.seats') WHERE json_extract(settings, '$.seats') IS NOT NULL AND json_extract(settings, '$.max_members') IS NULL;");
try { Exec(_writeConn, "ALTER TABLE channel_avatars ADD COLUMN thumb TEXT;"); } catch (SqliteException) { /* 新建的表或已经迁移过 */ }
// Additive table: retain the running v2 database and its existing channel data.
Exec(_writeConn, """
CREATE TABLE IF NOT EXISTS channel_key_packages (
@@ -48,16 +49,76 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
CREATE INDEX IF NOT EXISTS ix_channel_keys_username ON channel_key_packages(username);
CREATE TABLE IF NOT EXISTS workspace_structures (space_id BLOB PRIMARY KEY, state TEXT NOT NULL) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS workspace_crypto (space_id BLOB PRIMARY KEY, state TEXT NOT NULL) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS channel_avatars (space_id BLOB PRIMARY KEY, avatar TEXT NOT NULL, thumb TEXT) WITHOUT ROWID;
""");
MigrateAvatars();
}
/// <summary>旧版把头像的 data URL 整段存在数据库里:搬成磁盘文件(幂等,一行一行处理,不一次读入),再删掉没人引用的文件</summary>
private void MigrateAvatars()
{
var legacy = new List<byte[]>();
using (var cmd = _writeConn.CreateCommand())
{
cmd.CommandText = "SELECT space_id FROM channel_avatars WHERE avatar LIKE 'data:%' OR thumb LIKE 'data:%'";
using var reader = cmd.ExecuteReader();
while (reader.Read()) legacy.Add((byte[])reader[0]);
}
foreach (var key in legacy)
{
string? avatar, thumb;
using (var cmd = _writeConn.CreateCommand())
{
cmd.CommandText = "SELECT avatar, thumb FROM channel_avatars WHERE space_id=@c";
cmd.Parameters.AddWithValue("@c", key);
using var reader = cmd.ExecuteReader();
reader.Read();
avatar = reader.GetString(0); thumb = reader.IsDBNull(1) ? null : reader.GetString(1);
}
using var update = _writeConn.CreateCommand();
update.CommandText = "UPDATE channel_avatars SET avatar=@a, thumb=@t WHERE space_id=@c";
update.Parameters.AddWithValue("@c", key);
update.Parameters.AddWithValue("@a", AvatarFiles.IsLegacy(avatar) ? _avatars.Save(avatar) : avatar);
update.Parameters.AddWithValue("@t", (object?)(AvatarFiles.IsLegacy(thumb) ? _avatars.Save(thumb!) : thumb) ?? DBNull.Value);
update.ExecuteNonQuery();
}
var referenced = new HashSet<string>();
using (var cmd = _writeConn.CreateCommand())
{
cmd.CommandText = "SELECT avatar, thumb FROM channel_avatars";
using var reader = cmd.ExecuteReader();
while (reader.Read()) { referenced.Add(reader.GetString(0)); if (!reader.IsDBNull(1)) referenced.Add(reader.GetString(1)); }
}
_avatars.Sweep(referenced);
}
/// <summary>把整个数据库(含账号表,它们在同一个文件里)做一份一致的快照,不阻塞正在服务的请求</summary>
public void Snapshot(string path)
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "VACUUM INTO $path";
cmd.Parameters.AddWithValue("$path", path);
cmd.ExecuteNonQuery();
}
public T Read<T>(Func<IResourceTx, T> action)
{
using var conn = Open();
// 显式事务让一次 Read 内的多条查询看到同一份快照
// 读事务延迟到首次访问时才取快照,避免并发读预检抢占 SQLite 写锁。
using var tx = conn.BeginTransaction(deferred: true);
return action(new SqliteTx(conn));
for (int attempt = 0; ; attempt++)
{
using var conn = Open();
// 显式事务让一次 Read 内的多条查询看到同一份快照
// 读事务延迟到首次访问时才取快照,避免并发读预检抢占 SQLite 写锁。
SqliteTransaction tx;
try { tx = conn.BeginTransaction(deferred: true); }
// 并发压力下偶发 SQLITE_ERROR(CI 的并行追加测试里出现过):丢弃这条连接和池里的闲置连接,换新连接重试
catch (SqliteException e) when (e.SqliteErrorCode == 1 && attempt < 2)
{
SqliteConnection.ClearPool(conn);
continue;
}
using (tx) return action(new SqliteTx(conn, _avatars));
}
}
public T Write<T>(Func<IResourceTx, T> action)
@@ -67,7 +128,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
using var tx = _writeConn.BeginTransaction();
try
{
T result = action(new SqliteTx(_writeConn));
T result = action(new SqliteTx(_writeConn, _avatars));
tx.Commit();
return result;
}
@@ -131,15 +192,6 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
);
CREATE INDEX ix_channels_expires ON channels(expires_at);
CREATE TABLE seats (
channel_id BLOB NOT NULL,
endpoint_hash BLOB NOT NULL,
label BLOB,
account_name TEXT,
joined_at INTEGER NOT NULL,
PRIMARY KEY (channel_id, endpoint_hash)
) WITHOUT ROWID;
CREATE TABLE items (
channel_id BLOB NOT NULL,
item_id BLOB NOT NULL,
@@ -254,7 +306,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
CREATE TABLE channel_members(channel_id BLOB NOT NULL,username TEXT NOT NULL,role TEXT NOT NULL,joined_at INTEGER NOT NULL,PRIMARY KEY(channel_id,username));
""";
private sealed class SqliteTx(SqliteConnection conn) : IResourceTx
private sealed class SqliteTx(SqliteConnection conn, AvatarFiles avatars) : IResourceTx
{
private const string ChannelCols = "id, mode, settings, read_hash, write_hash, owner_hash, created_at, expires_at, last_seq, compacted_through, bytes, creator_account, join_password_hash, join_password_salt";
public WorkspaceState? GetWorkspace(Id16 id) => One("SELECT state FROM workspace_structures WHERE space_id=@id",
@@ -473,16 +525,14 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
WHERE k.username=@u AND c.expires_at>@now
""", r => new ChannelKeyPackage(IdAt(r, 0), r.GetString(1), r.GetString(2), r.GetString(3), r.GetInt64(4)),
("@u", username), ("@now", now));
public void DeleteAccountSeats(Id16 id, string username) => Run("DELETE FROM seats WHERE channel_id=@id AND account_name=@u", ("@id", id.ToArray()), ("@u", username));
private static ChannelMember MapMember(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), r.GetInt64(2));
public ChannelMember? GetChannelMember(Id16 id, string username) => One("SELECT username,role,joined_at FROM channel_members WHERE channel_id=@id AND username=@u", MapMember, ("@id", id.ToArray()), ("@u", username));
public IReadOnlyList<ChannelMember> ListChannelMembers(Id16 id) => Many("SELECT username,role,joined_at FROM channel_members WHERE channel_id=@id ORDER BY joined_at,username", MapMember, ("@id", id.ToArray()));
public void PutChannelMember(Id16 id, ChannelMember member) => Run("INSERT OR IGNORE INTO channel_members(channel_id,username,role,joined_at) VALUES(@id,@u,@r,@t)", ("@id", id.ToArray()), ("@u", member.Username), ("@r", member.Role), ("@t", member.JoinedAt));
public void DeleteChannelMember(Id16 id, string username) => Run("DELETE FROM channel_members WHERE channel_id=@id AND username=@u AND role<>'owner'", ("@id", id.ToArray()), ("@u", username));
public bool IsJoinDenied(Id16 id, string? username, byte[] endpointHash) => Scalar("SELECT 1 FROM channel_join_denials WHERE channel_id=@id AND (target=@a OR target=@e) LIMIT 1", ("@id", id.ToArray()), ("@a", username is null ? null : "account:" + username), ("@e", "endpoint:" + Codec.Encode(endpointHash))) is not null;
public bool IsJoinDenied(Id16 id, string username) => Scalar("SELECT 1 FROM channel_join_denials WHERE channel_id=@id AND target=@a LIMIT 1", ("@id", id.ToArray()), ("@a", "account:" + username)) is not null;
public void DenyAccount(Id16 id, string username) => Run("INSERT OR IGNORE INTO channel_join_denials VALUES(@id,@t)", ("@id", id.ToArray()), ("@t", "account:" + username));
public void AllowAccount(Id16 id, string username) => Run("DELETE FROM channel_join_denials WHERE channel_id=@id AND target=@t", ("@id", id.ToArray()), ("@t", "account:" + username));
public void DenyEndpoint(Id16 id, byte[] endpointHash) => Run("INSERT OR IGNORE INTO channel_join_denials VALUES(@id,@t)", ("@id", id.ToArray()), ("@t", "endpoint:" + Codec.Encode(endpointHash)));
public void AddChannelBytes(Id16 id, long delta) =>
Run("UPDATE channels SET bytes=bytes+@d WHERE id=@id", ("@d", delta), ("@id", id.ToArray()));
@@ -500,54 +550,37 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
Run("DELETE FROM frames WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM log WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM items WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM seats WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM channels WHERE id=@c", ("@c", key));
Run("DELETE FROM channel_invitations WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM channel_key_packages WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM workspace_structures WHERE space_id=@c", ("@c", key));
Run("DELETE FROM workspace_crypto WHERE space_id=@c", ("@c", key));
Run("DELETE FROM channel_avatars WHERE space_id=@c", ("@c", key));
Run("DELETE FROM channel_members WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM channel_join_denials WHERE channel_id=@c", ("@c", key));
}
public AvatarBlob? GetChannelAvatar(Id16 id, bool thumb) => avatars.Resolve(
Scalar(thumb ? "SELECT COALESCE(thumb, avatar) FROM channel_avatars WHERE space_id=@c" : "SELECT avatar FROM channel_avatars WHERE space_id=@c", ("@c", id.ToArray())) as string);
/// <summary>头像文件名(内容哈希)就是版本:内容变了名字才会变</summary>
public string? GetChannelAvatarVersion(Id16 id) =>
Scalar("SELECT avatar || '|' || COALESCE(thumb, '') FROM channel_avatars WHERE space_id=@c", ("@c", id.ToArray())) as string;
public void SetChannelAvatar(Id16 id, string? avatar, string? thumb)
{
if (avatar is null) { Run("DELETE FROM channel_avatars WHERE space_id=@c", ("@c", id.ToArray())); return; }
Run("INSERT INTO channel_avatars (space_id, avatar, thumb) VALUES (@c,@a,@t) ON CONFLICT(space_id) DO UPDATE SET avatar=excluded.avatar, thumb=excluded.thumb",
("@c", id.ToArray()), ("@a", avatars.Save(avatar)), ("@t", (object?)(thumb is null ? null : avatars.Save(thumb)) ?? DBNull.Value));
}
public IReadOnlyList<Id16> ListExpiredChannels(long now, int limit) =>
Many("SELECT id FROM channels WHERE expires_at<=@now LIMIT @n", r => IdAt(r, 0), ("@now", now), ("@n", limit));
// ───────── 席位 ─────────
private static Seat MapSeat(SqliteDataReader r) => new(Blob(r, 0), r.GetInt64(1), r.IsDBNull(3) ? null : r.GetString(3), r.IsDBNull(4) ? null : r.GetString(4));
public Seat? GetSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash) => One(
"SELECT endpoint_hash, joined_at, label, account_name,account_device_id FROM seats WHERE channel_id=@c AND endpoint_hash=@e",
MapSeat, ("@c", channelId.ToArray()), ("@e", endpointHash.ToArray()));
public int CountSeats(Id16 channelId) =>
(int)(long)Scalar("SELECT COUNT(*) FROM seats WHERE channel_id=@c", ("@c", channelId.ToArray()))!;
// ───────── 成员 ─────────
public int CountMembers(Id16 channelId) =>
(int)(long)Scalar("SELECT (SELECT COUNT(*) FROM channel_members WHERE channel_id=@c) + (SELECT COUNT(*) FROM seats WHERE channel_id=@c AND account_name IS NULL)", ("@c", channelId.ToArray()))!;
public void InsertSeat(Id16 channelId, Seat seat) => Run(
"INSERT INTO seats (channel_id, endpoint_hash, joined_at,account_name,account_device_id) VALUES (@c,@e,@j,@a,@d)",
("@c", channelId.ToArray()), ("@e", seat.EndpointHash), ("@j", seat.JoinedAt), ("@a", seat.AccountName), ("@d", seat.AccountDeviceId));
public bool DeleteSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash) => Run(
"DELETE FROM seats WHERE channel_id=@c AND endpoint_hash=@e",
("@c", channelId.ToArray()), ("@e", endpointHash.ToArray())) > 0;
public IReadOnlyList<Seat> ListSeats(Id16 channelId) => Many(
"SELECT endpoint_hash, joined_at, label, account_name,account_device_id FROM seats WHERE channel_id=@c ORDER BY joined_at, endpoint_hash",
MapSeat, ("@c", channelId.ToArray()));
public IReadOnlyList<Id16> DeleteDeviceSeats(string username, string deviceId)
{
var ids = Many("SELECT DISTINCT channel_id FROM seats WHERE account_name=@u AND account_device_id=@d", r => IdAt(r, 0), ("@u", username), ("@d", deviceId));
Run("DELETE FROM seats WHERE account_name=@u AND account_device_id=@d", ("@u", username), ("@d", deviceId));
return ids;
}
public void BindSeatDevice(Id16 id, byte[] endpointHash, string deviceId) => Run(
"UPDATE seats SET account_device_id=@d WHERE channel_id=@c AND endpoint_hash=@e",
("@d", deviceId), ("@c", id.ToArray()), ("@e", endpointHash));
(int)(long)Scalar("SELECT COUNT(*) FROM channel_members WHERE channel_id=@c", ("@c", channelId.ToArray()))!;
// ───────── 条目 ─────────
@@ -1,41 +0,0 @@
using WebDrop.Core;
namespace WebDrop.Storage;
public sealed partial class SqliteAccountStore
{
public AccountProfile Profile(string username)
{
lock (_gate)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT display_name,avatar,modified_at FROM account_profiles WHERE username=$u";
cmd.Parameters.AddWithValue("$u", username);
using var reader = cmd.ExecuteReader();
return reader.Read() ? new AccountProfile { Username = username, DisplayName = reader.GetString(0), Avatar = reader.IsDBNull(1) ? null : reader.GetString(1), ModifiedAt = reader.GetInt64(2) }
: new AccountProfile { Username = username, DisplayName = username, ModifiedAt = 0 };
}
}
public AccountProfile UpdateProfile(string username, string displayName, string? avatar)
{
if (displayName is null || displayName.Trim().Length > 48 || displayName.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
if (avatar is not null)
{
string? prefix = new[] { "data:image/png;base64,", "data:image/jpeg;base64,", "data:image/webp;base64," }.FirstOrDefault(p => avatar.StartsWith(p, StringComparison.Ordinal));
if (prefix is null || avatar.Length > 350_000 || avatar.Length <= prefix.Length) throw new WebDropException(ErrorCode.BadRequest);
try { _ = Convert.FromBase64String(avatar[prefix.Length..]); }
catch (FormatException) { throw new WebDropException(ErrorCode.BadRequest); }
}
lock (_gate)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "INSERT INTO account_profiles(username,display_name,avatar,modified_at) VALUES($u,$n,$a,$t) ON CONFLICT(username) DO UPDATE SET display_name=excluded.display_name,avatar=excluded.avatar,modified_at=MAX(account_profiles.modified_at+1,excluded.modified_at)";
cmd.Parameters.AddWithValue("$u", username);
cmd.Parameters.AddWithValue("$n", displayName.Trim());
cmd.Parameters.AddWithValue("$a", (object?)avatar ?? DBNull.Value);
cmd.Parameters.AddWithValue("$t", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
cmd.ExecuteNonQuery();
return Profile(username);
}
}
}
Loaded 100 of 410 files, more files were not shown because too many files have changed in this diff. Show more