折腾 NAS 的朋友都清楚,群晖、威联通、飞牛这些商业系统功能确实全面,但如果你只是想把家里或者小团队里的几台电脑文件集中起来,再配合 rclone 这类工具挂载成本地磁盘访问,用一台旧电脑、树莓派,甚至一台常年不关机的云主机,跑一个纯 PHP 写的小服务反而更省心。这篇文章就来聊聊如何不依赖数据库,用 PHP 从零搭一个真正能用的轻量级私有 NAS。
先说清楚这个东西能干什么。整套系统只依赖 PHP 运行环境和文件系统,不需要 MySQL、不需要 SQLite,连 Composer 都可以不用。它支持文件上传下载、目录浏览、分享链接、图片缩略图预览,还能通过 WebDAV 协议被 rclone、Windows 文件资源管理器等客户端直接挂载成本地磁盘。对想在内网快速搭一套文件管理服务、又不想维护复杂环境的人来说,这是一个非常划算的路线。
1. 为什么用纯 PHP 做私有 NAS
1.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 realm="NAS"。注意 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:status>HTTP/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:D="DAV:"> <D:response> <D:href>/docs/</D:href> <D:propstat> <D:prop> <D:resourcetype><D:collection/></D:resourcetype> </D:prop> <D:status>HTTP/1.1 200 OK</D:status> </D:propstat> </D:response> </D:multistatus>rclone 对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_size,Nginx 会在还没到达 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 手工发请求,观察返回的 XML:
curl -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和PUT,rclone 在后续操作时会直接报错。这个问题经常出现在把 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设为Off,log_errors设为On。一旦开启display_errors,SQL 报错或文件路径信息可能会直接泄露给攻击者,尤其是在 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即可。这套方案的扩展空间,比你想象中要大不少。