JSP富文本编辑器ewebeditor拆解、部署与安全改造

发布时间:2026/10/25 23:18:21
JSP富文本编辑器ewebeditor拆解、部署与安全改造

简介:eWebEditor在线文本编辑器 v0.1.4 For JSP 是面向 Java Web 开发者的富文本编辑组件,适用于在 JSP 页面中集成可视化内容编辑、图片上传、超链接插入与 HTML 源码编辑等功能的项目场景,尤其适合中小型后台管理系统与内容发布模块的搭建。压缩包共 264 个文件、约 796KB,包含 186 个 gif 界面素材、27 个 css 样式、21 个 htm 示例页、11 个 class 编译类、6 个 jpg 图片、4 个 jsp 集成页、4 个 js 脚本,以及 xml 配置、txt 说明和 jar 依赖,基本覆盖前端资源、服务端调用与部署示例。已有 173 人学习下载,可作为了解该编辑器目录结构和吕海鹏修改版改动的参考。读者可从中获得一套可直接部署到 JSP 工程的编辑器文件集合,借助示例页与样式资源理解按钮布局、上传处理和接口调用方式,并据 jar 与 class 文件排查依赖与兼容性问题,为后续二次开发和功能定制提供基础。

1. 一个 2005 年前后的 JSP 富文本编辑器,今天拆它还有用

前几天有个朋友丢过来一个压缩包,文件名是 ewebeditor014jsp,说后台的文章发布页要一个能直接排版的输入框,最好别引 npm 那一套。我解压一看,这就是当年装机量极大的那款在线文本编辑器,v0.1.4 的 JSP 版本:纯 JSP 加 JavaScript,往 Tomcat 里一扔就能跑,工具栏上的加粗、字号、插入图片、表格、源码切换一应俱全,图片还能直接传到服务器目录。对还在维护老 Java Web 后台的人来说,它是能直接救火的轮子;对想搞明白「所见即所得编辑区到底怎么实现的」的人来说,它没有构建步骤、没有依赖树,出问题就集中在几个 JSP 和配置项上,黑匣子程度很低,是极好的解剖样本。

2. 技术栈与目录结构:iframe 编辑区是怎么搭起来的

这个版本的技术选型放在今天看非常朴素:服务端只有 JSP 和 Servlet 容器,没有 Spring,没有连接池框架,甚至不要求数据库;客户端是原生 JavaScript,不用 jQuery,不用模块打包。它的全部能力都建立在浏览器原生提供的一个能力上——iframe里的designMode。理解这一点,后面所有看起来神奇的按钮就都能对上了。

2.1 运行时依赖:比想象中还要少

把一个老 Java Web 项目跑起来,最怕的是依赖链断在半路。这款编辑器的幸运之处在于它的依赖面窄得可怜,整个运行时就三层:

层次依赖说明
运行环境JDK 1.6 及以上老版本用commons-fileupload走 multipart,JDK 版本不宜过低
Web 容器Tomcat 6/7/8支持 JSP 2.x 的容器都行,Servlet 3.0 之后上传可走原生 API
第三方包commons-fileupload、commons-io只在图片/文件上传的 JSP 里用到,缺失会直接 500

它不需要数据库,编辑器本身不存内容,只负责把 HTML 片段交回给宿主页面,落库是宿主业务的事。这一点很关键,很多新手以为解压完要配数据源,其实完全没有。真要接数据库,是你在自己的发布页里接,编辑器只吐出字符串。

提示:如果你的容器已经是 Tomcat 9 以上,commons-fileupload依然能用,但更推荐直接用request.getParts(),少一个 jar 少一次踩坑。

2.2 目录结构:三个 JSP 加一份配置的分工

解压后目录一般长这样,名字可能略有出入,但职责划分基本固定:

ewebeditor/ ├── ewebeditor.jsp # 编辑器外壳页,iframe 加载的就是它 ├── ewebeditor.js # 工具栏、命令分发、取值赋值的核心脚本 ├── config.jsp # 样式、工具栏按钮、上传路径的配置入口 ├── upload.jsp # 图片/文件上传的处理端 ├── browse.jsp # 服务器端图片浏览与选择 ├── style/ # 各套皮肤,每套一套 css 和图标 ├── uploadfile/ # 默认上传落地目录,需要写权限 └── dialog/ # 插入表格、插入图片等弹窗页面

