一个运维向的轻量 Wiki,Flask + Vanilla JS,AES-GCM 混合加密,拖拽管理,开箱即用。
1、起因:为什么要自己造轮子
若不是我们的运维同事要离职并且交接的东西非常零散,我都真不想自己造这个"轮子"。过往同事们的交接都是用 SVN归档 + 文档指引来完成的。这样做并没有什么大问题,只是用起来不太好用而已。譬如要查个东西,你要先知道他存在哪里,然后再看内容在哪里...坦白说有点费时费力。
况且有的时候交接完了也不会马上全部"烂熟于心",等有紧急情况才挖出来看的,这个时候真的抓马。这个时候要是有个能够在线统一归档、查看的知识库就好了。
说实话,第一个反应是「这有什么难的,Confluence 不是现成的吗(之前我记得已经分享过了如何搭建 Jira 和 Confluence)」。但仔细一想,问题来了:
- Confluence 太他妈重了。Java 跑起来内存先吃掉 2G,资源不够根本扛不住。况且功能也太过复杂了,很多东西我都没有用到的,资源浪费。
- 部署麻烦。不管用 MediaWiki 还是 Wiki.js,光是数据库、Node 版本、反代配置就能折腾半天。
- 数据安全。大部分 Wiki 系统,数据库里的内容是明文的。运维文档里难免有些敏感信息------服务器 IP、密码规则、内部拓扑。数据库文件被人拖走了怎么办?这事不是没发生过。就更不可能说用 Notion、语雀、印象笔记等第三方知识库了。
- 权限控制。我需要「这篇文章只有张三和李四能看」,而不是简单的「管理员 / 普通用户」二元权限。
选来选去实在没找到同时满足 轻量部署 + 内容加密 + 细粒度分享 这三个条件的。所以这个周末,自己写了一个。
2、技术选型:为什么是 Flask + 原生 JS
很多人看到这个选型会觉得奇怪,"都 2026 年了,谁还用 Flask 写 Web 应用?前端连个框架都不上?"
2.1、后端:Flask + SQLAlchemy + SQLite
选 Flask 的理由其实很简单:够用,且部署成本为零。
整个应用就一个 app.py 入口,pip install 装完依赖直接跑。不需要 Nginx、不需要 WSGI 配置、不需要数据库迁移脚本------db.create_all() 一行搞定。
SQLite 确实有并发写的争议。但内部知识库的写入频率极低(一天可能就改几篇文档),WAL 模式足够应付。真到了并发瓶颈的时候,把连接字符串换成 PostgreSQL 就行,这就是我用 SQLAlchemy 的理由。
2.2、前端:Vanilla JS,零构建步骤
首先我必须承认,我前端是渣渣(因此这里稍微用 AI 帮我做了点东西)。
本人前端技术大概停留在 10 年前吧,为了让自己也能够看懂,不做 SPA 路由、不做虚拟 DOM、不上 Webpack。所有 UI 逻辑散在几个 JS 文件里,页面只有一个 index.html(操作 DOM 就操作 DOM 咯,能用就好)。而且零构建意味着:改一行 JS 直接刷新浏览器就能看到效果 。没有 npm run dev、没有 HMR 抽风、没有 node_modules 黑洞。说实在的,这挺好。
3、加密方案:拿了你也看不到
这是整个项目里我最花心思的部分。
3.1、设计目标
简单说就一条:即使有人物理拿到了 wiki.sqlite 文件,他也读不懂任何一篇文章的内容。
很多团队觉得「反正是内网部署,加密没必要」。但现实是------服务器被攻破、备份文件泄露、离职员工带数据走,这些事发生的时候你根本来不及处理。
3.2、技术实现:AES-256-GCM + RSA-2048 混合加密
核心思路和 Telegram、WhatsApp 的端到端加密类似,做了简化:
plain
1. 用户注册 → 服务端自动生成 RSA-2048 密钥对
2. 创建 article → 随机生成 AES-256 密钥 → AES-GCM 加密内容 → RSA 公钥加密 AES 密钥
3. 存储到数据库 → encrypted_content(密文) + encrypted_key(加密后的 AES 密钥)
4. 读取 article → RSA 私钥解密 AES 密钥 → AES 密钥解密内容 → 返回明文
代码其实不长,核心就 80 行:
python
def encrypt_content(plaintext: str, public_key_pem: str) -> tuple[str, str]:
aes_key = AESGCM.generate_key(bit_length=256)
aesgcm = AESGCM(aes_key)
nonce = os.urandom(12)
ciphertext = aesgcm.encrypt(nonce, plaintext.encode('utf-8'), None)
encrypted_content = base64.b64encode(nonce + ciphertext).decode('utf-8')
pubkey = serialization.load_pem_public_key(public_key_pem.encode('utf-8'))
encrypted_key = pubkey.encrypt(
aes_key,
asym_padding.OAEP(
mgf=asym_padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
return encrypted_content, base64.b64encode(encrypted_key).decode('utf-8')
4 个设计方向:
- 密钥存在服务端 。这个方案并不是真正的端到端加密,我选择将密钥对存在数据库的
User表里。那么有小伙伴就会问「那服务端被完全攻破了怎么办?」。答案是:如果攻击者拿到了运行时的内存和数据库,确实没法防。但我们的防御目标是「数据库文件被拖走」这个场景------攻击者只有静态文件,没有解密密钥。 - document 节点不加密,article 节点加密。个人觉得目录结构没必要加密,不然连树都渲染不了。只有文章内容走加密通道就可以了。
- 分享时的重新加密 。分享文档给另一个用户时,系统会用对方的 RSA 公钥重新加密 AES 密钥。这样对方用自己的私钥就能解密。权限回收同理,删除对应的
NodePermission记录就行,这样的话内容本身就不需要动了。 - 权限转让更复杂。如果把文档所有权转给另一个人,所有子节点的 AES 密钥都要用新所有者的公钥重新加密。递归转让时每个节点独立处理,若有 100 个节点的目录,就会有 100 次 RSA 加密操作。不过好在这是低频操作,性能不是问题。
3.3、一个真实的防御场景
假设你的服务器在联通云上,有人通过某个漏洞拿到了文件系统的访问权限,把 wiki.sqlite 下载走了。他能看到什么?
- User 表的邮箱、注册时间 ✓(能猜到是谁)
- Node 表的标题和树形结构 ✓(能看到文档目录)
- Node 表的
content字段 ✗(AES-GCM 密文,没有密钥解不开) - Node 表的
encrypted_key字段 ✗(RSA-2048 加密的 AES 密钥,没有私钥解不开)
说实话,标题泄露也是信息泄露。但比起全文泄露,这个风险对一般小微企业来说还是可以接受的。如果未来真的需要更彻底的方案,可以引入客户端加密------密钥存在浏览器 localStorage 里,服务端完全不碰明文。
4、编辑器体验:三个我满意的细节
编辑器是用户接触最多的部分,这里我吸收了别人家(Markdown 编辑器)的经验:
4.1、分屏拖拽吸附
左右分屏(Markdown 源码 / HTML 预览),中间有一条可拖拽的分隔条。但常规拖拽有个问题------你很难精确拖到「全屏 Markdown」或「全屏预览」。我的做法是加了一个吸附逻辑:
javascript
const SNAP_THRESHOLD = 20; // 距离边缘 20px 以内触发吸附
// 拖到最左边 → HTML 预览全屏(markdown pane 折叠)
// 拖到最右边 → Markdown 全屏(html pane 折叠)
// 中间任意位置 → 自由比例分屏
这个交互比想象中好用。写长文档时全屏 Markdown,校对排版时全屏预览,平时分屏对照------三种模式切换零成本。
4.2、双向滚动同步
实现方案是比例映射:两边各自计算 scrollTop / (scrollHeight - clientHeight) 的比例,然后按这个比例设置另一侧的滚动位置。用 requestAnimationFrame 防抖,再用一个全局锁 isScrollSyncing 防止两边互相触发形成死循环。
说实话,按比例映射在文档结构差异比较大的时候(比如 Markdown 开头 50 行大部分是 YAML front matter,但在 HTML 里就渲染成一行),同步精度会下降。但对于普通的技术文档来说,够用了。
4.3、Ctrl+S 保存
这个功能其实很小,但没做的话体验会很差。如果用户写了一篇文章忘记保存就关了页面,那感觉......懂的都懂。
用 beforeunload 事件 + isDirty 标记追踪未保存状态:
javascript
window.addEventListener('beforeunload', (e) => {
if (WikiEditor.isUnsaved()) {
e.preventDefault();
e.returnValue = '';
}
});
5、文件导入:Word 和 Excel 直接转 Markdown
运维有很多存量文档是 Word 格式的------故障排查、操作手册、部署文档等等。让人一篇一篇重新用 Markdown 写不现实。
所以做了三个导入通道:
- Markdown / 纯文本 / 代码文件:直接读内容,不转换
- Word (.docx) :用
python-docx解析,标题样式(Heading 1~4)自动映射到 Markdown 标题层级,正文转普通段落。图片提取保存到images/目录,并保留在文档中原段落的位置 - Excel (.xlsx) :用
openpyxl解析,每个 Sheet 渲染为独立的 Markdown 表格,以## Sheet名称作为二级标题
导入逻辑的大致流程:
- 先扫描
doc.part.rels,把文档里所有图片的关系 ID(rId)和实际图片数据对应起来,保存到images/目录 - 然后逐段落遍历,用
qn("w:drawing")检测当前段落是否包含图片 - 没图片的段落:直接按标题样式或正文提取文本
- 有图片的段落:按
<w:r>元素的原始顺序逐个处理,在文本和图片之间保持相对位置
python-docx 的 API 在图片提取这块支持不太好,需要手动遍历 OOXML 的 <w:drawing> 和 <a:blip> 元素才能拿到 r:embed 属性去查关系 ID。
6、权限与分享:不只是简单的 RBAC
大多数 Wiki 系统用的是「空间级」权限------要么你能看整个空间,要么你什么都看不了。但实际需求往往是这样的:
「小王,你把《Redis 故障处理》这篇文档分享给 DBA 组的李工看一下,但他不能改。」
树形节点 + 独立的授权记录表,天然支持这种粒度:
sql
-- 权限模型
User (id, email, rsa_public_key, rsa_private_key, ...)
Node (id, parent_id, title, content, owner_id, encrypted_key, ...)
NodePermission (node_id, grantor_id, grantee_id, is_active)
关键设计:
- 授权不是给用户分配角色,而是给节点绑定被授权人 。每个
NodePermission记录代表「用户 A 授权用户 B 看节点 C」 - 被分享者看到只读视图:全宽 HTML 预览,隐藏 Markdown 编辑器和保存按钮
- 祖先节点可见:被分享的节点在分享对象的树中,能看到完整的父级链------虽然父节点没有直接权限,但节点路径需要完整显示才有意义
批量操作也做了:Ctrl+Click 多选节点,然后批量授权或撤销。
还有个有意思的场景------权限转让。比如原来的文档负责人离职了,需要把所有权转给别人:
- 选中节点 → 右键 → 权限转让 → 选择新所有者
- 系统用旧所有者的私钥解密所有子节点的 AES 密钥
- 用新所有者的公钥重新加密
- 更新
owner_id
每个节点独立重新加密。处理时间大概几秒钟,对体验影响不大。
7、一些不完美的地方(坦白局)
7.1、前端没有做路由
所有页面状态靠 JS 变量维护,刷新页面就回到初始状态。虽然 SPA 本来就是这样,但没有 URL 状态意味着你没法复制一个链接发给同事说「你看这篇文档」。这是未来需要加的功能------至少把 ?node_id=123 映射到对应的文档打开状态。
7.2、密钥存在服务端
前面说过了。这是安全性和便利性的权衡。如果引入客户端加密,分享功能会复杂很多------需要客户端交换公钥。目前这个方案对内部团队来说够用,但如果要开放公网注册,这个设计需要重新评估。
7.3、SQLite 的并发天花板
虽然内部 Wiki 不太可能触发 SQLite 的并发瓶颈,但如果一个团队有几百人同时编辑,确实会出现写锁冲突。好在换成 PostgreSQL 只需要改一行配置:
python
# config.py
SQLALCHEMY_DATABASE_URI = 'postgresql://user:pass@localhost/wiki'
ORM 层完全不用动。
7.4、没有做 i18n
目前所有的 UI 文本、错误提示都是硬编码的中文。如果有国际化需求,需要重构一遍。不过对国内运维团队来说这不是什么问题。
8、部署:比冲一杯咖啡还快
我故意把部署成本压到了最低:
bash
pip install -r requirements.txt
python app.py
就这两步。默认监听 0.0.0.0:5100,浏览器打开就能用。
第一个注册的用户自动成为管理员。之后任何人注册都需要管理员在后台审批------避免匿名用户往你的 Wiki 里灌垃圾内容。
生产环境挂 gunicorn:
bash
gunicorn -w 4 -b 0.0.0.0:5100 app:create_app()
邮件通知是可选的,配了 SMTP 就用,不配也不影响核心功能。
9、最后:适合谁用
Ops Wiki 不是要替代 Confluence 或 Notion。它解决的是一个很具体的场景:
一个 10-50 人的技术团队,需要一个轻量的、安全的、零运维成本的内部知识库,支持 Markdown 编辑、文档加密、权限分享,且能从 Word/Excel 批量导入存量文档。
如果你符合这个场景,拿去用吧。不符合的话,它至少展示了「不用框架、不用构建工具、用原生 JS + Flask 做一个完整的 Web 应用是什么体验」。