Back to Dootask

DooTask WebDAV 技术设计方案

docs/webdav-design.md

1.9.1840.0 KB
Original Source

DooTask WebDAV 技术设计方案

状态:设计稿 适用主程序版本:1.8.89 范围:DooTask「我的文件」和「共享文件」 默认策略:管理员全局启用,用户使用独立 WebDAV 应用密码连接

1. 目标与结论

为 DooTask 文件系统提供标准 WebDAV 访问,使用户可以通过 Windows、macOS、Linux 和支持 WebDAV 的办公软件访问文件,同时保持以下行为与网页端一致:

  • 文件和目录权限一致。
  • 写入生成文件历史版本。
  • 移动、重命名、删除触发现有消息推送和搜索同步。
  • 共享文件保持现有所有者、创建者、只读、读写权限语义。
  • 凭据可以独立创建、过期和撤销,不影响主账号登录。
  • 并发写入遵循 WebDAV 锁和 HTTP 条件请求,避免静默覆盖。

WebDAV 暴露的是由 filesfile_contentsfile_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 管理员流程

  1. 管理员进入「系统设置 > 文件设置 > WebDAV」。
  2. 开启 WebDAV,并选择允许范围:全员或指定成员。
  3. 配置凭据数量、有效期、单文件上限、复制上限和审计保留天数。
  4. 保存后,状态接口返回服务地址和当前运行能力。
  5. 管理员可以查看连接用户、最近失败和写操作审计,并可停用某个用户的全部 WebDAV 凭据。
  6. 全局关闭时,所有 DAV 请求立即返回 503 Service Unavailable,凭据保留,以便重新启用;管理员可另行执行凭据全部撤销。

3.2 用户开通流程

  1. 用户进入文件页面,在右上角加号右侧点击圆形“更多”图标,在菜单中选择「WebDAV」。
  2. 页面打开 WebDAV 管理弹窗,展示管理员是否启用、服务器地址、支持范围和已有凭据。
  3. 用户点击「创建应用密码」,填写设备名称并选择有效期。
  4. 服务端返回一次性的用户名和应用密码;应用密码此后不可再次读取。
  5. 页面提供服务器地址、用户名和密码字段及复制按钮,同时提示必须使用 HTTPS。
  6. 用户在客户端连接后,页面更新最后使用时间、IP 和客户端名称。
  7. 用户可以撤销单个凭据;撤销后新请求立即失败,已有锁同步失效。已撤销或已过期的凭据可以永久删除,删除前保留一条不含秘密信息的操作审计;有效凭据必须先撤销。

3.3 文件操作闭环

每个写请求必须按以下顺序完成:

  1. 认证应用密码并检查全局、用户和凭据状态。
  2. 规范化并解析 DAV 路径,确认路径不能越过虚拟根和共享边界。
  3. 校验资源权限、锁 token、ETag 和目标冲突。
  4. 将请求体流式写入临时文件,并在写入时计算大小和 SHA-256。
  5. 调用统一文件领域服务执行数据库与物理文件操作。
  6. 写入新 FileContent 版本并更新 File 元数据。
  7. 触发现有 WebSocket 消息、Observer 和 Manticore 同步。
  8. 写入 DAV 审计日志并返回标准 DAV 状态码。
  9. 无论成功失败都关闭流并清理临时文件;异常遗留由定时清理兜底。

4. URL 与目录模型

4.1 服务地址

text
https://{host}/dav/

根目录使用固定、不随语言变化的 URI 段:

text
/dav/
├── files/
└── shared/

固定 ASCII URI 可以避免用户切换语言后挂载路径失效。前端说明可将其翻译为「我的文件」和「共享文件」。

4.2 files/ 映射

  • /dav/files/ 映射当前用户 pid = 0 AND userid = 当前用户 的资源。
  • 后续每一段按 pid + 完整文件名 解析。
  • 完整文件名为 name,文件有扩展名时为 name.ext
  • 文件夹不得带文件扩展名语义,按 type = folder 判断。

