ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PHP在线文本编辑器开发实战:从目录读取到安全保存的完整方案

PHP在线文本编辑器开发实战:从目录读取到安全保存的完整方案 去年维护一台老服务器时我遇到一个很别扭的需求得在浏览器里直接改某个配置文件但服务器上没装IDESSH操作又嫌重临时装个面板又有点小题大做。折腾几次之后我索性自己动手写了一套PHP文本在线编辑器。现在这套工具已经在我手里经历了三次重构今天把整个开发思路、核心实现、安全防线和踩过的坑都整理出来希望能帮你少走弯路。这套PHP文本在线编辑器能解决的核心问题很明确在只有PHP运行环境、没有图形界面的场景下通过浏览器随时查看、编辑和保存服务器上的文本文件同时兼顾权限控制、编码处理和并发冲突。尤其是那些还在用虚拟主机的个人网站或者喜欢轻量运维的开发者会非常受用。如果你正打算在自己的项目里集成一个简单的文件管理模块或者想从头写一套可复用的php源码级别的工具本文的思路可以直接参考。1. 从临时改配置到自建工具我为什么偏要用PHP做在线编辑器1.1 真实场景里现成方案都不够顺手当时的情况是这样的服务器上跑着一个第三方PHP程序某个配置项的路径需要微调但这个值藏在很深的目录里。远程桌面、FTP工具都能改但每次都要下载上传改错了还得来回好几轮。更烦的是那台机器出于安全考虑对外只开放80和443端口SSH要跳板机才能连过去。我先后试过几类现成方案。第一类是在线文件管理器像某些CMS后台自带的模板编辑功能体验基本停留在“能用”的水平。它们大多只能编辑站点目录下的部分文件扩展名也卡得很死想改一个系统配置文件根本无从下手。第二类是商用Web IDE功能确实强大但对运行环境的要求也高Composer、Node、Docker都要配一遍。在只有PHP的虚拟主机上这基本等于把整个服务器架构推倒重来。第三类是现成的开源php编辑器源码网上一搜一大把。问题在于很多项目年久失修PHP版本兼容性差代码里还夹杂着后门风险。我自己排查过两套发现都有关联外部域名的请求装上心里不踏实。转了一圈最终还是决定自己写一个。核心诉求很纯粹轻量、可控、结构简单到我能逐行审查安全逻辑。1.2 PHP在这个任务里比Python/Node更合适有人可能会问现在脚本语言那么多为什么偏偏是PHP理由一部署成本几乎为零。目标服务器已经有PHP运行环境往web目录里丢几个文件就能跑不需要额外装解释器或依赖包。Python和Node虽然也可以但虚拟主机上未必给你开对应的进程服务。理由二PHP自带一套相当完整的文件操作函数族scandir、fopen、file_get_contents、file_put_contents、rename、unlink全是面向这类需求设计的。做在线编辑器属于它的舒适区。理由三单文件入口配合PHP内置的运行时写出来的工具很容易和现有站点融合。它可以作为一个独立脚本放在某个子目录也可以改造成一个控制器嵌入到ThinkPHP或Laravel框架里。当然也要说清楚边界。PHP文本在线编辑器适合单机操作、中小文件编辑不适合多人实时协作文档也不适合处理超大文件。这是选型初就必须明确的事后面遇到的很多问题都跟这条边界有关。2. 整体设计目录列表、读取、编辑、保存四条主线并行推进2.1 功能需求拆解在动笔写代码之前我先列了一个功能清单把“编辑器”这个笼统的概念拆成具体的能力项。目录浏览进入指定根目录按层级展示文件和子目录。文件读取选中文件后在编辑区域显示内容。文件保存将编辑后的内容写回原文件。新建文件在指定目录创建新的文本文件。重命名修改文件名需校验新名称合法性。删除删除指定文件操作前二次确认。编码处理识别UTF-8、GBK等常见编码格式。安全校验阻止路径穿越和越权访问。每个能力对应到PHP函数时我心里就清晰多了。用scandir做目录列表用file_get_contents读取用file_put_contents保存用rename、unlink处理重命名和删除再用realpath做路径归一化。前端只需要一个页面配一个AJAX接口后端用单一入口转发请求。2.2 前端选型从裸textarea到CodeMirror第一版我用的是最原始的方式一个textarea塞满文件内容点保存就提交。用来改php.ini、.htaccess这类文件是够用的但一旦要改JS或CSS没有代码高亮和行号就很难受。第二版我换成了CodeMirror。这个选择有几个实际原因。首先是体积可控核心库加上需要的语言包压缩后几十KB对这个工具来说不构成负担。然后是API稳定我只需要初始化和取值两个动作学习成本很低。最后是它对老旧浏览器的容忍度比Monaco Editor好在服务器管理场景里我不能要求用户一定用最新版Chrome。如果只是临时改个配置其实textarea也完全够用。判断标准很简单你需不需要高亮和行号。需要就上CodeMirror不需要就保持原生没必要为了炫技引入几十个依赖。2.3 目录结构与文件分层我最终采用的目录结构如下php-file-editor/ ├── index.php // 前端页面入口 ├── api.php // 统一接口入口所有AJAX请求走这里 ├── config.php // 配置项根目录、允许扩展名、会话校验开关 ├── common.php // 公共函数路径校验、权限检查、日志记录 ├── assets/ │ ├── codemirror/ │ └── app.js └── data/ └── backup/ // 保存前的备份目录common.php里放的是所有接口都要用的安全函数config.php里定义白名单和根目录。api.php接收action参数分别路由到list、read、write、create、rename、delete这几个方法。index.php只负责渲染页面和加载前端资源。这个分层的好处是职责清晰。config.php像开关面板common.php像安检闸口api.php是业务流水线。后面要加功能只要在api.php里增一个分支就行不需要动前端页面。3. 核心代码实现目录列表到文件保存的完整链路3.1 目录列表过滤和排序的细节目录列表功能看起来简单实际上要处理两个容易忽视的细节过滤掉隐藏文件以及把目录排到前面。我先定义了一个公共入口函数确保所有操作都从配置的根目录开始不允许跑到根目录之外。function safeRootPath() { $root rtrim(APP_ROOT, DIRECTORY_SEPARATOR); return realpath($root) ?: $root; } function listDirectory($relativePath ) { $root safeRootPath(); $target realpath($root . ($relativePath ? DIRECTORY_SEPARATOR . $relativePath : )); if ($target false || strpos($target, $root) ! 0) { return [error 路径不合法]; } $items []; $dirs []; $files []; $handle scandir($target); if ($handle false) { return [error 无法读取目录]; } foreach ($handle as $name) { if ($name . || $name ..) continue; if ($name[0] .) continue; $fullPath $target . DIRECTORY_SEPARATOR . $name; if (is_dir($fullPath)) { $dirs[] [name $name, type dir, path ltrim($relativePath . DIRECTORY_SEPARATOR . $name, DIRECTORY_SEPARATOR)]; } else { $files[] [ name $name, type file, path ltrim($relativePath . DIRECTORY_SEPARATOR . $name, DIRECTORY_SEPARATOR), size filesize($fullPath), mtime filemtime($fullPath) ]; } } usort($dirs, function($a, $b) { return strcmp($a[name], $b[name]); }); usort($files, function($a, $b) { return $b[mtime] - $a[mtime]; }); return array_merge($dirs, $files); }这里我把隐藏文件直接过滤掉了因为配置目录里经常有.settings之类的文件夹展示出来意义不大。目录名按字母排序文件按修改时间倒序排这样最近改过的文件一定在最前面找起来效率高很多。3.2 文件读取编码检测和BOM处理文本编辑器最麻烦的问题不是读取而是读取出来之后怎么让浏览器正确显示。直接调用file_get_contents读取文件内容后把内容塞给textarea或CodeMirror会遇到编码混乱的问题。我在读取接口里加入了编码检测和转换逻辑。function detectEncoding($content) { if (preg_match(/^\\xEF\\xBB\\xBF/, $content)) return UTF-8 BOM; if (preg_match(/^\\xFF\\xFE/, $content)) return UTF-16 LE; if (preg_match(/^\\xFE\\xFF/, $content)) return UTF-16 BE; $sample substr($content, 0, 8000); $encoding mb_detect_encoding($sample, [UTF-8, GBK, GB2312, ISO-8859-1], true); return $encoding ?: UTF-8; } function readFileContent($relativePath) { $target validatePath($relativePath); if (!$target) return [error 路径不合法]; $content file_get_contents($target); $encoding detectEncoding($content); if ($encoding UTF-8 BOM) { $content preg_replace(/^\\xEF\\xBB\\xBF/, , $content); } elseif ($encoding GBK || $encoding GB2312) { $content mb_convert_encoding($content, UTF-8, GBK); } return [ content $content, encoding $encoding, size filesize($target) ]; }注意detectEncoding里我优先判断了BOM头因为mb_detect_encoding对带BOM的UTF-8文件有时会判断成ISO-8859-1这是个很隐蔽的坑。文件保存时再根据原来的编码转回去保证不改动原文件的编码风格。3.3 保存接口原子写入和自动备份保存文件是编辑器的核心动作也是风险最高的动作。一个不谨慎的file_put_contents可能会因为PHP进程被中断导致文件只写了一半网站直接挂掉。我采用了三步策略先备份再写临时文件最后rename覆盖原文件。这样即使中途出错最多丢失本次修改原文件依然完整。function saveFileContent($relativePath, $content) { $target validatePath($relativePath); if (!$target) return [error 路径不合法]; if (!is_writable($target)) return [error 文件不可写]; $backupDir DATA_PATH . /backup/ . date(Ymd); if (!is_dir($backupDir)) { mkdir($backupDir, 0755, true); } $backupFile $backupDir . / . md5($relativePath) . _ . date(His) . .bak; copy($target, $backupFile); $tmpFile $target . .tmp. . getmypid(); if (file_put_contents($tmpFile, $content, LOCK_EX) false) { return [error 写入临时文件失败]; } chmod($tmpFile, 0644); if (!rename($tmpFile, $target)) { unlink($tmpFile); return [error 覆盖文件失败]; } return [ok true]; }备份目录按天分文件夹文件名加了原路径的MD5值和时间戳方便回溯。每次保存都会产生一份备份磁盘占用不大但出问题的时候真是救命。3.4 新建、重命名、删除操作新建文件时需要注意目录存在性以及文件是否已存在。重命名时以原路径为基准只允许修改文件名不能修改目录层级避免有人把a.txt改成../../etc/passwd。删除操作同样要校验路径同时支持后端删除前把内容备份到backup目录以防误删。function renameFile($oldRelative, $newName) { $old validatePath($oldRelative); if (!$old) return [error 原始路径不合法]; $newBase dirname($old); $newName basename(trim($newName)); if (!preg_match(/^[\\w\\-\\s.]$/, $newName)) { return [error 新文件名不合法]; } $newPath $newBase . DIRECTORY_SEPARATOR . $newName; if (file_exists($newPath)) return [error 已存在同名文件]; if (!rename($old, $newPath)) return [error 重命名失败]; return [ok true]; }新建和删除的实现思路类似核心都是先走validatePath再做业务操作。这里的validatePath就是安全机制中的核心函数后面单独展开。4. 安全是底线路径穿越、权限控制与XSS防护4.1 路径穿越是这类工具的头号漏洞任何接收用户传入路径参数的服务端程序都必须认真对待路径穿越。攻击者用../组合理论上可以读取服务器上任意文件比如/etc/passwd或者应用源码。我的validatePath函数做了两道防线第一道物理路径校验。将传入的相对路径拼接到根目录后用realpath取真实路径然后判断是否以根目录的realpath字符串开头。function validatePath($relativePath) { $relativePath str_replace([.., \0], , $relativePath); $root safeRootPath(); $fullPath realpath($root . DIRECTORY_SEPARATOR . $relativePath); if ($fullPath false) return false; $rootReal realpath($root); if (strpos($fullPath, $rootReal) ! 0) return false; return $fullPath; }第二道禁用危险符号。我把文件路径中的双点直接去除虽然这会影响访问名称中真的包含“..”的合法文件但对一个编辑器而言牺牲这种极端场景换来安全是值得的。4.2 会话认证和扩展名白名单编辑器一旦部署到公网就必须做访问控制。我在config.php里定义了一个开关可以启用或禁用登录验证。启用后所有接口在执行业务逻辑前都会检查session中是否有登录标记。登录页本身用了一个极简的PHP脚本加密码哈希校验不引入额外的用户表。此外我加了扩展名白名单机制。默认只允许编辑php、html、js、css、txt、json、xml、ini、env、md、sql这几种常见的文本类型。这样即使攻击者绕过了路径校验也无法直接把webshell写到web目录里。$allowedExtensions [php, html, htm, js, css, txt, json, xml, ini, env, md, sql, log, conf]; function isAllowedFile($filename) { global $allowedExtensions; $ext strtolower(pathinfo($filename, PATHINFO_EXTENSION)); return in_array($ext, $allowedExtensions, true); }4.3 前端XSS和CSRF的应对在线编辑器的内容区域会展示文件原文如果文件里包含
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进