Compare commits
52
Commits
e79fbf1112
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d2a0c089a | ||
|
|
4b4ab4ee4d | ||
|
|
7e6ef5e587 | ||
|
|
7274390aa1 | ||
|
|
8af4f284a0 | ||
|
|
5489407241 | ||
|
|
17638b6e20 | ||
|
|
ccb2c074d6 | ||
|
|
d3da3597cc | ||
|
|
d048fedc5e | ||
|
|
ea7895d85c | ||
|
|
a27534b2dd | ||
|
|
d1ff8bab7d | ||
|
|
5043c798ff | ||
|
|
78b4e2aa63 | ||
|
|
72a5e5b839 | ||
|
|
d66c93fe98 | ||
|
|
283196938e | ||
|
|
31ae3bc39e | ||
|
|
ba0b6c258a | ||
|
|
96f92c2ba9 | ||
|
|
1a4069576b | ||
|
|
77c9017129 | ||
|
|
a08aa7568f | ||
|
|
3b062687cd | ||
|
|
bef8898cd8 | ||
|
|
7ef298f587 | ||
|
|
1245aedfd2 | ||
|
|
a574583943 | ||
|
|
96c4335cb5 | ||
|
|
82d55a97f9 | ||
|
|
1dd54243ae | ||
|
|
059a29a8d5 | ||
|
|
915992776d | ||
|
|
6d6490055e | ||
|
|
6310f93469 | ||
|
|
fc1dc08ebe | ||
|
|
55f1b70d89 | ||
|
|
18c871ec85 | ||
|
|
18185c5bd9 | ||
|
|
b847916a5b | ||
|
|
79bfd560cb | ||
|
|
700be2ebae | ||
|
|
fb5800f81d | ||
|
|
12980ba6fe | ||
|
|
8233369cd4 | ||
|
|
c858bd2a09 | ||
|
|
b1ac11e1d7 | ||
|
|
f2e118c966 | ||
|
|
d77ca1f113 | ||
|
|
593eeba18e | ||
|
|
22f272e361 |
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,46 @@
|
||||
# 文件服务(fileAPI)的开发镜像:提交信息带 [CI],并且这次推送改到了文件服务用到的代码时才构建。
|
||||
# 推送 webdrop-files:dev(放文件的机器上的 podman auto-update 跟踪它)和 webdrop-files:dev-<提交>
|
||||
when:
|
||||
- event: push
|
||||
branch: [master, main]
|
||||
evaluate: 'CI_COMMIT_MESSAGE contains "[CI]"'
|
||||
path:
|
||||
include:
|
||||
- 'server/Containerfile.files'
|
||||
- 'server/Directory.Build.props'
|
||||
- 'server/src/WebDrop.FileApi/**'
|
||||
- 'server/src/WebDrop.FileProtocol/**'
|
||||
- 'server/src/WebDrop.Sync/**'
|
||||
- 'scripts/ci-publish-image.sh'
|
||||
- '.woodpecker/dev-image-files.yml'
|
||||
- event: manual
|
||||
|
||||
labels:
|
||||
ci: domain-linux-native
|
||||
platform: linux/amd64
|
||||
|
||||
clone:
|
||||
- name: git
|
||||
image: woodpeckerci/plugin-git:2.9.3
|
||||
settings:
|
||||
remote:
|
||||
from_secret: gitea_clone_webdrop
|
||||
lfs: false
|
||||
|
||||
# 文件服务不带前端,不用等前端构建
|
||||
depends_on:
|
||||
- ci
|
||||
|
||||
concurrency: 1
|
||||
|
||||
steps:
|
||||
publish-dev-image:
|
||||
image: bash
|
||||
environment:
|
||||
ZOT_USERNAME: justme
|
||||
ZOT_SECRET:
|
||||
from_secret: zot_secret
|
||||
ZOT_REGISTRY: registry.oeo.one
|
||||
commands:
|
||||
- sha="${CI_COMMIT_SHA:0:7}"
|
||||
- bash scripts/ci-publish-image.sh webdrop-files server/Containerfile.files "dev-$sha" dev
|
||||
@@ -1,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
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
- **频道**:唯一的顶层概念,一条只追加的帧流,有自己的 id、密钥、设置和账号成员人数上限。
|
||||
- **条目**:频道里的一项内容(文本、JSON、文件),由若干帧组成,可复制、下载、删除。
|
||||
- **成员**:频道里的人是用户(账号);设备只是账号的属性,同一账号的多设备只算一人,请求凭账号会话鉴权。未登录的**访客**以终端密钥 `S` 登记,每个访客终端各算一人。
|
||||
- **成员**:频道里的人是用户(账号);设备只是账号的属性,同一账号的多设备只算一人,请求凭账号会话鉴权。频道强制登录,没有未登录访客(便捷分享不受影响)。
|
||||
- **所有者 / 成员**:所有者持有管理密钥,成员通过频道链接加入。
|
||||
- **模式**:`enc`(默认,浏览器端 AES-256-GCM)和 `raw`(负载原样,服务器可见,用于公开或用户自行加密的内容)。
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "7.0.0"
|
||||
"version": "8.2.0"
|
||||
}
|
||||
+365
-31
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 | 成员人数已满 |
|
||||
|
||||
@@ -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 级文件:并行上传和断点续传 | 中途断网后续传成功,内存占用平稳 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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`,反之亦然。
|
||||
|
||||
|
||||
@@ -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
@@ -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」。浏览器没有暴露具体机型时显示能识别的平台名称;保留端点短标识以区分同型号设备。
|
||||
|
||||
|
||||
@@ -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` 重建它,主线程只传链接、帧字节和事件。
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
@@ -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/
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+15
-5
@@ -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();
|
||||
});
|
||||
|
||||
+45
-3
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
+72
-25
@@ -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
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
File renamed without changes.
+2
-2
@@ -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);
|
||||
File renamed without changes.
+24
-6
@@ -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()
|
||||
+38
@@ -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))]
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+21
-7
@@ -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);
|
||||
+5
-5
@@ -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>
|
||||
+8
-11
@@ -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);
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+254
-110
@@ -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 < from < 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);
|
||||
}
|
||||
+1
-1
@@ -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,
|
||||
+20
-27
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -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;
|
||||
+2
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
using WebDrop.FileApi;
|
||||
|
||||
var app = FileApiApp.Build(args);
|
||||
app.Run();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
+57
-2
@@ -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);
|
||||
+92
-59
@@ -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()))!;
|
||||
|
||||
// ───────── 条目 ─────────
|
||||
|
||||
File renamed without changes.
@@ -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
Reference in new issue
Block a user