4.3 shared/ 映射

共享根的每个顶层项使用以下稳定且无冲突的 DAV 名称:

text
{原完整名称} [#{共享根文件ID}]

示例:

text
/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复制资源文件及目录;遵循 DepthDestinationOverwrite
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。
  • PUTMOVECOPYDELETE 必须处理 If-MatchIf-None-Match 和 DAV If 头。
  • 条件不满足返回 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 覆盖目标”的方式保存。服务端必须特殊处理:

  1. 当前用户在目标目录创建临时文件。
  2. MOVE 的目标已存在且 Overwrite: T
  3. 用户对目标有写权限,并持有需要的锁。
  4. 服务端将临时文件最新内容作为目标文件的新版本,保留目标 files.id
  5. 删除临时文件记录,返回 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_atlast_used_iplast_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

字段类型说明
idbigint PK主键
public_idvarchar(40) uniqueBasic 用户名,不含秘密
useridbigint index所属用户
namevarchar(100)用户填写的设备名称
password_hashvarchar(255)应用密码哈希
password_suffixvarchar(4)展示末四位
expires_attimestamp nullable过期时间
last_used_attimestamp nullable最近使用
last_used_ipvarchar(45) nullable最近 IP
last_user_agentvarchar(255) nullable最近客户端
revoked_attimestamp nullable撤销时间
created_at/updated_attimestamps时间

不使用软删除,撤销记录保留到审计保留期结束。默认每用户最多 5 个有效凭据。

8.2 webdav_locks

字段类型说明
idbigint PK主键
tokenvarchar(100) uniqueopaquelocktoken:{uuid}
useridbigint index锁所有者
credential_idbigint index创建锁的凭据
file_idbigint nullable index已存在资源 ID;lock-null 时为空
urivarchar(1000) index prefix规范化 DAV URI
uri_hashchar(64) indexURI SHA-256,精确查询
ownervarchar(255) nullable客户端 owner
scopevarchar(20)首版固定 exclusive
depthvarchar(20)0infinity
timeout_attimestamp index过期时间
created_at/updated_attimestamps时间
  • 默认锁 30 分钟,允许客户端请求 1 分钟至 2 小时。
  • 通过定时任务清理过期锁。
  • MOVE/重命名目录时,在同一事务内更新该资源及子资源锁 URI。
  • 删除、撤销凭据或权限时删除相关锁。

8.3 webdav_properties

存储客户端通过 PROPPATCH 设置的死属性:

字段类型说明
idbigint PK主键
file_idbigint index资源 ID
namespacevarchar(255)XML namespace
namevarchar(255)属性名
valuelongtext安全序列化后的 XML 值
created_at/updated_attimestamps时间

唯一键为 file_id + namespace_hash + name_hash。删除文件时一并删除;复制时复制死属性,移动时无需变化。

8.4 webdav_operation_logs

记录认证结果和写操作,GET/PROPFIND 只进入结构化访问日志与指标,避免数据库日志量失控。

字段至少包括:request_iduseridcredential_idmethodurifile_idstatusresultbytesipuser_agentduration_mscreated_at

URI 可能包含敏感文件名,管理员页面默认只显示末级文件名,日志导出需要管理员权限。默认保留 90 天,由定时任务分批清理。

8.5 文件路径唯一性

WebDAV 要求同一集合内 URI 唯一,必须完成以下治理:

  1. 增加只读审计命令,检测同一有效父目录下完整名称冲突。
  2. 存在冲突时禁止管理员启用 WebDAV,并列出待处理文件 ID。
  3. 所有网页 API 和 DAV 写入统一经过文件领域服务,并对父目录加分布式锁和数据库行锁。
  4. 冲突检查使用 pid + userid + name + ext + deleted_at IS NULL 的现有数据库排序语义。
  5. 不直接增加包含 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 新增模块建议

text
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 响应:

php
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}
  • 打开最新内容只读流。
  • 复制内容时创建独立物理文件,避免两个 FileContent URL 引用同一路径后其中一个清理导致另一个损坏。
  • 删除物理文件前检查是否仍有其他 FileContent 引用同一 URL,兼容历史复制数据。
  • DB 失败时清理已移动文件;进程异常时由孤儿文件扫描任务兜底。

