docs/webdav-design.md
状态:设计稿 适用主程序版本:1.8.89 范围:DooTask「我的文件」和「共享文件」 默认策略:管理员全局启用,用户使用独立 WebDAV 应用密码连接
为 DooTask 文件系统提供标准 WebDAV 访问,使用户可以通过 Windows、macOS、Linux 和支持 WebDAV 的办公软件访问文件,同时保持以下行为与网页端一致:
WebDAV 暴露的是由 files、file_contents、file_users 组成的虚拟文件系统,不直接暴露 public/uploads 物理目录。
files/ 和 shared/ 两个虚拟集合。files/:当前用户拥有的根文件及全部子级。shared/:其他用户明确共享给当前用户的顶层共享项及全部子级。public/uploads/file 直接配置为 Nginx WebDAV 目录。503 Service Unavailable,凭据保留,以便重新启用;管理员可另行执行凭据全部撤销。每个写请求必须按以下顺序完成:
FileContent 版本并更新 File 元数据。https://{host}/dav/
根目录使用固定、不随语言变化的 URI 段:
/dav/
├── files/
└── shared/
固定 ASCII URI 可以避免用户切换语言后挂载路径失效。前端说明可将其翻译为「我的文件」和「共享文件」。
files/ 映射/dav/files/ 映射当前用户 pid = 0 AND userid = 当前用户 的资源。pid + 完整文件名 解析。name,文件有扩展名时为 name.ext。type = folder 判断。shared/ 映射共享根的每个顶层项使用以下稳定且无冲突的 DAV 名称:
{原完整名称} [#{共享根文件ID}]
示例:
/dav/shared/产品资料 [#128]/设计/首页.fig
/dav/shared/预算表 [#356].xlsx
[#ID] 只用于共享顶层 URI,子级保持原名称。displayname 属性返回原完整名称,不包含 [#ID]。404,不做永久重定向,避免 DAV 客户端缓存错误。files/,不在 shared/ 重复展示。.、..、空段和编码后的路径分隔符。\\ / : * ? " < > |。RequestContext,不得跨请求缓存权限结果。| 方法 | 作用 | 首版行为 |
|---|---|---|
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 响应头。
至少实现:
{DAV:}displayname{DAV:}resourcetype{DAV:}getcontentlength{DAV:}getcontenttype{DAV:}getetag{DAV:}getlastmodified{DAV:}creationdate{DAV:}supportedlock{DAV:}lockdiscovery不声明配额属性,直到项目存在真实的用户容量配额。文件夹大小不得在 PROPFIND 中递归计算。
"f-{file_id}-v-{latest_file_content_id}"。"f-{file_id}-v-0"。W/"d-{file_id}-{updated_at_timestamp}"。Last-Modified 使用 files.updated_at,统一输出 GMT。PUT、MOVE、COPY、DELETE 必须处理 If-Match、If-None-Match 和 DAV If 头。412 Precondition Failed,不得继续写入。FileContent。uploads/... 地址。php://input 对应的请求流分块写入临时文件,禁止 getContent() 整体载入内存。package_max_length 虽为 1 GB,但该配置只是允许请求大小,不证明原始 PUT body 在进入 Laravel 前不会被 Swoole 聚合到内存。实现阶段必须先通过 RSS 压测验证请求入口;未通过时必须启用 9.6 节的独立 DAV 入口。413 Content Too Large,未知长度请求在流式累计超限时中断。files.id,新增一条 file_contents,保证分享链接、历史记录和最近访问仍指向原文件。type 使用统一类型映射服务,不在 DAV 层复制 match 列表。桌面客户端常使用“上传临时文件,再 MOVE 覆盖目标”的方式保存。服务端必须特殊处理:
Overwrite: T。files.id。204 No Content。这样共享读写用户无需拥有目标文件的删除权限,也能完成 Office 原子保存。
| DooTask 权限 | DAV 能力 |
|---|---|
-1 无权限 | 统一表现为 404,避免泄露资源存在性 |
0 只读 | PROPFIND、HEAD、GET、作为 COPY 来源 |
1 读写 | 只读能力 + PUT、MKCOL、PROPPATCH、LOCK;可修改/重命名资源,不能删除或移走他人资源 |
1000 所有者或创建者 | 全部能力,包括 DELETE、MOVE 和共享边界管理允许的操作 |
shared/ 虚拟根永远不可写。403 Forbidden。created_id 是当前用户,可由该用户移动和删除。add(id) 行为一致。shared/ 顶层项 MOVE 到 files/,也不允许改变共享关系。files/:来源需可读,目标需可写,新副本归当前用户所有。files/ COPY 到共享目录:目标共享目录需读写,新副本所有者沿用共享根所有者,创建者为当前用户。404。dtw_01J...。Hash::make() 结果和密码末四位,不保存明文或可逆密文。选择独立公开用户名而不是邮箱,原因是:凭据可以独立撤销;无需处理 LDAP/SSO 密码;认证查询可以命中唯一索引;不会泄露登录邮箱。
凭据可处于:
active:可正常认证。expired:超过 expires_at。revoked:用户或管理员撤销。disabled:全局开关、允许范围或用户状态导致不可用,不改变凭据记录。认证成功后异步或限频更新 last_used_at、last_used_ip、last_user_agent,同一凭据最多每 5 分钟写库一次。
429。hash_equals 或 Laravel Hash 校验,错误响应不区分用户名不存在、密码错误和凭据已撤销。401 必须返回 WWW-Authenticate: Basic realm="DooTask WebDAV", charset="UTF-8"。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 个有效凭据。
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 | 时间 |
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。删除文件时一并删除;复制时复制死属性,移动时无需变化。
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 天,由定时任务分批清理。
WebDAV 要求同一集合内 URI 唯一,必须完成以下治理:
pid + userid + name + ext + deleted_at IS NULL 的现有数据库排序语义。deleted_at 的普通唯一索引,因为 MySQL 对 NULL 唯一值的行为不能保证软删除资源唯一;后续可通过生成列 active_path_key 增强约束。在生产依赖中增加兼容当前 PHP 版本的 sabre/dav 稳定版本,并锁定小版本范围。引入前执行许可证、PHP 8.4 和 LaravelS 兼容验证。
不得使用 Sabre 的 SAPI 直接输出或 exit。需要将 Illuminate Request 桥接为 Sabre HTTP Request,再将 Sabre Response 转换为 Symfony Response/StreamedResponse。
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
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 和网页端共享同一事务、权限和副作用。
FileContentStorage 负责:
uploads/file/{type}/{Ym}/{fileId}/{contentKey}。FileContent URL 引用同一路径后其中一个清理导致另一个损坏。FileContent 引用同一 URL,兼容历史复制数据。每次 DAV 请求创建新的 Sabre Server、树、认证 backend 和响应对象。当前认证用户写入 RequestContext,请求结束由 WebDAV middleware 清理。
禁止将以下对象注册为保存请求状态的单例:
完整实现需要支持现有系统允许的最大文件,同时不能让单个 PUT 占用等量 Worker 内存。采用两级决策:
/dav 继续复用 LaravelS。webdav PHP-FPM 容器;Nginx 仅将 /dav/ 转发给该容器,普通 API 和 WebSocket 仍走 LaravelS。php://input 流式读取。不得采用以下降级方式规避问题:把 1 GB body 放入 Redis、由 Swoole Worker 整体读取后再分块、或仅依靠提高容器内存。若独立入口尚未完成,管理员页面必须把 WebDAV 单文件上限限制为已压测证明安全的值。
在 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 入口;请求缓冲、临时目录和超时以实测的恒定内存为验收标准。
管理接口使用 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 多状态响应和独立异常处理。
所有管理 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 表示当前用户是否在允许范围,两者不能混用。用户可配置策略存储在 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。
webdav_locks,对客户端可见,实现 DAV LOCK/UNLOCK。App\Module\Lock,保护同一父目录的命名空间和同一文件版本写入。两者不可相互替代。即使客户端未主动 LOCK,服务端仍必须使用短期互斥锁保证事务一致性。
为避免死锁,统一按以下顺序:
lockForUpdate 锁父目录、源资源、目标资源。| 场景 | 状态码 |
|---|---|
| 未提供或无效凭据 | 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 响应。
X-Forwarded-Proto 判断。Destination 必须属于当前 Host 和 /dav/ 基础路径,拒绝跨服务 COPY/MOVE。Content-Disposition、正确 MIME、X-Content-Type-Options: nosniff。Depth: 1 一次批量查询子节点和最新内容 ID,避免 N+1。package_max_length = 1 GB 只代表 Swoole 接受上限,不作为流式能力证明;入口进程 RSS 是强制验收指标。在现有「文件设置」增加 WebDAV 区域:
在文件页面右上角现有加号按钮右侧增加圆形“更多”按钮:
ios-more,按钮尺寸、圆形样式和固定占位与加号保持一致。所有新增可见中文同步登记到 language/original-web.txt 和 language/original-api.txt,并更新相关 ai-kb 功能 chunk。
至少统计:
request_id 并加入响应头 X-Request-Id。新增任务:
name/ext/type 转换。files/shared 节点解析和共享顶层 [#ID]。curl 覆盖所有方法和条件头。davfs2:挂载和并发文件操作。#、%、emoji、超长名称和大小写冲突文件。客户端测试记录环境、步骤、状态码和结果截图到 tests/playwright-results/ 或新增的 DAV 测试结果目录;协议测试不伪装为 Playwright 自动化结果。
实现完成后执行:
./cmd composer stan
npm run lint
npm run check:lang
./cmd artisan doc:api-map
不主动运行 ./cmd dev、./cmd prod 或 ./cmd build。
验收:大文件入口架构已经用 RSS 数据确定;原文件页面全部操作通过;现有 API 响应无回归;新增并发测试通过。
验收:凭据只展示一次;撤销和全局关闭立即生效;日志无秘密信息。
files/shared 虚拟树和权限隐藏。验收:大文件恒定内存;无权限资源不泄露;共享列表无重复和歧义。
验收:网页端和 DAV 互相实时可见;覆盖产生历史;异常不留半成品。
验收:Litmus 目标用例通过;Office 原子保存稳定;并发编辑不静默丢失数据。
| 工作包 | 内容 | 前置依赖 | 交付判定 |
|---|---|---|---|
| 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 才补。
webdav_enabled,立即阻断协议流量。files/file_contents 数据。files/、shared/ URI 名称和共享顶层 ID 规则。| 风险 | 处理决策 |
|---|---|
| 现有控制器含业务逻辑 | 先收敛到 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 不唯一 | 启用前审计、父目录锁、统一服务写入 |
只有同时满足以下条件,WebDAV 才算功能闭环:
files/shared 的读写与网页权限一致。