
折腾 NAS 的朋友都清楚群晖、威联通、飞牛这些商业系统功能确实全面但如果你只是想把家里或者小团队里的几台电脑文件集中起来再配合 rclone 这类工具挂载成本地磁盘访问用一台旧电脑、树莓派甚至一台常年不关机的云主机跑一个纯 PHP 写的小服务反而更省心。这篇文章就来聊聊如何不依赖数据库用 PHP 从零搭一个真正能用的轻量级私有 NAS。先说清楚这个东西能干什么。整套系统只依赖 PHP 运行环境和文件系统不需要 MySQL、不需要 SQLite连 Composer 都可以不用。它支持文件上传下载、目录浏览、分享链接、图片缩略图预览还能通过 WebDAV 协议被 rclone、Windows 文件资源管理器等客户端直接挂载成本地磁盘。对想在内网快速搭一套文件管理服务、又不想维护复杂环境的人来说这是一个非常划算的路线。1. 为什么用纯 PHP 做私有 NAS1.1 轻量级 NAS 的两条路线自建 NAS 大致有两条路线。第一条是上商业系统或者开源全家桶比如群晖 DSM、飞牛 fnOS或者 Nextcloud、Seafile 这类带数据库和后台任务的项目。这类系统功能完善搜索、权限、文件版本、移动端 App 都有但代价是要求硬件资源不低部署后还要维护数据库、缓存服务、后台队列对于只是想集中存点资料的人来说确实有点重。第二条路线就是极简方案。只要系统能跑 PHP就能把一个目录变成带 Web 界面的网络存储。数据全部平铺在磁盘上元数据要么从文件系统实时读取要么用几个 JSON 文件保存整个过程没有任何常驻进程之外的状态管理。好处很明显迁移一个目录就能迁移整个系统备份直接复制文件几乎不存在“数据库损坏导致全盘服务不可用”的情况。我最早是在一台树莓派 3B 上跑这套方案的。1GB 内存装完系统后还要跑 PHP-FPM剩余资源依然很充裕。后来换到一台老笔记本上把硬盘换成 SSD 之后局域网内跑满千兆基本没什么压力。对比之前用过的开源文档管理系统资源占用少了不止一个量级。1.2 “无数据库”的取舍与适用边界很多朋友一听到“无数据库”就觉得不靠谱担心文件索引、分享信息、用户数据没地方放。实际上文件管理类场景里数据库并不是必需品。目录树本身就是天然的索引结构文件名和路径就是元数据。查询某个文件是否存在file_exists()一条函数就够了根本不需要去数据库里跑一条 SELECT。那放弃了数据库代价是什么首先是没有办法做全盘内容搜索比如根据文件内容里的某个关键词去找文件。其次如果以后要支持多用户、细粒度权限、文件版本回溯纯文件方案会越来越吃力。最后并发写入同一个 JSON 文件时会有概率丢数据所以多写并发高的场景不适合这套方案。适用边界其实很清晰个人家庭存储、三五人的小团队内部文件共享、开发测试环境里的临时文件交换、以及需要被 WebDAV 客户端挂载的外部存储。只要不是要做一个几十上百人的企业网盘这套东西完全够用。2. 整体设计与技术选型2.1 核心功能清单在动手写代码前先列出这套轻量级 NAS 必须具备的功能避免写着写着就跑偏。功能模块说明优先级Web 目录浏览列出目录下的文件与文件夹显示大小和修改时间必须文件上传支持普通文件提交大文件可分块上传必须文件下载支持原文件下载和目录打包下载必须文件管理重命名、删除、移动和复制推荐图片预览自动生成缩略图Web 端可直接预览可选视频音频播放前端用 HTML5 标签直接播放后端透传文件推荐分享链接生成带随机 Token 的临时下载链接必须WebDAV 协议供 rclone、Windows 等客户端挂载为本地磁盘推荐访问控制登录认证、目录隔离、路径穿越防护必须这些功能里最难的是 WebDAV 协议支持。很多自建的 PHP NAS 死在 WebDAV 这一关因为 PROPFIND 要返回正确的 XML 结构上传、删除、移动等方法和 HTTP 状态码必须严格匹配否则 rclone 不会正常工作。后面我会专门用一整节来写这块的实现。2.2 目录结构与数据存储整个系统的目录设计围绕“程序文件与存储文件分离”的原则避免把用户数据混在代码目录里也方便备份。nas/ ├── index.php # Web 入口与页面路由 ├── api.php # 上传、下载、分享、管理等接口 ├── webdav.php # WebDAV 处理器 ├── functions.php # 公共函数如路径安全校验 ├── config.php # 配置文件用户和 Token 都在这里 ├── data/ # 用户存储根目录 │ ├── docs/ │ ├── photos/ │ └── software/ └── shares/ # 分享链接元数据目录 ├── share_abc123.json └── share_def456.json用户数据全部放在data/下面shares/目录单独存放分享链接的 JSON 文件格式很简单大概是这样的{ token: abc123, path: /docs/工作总结.pdf, expire_at: 2025-12-31 23:59:59, created_at: 2025-01-01 10:00:00 }无数据库不等于无状态分享链接这类信息还是需要持久化的只是存储介质从数据库换成了 JSON 文件。每个分享链接对应一个文件不用关心索引问题文件名就是天然搜索键。2.3 安全模型设计这类系统最容易被人担心的是安全问题。我设计安全模型时重点考虑了三个点。第一是路径穿越防护。所有从 URL 参数或请求体中拿到的路径必须经过一个统一的白名单校验函数使用realpath()解析后确认目标在允许的根目录以内否则直接拒绝。这一条能挡住../../etc/passwd之类的经典攻击。第二是访问认证。Web 管理端采用 Token 认证登录成功后把 Token 放在请求头X-Auth-Token或者 Cookie 里。WebDAV 客户端无法设置自定义头就采用 HTTP Basic Auth使用同样的账号密码进行校验校验通过后组装出对应的内部 Token。第三是上传文件类型限制。严格模式下一律禁止上传 PHP、phtml、php5 等可执行脚本哪怕用户就是想让服务器跑脚本也应该把可执行目录单独隔离开与数据目录区分开。3. 核心代码实现与实操细节3.1 入口与路由设计整个系统的入口分成两个index.php负责 Web 页面展示api.php负责 JSON 接口webdav.php统一处理所有 WebDAV 请求。用 PHP 内置服务器调试的时候直接运行php -S 0.0.0.0:8080 index.php如果要用一个入口处理所有路由需要在.htaccess或者 Nginx 配置里把请求重写到index.php再在里面做路由分发。但我在实际项目中更喜欢拆分成多个入口这样不同服务之间互不干扰WebDAV 请求长连接也不会占用 Web 页面的 PHP-FPM 进程数。在functions.php里有一个核心的安全校验函数function safe_path(string $input, string $root): ?string { $root realpath($root); $full realpath($root . / . ltrim($input, /)); if ($full false) { // realpath 返回 false 说明目标不存在尝试检查父目录 $parent realpath(dirname($root . / . ltrim($input, /))); if ($parent false || strpos($parent, $root) ! 0) { return null; } return $root . / . ltrim($input, /); } if (strpos($full, $root) ! 0) { return null; } return $full; }这个函数把用户输入解析成绝对路径后再检查是否在允许的根目录内。对于不存在的目录或文件realpath()会返回 false所以需要退回检查父目录同时还要额外处理一次..的情况。3.2 用户认证与 Token 机制用户和密码直接写在config.php里密码用password_hash()生成哈希值存储不保存明文。// config.php return [ username admin, password_hash $2y$10$..., // password_hash(yourpassword, PASSWORD_DEFAULT) 生成 web_token a1b2c3d4e5f6a7b8a9b0c1d2e3f4a5b6, storage_root __DIR__ . /data, share_root __DIR__ . /shares, max_upload_size 2 * 1024 * 1024 * 1024, // 2GB ];登录接口的逻辑很直接从 JSON 请求体里接收用户名和密码校验通过后返回固定的web_token。这个 Token 不用每次登录重新生成因为这是单用户或者核心用户群固定的场景不是公网开放注册的论坛。如果有人觉得这样不够安全可以在 Token 里拼接时间戳再哈希不过会牺牲一部分使用便利性。WebDAV 的 Basic Auth 校验稍微繁琐一点需要在每次请求时读取Authorization头解析用户名密码后重新做一次password_verify()校验。如果校验失败返回401 WWW-Authenticate: Basic realmNAS。注意 WebDAV 客户端在遇到 401 后会自动弹出密码框所以这里不要用 JSON 格式的响应体必须严格按照 HTTP 规范只输出状态码和响应头。3.3 文件上传与下载的实现Web 端的上传接口接收multipart/form-data提交的内容保存时要注意几点中文文件名必须做 UTF-8 处理避免 Windows 上传的中文名在 Linux 下乱码同名文件要在文件名后追加日期或者序号不能直接覆盖目录名不能以.开头防止误隐藏。分块上传的实现思路是在前端把大文件切成块每次上传时把块写入临时文件全部传完后用file_put_contents($finalPath, $flag FILE_APPEND)合并。但这套方案在纯 PHP 实现里对内存和磁盘 IO 要求都高我实际用的是在每个分块里携带总文件 ID 和块序号后端按序号保存为xxx.part1、xxx.part2全部齐全后再用流式方式合并$fin fopen($finalPath, wb); for ($i 1; $i $totalChunks; $i) { $part $tempDir . / . $fileId . .part . $i; $in fopen($part, rb); stream_copy_to_stream($in, $fin); fclose($in); unlink($part); } fclose($fin);下载目录的时候用 ZipArchive 打包这里有一个很典型的坑打包时 ZipArchive 会把完整路径写进压缩包导致用户解压后多了一层嵌套目录。处理办法是在addFile()时设置内部文件名只保留相对当前目录的名字$zip new ZipArchive(); $zip-open($tempZip, ZipArchive::CREATE | ZipArchive::OVERWRITE); $files scandir($dir); foreach ($files as $file) { if ($file . || $file ..) continue; $full $dir . / . $file; if (is_file($full)) { $zip-addFile($full, $file); } elseif (is_dir($full)) { $zip-addDir($full, $file); } } $zip-close();这里如果文件名里有中文ZipArchive 在旧版本 PHP 里会乱码遇到这种情况可以在addFile之前用iconv()或者mb_convert_encoding()强制转成 UTF-8并且在压缩包内部保留一个 UTF-8 注释标记。新版 PHP 配合 ZipArchive 的setArchiveComment做兼容处理。3.4 WebDAV 协议支持让 rclone 能挂载成本地磁盘这是整套系统最有技术含量的部分也是网上一堆 PHP NAS 教程里讲得最少的部分。rclone 挂载 WebDAV 时实际上会向服务器发送大量带Depth头的 PROPFIND 请求服务器必须按照 RFC 4918 规范返回 207 Multi-Status 响应并且 XML 里的D:statusHTTP/1.1 200 OK/D:status之类的状态码要正确。我用一个精简版webdav.php支持六个方法PROPFIND、GET、PUT、MKCOL、DELETE、MOVE。这六个方法覆盖了 rclone 目录浏览和文件操作的大部分场景。核心代码框架大致是$method $_SERVER[REQUEST_METHOD]; $path parse_path($_SERVER[REQUEST_URI]); switch ($method) { case PROPFIND: return handle_propfind($path); case GET: return handle_get($path); case PUT: return handle_put($path); case MKCOL: return handle_mkcol($path); case DELETE: return handle_delete($path); case MOVE: return handle_move($path); default: http_response_code(405); }其中 PROPFIND 是重难点。当Depth头为0时返回当前目录本身的信息为1时需要列出所有子目录和文件的信息。响应体是一个 XML 字符串要在Content-Type: application/xml头下返回。常用的响应体模板是D:multistatus xmlns:DDAV: D:response D:href/docs//D:href D:propstat D:prop D:resourcetypeD:collection//D:resourcetype /D:prop D:statusHTTP/1.1 200 OK/D:status /D:propstat /D:response /D:multistatusrclone 对href里的目录结尾非常敏感目录必须以/结尾否则它会认为这是一个文件。此外MOVE方法需要处理Destination请求头这个头包含了目标路径必须解析出来做路径校验。PUT 方法则要接收原始请求体并写入沙箱目录整个过程不要经过$_FILES框架直接用php://input流读取。我实际测下来以下这个最小实现足够让 rclone 正常挂载rclone mount nas:/ /mnt/nas --vfs-cache-mode full这里的nas是在 rclone 里配置的 WebDAV remote地址填http://your-server:8080/webdav.php账号密码填写config.php里对应的值。挂载成功后Linux 下可以直接对/mnt/nas下的文件做读写操作Windows 下也可以映射网络驱动器使用体验和本地磁盘基本没有区别。注意 WebDAV 客户端有的会自动发送OPTIONS请求进行能力探测服务器要正确返回Allow: PROPFIND, GET, PUT, DELETE, MKCOL, MOVE, COPY, OPTIONS等方法头否则客户端会认为服务不可用。另外rclone 的--vfs-cache-mode full会在本地缓存文件如果 NAS 上文件被其他设备修改缓存目录可能会出现过期数据建议改成--vfs-cache-mode off或者按需选模式。3.5 分享链接与权限控制分享链接的核心逻辑是随机 Token 生成 路径映射。生成 Token 的代码很简单$token bin2hex(random_bytes(16)); $share [ token $token, path $path, expire_at date(Y-m-d H:i:s, time() 3600 * 24 * 7), ]; file_put_contents($shareRoot . /share_ . $token . .json, json_encode($share, JSON_UNESCAPED_UNICODE));访问分享链接时表单提交的验证码逻辑类似于反向校验根据share_XXX.json是否存在判断链接是否有效然后读取 JSON 里的路径信息。做这个功能的时候要注意每个分享链接生成后立刻写入 JSON并且加一个定时清理机制比如在每次创建新分享时顺带扫描一遍shares/目录删除过期的 JSON 文件防止分享目录无限膨胀。另外分享链接的作用范围要做严格限制。万一有人的 Token 被泄露不能让对方通过修改路径参数去下载整个存储根目录里的其他文件。我的做法是在生成分享时就把完整物理路径写到 JSON 中访问时只从 JSON 中读路径不接受任何来自 URL 的路径参数。这是无状态系统里最不容易出错的做法。4. 部署步骤与配置要点4.1 本机快速启动开发测试阶段用 PHP 内置服务器最省事。在项目根目录执行php -S 0.0.0.0:8080 index.php这里有一个坑内置服务器默认把请求参数里的.php后缀文件交给 PHP 解析如果data/目录里有人传了一个shell.php内置服务器可能会直接执行它。所以开发阶段也要注意存储目录不要放在 Web 根目录下或者通过路由黑名单拒绝带有.php后缀的访问。访问http://localhost:8080/输入配置好的账号密码应该能看到文件列表。首次使用建议先上传一个测试文件验证目录可写权限和路径正确性。4.2 Nginx PHP-FPM 常规部署正式环境建议用 Nginx PHP-FPM。Nginx 配置核心要点有三个最大的上传体积限制、请求超时时间、以及将对应 URL 重写到入口文件。server { listen 80; server_name nas.example.com; root /var/www/nas; index index.php; # 上传大小限制按需调整此处设置为 4GB client_max_body_size 4096m; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ ^/webdav\.php { include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root/webdav.php; fastcgi_pass unix:/run/php/php8.2-fpm.sock; fastcgi_read_timeout 3600s; client_body_timeout 3600s; } }很多人上传大文件失败第一反应是改 PHP 的upload_max_filesize结果忽略了 Nginx 自己的client_max_body_sizeNginx 会在还没到达 PHP 的时候就直接返回 413。这两个参数必须配合调整。PHP-FPM 的配置也要对应修改upload_max_filesize 4096M post_max_size 4096M max_execution_time 3600 max_input_time 3600 memory_limit 512Mpost_max_size必须不小于upload_max_filesize建议直接让两者相同否则大文件上传时 PHP 会直接忽略请求体。4.3 宝塔面板部署补充如果你用的是宝塔面板这类图形化管理工具操作会简单不少但也要注意几个容易踩坑的地方。第一创建网站时 PHP 版本要选对建议用 8.0 以上版本因为str_contains这类新函数在旧版本里不存在。第二上传限制需要在“PHP 版本管理-配置修改”里同时改upload_max_filesize、post_max_size和max_execution_time。第三伪静态配置在宝塔后台“网站设置-伪静态”中填入location / { try_files $uri $uri/ /index.php?$query_string; }如果 WebDAV 路径也在这个网站下记得在上面的location /里排除它或者单独加一条精确匹配规则。很多人在宝塔里把 WebDAV 请求直接交到index.php入口最后请求被路由到 Web 页面而不是 WebDAV 处理器折腾半天才发现是伪静态规则写错了。5. 常见问题与排查技巧5.1 rclone 挂载 WebDAV 失败排查rclone 挂载失败是最常见的问题我总结出三个高频原因几乎覆盖了九成以上的故障场景。第一PROPFIND 返回的 XML 格式不符合规范。rclone 对href结尾的斜杠要求非常严格如果目录项的href没有以/结尾rclone 会认为这是一个文件导致目录层级错乱。排查方法是先用 curl 手工发请求观察返回的 XMLcurl -X PROPFIND -u admin:password -H Depth: 1 http://your-server/webdav.php/重点检查 XML 里目录项的D:href是否以/结尾。第二Basic Auth 的用户名密码里包含特殊字符。rclone 配置文件里的pass是经过加密存储的如果密码里有、:、#这类字符在 URL 中拼接时容易被解析错误。建议用 rclone 交互式配置在提示输入密码时直接粘贴而不是手动拼 URL。第三服务器返回的Allow头不完整。rclone 在初始化连接时会发送OPTIONS请求探测服务器能力如果Allow头里没有PROPFIND和PUTrclone 在后续操作时会直接报错。这个问题经常出现在把 WebDAV 处理器和 Web 页面路由混在一起的情况中。5.2 上传失败与超时排查上传大文件报 413 的问题按顺序检查 Nginx 的client_max_body_size、PHP 的post_max_size、PHP 的upload_max_filesize三个参数缺一不可。如果你用的是 Docker 部署还要检查 Nginx 容器启动时有没有把client_max_body_size的配置挂载进去很多镜像默认是 1MB。上传时间超时的排查则要分两层看。Web 请求层面的超时由 Nginx 的fastcgi_read_timeout控制PHP 层面的超时由max_execution_time控制。这两个值都要调大并且每次修改完配置都要重启 PHP-FPM 和 Nginx只重载部分配置在某些环境下不生效。实测下来局域网内传一个 3GB 的视频文件千兆网络大约需要 30 秒到 1 分钟如果出现传了大半突然掉线的情况大概率是fastcgi_read_timeout不够优先把它调到 3600 秒再试。5.3 中文文件名与编码问题中文文件名问题非常折磨人。PHP 在 Linux 下默认对文件名的处理是二进制安全的也就是字节流。Windows 上传的文件名编码通常是 GBK或者说本地代码页直接在 Linux 上保存会变成一堆乱码字符反过来Linux 上保存的 UTF-8 文件名在 Windows 的 WebDAV 客户端里也偶尔显示异常。规范的做法是统一约定文件名为 UTF-8。在functions.php里加一个标准化函数上传时把接收到的文件名做一次mb_convert_encoding($name, UTF-8, auto)转换。另外下载文件时设置Content-Disposition头要注意浏览器对中文名的兼容问题最好用 RFC 5987 格式的filename*参数header(Content-Disposition: attachment; filename . rawurlencode($filename) . );如果发现不一致优先检查 filesystem 实际文件名的字节流和期望字符串的编码是否一致不要盲目去改系统 locale 设置。5.4 权限与安全加固这套系统最怕的是两个问题目录可写导致上传 PHP 恶意脚本以及无良爬虫扫路径时引发的路径穿越尝试。上传校验除了扩展名黑名单建议再做一个 MIME 类型白名单。黑名单永远防不全比如.php5、.phtml这种可执行扩展名容易被漏掉而白名单只允许jpg/png/gif/pdf/zip/mp4等已知类型如果项目场景特殊再单独加。核心数据的存储目录权限设置为700只允许运行 PHP-FPM 的用户读写其他用户一律没有访问权限。storage_root目录不能放在 Web 根目录下必须放到 Nginx 静态文件直接访问不到的位置。如果实在只能放在 Web 根目录下用 Nginx 配置显式拒绝访问该目录location ~ ^/data/ { deny all; }路径穿越防护的重点是realpath()校验。我在开发时用一套自动化脚本测试了../../、..%2f、%2e%2e%2f等编码变体发现有部分特殊编码确实能绕过简单的字符串过滤但过不了realpath()这一步。所以宁可多写几行代码也要把所有路径处理都收敛到safe_path()这一个函数里。PHP 错误显示也要在正式环境关闭。display_errors设为Offlog_errors设为On。一旦开启display_errorsSQL 报错或文件路径信息可能会直接泄露给攻击者尤其是在 WebDAV 的 XML 错误响应里暴露出的绝对路径信息会给后续攻击提供非常大的便利。同时在php.ini里把track_errors的设置彻底去掉避免旧版本遗留配置在启动时直接报 fatal error。还有一个容易被忽略的点是 PHP 的session文件目录权限。如果同一台机器上有多个站点PHP-FPM 默认的 session 保存目录可能被其他站点干扰极端情况下会导致登录状态被篡改。建议在config.php里指定 session 保存目录为项目内不可被公网访问的目录。6. 从一个小项目到长期使用这套系统搭完之后我个人的实际体验是“够用且省心”。它不会像商业 NAS 那样频繁提醒你升级系统、更新套件也不会因为某次数据库迁移失败导致整套服务需要重新配置。数据就静静躺在磁盘上想备份直接复制目录就行。最后分享两个我觉得特别实用的小技巧。第一个把整个storage_root目录挂载进 rclone 后再配合rclone copy的定时任务就能把 NAS 数据定期同步到网盘或对象存储做异地备份。第二个如果以后想加更多用户不需要引入数据库只需要在config.php里维护一个用户数组然后给每个用户分配独立的根目录登录时根据用户名切换storage_root即可。这套方案的扩展空间比你想象中要大不少。