9.5 请求生命周期

每次 DAV 请求创建新的 Sabre Server、树、认证 backend 和响应对象。当前认证用户写入 RequestContext,请求结束由 WebDAV middleware 清理。

禁止将以下对象注册为保存请求状态的单例:

  • Sabre Server
  • 当前 User
  • 当前 Credential
  • PathResolver 的节点缓存
  • 请求/响应流

9.6 大文件请求入口

完整实现需要支持现有系统允许的最大文件,同时不能让单个 PUT 占用等量 Worker 内存。采用两级决策:

  1. 首先在当前 LaravelS 入口分别上传 100 MB、500 MB、1 GB 文件,记录 Nginx、Swoole Worker 和容器 RSS 峰值。
  2. 只有 RSS 增量保持在固定缓冲上限内,才允许 /dav 继续复用 LaravelS。
  3. 如果 RSS 随文件大小线性增长,则生产架构增加独立 webdav PHP-FPM 容器;Nginx 仅将 /dav/ 转发给该容器,普通 API 和 WebSocket 仍走 LaravelS。
  4. 独立入口复用同一份 Laravel 代码、数据库、Redis 和项目文件卷,但每请求启动独立应用生命周期,通过 php://input 流式读取。
  5. PHP-FPM 方案仍需验证 Nginx/FastCGI 是否落临时文件或流式传递,并统一临时目录容量、超时和请求大小。

不得采用以下降级方式规避问题:把 1 GB body 放入 Redis、由 Swoole Worker 整体读取后再分块、或仅依靠提高容器内存。若独立入口尚未完成,管理员页面必须把 WebDAV 单文件上限限制为已压测证明安全的值。

10. 路由与 API

10.1 DAV 协议路由

在 SPA 兜底路由之前注册:

text
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/adminsettingGET/POSTadmin获取/保存全局配置
api/file/dav/adminstatusGETadmin运行状态、冲突审计和近期失败
api/file/dav/userrevokePOSTadmin撤销用户全部凭据
api/file/dav/statusGET登录用户当前可用性、URL、策略
api/file/dav/credentialsGET登录用户凭据列表,不返回哈希
api/file/dav/createPOST登录用户创建并一次性返回密码
api/file/dav/revokePOST登录用户撤销凭据
api/file/dav/deletePOST登录用户永久删除本人已撤销或已过期的凭据,保留操作审计

这些 URL 保持 file/{method}/{action} 的两段动态路由限制,控制器方法分别为 dav__adminsettingdav__adminstatusdav__userrevokedav__statusdav__credentialsdav__createdav__revokedav__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。核心载荷如下:

text
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

text
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,对客户端可见,实现 DAV LOCK/UNLOCK
  • 服务端互斥锁:复用 App\Module\Lock,保护同一父目录的命名空间和同一文件版本写入。

两者不可相互替代。即使客户端未主动 LOCK,服务端仍必须使用短期互斥锁保证事务一致性。

11.2 加锁顺序

为避免死锁,统一按以下顺序:

  1. 规范化资源路径。
  2. 检查 DAV 锁 token。
  3. 获取按数字 ID 排序后的父目录分布式锁。
  4. 开启数据库事务。
  5. 按 ID 升序 lockForUpdate 锁父目录、源资源、目标资源。
  6. 再次检查权限、名称冲突和条件请求。
  7. 写数据库并提交。
  8. 事务外投递可重试的通知;现有必须同步的副作用保持原行为。

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 Large507
不支持的方法405 Method Not Allowed
PROPFIND/PROPPATCH 多状态207 Multi-Status
PUT 新建成功201 Created
PUT 覆盖成功204 No Content
MOVE/COPY 成功201204
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.txtlanguage/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 质量门禁