这个分法很直白:ewebeditor.jsp当壳,负责渲染工具栏和那个可编辑的 iframe;ewebeditor.js是大脑,所有按钮点击最后都归到execCommand;config.jsp是配置中心,改工具栏、改皮肤、改上传目录都在这里;upload.jsp是唯一碰磁盘的地方。定位问题的时候按这条线走,基本不会迷路。

2.3 编辑区实现:designMode 加 execCommand 的老套路

真正让文字能加粗、能变色的,不是编辑器自己写的算法,而是浏览器内置的富文本编辑能力。它的初始化核心就几行:

// ewebeditor.jsp 内联脚本的典型初始化流程 window.onload = function () { var doc = document.getElementById("editorFrame").contentWindow.document; // 打开可编辑模式,浏览器会接管光标、选区、撤销栈 doc.designMode = "on"; // 塞入初始内容,宿主页面传进来的值在这里落地 doc.body.innerHTML = window.parent.document.getElementById("content1").value || ""; // 统一基础样式,避免粘贴进来的内容把编辑区撑变形 doc.body.style.fontSize = "14px"; doc.body.style.margin = "8px"; };

designMode = "on"这行是整个编辑器的地基。打开之后,浏览器会自动维护一个可编辑文档模型,光标、选区、回车换行、撤销重做都由它负责。工具栏上那些按钮做的事情就非常简单了:

// 工具栏按钮的统一入口,cmd 是 execCommand 的命令名 function exec(cmd, param) { var win = document.getElementById("editorFrame").contentWindow; win.focus(); // 必须先把焦点还给 iframe,否则命令丢空 win.document.execCommand(cmd, false, param || null); syncToTextarea(); // 每次操作后回写,防止提交时取到旧值 }

execCommand的第二个参数是 UI 参数,老代码里几乎永远是false,让浏览器不要弹自己的对话框;第三个参数随命令变化:bold不需要参数,fontsize传"4",forecolor传"#ff0000"。这套 API 现在已经被标记为废弃,但主流浏览器仍在兼容,这也正是这个老项目至今还能跑的原因。

取值的方向反过来,把 iframe 里的 HTML 抠出来塞进隐藏域:

function syncToTextarea() { var doc = document.getElementById("editorFrame").contentWindow.document; // 取 body.innerHTML 而不是 documentElement,避免带出多余的 html/body 标签 document.getElementById("content1").value = doc.body.innerHTML; }

参数说明:content1是宿主页面上隐藏 textarea 的 id,也是整个链路里唯一需要你和业务对齐的名字。表单提交时浏览器只看这个 textarea,编辑器本身不参与提交,所以只要保证提交前调用过一次同步,数据就不会丢。

3. 跑起来:Tomcat 部署与最小可运行配置

环境对了,这个项目跑起来只需要几分钟;环境不对,会一直在 404 和 500 之间打转。这一章按「先跑通空白页,再接通上传,最后嵌进业务表单」的顺序走,每一步都有可验证的结果。

3.1 环境准备与部署步骤

准备 JDK 1.6+ 和一个 Tomcat,版本不必追新,Tomcat 7 或 8 最稳,因为老 JSP 里可能有对javax.servlet老包名的硬引用。部署动作很机械:

# 1. 解压后把整个 ewebeditor 目录丢进容器 unzip ewebeditor014jsp.rar -d /tmp/eweb cp -r /tmp/eweb/ewebeditor "$CATALINA_HOME/webapps/" # 2. 上传目录必须可写,Linux 下这一步最容易漏 chmod -R 755 "$CATALINA_HOME/webapps/ewebeditor/uploadfile" chown -R tomcat:tomcat "$CATALINA_HOME/webapps/ewebeditor/uploadfile" # 3. 启动后先访问编辑器自身的演示页 # http://localhost:8080/ewebeditor/ewebeditor.jsp?id=content1

第三步是关键的验证点:直接打开ewebeditor.jsp能看到工具栏和一块可以打字的区域,说明 JSP 编译通过、JS 加载正常、皮肤路径没写错。如果页面白屏但状态码是 200,八成是皮肤目录没跟着复制,或者config.jsp里的样式名和style/下的目录名对不上。如果直接 404,检查webapps下是不是多套了一层目录。

3.2 配置文件:上传路径和工具栏裁剪

config.jsp是这个项目里最值得先读一遍的文件,业务适配基本都在这里改。核心几项含义如下:

配置项典型值作用
stylecoolblue/standard皮肤名,对应style/下的目录
uploadDir/uploadfile/上传落地目录,相对 Web 根
allowExtjpg,jpeg,png,gif上传白名单,老版本常常写得很宽松
maxSize2048单文件大小上限,单位 KB
toolbar按钮 id 串决定哪些按钮出现在工具栏上

改工具栏是最常见的诉求,比如业务只需要加粗、斜体、插入链接和图片,其余全砍掉,减少运营误操作:

<%-- config.jsp 中裁剪工具栏的常见写法,数组里是要保留的按钮 id --%> <% String[] toolbar = new String[]{ "bold", "italic", "underline", // 基础格式 "forecolor", "backcolor", // 颜色 "link", "unlink", "img", // 链接与图片 "source" // 源码切换,排障时很有用 }; request.setAttribute("toolbarItems", toolbar); %>

参数说明:按钮 id 必须和ewebeditor.js里注册的命名一致,拼错了不会报错,只是那个按钮不显示,这是排查时最容易浪费时间的点。建议改完先在源码视图里搜一遍 id,确认脚本里存在同名注册。

allowExt一定要收紧。老版本默认白名单里经常混着html、swf之类,允许上传 HTML 等于给自己开了一个存储型 XSS 的口子,后面避坑章节会细说。

3.3 嵌进业务表单:三个改动点

编辑器真正投入使用,是在你自己的发布页里嵌一个 iframe 指向它,再把内容同步到一个隐藏域。最小集成代码如下:

<%@ page contentType="text/html;charset=UTF-8" %> <form id="postForm" action="/admin/save.jsp" method="post"> <input type="text" name="title" placeholder="标题" /> <%-- 隐藏域:真正参与提交的字段 --%> <textarea id="content1" name="content" style="display:none"></textarea> <%-- 编辑器外壳,id 参数要和上面的 textarea 对应 --%> <iframe id="editorFrame" src="/ewebeditor/ewebeditor.jsp?id=content1&style=standard" style="width:100%;height:420px;border:1px solid #ccc"></iframe> <button type="button" onclick="submitPost()">发布</button> </form> <script> function submitPost() { // 提交前强制同步一次,这是最容易忘、也是丢内容最常见的原因 var doc = document.getElementById("editorFrame").contentWindow.document; document.getElementById("content1").value = doc.body.innerHTML; document.getElementById("postForm").submit(); } </script>

三个改动点分别是:隐藏域的名字要和后端接收参数一致;iframe 的id查询参数要和 textarea 的 id 一致;提交前必须手动同步一次。第三点尤其重要,因为designMode的内容不会自动流回表单,只要漏掉这一步,用户写多少丢多少,而且不报错。

注意:iframe的src里带&在 JSP 中建议写成&amp;或放进变量里拼接,某些容器对属性解析严格时会截断参数,导致皮肤或 id 丢失。

4. 核心链路拆解:一次贴图从点击到落库

编辑器日常最高频的两个动作是「插图片」和「提交内容」,前者跨了浏览器与服务端,后者决定了数据安不安全。把这两条链路拆开看,整个项目的骨架就清楚了。

4.1 上传接口:multipart 请求怎么落到磁盘

点击工具栏的图片按钮,弹窗里选完文件,表单会以 multipart 形式 POST 到upload.jsp。老版本用commons-fileupload解析,处理逻辑大致如下:

<%@ page import="org.apache.commons.fileupload.*,org.apache.commons.fileupload.disk.*,java.util.*,java.io.*" %> <% // 只允许 POST,避免 GET 被当成上传入口 if (!"POST".equalsIgnoreCase(request.getMethod())) { response.sendError(405); return; } FileItemFactory factory = new DiskFileItemFactory(); ServletFileUpload upload = new ServletFileUpload(factory); upload.setSizeMax(2 * 1024 * 1024); // 与 config 中的 maxSize 保持一致 String saveDir = application.getRealPath("/uploadfile/"); new File(saveDir).mkdirs(); // 目录不存在就建,避免首次上传 500 List<FileItem> items = upload.parseRequest(request); for (FileItem item : items) { if (item.isFormField()) continue; // 跳过普通字段,只处理文件 String name = new File(item.getName()).getName(); // 关键:去掉客户端带过来的路径 String ext = name.substring(name.lastIndexOf('.') + 1).toLowerCase(); // 扩展名白名单,别信前端 accept 属性 if (!Arrays.asList("jpg", "jpeg", "png", "gif").contains(ext)) { out.print("<script>alert('不支持的格式');history.back();</script>"); return; } // 重命名,避免中文名、重名、路径穿越三类问题 String newName = System.currentTimeMillis() + "." + ext; item.write(new File(saveDir, newName)); // 回调父窗口,把 URL 插进编辑区 String url = request.getContextPath() + "/uploadfile/" + newName; out.print("<script>parent.insertImage('" + url + "');</script>"); } %>

逐段说明:setSizeMax是服务端硬限制,和配置页里的maxSize是两道闸,改一处不改另一处就会出现「提示超限但仍传上去了」的怪现象。new File(item.getName()).getName()这一行是血泪经验,早期浏览器会把完整本地路径一起提交,不去掉会在服务器上建出诡异的目录。重命名用时间戳虽然原始,但能一次性解决中文乱码、同名覆盖和../路径穿越三个问题,比自己写校验省事。

4.2 内容入库:富文本的过滤比想象中麻烦

编辑器交回来的是一段 HTML,直接存库没问题,直接回显就危险了。老项目最常见的翻车方式就是「后台编辑们互相插脚本」。落库前的处理建议分两步,先做标签白名单,再入库:

// 保存接口里的过滤片段,思路是白名单放行 + 属性剥离 private static final Set<String> ALLOW = new HashSet<>(Arrays.asList( "p", "br", "strong", "b", "em", "i", "u", "span", "a", "img", "table", "tr", "td", "ul", "ol", "li")); public static String clean(String html) { if (html == null) return ""; // 先干掉脚本、样式、iframe 整段内容,避免残留 html = html.replaceAll("(?is)<(script|style|iframe)[^>]*>.*?</\\1>", ""); // 再去掉所有 on 开头的事件属性和 javascript: 协议 html = html.replaceAll("(?i)\\son\\w+\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)", ""); html = html.replaceAll("(?i)javascript\\s*:", ""); // 最后按白名单剥离不认识的标签,保留内部文本 html = html.replaceAll("(?is)</?([a-z0-9]+)[^>]*>", m -> { String tag = m.group(1).toLowerCase(); return ALLOW.contains(tag) ? m.group() : ""; }); return html; }

说明:正则做 HTML 清洗永远不是最优解,成熟做法是引jsoup的Safelist,一行Jsoup.clean(html, Safelist.basicWithImages())就够。上面这段适合不想加依赖的老项目应急。a标签的href还要单独校验协议,只放行http、https、mailto,否则javascript:变个体就能绕过去。

入库用预编译语句,别拼字符串:

String sql = "INSERT INTO article(title, content, create_time) VALUES(?,?,?)"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, title); ps.setString(2, clean(content)); // 过滤后的 HTML 入库 ps.setTimestamp(3, new Timestamp(System.currentTimeMillis())); ps.executeUpdate(); }

参数说明:存的是过滤后的 HTML 原文,不做 HTML 转义。转义是「回显时」的事——如果前端用innerHTML渲染就不转义,用文本节点渲染才转义,这一点搞反了要么样式全丢,要么直接 XSS。

4.3 回显与取值:编辑已有文章时的两个坑

编辑旧文章时,要把库里的 HTML 塞回编辑区。常见写法是在 iframe 加载完成后赋值,但顺序错了就会白屏:

// 正确顺序:等 iframe 内部文档就绪,再写 innerHTML document.getElementById("editorFrame").onload = function () { var doc = this.contentWindow.document; doc.designMode = "on"; // 用 textarea 里预置的旧值回填,注意不要用 innerHTML 做二次解析 doc.body.innerHTML = document.getElementById("content1").value; };

两个坑:一是designMode必须在写innerHTML之前或之后立刻设置,顺序颠倒会让光标无法定位;二是旧内容里如果有<script>,赋值时就会执行,所以回显前一定要过一次 4.2 的过滤,读和写走同一个清洗函数,别只在保存时过滤。

5. 避坑与排查:老项目上线最容易翻车的五个点

这类项目的坑有一个共同特征:不报错,只是功能静默失效。下面五条都是实际部署里反复出现的,按「现象 → 原因 → 解决」记下来能省很多时间。

现象一:编辑区能打字,但提交后内容是空的。原因:提交前没有把 iframe 里的 HTML 同步回隐藏域,表单提交的永远是空 textarea。 解决:在提交按钮的处理函数里强制同步一次,或者给表单绑onsubmit事件统一处理,别指望编辑器自己回写。

现象二:上传图片提示成功,但编辑区里是个裂图。原因:upload.jsp回调里拼的 URL 少了上下文路径,或者上传目录落在了 Web 根之外。 解决:统一用request.getContextPath()拼前缀,并在浏览器地址栏直接访问那张图确认可访问。运维把uploadfile挪到独立存储时,这一处必须同步改。

现象三:上传大一点的图就 500,日志里是SizeLimitExceededException。原因:容器或commons-fileupload的大小限制小于配置页里写的maxSize。 解决:三处对齐——config.jsp的maxSize、upload.jsp的setSizeMax、容器的maxPostSize。只改一处等于没改。

现象四:中文文件名上传后变成乱码,或者图片存进去但打不开。原因:请求编码没设,commons-fileupload按平台默认编码解析文件名。 解决:解析前upload.setHeaderEncoding("UTF-8"),并且在文件落地时统一重命名成英文加时间戳,从根上绕开编码问题。

现象五:改完config.jsp工具栏没变化,清缓存也没用。原因:JSP 编译产物缓存,或者浏览器缓存了ewebeditor.js这个带版本号的静态资源。 解决:先删容器work目录下的编译缓存重启;再确认页面引用 JS 时带了?v=时间戳。老项目里这类玄学问题,九成是缓存。

6. 二次开发:把它改成能接现代后端的形态

这份代码最值钱的地方不是功能,而是它的结构足够松,松到你可以只留前端那层壳,把后端整个换掉。我的建议是分层改造,别想着一次性重写。

第一步,把upload.jsp换成独立的文件服务接口。编辑器弹窗里上传地址是可配的,改成一个返回 JSON 的接口即可,返回体里给{"url": "...", "width": ..., "height": ...},前端回调改两行就能接上。这样图片可以进对象存储,不再依赖本地磁盘写权限。

第二步,把内容清洗从「保存时过滤」前移到「保存和回显都过滤」,并换成jsoup白名单。这一步做完,后台编辑插脚本的问题基本就绝迹了。

第三步,如果业务要求导出 Word 或做内容审核,可以在保存接口后面挂一个异步任务,把 HTML 转成结构化数据。编辑器本身不用动,它只负责产出 HTML。

验证改造是否成功,按这张表逐项过一遍就行:

验证项操作通过标准
基础编辑加粗、改色、插表格内容正确,撤销可用
图片链路上传 png 与超限文件正常图可插入,超限有明确提示
内容安全粘贴一段带onerror的 HTML保存后事件属性被剥离
回显一致保存后重新打开编辑样式与首次编辑一致,无脚本执行
并发覆盖两个标签页同时编辑同一篇后提交者不静默覆盖,有提示

最后一条是我自己加的。早年改一个类似的老编辑器时,两个运营同时开同一篇文章,后保存的把前面的覆盖了,追了两天才定位到是回显时没带版本号。从那以后我每次接这类「无状态编辑器 + 表单提交」的活,都强制在隐藏域里塞一个内容版本号,提交时比对,不一致就拦住。这个习惯救过我不止一次,希望你也能少走这一趟。

本文还有配套的精品资源,点击获取