政务CMS里接ueditor,做网站内容管理的人应该都不陌生。真正把ueditor“喂饱”,让它支持docx、pdf、xlsx、pptx这些常见公文格式,并且能上传、能预览、能记录操作日志,这个坑其实比想象中深。我在政务项目上接过几回ueditor的扩展,说实话,默认的配置只够传图片和小文件,碰到多格式文档需求时,单靠改config.json里的文件格式白名单远远不够。这篇就围绕“政务CMS如何扩展ueditor的多格式文档支持”这个问题,把前端放开格式、后端自定义上传action、文档转预览、权限审计这几条线完整捋一遍。适合正在做政务网站、政府OA系统,或者任何对文档管理要求比较高的PHP后端网友参考。
1. 政务CMS中ueditor的定位与扩展需求
1.1 ueditor在政务CMS里的核心价值
政务CMS和普通商业CMS最大的差别在于:内容涉及公共信息,发布过程必须留痕、可控、可审计。ueditor作为后端富文本编辑器,承担的是“让编辑像用Word一样写文章”的职责。上传图片、附件、文档是它的主要工作。在政务场景里,政策文件、办事指南、公示材料经常以PDF、Word、Excel的形式作为附件挂到正文下方,甚至需要直接在正文里展示文档预览。所以ueditor并不只是一个编辑器,更是内容生产管线的入口。丢开这个前提去谈扩展,很多细节都会走偏。
1.2 默认上传能力的边界在哪
先看看ueditor默认的上传能力。编辑器自带uploadimage处理图片,uploadfile处理文件。文件类型的白名单在config.json的fileAllowFiles数组里,常见的配置其实已经包含doc、docx、pdf、xls等主流格式,但有两个硬伤。
第一,配置只影响“能上传”,不影响“能预览”。政务场景需要的在线预览,靠ueditor自己是做不了的。编辑辛辛苦苦传上来的PDF,访客只能点链接下载或者干脆打不开,体验很差。
第二,默认的uploadfile返回的是文件URL,插入正文的是下载链接。如果希望正文里直接展开PDF、Office文档内容,必须另外做转换模块。另外,默认配置对手机端适配也偏弱,老一代PC版ueditor在移动端打开时,文档控件往往展示不友好。所以需求一旦多起来,就不仅仅是改几个后缀名能解决的。
第三,从系统集成角度看,ueditor默认的上传接口没有做权限校验和审计信息记录。政务CMS要求每次操作都查得到谁传了文件、什么时候传的、文件哈希是多少。默认的action_upload.php只是机械地接收文件并落盘,这些东西全都没有。所以扩展ueditor,本质上是把一个“能用”的编辑器改造成一个“合规”的内容生产工具。
2. 多格式文档支持的整体设计思路
2.1 需求梳理:政务场景要哪些文档格式
先盘一盘政务系统里常见的文件格式。文本类:doc、docx、pdf、txt、md;表格类:xls、xlsx、csv;演示类:ppt、pptx;还有一些较新的:odt、odp、ods、rtf、epub也偶尔出现。
值得注意的是,政务文件强调可打印、可保存,PDF的优先级往往最高,很多在线发布的正式文件都要提供PDF版本。同时,部分地区因为统一办公软件的原因,要求兼容国产办公格式,比如UOF(标文通)和WPS全系。这里要看项目所在环境的实际要求,我一般列一个“格式断舍离清单”,把所有可能出现格式分成三类:
| 类别 | 说明 | 处理策略 |
|---|---|---|
| 在线预览必须支持 | pdf、docx、xlsx、pptx | 服务器转PDF后用pdf.js渲染 |
| 仅下载不预览 | zip、rar、exe、dmg | 只做上传和链接插入,不做预览 |
| 完全不支持 | 脚本、可执行文件、未知后缀 | 后端直接拒绝,不进白名单 |
这个清单要和业务方一起确认,不要自己想当然。我遇到过项目里领导要求支持psd文件,后来发现只是某个部门要传设计稿,实际上传到后台给内部下载用,根本不需要预览。分清需求再动手,避免浪费开发资源。
2.2 扩展路径:前端、后端、预览三条线
一般我会把整个扩展分成三个并行的工作流:
前端线:改ueditor上传对话框的文件类型过滤,让用户在编辑器里可以选择这些格式上传;修改编辑器配置,让插入的文档类型可以以不同样式展示,比如PDF用图标+链接,图片用img标签,Office文档用预览容器。前端这一步要操作的是ueditor.all.js和dialogs中子组件的html,具体细节后面讲。
后端线:在PHP的action_upload.php里增加自定义action,比如action=uploaddoc,并扩展白名单、大小限制、路径策略。如果项目发布环境做了CDN或对象存储,还要在上传环节直接对接OSS/S3。后端的安全校验、路径生成、日志记录都在这一层。
预览线:对上传成功的文档做处理。最简单的方法是服务器装LibreOffice,用命令行把docx/pptx/xlsx转成PDF或者HTML,再把PDF交给pdf.js渲染,或者用OnlyOffice/DzzOffice这一类在线协作组件做预览。预览线是“多格式文档支持”里最容易被低估的部分,很多开发把它放到二期再做,结果一上线就被用户投诉“为什么文件传上来不能看”,相当被动。
2.3 为何要分开处理图片和文档上传
很多人直接复用ueditor默认的uploadfile接口,把它当成“文件上传万能接口”。实际上,政务系统里对文档和图片的管理策略完全不同。
图片属于正文素材,一般存私有目录,被引用时可能需要防盗链;文档属于正式附件,需要加水印、需要版本管理、需要权限控制,甚至需要归档。如果共享同一套上传接口,后面做权限、统计、审计都会很别扭。所以我会在扩展时新增action,而不是改uploadfile的默认行为。
用程序员的话说,这就是开闭原则——对扩展开放,对修改封闭。默认的uploadfile已经有很多老页面在用,直接修改它的校验逻辑,可能导致原有用例一同变化,最好的方式是新开一个独立入口,老逻辑不动,新逻辑单独维护。
3. 实操过程与核心环节实现
3.1 修改ueditor前端配置,放开文档格式
以ueditor 1.4.3.3 PHP版为例。打开编辑器目录下的ueditor/ueditor.config.js,里面可以覆盖后端config.json里的配置项。我们在页面初始化时,通过UEDITOR_CONFIG变量去覆盖默认配置:
UEDITOR_CONFIG.uploadFileUrl = '/api/upload.php?action=uploaddoc'; UEDITOR_CONFIG.fileAllowFiles = ['.doc', '.docx', '.pdf', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.md', '.odt', '.ods', '.odp', '.rtf', '.csv'];这里改了uploadFileUrl之后,前端会把上传动作指向自定义接口。同时要修改前端对话框的accept属性。在ueditor/dialogs/file/file.js里,找到文件选择input元素,原先写的是固定的accept值,我们要放开到项目支持的格式。
更稳妥的做法是在文件选择input上直接用multiple属性,因为accept限制只影响系统文件选择器的默认过滤,不是硬性安全边界。安全校验必须放在后端。
3.2 后端action_upload.php扩展:新增action与白名单
ueditor服务端默认是一个统一分发文件,最常见的入口路径就是类似/ueditor/php/action_upload.php?action=uploadimage&config这样的形式。它根据GET参数里的action值,决定到底走图片上传逻辑、视频上传逻辑还是普通文件上传逻辑。我们要做的,就是在这些分支之外,新增一个专门处理政府文档和Office文件的分支。
在action_upload.php的switch分支里,增加一个新的case:
switch ($action) { case 'uploadimage': // 原有逻辑 break; case 'uploadfile': // 原有逻辑 break; case 'uploaddoc': $config = [ 'pathFormat' => '/uploads/doc/{yyyy}{mm}{dd}/{time}{rand:6}', 'maxSize' => 10 * 1024 * 1024, 'allowFiles' => ['.doc', '.docx', '.pdf', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.md', '.odt', '.ods', '.odp', '.rtf', '.csv'] ]; $up = new Uploader('upfile', $config); $info = $up->getFileInfo(); if ($info['state'] === 'SUCCESS') { saveDocumentRecord($info); } echo json_encode($info); break; default: break; }有几个点要特别注意。pathFormat里的时间变量需要服务器时区正确,否则生成目录不对;rand:6是6位随机数,避免并发重名。
Uploader类本身在Uploader.class.php中,它会对文件类型做后缀校验、MIME校验(依赖fileinfo扩展)和大小校验。我踩过坑的是,有些生产环境没开fileinfo扩展,导致MIME检测失败。如果没开fileinfo,可以在Uploader初始化时强制使用后缀白名单,但生产环境建议还是开启,多一层校验多一分安全。
3.3 文档转码与在线预览的实现
文档上传完成后,预览是关键。我推荐本地用LibreOffice做转换。以Ubuntu服务器为例,先安装组件:
apt install libreoffice-writer libreoffice-calc libreoffice-impress然后写一个命令行转换脚本:
soffice --headless --convert-to pdf /uploads/doc/20240101/123456.docx --outdir /preview/20240101/转换成功后,用pdf.js在前端渲染PDF。这里有一个政务场景特别敏感的问题:LibreOffice转换公文红头、仿宋字体时,如果服务器上没有安装对应的中文字体,转出来的PDF排版会很奇怪,甚至出现方块字。我吃过这个亏,后来在服务器上补装了仿宋、黑体、楷体、宋体等常用字体,转换效果才基本稳定。
还有一个轻量方案,使用微软Office Online Viewer,形式是https://view.officeapps.live.com/op/view.aspx?src=<文件URL>。但这个方案强依赖外网,政务内网几乎不可用,所以自己还是得装转换服务。OnlyOffice功能更多但部署更重,我一般只在预算充足的项目里上,日常政务CMS部署用LibreOffice足够。
3.4 将上传结果插入编辑器内容
ueditor插入文件有几种做法。默认的文件上传结果,是用execCommand插入一段带链接的HTML。像这样:
editor.execCommand('inserthtml', '<a href="' + url + '" target="_blank">' + fileName + '</a>');但如果要支持PDF预览块,就不能只插链接。我自己写过一个扩展,把上传成功的PDF转成预览块插入:
editor.execCommand('inserthtml', '<div class="ueditor-doc-preview">post_max_size = 100M upload_max_filesize = 100M max_execution_time = 300 memory_limit = 256M还要注意Nginx的client_max_body_size,默认是1M,必须同步改到100M,否则大文件在Nginx层就被拒了,连PHP都到不了。
LibreOffice转换大文件时也很吃内存,我试过50MB的PPT在512M内存的虚拟机里直接OOM,后来加了swap并限制LibreOffice并发任务数才稳住。具体做法是在转换脚本里加一个并发信号量,比如同一时间只允许2个soffice进程运行。
4.4 文件名安全:绕过、重名与路径穿越
政务系统对安全最敏感,文件上传安全必须做扎实。后缀校验不能只查最后一个点,像“a.docx.php”这种可以从后面找到.php绕过,所以要负责任地从后往前迭代出最后一个合法的扩展名。文件名里的../和特殊字符一律替换掉,前端展示用原始文件名,存储名则用随机数。
路径组合时,不要把用户输入直接拼进去。比如$path = $uploadDir . '/' . $_POST['filename']这种写法就是作死。我会强制用固定前缀加随机数去拼存储名,用户输入完全不影响最终路径。
另外,上传目录要禁止执行PHP脚本,在Nginx location里配置:
location ^~ /uploads/ { location ~ \.(php|php5|phtml)$ { deny all; } }这样即使攻击者突破了后缀白名单,也没办法在uploads目录下执行脚本。
5. 实战中反复验证的几条补充建议
回到扩展ueditor这件事本身,虽然做完功能能用,但有几个经验是踩过不少坑才沉淀下来的。
第一,最好先做一个“格式支持矩阵”。把领导关注的格式都列出来,真的开发前看清楚优先给哪些格式保预览,哪些只给下载。宁可先支持PDF和DOCX,也不要一股脑全打开,后面版本控制、转码排队、字体适配全是隐形成本。
第二,预览转换最好异步做。上传成功立即返回,但预览任务进队列,由cron脚本或消息队列消费。如果同步等soffice跑完再返回,用户会一直停留在上传转圈状态,体验很差。尤其是多人在线同时上传时,同步转换会让PHP进程被占满,其他请求全部超时。
第三,多格式支持是一个持续的维护问题。后来我直接在扩展包里加了一个“格式健康检查页面”,管理员能看到哪些格式上传成功率高、哪些转换容易出问题。比如某段时间.odt文件转换失败率特别高,通过这个页面能第一时间发现,比等用户投诉再修要好得多。
最后再补充一个小技巧:ueditor默认的文件上传接口返回的JSON字段和自定义接口不一定完全一致,前端拿到结果后要先做一次字段归一化,把URL和title字段统一再插入编辑器。我在一个项目上就是因为返回字段名差了大小写,导致编辑器里插入的内容全是undefined。提前封装好兼容层,后面再扩展格式,前端代码基本不用动。