实现完成后执行:

text
./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:灰度上线

  • 先对指定内部用户启用。
  • 观察至少一个完整凭据和锁超时周期。
  • 检查错误率、孤儿文件、同步失败和数据库慢查询。
  • 再逐步扩大允许范围,最后由管理员决定是否全员开放。

工作包与依赖

工作包内容前置依赖交付判定
W0LaravelS/PHP-FPM 大文件入口验证形成 RSS 数据和确定的部署拓扑
W1FileSystemService、类型解析、内容存储原网页文件 API 全部复用服务且行为无回归
W2凭据、锁、属性、审计迁移与模型迁移和模型单测通过,不修改现有文件数据
W3管理配置、用户凭据 API 与前端W2开启、创建、一次展示、撤销、停用形成闭环
W4Sabre 请求桥、Basic backend、DAV 中间件W0、W2OPTIONS 和认证挑战符合协议,异常不返回 JSON
W5虚拟树、路径解析、PROPFIND/HEAD/GETW1、W4我的文件和共享文件只读客户端验证通过
W6PUT/MKCOL/COPY/MOVE/DELETEW1、W5写入历史、权限、副作用和失败补偿测试通过
W7LOCK/UNLOCK、PROPPATCH、条件请求W2、W5、W6并发编辑返回正确 412/423,无静默覆盖
W8审计、指标、清理任务和管理状态W2、W4可定位失败请求,过期数据自动分批收敛
W9Litmus、系统客户端、Office 和压测W5、W6、W7、W8目标兼容矩阵和性能门禁全部有记录
W10API map、语言、ai-kb、部署和运维文档W3 至 W9文档与最终行为一致,版本号完成复核

W0、W1、W2 可以并行;W4 不得在 W0 未定结论时固化部署实现;W6 不得绕过 W1 直接写模型。每个工作包都应包含对应自动化测试,避免把测试集中到 W9 才补。

19. 发布、回滚与数据安全

19.1 发布前

  • 数据库备份。
  • 执行路径冲突审计,存在冲突则停止启用。
  • 验证 HTTPS、代理头、大文件限制和临时目录容量。
  • 安装依赖并完成 PHP 8.4/LaravelS 冒烟测试。
  • 迁移只新增表和索引,不删除现有数据。

19.2 回滚

  1. 首先关闭 webdav_enabled,立即阻断协议流量。
  2. 保留凭据、锁和审计表,便于调查和再次启用。
  3. 回滚协议路由和代码不影响已有 files/file_contents 数据。
  4. 不自动删除 DAV 创建的文件,因为它们已经是正常 DooTask 文件。
  5. 如需卸载表结构,必须另行确认并先导出审计;不作为常规代码回滚步骤。

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 bodyRSS 压测作为门禁;不满足时使用独立 PHP-FPM DAV 入口
复制内容共享物理 URL新复制创建独立内容,旧数据删除前查引用
异常留下物理孤儿补偿清理 + 孤儿扫描报告
DAV 错误落成 JSON独立中间件和异常响应转换
路径冲突导致 URI 不唯一启用前审计、父目录锁、统一服务写入

21. 完成定义

只有同时满足以下条件,WebDAV 才算功能闭环:

  • 管理员可以启用、限制、观测和关闭服务。
  • 用户可以创建、使用、查看状态和撤销应用密码。
  • files/shared 的读写与网页权限一致。
  • 所有协议方法返回标准状态码和 XML。
  • 写入保留历史并触发现有通知、搜索和回收站行为。
  • 锁、ETag 和条件请求可以防止并发静默覆盖。
  • 大文件不会整体进入 PHP 内存,失败会清理临时文件。
  • Windows、macOS、Linux 和办公客户端有可追溯的验证结果。
  • 功能默认关闭,可灰度,可即时停用,停用不破坏文件数据。
  • API 对照表、语言文件、ai-kb、运维文档和测试在同一次功能交付中同步更新。