40 KiB
DooTask WebDAV 技术设计方案
状态:设计稿 适用主程序版本:1.8.89 范围:DooTask「我的文件」和「共享文件」 默认策略:管理员全局启用,用户使用独立 WebDAV 应用密码连接
1. 目标与结论
为 DooTask 文件系统提供标准 WebDAV 访问,使用户可以通过 Windows、macOS、Linux 和支持 WebDAV 的办公软件访问文件,同时保持以下行为与网页端一致:
- 文件和目录权限一致。
- 写入生成文件历史版本。
- 移动、重命名、删除触发现有消息推送和搜索同步。
- 共享文件保持现有所有者、创建者、只读、读写权限语义。
- 凭据可以独立创建、过期和撤销,不影响主账号登录。
- 并发写入遵循 WebDAV 锁和 HTTP 条件请求,避免静默覆盖。
WebDAV 暴露的是由 files、file_contents、file_users 组成的虚拟文件系统,不直接暴露 public/uploads 物理目录。
2. 范围边界
2.1 首版包含
- 根目录固定包含
files/和shared/两个虚拟集合。 files/:当前用户拥有的根文件及全部子级。shared/:其他用户明确共享给当前用户的顶层共享项及全部子级。- 文件和目录的查询、下载、上传、创建、复制、移动、重命名、删除。
- WebDAV 排他写锁、ETag、条件请求和 Range 下载。
- 管理员开关、用户应用密码、撤销、审计和运维指标。
- 文件历史、WebSocket 通知、Manticore 搜索同步及回收站行为。
2.2 首版不包含
- 聊天附件、任务附件、项目协作文件聚合视图。
- 匿名链接和游客访问。
- CalDAV、CardDAV。
- 将 DooTask 作为外部 WebDAV 的客户端或存储后端。
- Windows 文件扩展属性、NTFS ACL 和 POSIX 权限的完全映射。
- 离线同步客户端;系统只提供服务端协议。
2.3 不允许的实现
- 不允许将
public/uploads/file直接配置为 Nginx WebDAV 目录。 - 不允许通过 URL 参数携带主登录 token。
- 不允许直接使用用户账号密码作为 WebDAV 密码。
- 不允许 DAV 控制器复制一套文件业务逻辑。
- 不允许在静态属性、单例或全局变量中保存当前 DAV 用户、锁或请求路径。
3. 产品闭环
3.1 管理员流程
- 管理员进入「系统设置 > 文件设置 > WebDAV」。
- 开启 WebDAV,并选择允许范围:全员或指定成员。
- 配置凭据数量、有效期、单文件上限、复制上限和审计保留天数。
- 保存后,状态接口返回服务地址和当前运行能力。
- 管理员可以查看连接用户、最近失败和写操作审计,并可停用某个用户的全部 WebDAV 凭据。
- 全局关闭时,所有 DAV 请求立即返回
503 Service Unavailable,凭据保留,以便重新启用;管理员可另行执行凭据全部撤销。
3.2 用户开通流程
- 用户进入文件页面,在右上角加号右侧点击圆形“更多”图标,在菜单中选择「WebDAV」。
- 页面打开 WebDAV 管理弹窗,展示管理员是否启用、服务器地址、支持范围和已有凭据。
- 用户点击「创建应用密码」,填写设备名称并选择有效期。
- 服务端返回一次性的用户名和应用密码;应用密码此后不可再次读取。
- 页面提供服务器地址、用户名和密码字段及复制按钮,同时提示必须使用 HTTPS。
- 用户在客户端连接后,页面更新最后使用时间、IP 和客户端名称。
- 用户可以撤销单个凭据;撤销后新请求立即失败,已有锁同步失效。已撤销或已过期的凭据可以永久删除,删除前保留一条不含秘密信息的操作审计;有效凭据必须先撤销。
3.3 文件操作闭环
每个写请求必须按以下顺序完成:
- 认证应用密码并检查全局、用户和凭据状态。
- 规范化并解析 DAV 路径,确认路径不能越过虚拟根和共享边界。
- 校验资源权限、锁 token、ETag 和目标冲突。
- 将请求体流式写入临时文件,并在写入时计算大小和 SHA-256。
- 调用统一文件领域服务执行数据库与物理文件操作。
- 写入新
FileContent版本并更新File元数据。 - 触发现有 WebSocket 消息、Observer 和 Manticore 同步。
- 写入 DAV 审计日志并返回标准 DAV 状态码。
- 无论成功失败都关闭流并清理临时文件;异常遗留由定时清理兜底。
4. URL 与目录模型
4.1 服务地址
https://{host}/dav/
根目录使用固定、不随语言变化的 URI 段:
/dav/
├── files/
└── shared/
固定 ASCII URI 可以避免用户切换语言后挂载路径失效。前端说明可将其翻译为「我的文件」和「共享文件」。
4.2 files/ 映射
/dav/files/映射当前用户pid = 0 AND userid = 当前用户的资源。- 后续每一段按
pid + 完整文件名解析。 - 完整文件名为
name,文件有扩展名时为name.ext。 - 文件夹不得带文件扩展名语义,按
type = folder判断。
4.3 shared/ 映射
共享根的每个顶层项使用以下稳定且无冲突的 DAV 名称:
{原完整名称} [#{共享根文件ID}]
示例:
/dav/shared/产品资料 [#128]/设计/首页.fig
/dav/shared/预算表 [#356].xlsx
[#ID]只用于共享顶层 URI,子级保持原名称。displayname属性返回原完整名称,不包含[#ID]。- 顶层 ID 防止不同所有者共享同名资源时产生歧义。
- 共享根重命名后 URI 名称变化,但 ID 保持不变;解析时必须同时验证 ID 和当前名称。旧路径返回
404,不做永久重定向,避免 DAV 客户端缓存错误。 - 用户自己共享出去的文件仍位于
files/,不在shared/重复展示。
4.4 路径规范化
- URL 路径按 UTF-8 解码,每段只解码一次。
- 拒绝非法 UTF-8、NUL、控制字符、
.、..、空段和编码后的路径分隔符。 - 使用 Unicode NFC 作为比较前的规范形式,但数据库保存用户原始显示形式。
- 文件名继续禁止
\\ / : * ? " < > |。 - 新增 DAV 写入允许 1 至 200 个字符;现有网页端“至少 2 个字”的限制应同步改为至少 1 个字符,否则两种入口行为不一致。
- 路径比较遵循数据库当前排序规则;不得仅在 PHP 中做大小写敏感判断。
- 路径解析结果只可缓存于当前
RequestContext,不得跨请求缓存权限结果。
5. 协议能力
5.1 方法矩阵
| 方法 | 作用 | 首版行为 |
|---|---|---|
OPTIONS |
能力发现 | 返回 DAV: 1, 2、允许方法和 MS DAV 扩展头 |
PROPFIND |
查询资源属性 | 支持 Depth: 0/1,对 infinity 返回 403,避免全树扫描 |
PROPPATCH |
设置死属性 | 支持非保护属性,系统属性返回 403 |
HEAD |
文件元数据 | 与 GET 同头部,不返回内容 |
GET |
下载文件 | 支持 Range、ETag、Last-Modified 和条件读取 |
PUT |
新建或覆盖文件 | 流式写入;覆盖创建历史版本 |
MKCOL |
创建文件夹 | 请求体非空返回 415 |
COPY |
复制资源 | 文件及目录;遵循 Depth、Destination、Overwrite |
MOVE |
移动或重命名 | 同一 DAV 服务内;跨 files/shared 边界按权限判断 |
DELETE |
删除资源 | 进入现有文件回收站;递归删除目录 |
LOCK |
创建或刷新写锁 | 支持排他写锁和 lock-null 资源 |
UNLOCK |
释放写锁 | 校验 Lock-Token 和凭据所属用户 |
不支持的方法返回 405 Method Not Allowed,并带 Allow 响应头。
5.2 属性
至少实现:
{DAV:}displayname{DAV:}resourcetype{DAV:}getcontentlength{DAV:}getcontenttype{DAV:}getetag{DAV:}getlastmodified{DAV:}creationdate{DAV:}supportedlock{DAV:}lockdiscovery
不声明配额属性,直到项目存在真实的用户容量配额。文件夹大小不得在 PROPFIND 中递归计算。
5.3 ETag 与时间
- 文件强 ETag:
"f-{file_id}-v-{latest_file_content_id}"。 - 空文件强 ETag:
"f-{file_id}-v-0"。 - 文件夹弱 ETag:
W/"d-{file_id}-{updated_at_timestamp}"。 - 虚拟根 ETag 包含用户 ID 和可见共享列表的最大更新时间。
Last-Modified使用files.updated_at,统一输出 GMT。PUT、MOVE、COPY、DELETE必须处理If-Match、If-None-Match和 DAVIf头。- 条件不满足返回
412 Precondition Failed,不得继续写入。
5.4 内容读取与写入
- GET 只读取最新未删除
FileContent。 - 物理文件通过鉴权后的响应流输出,不返回
uploads/...地址。 - 空文件返回长度为 0 的正常文件,不沿用网页预览接口的空 Office 模板。
- PUT 使用
php://input对应的请求流分块写入临时文件,禁止getContent()整体载入内存。 - 当前 LaravelS/Swoole 的
package_max_length虽为 1 GB,但该配置只是允许请求大小,不证明原始 PUT body 在进入 Laravel 前不会被 Swoole 聚合到内存。实现阶段必须先通过 RSS 压测验证请求入口;未通过时必须启用 9.6 节的独立 DAV 入口。 - 超过配置大小时尽早返回
413 Content Too Large,未知长度请求在流式累计超限时中断。 - 覆盖现有文件时保留
files.id,新增一条file_contents,保证分享链接、历史记录和最近访问仍指向原文件。 - 新文件扩展名和
type使用统一类型映射服务,不在 DAV 层复制match列表。
5.5 原子保存兼容
桌面客户端常使用“上传临时文件,再 MOVE 覆盖目标”的方式保存。服务端必须特殊处理:
- 当前用户在目标目录创建临时文件。
- MOVE 的目标已存在且
Overwrite: T。 - 用户对目标有写权限,并持有需要的锁。
- 服务端将临时文件最新内容作为目标文件的新版本,保留目标
files.id。 - 删除临时文件记录,返回
204 No Content。
这样共享读写用户无需拥有目标文件的删除权限,也能完成 Office 原子保存。
6. 权限模型
6.1 权限级别映射
| DooTask 权限 | DAV 能力 |
|---|---|
-1 无权限 |
统一表现为 404,避免泄露资源存在性 |
0 只读 |
PROPFIND、HEAD、GET、作为 COPY 来源 |
1 读写 |
只读能力 + PUT、MKCOL、PROPPATCH、LOCK;可修改/重命名资源,不能删除或移走他人资源 |
1000 所有者或创建者 |
全部能力,包括 DELETE、MOVE 和共享边界管理允许的操作 |
6.2 共享目录规则
shared/虚拟根永远不可写。- 只读共享项内任何写方法返回
403 Forbidden。 - 读写共享项允许创建子项和更新已有内容。
- 用户创建的子项因
created_id是当前用户,可由该用户移动和删除。 - 用户不得删除或移走共享所有者创建的资源。
- 对已有资源的同目录重命名按写权限处理,与现有
add(id)行为一致。 - 原子覆盖按照 5.5 节处理,不把覆盖解释为删除目标。
- 不允许把
shared/顶层项 MOVE 到files/,也不允许改变共享关系。 - 从共享目录 COPY 到
files/:来源需可读,目标需可写,新副本归当前用户所有。 - 从
files/COPY 到共享目录:目标共享目录需读写,新副本所有者沿用共享根所有者,创建者为当前用户。
6.3 权限变化
- 每个请求实时读取当前共享权限,不依赖凭据创建时权限。
- 共享撤销后,后续请求立即变为
404。 - 对已锁资源撤销共享时,相关 DAV 锁同步删除。
- 用户停用、删除或被移出 WebDAV 允许范围时,所有凭据立即不可用并清理锁。
7. 认证与凭据
7.1 认证协议
- 使用 HTTPS 上的 HTTP Basic Authentication。
- Basic 用户名使用服务端生成的公开标识,例如
dtw_01J...。 - 密码使用 32 字节加密随机数生成的 base64url 字符串。
- 数据库只保存 Laravel
Hash::make()结果和密码末四位,不保存明文或可逆密文。 - 创建响应只返回一次完整密码。
- 不支持主账号密码、登录 token、URL token 和匿名访问。
选择独立公开用户名而不是邮箱,原因是:凭据可以独立撤销;无需处理 LDAP/SSO 密码;认证查询可以命中唯一索引;不会泄露登录邮箱。
7.2 凭据状态
凭据可处于:
active:可正常认证。expired:超过expires_at。revoked:用户或管理员撤销。disabled:全局开关、允许范围或用户状态导致不可用,不改变凭据记录。
认证成功后异步或限频更新 last_used_at、last_used_ip、last_user_agent,同一凭据最多每 5 分钟写库一次。
7.3 防护
- 按 IP 和公开用户名组合限流,建议失败 10 次/分钟后返回
429。 - 使用
hash_equals或 Laravel Hash 校验,错误响应不区分用户名不存在、密码错误和凭据已撤销。 401必须返回WWW-Authenticate: Basic realm="DooTask WebDAV", charset="UTF-8"。- 管理界面创建和撤销凭据使用现有登录 token,并记录安全审计。
- 生产环境不是 HTTPS 时禁止创建凭据;已有 DAV 请求返回配置错误。
8. 数据模型
8.1 webdav_credentials
| 字段 | 类型 | 说明 |
|---|---|---|
id |
bigint PK | 主键 |
public_id |
varchar(40) unique | Basic 用户名,不含秘密 |
userid |
bigint index | 所属用户 |
name |
varchar(100) | 用户填写的设备名称 |
password_hash |
varchar(255) | 应用密码哈希 |
password_suffix |
varchar(4) | 展示末四位 |
expires_at |
timestamp nullable | 过期时间 |
last_used_at |
timestamp nullable | 最近使用 |
last_used_ip |
varchar(45) nullable | 最近 IP |
last_user_agent |
varchar(255) nullable | 最近客户端 |
revoked_at |
timestamp nullable | 撤销时间 |
created_at/updated_at |
timestamps | 时间 |
不使用软删除,撤销记录保留到审计保留期结束。默认每用户最多 5 个有效凭据。
8.2 webdav_locks
| 字段 | 类型 | 说明 |
|---|---|---|
id |
bigint PK | 主键 |
token |
varchar(100) unique | opaquelocktoken:{uuid} |
userid |
bigint index | 锁所有者 |
credential_id |
bigint index | 创建锁的凭据 |
file_id |
bigint nullable index | 已存在资源 ID;lock-null 时为空 |
uri |
varchar(1000) index prefix | 规范化 DAV URI |
uri_hash |
char(64) index | URI SHA-256,精确查询 |
owner |
varchar(255) nullable | 客户端 owner |
scope |
varchar(20) | 首版固定 exclusive |
depth |
varchar(20) | 0 或 infinity |
timeout_at |
timestamp index | 过期时间 |
created_at/updated_at |
timestamps | 时间 |
- 默认锁 30 分钟,允许客户端请求 1 分钟至 2 小时。
- 通过定时任务清理过期锁。
- MOVE/重命名目录时,在同一事务内更新该资源及子资源锁 URI。
- 删除、撤销凭据或权限时删除相关锁。
8.3 webdav_properties
存储客户端通过 PROPPATCH 设置的死属性:
| 字段 | 类型 | 说明 |
|---|---|---|
id |
bigint PK | 主键 |
file_id |
bigint index | 资源 ID |
namespace |
varchar(255) | XML namespace |
name |
varchar(255) | 属性名 |
value |
longtext | 安全序列化后的 XML 值 |
created_at/updated_at |
timestamps | 时间 |
唯一键为 file_id + namespace_hash + name_hash。删除文件时一并删除;复制时复制死属性,移动时无需变化。
8.4 webdav_operation_logs
记录认证结果和写操作,GET/PROPFIND 只进入结构化访问日志与指标,避免数据库日志量失控。
字段至少包括:request_id、userid、credential_id、method、uri、file_id、status、result、bytes、ip、user_agent、duration_ms、created_at。
URI 可能包含敏感文件名,管理员页面默认只显示末级文件名,日志导出需要管理员权限。默认保留 90 天,由定时任务分批清理。
8.5 文件路径唯一性
WebDAV 要求同一集合内 URI 唯一,必须完成以下治理:
- 增加只读审计命令,检测同一有效父目录下完整名称冲突。
- 存在冲突时禁止管理员启用 WebDAV,并列出待处理文件 ID。
- 所有网页 API 和 DAV 写入统一经过文件领域服务,并对父目录加分布式锁和数据库行锁。
- 冲突检查使用
pid + userid + name + ext + deleted_at IS NULL的现有数据库排序语义。 - 不直接增加包含
deleted_at的普通唯一索引,因为 MySQL 对 NULL 唯一值的行为不能保证软删除资源唯一;后续可通过生成列active_path_key增强约束。
9. 代码架构
9.1 依赖
在生产依赖中增加兼容当前 PHP 版本的 sabre/dav 稳定版本,并锁定小版本范围。引入前执行许可证、PHP 8.4 和 LaravelS 兼容验证。
不得使用 Sabre 的 SAPI 直接输出或 exit。需要将 Illuminate Request 桥接为 Sabre HTTP Request,再将 Sabre Response 转换为 Symfony Response/StreamedResponse。
9.2 新增模块建议
app/
├── Http/
│ ├── Controllers/
│ │ ├── Api/FileDavController.php
│ │ └── WebDavProtocolController.php
│ └── Middleware/WebDavRequest.php
├── Models/
│ ├── WebDavCredential.php
│ ├── WebDavLock.php
│ ├── WebDavProperty.php
│ └── WebDavOperationLog.php
├── Services/WebDav/
│ ├── WebDavServerFactory.php
│ ├── WebDavAuthBackend.php
│ ├── WebDavTree.php
│ ├── WebDavDirectory.php
│ ├── WebDavFile.php
│ ├── WebDavLockBackend.php
│ ├── WebDavPropertyBackend.php
│ ├── WebDavPathResolver.php
│ └── WebDavAuditService.php
└── Services/FileSystem/
├── FileSystemService.php
├── FileContentStorage.php
├── FileName.php
├── FileTypeResolver.php
└── FileOperationResult.php
9.3 FileSystemService 边界
服务方法接收明确的 User 和结构化参数,不读取全局 Request,不返回 HTTP 响应:
list(User $actor, int $parentId, string $scope): Collection
resolveChild(User $actor, int $parentId, string $fullName): File
createDirectory(User $actor, int $parentId, string $name): FileOperationResult
putFromStream(User $actor, int $parentId, string $fullName, $stream, PutOptions $options): FileOperationResult
rename(User $actor, File $file, string $newName): FileOperationResult
move(User $actor, File $file, int $targetParentId, MoveOptions $options): FileOperationResult
copy(User $actor, File $file, int $targetParentId, CopyOptions $options): FileOperationResult
delete(User $actor, File $file): FileOperationResult
read(User $actor, File $file, ?int $versionId = null): FileReadHandle
现有 FileController 的 add、copy、move、remove、content save/upload 逐步改为调用此服务,保持 API 响应不变。这样 DAV 和网页端共享同一事务、权限和副作用。
9.4 内容存储
FileContentStorage 负责:
- 临时流落盘、大小限制和哈希计算。
- 将临时文件原子移动到
uploads/file/{type}/{Ym}/{fileId}/{contentKey}。 - 打开最新内容只读流。
- 复制内容时创建独立物理文件,避免两个
FileContentURL 引用同一路径后其中一个清理导致另一个损坏。 - 删除物理文件前检查是否仍有其他
FileContent引用同一 URL,兼容历史复制数据。 - DB 失败时清理已移动文件;进程异常时由孤儿文件扫描任务兜底。
9.5 请求生命周期
每次 DAV 请求创建新的 Sabre Server、树、认证 backend 和响应对象。当前认证用户写入 RequestContext,请求结束由 WebDAV middleware 清理。
禁止将以下对象注册为保存请求状态的单例:
- Sabre Server
- 当前 User
- 当前 Credential
- PathResolver 的节点缓存
- 请求/响应流
9.6 大文件请求入口
完整实现需要支持现有系统允许的最大文件,同时不能让单个 PUT 占用等量 Worker 内存。采用两级决策:
- 首先在当前 LaravelS 入口分别上传 100 MB、500 MB、1 GB 文件,记录 Nginx、Swoole Worker 和容器 RSS 峰值。
- 只有 RSS 增量保持在固定缓冲上限内,才允许
/dav继续复用 LaravelS。 - 如果 RSS 随文件大小线性增长,则生产架构增加独立
webdavPHP-FPM 容器;Nginx 仅将/dav/转发给该容器,普通 API 和 WebSocket 仍走 LaravelS。 - 独立入口复用同一份 Laravel 代码、数据库、Redis 和项目文件卷,但每请求启动独立应用生命周期,通过
php://input流式读取。 - PHP-FPM 方案仍需验证 Nginx/FastCGI 是否落临时文件或流式传递,并统一临时目录容量、超时和请求大小。
不得采用以下降级方式规避问题:把 1 GB body 放入 Redis、由 Swoole Worker 整体读取后再分块、或仅依靠提高容器内存。若独立入口尚未完成,管理员页面必须把 WebDAV 单文件上限限制为已压测证明安全的值。
10. 路由与 API
10.1 DAV 协议路由
在 SPA 兜底路由之前注册:
OPTIONS /dav/{path?}
PROPFIND /dav/{path?}
PROPPATCH /dav/{path?}
HEAD /dav/{path?}
GET /dav/{path?}
PUT /dav/{path?}
MKCOL /dav/{path?}
COPY /dav/{path?}
MOVE /dav/{path?}
DELETE /dav/{path?}
LOCK /dav/{path?}
UNLOCK /dav/{path?}
path 使用 .* 约束。/dav/* 加入 CSRF 排除,但仍由 WebDAV Basic 认证保护。Nginx 明确增加优先于 SPA 的 /dav/ location。该 location 按 9.6 节验证结果转发到 LaravelS 或独立 PHP-FPM DAV 入口;请求缓冲、临时目录和超时以实测的恒定内存为验收标准。
10.2 管理 API
管理接口使用 api/file/dav/xxx 命名,但由独立 Api\FileDavController 承载,不向冻结的巨型 FileController 新增方法:
| API | 方法 | 权限 | 用途 |
|---|---|---|---|
api/file/dav/adminsetting |
GET/POST | admin | 获取/保存全局配置 |
api/file/dav/adminstatus |
GET | admin | 运行状态、冲突审计和近期失败 |
api/file/dav/userrevoke |
POST | admin | 撤销用户全部凭据 |
api/file/dav/status |
GET | 登录用户 | 当前可用性、URL、策略 |
api/file/dav/credentials |
GET | 登录用户 | 凭据列表,不返回哈希 |
api/file/dav/create |
POST | 登录用户 | 创建并一次性返回密码 |
api/file/dav/revoke |
POST | 登录用户 | 撤销凭据 |
api/file/dav/delete |
POST | 登录用户 | 永久删除本人已撤销或已过期的凭据,保留操作审计 |
这些 URL 保持 file/{method}/{action} 的两段动态路由限制,控制器方法分别为 dav__adminsetting、dav__adminstatus、dav__userrevoke、dav__status、dav__credentials、dav__create、dav__revoke、dav__delete。
路由中先将 method = dav 明确分派到 FileDavController,再让其他 file/{method}/{action} 进入现有 FileController;现有 FileController 路由应增加排除 dav 的约束,避免相同 URI 模式产生不确定匹配。新增控制器和路由后运行 ./cmd artisan doc:api-map。
这里的 api/file/dav/xxx 只承载网页使用的 JSON 管理接口。WebDAV 客户端仍连接 /dav/{path?} 协议路由,因为它需要任意深度路径、自定义 HTTP 方法、XML 多状态响应和独立异常处理。
10.3 API 契约
所有管理 API 继续使用 Base::retSuccess() / Base::retError(),不返回 WebDAV XML。核心载荷如下:
POST api/file/dav/create
request: { name: string, expire_days: int }
response: { id, public_id, password, password_suffix, url, expires_at }
POST api/file/dav/revoke
request: { id: int }
response: { id, revoked_at }
GET api/file/dav/credentials
response: [{ id, public_id, name, password_suffix, expires_at,
last_used_at, last_used_ip, last_user_agent, status }]
GET api/file/dav/status
response: { enabled, allowed, https, url, max_credentials,
active_credentials, default_expire_days, max_expire_days,
max_file_bytes }
POST api/file/dav/userrevoke
request: { userid: int }
response: { userid, revoked_count, revoked_at }
password只存在于创建成功响应,列表和日志不得出现。expire_days必须在管理员策略范围内;0仅在管理员允许永不过期时有效。- 撤销接口幂等,重复撤销返回成功和原
revoked_at。 status.enabled表示全局开关,allowed表示当前用户是否在允许范围,两者不能混用。- 管理设置保存采用字段白名单和完整归一化,前端未提交的敏感策略不得被空值覆盖。
10.4 配置
用户可配置策略存储在 fileSetting:
webdav_enabled
webdav_permission_type all / appoint
webdav_permission_userids
webdav_max_credentials
webdav_default_expire_days
webdav_max_expire_days
webdav_max_file_bytes
webdav_copy_max_nodes
webdav_audit_retention_days
协议硬限制和默认值放在 config/dootask.php,业务代码不直接读取 env()。配置/路由变更部署后需要重启 LaravelS。
11. 并发、事务与锁
11.1 两类锁
- 协议锁:
webdav_locks,对客户端可见,实现 DAVLOCK/UNLOCK。 - 服务端互斥锁:复用
App\Module\Lock,保护同一父目录的命名空间和同一文件版本写入。
两者不可相互替代。即使客户端未主动 LOCK,服务端仍必须使用短期互斥锁保证事务一致性。
11.2 加锁顺序
为避免死锁,统一按以下顺序:
- 规范化资源路径。
- 检查 DAV 锁 token。
- 获取按数字 ID 排序后的父目录分布式锁。
- 开启数据库事务。
- 按 ID 升序
lockForUpdate锁父目录、源资源、目标资源。 - 再次检查权限、名称冲突和条件请求。
- 写数据库并提交。
- 事务外投递可重试的通知;现有必须同步的副作用保持原行为。
11.3 失败恢复
- 请求体接收失败:删除临时文件,不创建 File/FileContent。
- 物理文件移动失败:回滚数据库。
- 数据库失败:删除本次新物理文件;删除失败记入孤儿清理队列。
- 消息或搜索异步投递失败:主文件操作成功,记录失败并走现有重试机制。
- 客户端断开:检测连接状态并停止继续读取,finally 清理临时文件。
- MOVE/COPY 多资源失败:不得留下半棵可见目录;先在事务中完成元数据,超出同步上限直接在执行前拒绝。
12. 状态码与错误映射
| 场景 | 状态码 |
|---|---|
| 未提供或无效凭据 | 401 Unauthorized |
| 全局关闭或维护中 | 503 Service Unavailable |
| 无查看权限或资源不存在 | 404 Not Found |
| 有查看权限但无写权限 | 403 Forbidden |
| 同名目标且不允许覆盖 | 412 Precondition Failed |
| ETag 或 DAV If 条件失败 | 412 Precondition Failed |
| 资源被其他锁占用 | 423 Locked |
| 父目录不存在 | 409 Conflict |
| 文件夹达到 300 项 | 507 Insufficient Storage |
| 文件或复制规模超限 | 413 Content Too Large 或 507 |
| 不支持的方法 | 405 Method Not Allowed |
| PROPFIND/PROPPATCH 多状态 | 207 Multi-Status |
| PUT 新建成功 | 201 Created |
| PUT 覆盖成功 | 204 No Content |
| MOVE/COPY 成功 | 201 或 204 |
| DELETE 成功 | 204 No Content |
DAV 路由的异常必须由 DAV 专用异常渲染器转换为 XML 或空响应,不能落入全局 ApiException JSON 响应。
13. 安全设计
- 只允许 HTTPS;反向代理场景使用可信的
X-Forwarded-Proto判断。 - XML 使用禁用外部实体和网络访问的解析器,限制 XML 体积和节点数量,防止 XXE 与 XML bomb。
- 拒绝双重编码、路径穿越、编码斜杠、超长路径和控制字符。
Destination必须属于当前 Host 和/dav/基础路径,拒绝跨服务 COPY/MOVE。- 响应不暴露物理路径、SQL、文件所有者邮箱和权限查询细节。
- 下载设置
Content-Disposition、正确 MIME、X-Content-Type-Options: nosniff。 - 凭据明文只出现于创建响应;前端不得写入 localStorage、日志或埋点。
- 操作审计覆盖凭据创建、撤销、认证失败以及全部 DAV 写方法。
- 管理员允许名单变更、用户停用和密码策略变化不需要重发 WebDAV 密码,但必须实时影响访问状态。
14. 性能与容量
- PROPFIND
Depth: 1一次批量查询子节点和最新内容 ID,避免 N+1。 - 当前每目录最多 300 项,可在单次响应内返回;仍应使用游标式内部查询和固定字段选择。
- 共享根一次查询全部可见共享根,结果只在当前请求缓存。
- GET/PUT 采用 1 MB 左右分块流式处理,实际块大小通过压测确定。
- Nginx、LaravelS、PHP 临时目录和应用限制必须统一,避免某一层提前截断。
package_max_length = 1 GB只代表 Swoole 接受上限,不作为流式能力证明;入口进程 RSS 是强制验收指标。- COPY 目录默认最多 10,000 个节点;执行前先计数,超限不启动复制。
- 大文件和长请求设置独立的 Nginx 超时,不影响普通 API。
- 审计写入可通过 Swoole Task 异步投递,但认证失败和安全事件必须保证记录或进入结构化日志。
15. 前端设计
15.1 管理端
在现有「文件设置」增加 WebDAV 区域:
- 启用开关。
- 允许使用范围:全员/指定成员。
- 每用户凭据数、默认有效期、最大有效期。
- 单文件和目录复制限制。
- 当前服务 URL 与 HTTPS 状态。
- 路径冲突审计状态;存在冲突时禁用开启按钮并提供文件 ID 列表。
- 最近 24 小时认证失败数和写入失败数。
15.2 用户端
在文件页面右上角现有加号按钮右侧增加圆形“更多”按钮:
- 使用现有图标库的
ios-more,按钮尺寸、圆形样式和固定占位与加号保持一致。 - 点击后打开下拉菜单,首版包含「WebDAV」入口,后续文件级全局能力可以继续放入该菜单。
- 入口在“我的文件”和“共享文件”板块显示;“协作文件”不属于 DAV 范围,不显示该入口。
- 选择「WebDAV」后打开独立管理弹窗,不跳转到系统设置或个人安全页面。
- 弹窗展示可用状态、服务地址和凭据列表:设备名、末四位、创建时间、过期时间、最后使用时间和客户端。
- 弹窗内可以创建应用密码;创建成功后一次性展示连接信息。
- 弹窗内可以撤销单个凭据并二次确认。
- 管理员关闭时弹窗只展示不可用状态,不能创建新凭据。
- 移动端空间不足时保留加号和更多两个固定尺寸图标,搜索框优先收缩,按钮不得换行或覆盖。
所有新增可见中文同步登记到 language/original-web.txt 和 language/original-api.txt,并更新相关 ai-kb 功能 chunk。
16. 可观测性与运维
16.1 指标
至少统计:
- 按方法和状态码的请求数。
- 认证成功、失败、限流次数。
- 活跃用户和活跃凭据数。
- GET/PUT 字节数及耗时分布。
- 锁创建、冲突、超时数。
- ETag 冲突和覆盖次数。
- 临时文件、孤儿文件数量及清理失败数。
- DAV 写入后的消息/搜索同步失败数。
16.2 日志关联
- 每个请求生成
request_id并加入响应头X-Request-Id。 - DAV 操作日志、应用日志和 Nginx 日志都记录该 ID。
- 密码、Authorization、Lock-Token 不得进入日志。
- URI 记录前去除认证信息并限制长度。
16.3 定时维护
新增任务:
- 每分钟或按需清理过期 DAV 锁。
- 每日清理过期/撤销且超过保留期的凭据记录。
- 每日分批清理过期操作日志。
- 复用临时文件清理任务清理超时 DAV 上传目录。
- 定期扫描无 FileContent 引用的物理孤儿文件,只报告;自动删除需另行评审。
17. 测试方案
17.1 单元测试
- 路径编码、NFC、非法字符、穿越和双重解码。
- 完整文件名与
name/ext/type转换。 files/shared节点解析和共享顶层[#ID]。- 权限矩阵的每个方法。
- ETag 和全部条件请求组合。
- 锁创建、刷新、继承、冲突、过期和撤销。
- WebDAV 状态码与 DooTask 异常映射。
- 应用密码生成、哈希校验、过期和撤销。
17.2 Feature 测试
- OPTIONS 和 Basic challenge。
- PROPFIND Depth 0/1 的 XML 响应。
- PUT 新建、覆盖及历史版本。
- MKCOL、COPY、MOVE、DELETE 全流程。
- 临时文件 MOVE 覆盖目标且保留目标 ID。
- 共享只读、共享读写、创建者删除和越权访问。
- Range GET、HEAD、空文件和大文件流。
- 两客户端并发 PUT、锁冲突和 ETag 冲突。
- 凭据撤销、用户停用、全局关闭立即生效。
- 写操作后 WebSocket 推送和 Manticore Task 被正确投递。
- 数据库/物理写入异常时无可见半成品。
17.3 协议与客户端测试
- 使用 WebDAV Litmus 测试套件作为协议基线。
curl覆盖所有方法和条件头。- Windows 11 文件资源管理器:挂载、Office 保存、重命名、覆盖、删除。
- macOS Finder:连接、复制目录、锁定编辑、断线重连。
- Linux
davfs2:挂载和并发文件操作。 - Microsoft Office/LibreOffice:临时文件原子覆盖、锁刷新和冲突提示。
- 中文、空格、
#、%、emoji、超长名称和大小写冲突文件。
客户端测试记录环境、步骤、状态码和结果截图到 tests/playwright-results/ 或新增的 DAV 测试结果目录;协议测试不伪装为 Playwright 自动化结果。
17.4 质量门禁
实现完成后执行:
./cmd composer stan
npm run lint
npm run check:lang
./cmd artisan doc:api-map
不主动运行 ./cmd dev、./cmd prod 或 ./cmd build。
18. 实施阶段与验收
阶段 0:领域服务收敛
- 先完成 9.6 节请求体内存验证,并确定 LaravelS 或独立 PHP-FPM 入口;该结论记录到测试结果。
- 建立 FileSystemService、类型解析和内容存储。
- 现有文件 API 迁入服务,接口行为保持兼容。
- 修复复制内容物理引用和路径并发问题。
- 增加路径冲突审计命令。
验收:大文件入口架构已经用 RSS 数据确定;原文件页面全部操作通过;现有 API 响应无回归;新增并发测试通过。
阶段 1:开关与凭据
- 增加迁移、模型、管理员配置和用户凭据界面。
- 完成 Basic backend、限流、撤销和安全审计。
- 功能开关默认关闭。
验收:凭据只展示一次;撤销和全局关闭立即生效;日志无秘密信息。
阶段 2:只读协议
- 完成 OPTIONS、PROPFIND、HEAD、GET、ETag、Range。
- 完成
files/shared虚拟树和权限隐藏。 - 接入 Litmus 与三个操作系统的只读验证。
验收:大文件恒定内存;无权限资源不泄露;共享列表无重复和歧义。
阶段 3:写协议
- 完成 PUT、MKCOL、COPY、MOVE、DELETE、PROPPATCH。
- 完成统一事务、副作用、临时文件和原子覆盖。
- 完成共享权限矩阵。
验收:网页端和 DAV 互相实时可见;覆盖产生历史;异常不留半成品。
阶段 4:锁与兼容
- 完成 LOCK/UNLOCK、DAV If 头和锁清理。
- 完成 Windows/macOS/Office 兼容修正和性能压测。
- 完成运维仪表和告警。
验收:Litmus 目标用例通过;Office 原子保存稳定;并发编辑不静默丢失数据。
阶段 5:灰度上线
- 先对指定内部用户启用。
- 观察至少一个完整凭据和锁超时周期。
- 检查错误率、孤儿文件、同步失败和数据库慢查询。
- 再逐步扩大允许范围,最后由管理员决定是否全员开放。
工作包与依赖
| 工作包 | 内容 | 前置依赖 | 交付判定 |
|---|---|---|---|
| W0 | LaravelS/PHP-FPM 大文件入口验证 | 无 | 形成 RSS 数据和确定的部署拓扑 |
| W1 | FileSystemService、类型解析、内容存储 | 无 | 原网页文件 API 全部复用服务且行为无回归 |
| W2 | 凭据、锁、属性、审计迁移与模型 | 无 | 迁移和模型单测通过,不修改现有文件数据 |
| W3 | 管理配置、用户凭据 API 与前端 | W2 | 开启、创建、一次展示、撤销、停用形成闭环 |
| W4 | Sabre 请求桥、Basic backend、DAV 中间件 | W0、W2 | OPTIONS 和认证挑战符合协议,异常不返回 JSON |
| W5 | 虚拟树、路径解析、PROPFIND/HEAD/GET | W1、W4 | 我的文件和共享文件只读客户端验证通过 |
| W6 | PUT/MKCOL/COPY/MOVE/DELETE | W1、W5 | 写入历史、权限、副作用和失败补偿测试通过 |
| W7 | LOCK/UNLOCK、PROPPATCH、条件请求 | W2、W5、W6 | 并发编辑返回正确 412/423,无静默覆盖 |
| W8 | 审计、指标、清理任务和管理状态 | W2、W4 | 可定位失败请求,过期数据自动分批收敛 |
| W9 | Litmus、系统客户端、Office 和压测 | W5、W6、W7、W8 | 目标兼容矩阵和性能门禁全部有记录 |
| W10 | API map、语言、ai-kb、部署和运维文档 | W3 至 W9 | 文档与最终行为一致,版本号完成复核 |
W0、W1、W2 可以并行;W4 不得在 W0 未定结论时固化部署实现;W6 不得绕过 W1 直接写模型。每个工作包都应包含对应自动化测试,避免把测试集中到 W9 才补。
19. 发布、回滚与数据安全
19.1 发布前
- 数据库备份。
- 执行路径冲突审计,存在冲突则停止启用。
- 验证 HTTPS、代理头、大文件限制和临时目录容量。
- 安装依赖并完成 PHP 8.4/LaravelS 冒烟测试。
- 迁移只新增表和索引,不删除现有数据。
19.2 回滚
- 首先关闭
webdav_enabled,立即阻断协议流量。 - 保留凭据、锁和审计表,便于调查和再次启用。
- 回滚协议路由和代码不影响已有
files/file_contents数据。 - 不自动删除 DAV 创建的文件,因为它们已经是正常 DooTask 文件。
- 如需卸载表结构,必须另行确认并先导出审计;不作为常规代码回滚步骤。
19.3 兼容承诺
- DAV 创建的文件必须能在网页端正常预览、下载、移动和恢复历史。
- 网页端修改必须在下一次 DAV 请求立即可见。
- 禁用 WebDAV 不改变任何文件、共享关系或历史版本。
- 后续升级不得改变
files/、shared/URI 名称和共享顶层 ID 规则。
20. 风险与决策记录
| 风险 | 处理决策 |
|---|---|
| 现有控制器含业务逻辑 | 先收敛到 FileSystemService,再接 DAV |
| 应用密码被窃取 | 强制 HTTPS、只存哈希、可撤销、限流、审计 |
| Swoole 请求状态串联 | 每请求建 Server,用户和缓存放 RequestContext |
| 客户端静默覆盖 | ETag + DAV If + LOCK,失败返回 412/423 |
| 共享写权限与删除权限不同 | 保留现有语义,原子覆盖不解释为删除目标 |
| 同名共享根冲突 | 共享顶层 URI 固定附加 [#file_id] |
| 大文件耗尽内存 | 全链路流式、统一上限、临时目录监控 |
| Swoole 在 Laravel 前聚合 PUT body | RSS 压测作为门禁;不满足时使用独立 PHP-FPM DAV 入口 |
| 复制内容共享物理 URL | 新复制创建独立内容,旧数据删除前查引用 |
| 异常留下物理孤儿 | 补偿清理 + 孤儿扫描报告 |
| DAV 错误落成 JSON | 独立中间件和异常响应转换 |
| 路径冲突导致 URI 不唯一 | 启用前审计、父目录锁、统一服务写入 |
21. 完成定义
只有同时满足以下条件,WebDAV 才算功能闭环:
- 管理员可以启用、限制、观测和关闭服务。
- 用户可以创建、使用、查看状态和撤销应用密码。
files/shared的读写与网页权限一致。- 所有协议方法返回标准状态码和 XML。
- 写入保留历史并触发现有通知、搜索和回收站行为。
- 锁、ETag 和条件请求可以防止并发静默覆盖。
- 大文件不会整体进入 PHP 内存,失败会清理临时文件。
- Windows、macOS、Linux 和办公客户端有可追溯的验证结果。
- 功能默认关闭,可灰度,可即时停用,停用不破坏文件数据。
- API 对照表、语言文件、ai-kb、运维文档和测试在同一次功能交付中同步更新。