MoreRSS

site iconE99p1ant修改

博客名:Light Cube。腾讯安全玄武实验室。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

E99p1ant的 RSS 预览

我还是放弃了 WordPress · LightCube 九周年总结

2024-10-07 17:27:05

又到了一年国庆假期,这个小站也迎来了他的九岁生日。每年坐在电脑前静下心来写的周年总结,也是对我过去一年所发生的事情的回顾。去年国庆我经历了忙碌无休的加班,整个假期根本抽不出时间来写一篇文章,最可笑的是最后却是竹篮打水一场空,我一无所获。

而到了今年国庆,我却是已经搬离了生活快六年的杭州,在上海的一间小小公寓内写下这些文字。我在上海有了新的工作,认识了新的同事,见到了很多新的技术。变化如此之大,回看年初四月那段泥泞坎坷的经历,还是很佩服自己当时的决心。我对自己现在的工作和生活十分满意,最近也总是感慨:“要是日子能一直这样下去就好了。” 但我也知道自己无时无刻是在逆水行舟,不能懈怠。

言归正传,还是看看过去的一年内,这个小站又发生了哪些变化吧~

WordPress -> Hugo

我是在高一的国庆假期,偶然刷到了一个 b 站视频,视频介绍了如何在 Redhat OpenShift 上搭建自己的 WordPress 博客。这也是我第一次接触 WordPress、PHP、MySQL 这些东西,用了一个下午时间,在 OpenShift 上搭建了 WordPress 站点。后续因不满足于 OpenShift 海外美国节点的访问速度,陆陆续续换了很多家网站托管商。因为域名没有备案,所以当时都还是用得香港节点。

上大学后,开通了阿里云学生机,又自学了 Docker,我便将网站迁到了学生机的 Docker 里。但由于使用的是 Apache、PHP、MySQL 官方镜像,没有调节任何参数,整个网站即使在国内学生机上,前台访问也总是卡卡的。WordPress 后台就更别说了,后台首页加载要七八秒。本想自己造轮子写一套博客系统的,在 2020 年的时候尝试把容器镜像换成了 WordPress 官方镜像,居然不卡了。造博客系统轮子的计划也随之弃坑。

大学毕业后,学生机无法续费的,便开始玩上了竞价实例 + K8s 集群,博客也从原来学生机上的 Docker,迁移到了集群内。但我为了省钱,竞价实例节点出价总是比最低价格多一分钱,导致隔段时间实例就会因为市场价格变化而被回收。然后我的阿里云账号余额又总是维持在 90 - 100 附近,余额低于 100 就开不出新的实例。每次都是节点被销毁了,站点告警提醒我博客挂了,我再赶紧拿出手机充钱。(甚至在谈恋爱第一次约会请吃饭的时候,突然收到告警说实例被销毁了,我只能假装是在拿手机点餐,实则在给阿里云充钱)

而压倒骆驼的最后一根稻草,是我发现用了这么多年的 WordPress 主题,居然不支持 PHP 8。切到 PHP 8 后,会提示满屏的方法已弃用,完全跑不起来。这套主题是我 2018 年高考后花钱购买的主题,早已不维护了,主题作者的网站现在都已经变成下载站了。

因此,我决定放弃用了 9 年的 WordPress,转向静态网站。

我在今年二月开始,花了大概一个月的的时间,将原 WordPress 主题搬到了 Hugo 上。搬的方法也是很简单粗暴,大批大批地复制 HTML、CSS,再按 Hugo 模板的结构一点点拆。期间舍去了很多看起来很炫,但实则没什么用的功能。(纯属因为太麻烦了不想做)例如页面滑到最底可以自动加载下一页,被改成了只能通过导航器翻页;去掉了移动端的下拉导航,做成了将导航菜单放到 Logo 下面;删除了以前在 WordPress 中乱七八糟的 Tag 和文章分类,统一成 “随笔”、“技术”、“创意”、“安全”、“分享” 五个分类。

换成 Hugo 静态网站后,得到的速度提升也是很明显的。目前网站部署在腾讯云 COS 对象存储中,前面套了一层腾讯云的 CDN。对于文章头图这类比较耗 CDN 流量的资源,我找了个京东某系统的上传,将图片上传到京东 360buyimg.com 的全球 CDN 上。京东这 CDN 还挺强大,还支持图片裁剪、缩放、格式转换等处理参数。详情可以查看官方文档:京东图片调用详解

像一些简单的前端交互或者数据双向绑定,我就直接拿 AlpineJS 来做了。像这些主流的 JavaScript 公共库,可以直接走字节的 CDN:字节跳动静态资源公共库,在 URL 路径中还可以设置缓存的时长。(之前用七牛的 staticfile.net ,这垃圾玩意的所有响应都带 no-cache 头,这本地缓存个寂寞 😅)

静态网站的评论系统

迁移到静态网站后,“评论系统” 总是绕不开的一个话题。其本质还是持久化数据存哪的问题。

像开源的一些基于 GitHub 账号的评论,数据存 GitHub Issues,但国内的访问速度不佳,且留言者必须登录自己的 GitHub 账号。或者是接一些第三方的 SaaS,如 DISQUS,这类系统会要求使用第三方账号登录,或者注册一个 DISQUS 账号。我对这种收集留言者信息或者引流到第三方平台注册的行为,挺精神洁癖的。另一些基于 Serverless 服务的评论系统,则是存储在类 LeanCloud SaaS 或者 Self-hosted 的数据库中,这类在设计上没有问题,但开源的那几个不论是样式还是性能,都挺拉胯的。

我一开始选择的是 Waline,后端部署在阿里云的 Serverless 云函数上,背后接的内网 MySQL 数据库。首先遇到的是如何从 WordPress 迁移评论数据,GitHub 上发了帖 #2348 ,得到回复说要先迁移到 DISQUS,再转 Waline。好家伙,我还得把我博客的评论用户 IP 和 Email 数据提供给第三方服务是吧?果断拒绝,自己糊了个迁移脚本。

迁移完成后,加载评论咋还有点卡,这样式咋还是细细的边框跟我博客主题一点都不搭…… 真的太丑太垃圾了!不如自己写一个好了。

于是则有了你现在看到的博客评论系统,后端是基于之前介绍过的 Sayrud,前端是自己使用 daisyUI 糊的。相比 Waline 的留言框更加的轻巧大气。构建时还是老一套的 UMD 打包输出一个 .js.css,通过 window 变量来将当前的页面 URL 传递进 Vue 实例内。

值得一提的是,我这个评论系统还支持在评论内容中添加表情。这些表情图标都来自于字节系的产品(因为我很喜欢里面那个可爱的狼头)。

只需打开飞书网页版的聊天页面,将飞书聊天表情的精灵图与 CSS 扒下来即可。

https://sf3-cn.feishucdn.com/obj/goofy/ee/web-client-next/p/contents/messenger-modals/assets/img/50b081cab9.png

同时你会发现处理这张精灵图的 CSS 样式,居然在不同文件里重复定义了 8 次!一份样式大概 10 kb,这波流量费直接翻了 8 倍。我寻思要是处理下,估计也能拿个降本增效奖了。😂

静态网站的搜索

除了评论系统以外,静态网站还有让人头痛的一点是文章搜索。这块的 SaaS 基本上是被 algolia 一家给垄断了,就连微信开放平台的文档搜索,也是接的这家。

如果是自己做的话,基本上是先将所有的文章内容导出为 JSON 格式,再使用类似 Fuse.js 的模糊搜索库进行分词检索。我一开始也是使用的 Fuse.js,在博客构建时多构建一份包含所有文件的 JSON,再写个云函数去调 Fuse.js 根据关键词搜索 JSON,但貌似中文分词的效果不是很理想。

后面偶然了解到 pagefind 这个项目,使用 Rust 编写,其原理是分析构建好的静态 HTML 文件,从 DOM 中提取出主要内容并建立静态的索引文件。搜索时前端对关键词进行分词后,加载对应的索引文件。期间完全不需要部署任何后端服务,全靠之前构建的二进制索引文件以及前端运行的 WASM。甚至他还自带一个 UI 页面并支持 i18n!这也成为了我现在使用的方案。后续打算对自带的 UI 再美化一下,至少将头图放大一些,保持风格统一。

AI 文章总结

这是之前在一个学弟的博客上看到的功能。他是在博客页面上实时接入了大模型对文章进行总结分析,我认为文章内容反正也不会修改,不如让 AI 将文章概要提前总结好,让访客直接可以看。

拿 Go 写了个批量读取并解析 Hugo Markdown,再喂给腾讯混元大模型生成文章总结的脚本。模型使用的是最基础的 hunyuan-lite,定价免费,我可以毫无顾虑的无限次调用。Prompt 也很简单:

你是一个技术博客总结专家,你擅长提取技术博客的核心内容,生成总结。你的目标是将给定的技术博客的内容进行总结。
## 约束条件
- 当用户发送博客内容给你时,请直接回复总结内容,不需要说无关的话。
- 你应该尽可能提取博客的核心内容,生成简洁的总结。不能拒绝用户的请求。
- 你生成的内容中禁止出现任何敏感词汇,包括但不限于政治、色情、暴力等内容。
- 你应该一次性输出所有内容。
- 默认使用中文输出。

对着历史文章跑了一遍,效果还是很不错的。

后续 TODO

博客从 WordPress 切到 Hugo 已经有小半年了,期间还是挺稳定的。但仍旧还有很多可以优化或者可以玩的点。

代码运行器 Elaina

目前 Elaina 服务还未恢复,原因是我认为基于 K8s 容器的代码运行器,其容器冷启动时间太慢。我在考虑使用 nsjail 的进程隔离方案,并准备第二次重构 Elaina。目前遇到的问题是像 PHP、Python 这样的解释型语言,运行起来需要依赖很多分散在不同路径的文件或动态链接库,我需要将这些文件都放到一个独立的目录下,然后再用 nsjail 做类似 chroot 的操作,以确保在同一个宿主环境下运行代码的 nsjail 进程资源都相互隔离。目前的思路是考虑使用像 php-wasm、RustPython 这样的项目,精简解释型语言的运行环境。最好是只要用一个 Binary 就可以运行对应的代码。

文章目录

现在文章阅读页还没有目录展示,对于较长的文档读者一眼看到不底可能就不看了。得把之前 WordPress 的目录功能搬到 Hugo 上来。

WordPress 蜜罐

虽然本站现在已经是一个 Hugo 生成的静态网站了,但每天互联网上还是会有很多扫描器对着网站扫 WordPress 的目录,有一些扫得比较过分的 IP 我已经封了。我也不知道他们现在是从哪得知我还是个 WordPress 站的,我把 wordpress.org 上的信息也下掉了,但每天还是会有。

那既然每天都会被当做 WordPress 站扫描,那我何不写个 WordPress 蜜罐来反制他们?听起来是挺有意思的,但我也不知道有哪些反制的骚操作,以及如果要在腾讯云 CDN 中配置规则转发流量到蜜罐后端的话,需要升级 CDN 服务到 “边缘安全加速平台 EdgeOne”。这东西一个月套餐起步价就 30 块,比我一个月 CDN 流量费还高。因此目前还一直停留在 TODO……

嘛,大概就是这些。明年的今天就是十周年啦~ 也不知道那时的自己会在何处?虽说确实该整个大的,但是现在暂时还没想法。

今天也是国庆假期的最后一天,我挺期待明天第一天去新大楼上班。😋

基于 Traefik ForwardAuth 实现集群服务统一认证

2024-09-08 01:42:49

我在腾讯云上有一台 4C8G 的 LightHouse 轻量云服务器,服务器上使用 k3s 搭了个小集群部署自己开发的小玩意,以及一些常见的基础组件。如 Grafana 做仪表盘展示、Uptrace 记录 Go 程序的链路、Metabase 用作 NekoBox 的数据库 BI。这些服务通过 Helm Charts 部署至集群,配置 Ingress 后直接通过公网域名即可以访问。

我时常在想这些第三方应用会不会哪天爆出个 0day 被打穿。进而导致我存在里面的数据库配置、云 AK SK 之类的凭证泄露。因而在想能否在集群的 Ingress 反代层面做统一的权限认证,就像公司内的某统一认证系统一样 —— 具体名字我不知道能不能说,不过你应该可以在公网上找到它的痕迹。

我一直觉得,这种架设在反代上的统一认证,比那些跳第三方 OAuth 的验证方式安全多了。

经常能看到一些企业内部的 Web 站,做的前后端分离的架构。第一次访问时加载前端页面,前端逻辑判断用户未登录,跳转到第三方 SSO 做统一登录。登录成功后 callback 一个 SSO Token 回原站点。然后后端 API 签一个自己业务的 Token 发给前端,前端把业务 Token 放 Local Storage 里存着。由于网站是前后端分离的,攻击者在未登录的时候就可以访问前端,他就可以从前端打包后的 JavaScript 里把后端接口全提取出来去 Fuzz。(更别说还有些不关 Sourcemap 的)后端在实现上万一漏了个路由,鉴权中间件没包到(往往还是些上传下载文件的接口),然后就接口越权一把梭了。

因此我觉得供内部使用的服务,不管是基于第三方的还是自建的,都应该在网关层面做一套统一的鉴权。

那么说干就干!在查阅了相关资料后,站在前人的肩膀上,我造了个小轮子 —— ikD。

ikd_web_screenshot

比 traefik-forward-auth 简洁

由于使用 k3s 搭建的集群会内置一个 Traefik 做为默认的 Ingress Class,我也就围绕 Traefik 来展开了。ikD 这个名字,其实也就是取自 Traefik ID 中的三个字母。一开始想叫 ikID 的,但是仔细一读像是什么儿童品牌……?遂改名。

我的想法是先找找看 Traefik 有没有类似 K8s Mutating Webhook 的特性,当准备代理一个集群内的 Service 时,先去调用一下我写得“WebHook”,由我来指挥它后续的行为。找了一圈发现 Traefik 里还真有这样一个中间件:ForwardAuth,同时还找到了前人开发的 traefik-forward-auth 项目。该项目利用 ForwardAuth 中间件让 Traefik 反代支持前置使用 Google 账号或 OpenID 服务进行身份认证。然而我很少用 Google 账号登录,OAuth、OpenID、SAML 那些玩意更是傻傻分不清,总不能为了用这玩意我再去注册个 Auth0 吧?!

因此我在阅读了 traefik-forward-auth 的源码后,写了 ikD 这一版拥有更简洁更适合我自己使用的 Traefik ForwardAuth 认证服务。

ForwardAuth

Traefik 本身不支持用户编写自定义逻辑的中间件,只能将官方文档中给的内置中间件简单配置后使用。比如官方给你提供了个 Errors 错误中间件,那你可以自己配置哪些状态码要报错,以及报错页面的地址是啥。

ForwardAuth 就是官方提供的用于转发请求到外部服务进行验证的中间件。这里直接贴文档里的图,方便后文介绍。

authforward

对于使用了 ForwardAuth 中间件的路由,Traefik 会先请求 address 中配置的第三方服务地址,并使用 X-Forwarded-* 请求头传递上游请求的请求方式、协议、主机名、URL、源 IP 地址给第三方服务。第三方服务就可以根据这些信息来执行自定义的验证逻辑了,若第三方服务返回 2XX 响应码,则代表验证通过;否则验证不通过,Traefik 将把第三方服务的响应传给上游。

这个设计十分简洁。验证不通过时返回第三方服务的响应,可以方便我们将未验证用户 302 跳转到登录页面。

值得一提的是,我十分好奇 Traefik 源码中关于 2XX 响应码的判断方式,我以为会是 statusCode / 100 == 2 这样的写法,但实际是:

// https://github.com/traefik/traefik/blob/9dc2155e637318c347b8b00e084c3dd0c75f18e4/pkg/middlewares/auth/forward.go#L187-L189

// Pass the forward response's body and selected headers if it
// didn't return a response within the range of [200, 300).
if forwardResponse.StatusCode < http.StatusOK || forwardResponse.StatusCode >= http.StatusMultipleChoices {

它是判断状态码的数字是否落在 [200, 300) 这个区间内,我感觉这样的写法可以规避掉 statusCode / 100 == 2 中出现的 2 这个 Magic Number。在 Lint 上会更好一些。

完整的登录流程

ikd_user_signin

画了张图来梳理 ikD 是怎样处理用户登录的。

  1. 用户请求了 https://hello.example.com/index.php 网站,集群内 Traefik 请求 ikD 服务,ikD 发现用户未登录,返回 302 跳转到 https://ikd.example.com/?redirect=https://hello.example.com/index.php
  2. 由于状态码非 2XX,Traefik 知道这是验证不通过,将 ikD 的 302 响应返回给上游。用户的浏览器跳到了登录页。(这里跳转的 URL 里 Query 需要带一下来源 URL,方便登录成功后跳回去)
  3. 登录页https://ikd.example.com/ 是单独做的 Web 服务,用户在这里提交凭证登录成功,后端接口会在来源 URL 中加上一个 ikdcode Query 参数,如:https://hello.example.com/index.php?ikdcode=a1b2c3d4e5f6g7 前端控制用户浏览器跳转到该地址。
  4. 跳到 hello.example.com 域下后,又被 ikD ForwardAuth 中间件拦了,但它发现这次多了个 ikdcode 参数,会去验证这个参数是否有效。如果有效,则会在返回 302 跳转到去除 ikdcode 的地址:https://hello.example.com/index.php并 Set-Cookie这里是整个登录过程中我认为最巧妙的地方:ForwardAuth 中间件劫持了目标站的响应,返回 Set-Cookie 头让它可以在目标站的域名下写一个 ikD 的 Cookie。
  5. 用户浏览器再次跳到 https://hello.example.com/index.php ,会带上之前一步设置的 Cookie。此时再被 ikD 拦截,ikD 认出了这个 Cookie 并验证通过,返回状态码 200 OK,至此请求终于能够被转发到后面的 hello.example.com 服务的 Service 上了。

具体到代码实现上,有一些细节需要注意:

  1. ikD 登录页登录成功后,也需要给 https://ikd.example.com/ Session 存个登录态,下次再跳过来,发现之前已经登录过了,直接跳走就行。
  2. ikdcode 拼接后作为 Redis 的 Key,Value 存储目的站点的 Proto + Host。实际登录时,用户拿到带 ikdcode 的 URL 几秒不到就跳转去验证了,所以 Key 的有效期可以设置的短一点,如一分钟。像上面的例子在 Redis 中存储的就是:
redis.SetEx("ikd:authcode:a1b2c3d4e5f6g7", "https://hello.example.com", 1*time.Minute)
  1. 校验ikdcode 时,使用 GETDEL 来获取 Redis Key,确保 ikdcode 仅可使用一次。还要将 Value 中存储的目的站点和实际要跳转的站点进行比对,防止一开始使用恶意站点 A 获取到的 ikdcode 可以用来登录站点 B。
  2. 最后 Set-Cookie 的值可以签一个存储了目的站点 Proto + Host 和有效期的 JWT。因为访问目的站点的每个请求都要先打到 ikD 上,这个请求量比较大,验证 JWT 比查 Redis 验证 SessionID 快多了。

一次性字符串凭证登录

你会发现 ikD 的登录页并没有要求输入用户名和密码,而是一个 发送登录凭证 的按钮。这里的登录方式和 Notion 类似 —— 随机发送由三个英文单词组成的字符串到我的手机上,我输入字符串登录。

在 macOS 环境下可以读取 /usr/share/dict/words 文件来获得英文单词,这个 words 文件是软链接到同目录下的 web2 文件。线上基于 Alpine 打包的 Docker 镜像,可以从苹果开源 https://opensource.apple.com/source/files/files-473/usr/share/dict/web2 下载到这份单词表。GitHub Actions 打镜像的时候丢进去就行。

发送字符串是后端请求我手机 Bark App 的 WebHook URL 发送推送消息。收到推送后手机上复制,iCloud 剪贴板同步粘贴到电脑浏览器即可登录。由于是直接复制的内容,几乎不可能出错。所以每次发送的字符串凭证的验证仅有一次机会,输入错误了就得再重新下发一个新的。嗯,感觉十分的安全呢。后续其实可以做个 App 来弹出个框让我点确认的。

ikd_bark_notification

接下来呢?

现在我已将集群内的 Metabase、Grafana、Uptrace、以及自己开发的自用服务接上了 ikD 做统一认证。好好好,这下 ikD 被打穿了就全部完蛋!

但目前还只是个刚好能用的状态,对于各种操作还需要记录行为日志,后续可以考虑下把集群里搭的 Loki 用起来。

我一开始是想用 WebAuthn 来做一个帅到爆的 TouchID 刷指纹登录的。但尝试了下 WebAuthn 单独拆出来做成单用户调用还挺复杂的。真要做的话只能老老实实地按照 SDK 文档先做注册生成公私钥,公钥还得分用户存数据库,登录的时候发送 Challenge 挑战给客户端,解完后还得查库找到对应的用户。那就又回归到了朴实无华的 Go 写一套用户账号的 CRUD 了,已经不想再写 CRUD 了!放弃!😖

以及最后那个问题,ikD 开源吗?很遗憾,依旧不想开源。如果你对此有兴趣,可以找我讨论。😋

Sayrud:因为不想重复写 CRUD,我把 18 岁那年开的坑填完了

2024-07-14 21:50:24

少年 18 岁时的梦

记得我 18 岁那年高考完在家,还没放松几天就被我爸催着去找份暑假工作。当时我对工作一点概念也没有,糊了份简历就在 58 同城上乱投,投完第二天跟一家公司约了线下聊聊,结果还真让我聊到个在家兼职的工作。(后来发现其实巨不靠谱)

工作内容大致是开发微信小程序,我当时仅有一点自学的微信小程序的开发经验和 PHP CodeIgniter 后端经验,差不多能 Hold 住对面的需求,甚至还在 GitHub 上给一个小程序前端组件库提了 PR。(现在回过头看当初写的代码,真的是“满目疮痍”——前端 UI 没对齐,后端 SQL 注入满天飞,黑历史了属于是)

直到大学开学前,暑假的两个月里我给那边开发了两个微信小程序。因为每次都要用 CodeIgniter 框架写功能类似的后端,年少的我在想能否把 MVC 的 Model 操作数据库,Controller 处理逻辑,View 返回响应给封装成一个线上的服务,我在图形化的 Web 页面上点点点就可以实现建表、验证表单、定义 API 接口等操作。

我被自己这个天才般的点子所鼓舞,用 PHP 写了 WeBake ,当时的想法是用来快速构建微信小程序后端。年少的我以为自己在做前人从来没做过的东西,沉浸其中并暗自窃喜。直到进入大学的前一天夜里,我在知乎上偶然看到了一家同类型的 SaaS 应用推广,也是在跟我做相同的东西,并且已经开始了商业化,我才知道业内有很多公司都已经在做了。那天晚上我直接心态爆炸。关于 WeBake 这个项目后面也就理所当然的弃坑了。

后来发生的事,大家也都知道了:微信后面发布了「微信云开发」的一站式后端解决方案,直接官方必死同人。再后来 “LowCode 低代码”的概念开始流行,LeanCloud 被心动游戏收购,国外 AirTable、国内黑帕云、维格表 Vika 等产品开始流行起来…… 而那个当时让我心态爆炸的做小程序后端的 SaaS 产品,在互联网上几乎找不到它的痕迹了。

开始填坑

我在 2021 年的时候看到了 Hooopo 的文章 Let’s clone a Leancloud,里面介绍了使用 Postgres 实现类似 LeanCloud 的 Schemaless Table 的特性。我直呼好家伙,没想到 Postgres 的视图和 JSON 数据类型还可以这样玩出花来。我当时对着文章用 Go 实现了个小 Demo,感觉确实有意思。但是因为没有具体的需求,那个 Demo 一直躺在我的 GitHub 里。

今年我放弃 WordPress 使用 Hugo 重构了本博客,一直没找到个能满足我需求的静态博客评论组件,便想自己造轮子写一个。但是评论服务的后端,不就跟留言板一样,都是些很基础很无脑的 CRUD 吗?我已经不想再用 Go 无脑写 CRUD 了!要不我把需求抽象一层,直接写个“低代码数据中台”出来?好像有点意思哦……?

就这样,Sayrud 诞生了。

Schemaless 特性

Schemaless,中文机翻为「无模式」,让人听得云里雾里的,让我们一步步来。

首先,数据库语境的 Schema 可以简单的理解为是数据库的表结构定义,我有一张学生表,表里有学号、姓名、班级三列,然后学号是主键…… 这些就是 Schema 。在关系型数据库中,我们得写 SQL 语句来定义这张表:

CREATE TABLE students (no TEXT, name TEXT, class TEXT);

后面需求改了,要再新增一列记录“出生日期”,那我们得写 SQL 修改表结构:

ALTER TABLE students ADD COLUMN birth_date DATE;

如果改得多了,那这就有点烦了。况且在实际的项目里我们还得去编写数据库迁移的 SQL 并在线上运行迁移的 Migration 程序。聪明的你估计想到了我们可以用 MongoDB 来做呀!要新增一列直接在 JSON 中加一个字段就行,无所谓什么“表结构”的概念。表结构的概念没了,也就是 Schema 没了。英文中形容词 -less 后缀指 without ,这就有了 Schemaless 这个词。简单来说就是跟 MongoDB 一样不受表结构定义的条条框框,想加字段就加字段。

市面上的很多 Schemaless 特性的产品,其后端大多都使用 MongoDB 实现。但我前文中提到了 Hooopo 那篇文章,再加上我对 Postgres 的热爱,我决定另辟蹊径使用 Postgres 来实现。

我们平时写后端,需要先建表,定义表里有哪些字段,最后往表里插数据,对应到 Sayrud 使用 sl_tables sl_fields sl_records 三张表来存储。(以下列出的表结构精简了项目分组、gorm.Model 里包含的字段)

  • sl_tables: Schemaless 表
字段名 类型(Go) 说明
name string 表名,给程序看的
desc string 表备注名,前端给人看的
increment_index int64 记录当前自增 ID
  • sl_fields:Schemaless 字段
字段名 类型(Go) 说明
sl_table_id int64 属于哪张表
name string 字段名
label string 字段备注,前端给人看的
type string 字段类型,包括 int text bool float timestamp reference generated
options json.RawMessage 字段额外的属性,如默认值、约束条件等
position int 字段在表中的顺序
  • sl_records :Schemaless 数据
字段名 类型(Go) 说明
sl_table_id int64 属于哪张表
data json.RawMessage JSON 存数据,Key 为字段的 ID,Value 为字段的值

然后神奇的事情就来了~ 我们按照 Hooopo 上述文章里所介绍的,为每一个 Schemaless 表当创建一张视图。以下是一个视图的 SQL 定义示例:

得益于 Postgres 对 JSON 类型的强大支持,我们可以从 sl_records 表中提取 JSON 字段的值作为内容,构建出一张“表”,效果如下:

当用户需要查询 Schemaless 表中的数据时,我们直接查询这张视图就行。对于 GORM 而言,这就跟查询一张普通的表一样!它都不会意识到这是由三张表拼凑提取出来的数据。更神奇的是,当你对着这张视图删除一条记录时,对应的 sl_records 原始表中的记录行也会被删除!Postgres 居然能把这俩关联起来。

具体到代码实现上,我们需要动态构造创建视图的 SQL 语句。而像字段、表名这类关键字在 SQL 语句中是不支持 SQL 预编译传入的,为了避免潜在的 SQL 注入风险,我使用了 github.com/tj/go-pg-escape 库来对字段名和表名进行转义。

正如 Hooopo 文章中所提到的,我将这个视图创建在了另一个 Postgres Schema 下,与默认的 public 进行区分,这也是一种简易的多租户实现了。

有坑注意! 之前看到过这篇文章: 《我们使用 Postgres 构建多租户 SaaS 服务时踩的坑》,文中提到使用 Postgres Schema 构建多租户时,如果每个 Postgres Schema 下都是同样的表结构,同时对所有 Postgres Schema 中的表结构变更会有性能问题。但上述场景在我们这里不存在,可以忽略该问题。

引用列、生成列、字段约束的实现

当我们开发一个博客评论后端时,功能上需要支持回复他人的评论,即数据之间会存在引用关系,我们一般会在 comments 表中加一列 parent_comment_id 来存储父评论的 ID。对应到 Schemaless 的字段类型里,就需要有 reference 这样一种引用类型。

我的设计是,当字段类型为 reference 时,其字段值存储的是所引用记录的 UID,字段额外属性 options 里记录它实际展示的列,如下图所示:

在生成视图时,使用 Postgres json_build_object 来构造 reference 类型字段展示的 JSON。(再次感叹 Postgres 真是太强大了!)JSON 中的字段 u 为关联记录的唯一 UID,方便前端处理时找到这一条记录。v 为关联记录的展示字段,用于在前端 Table 表格上展示给用户看。

在实际的博客评论记录中,一条评论是不能将自己作为自己的父级评论的。即我们要对 reference 字段的引用值进行约束。我给 reference 字段加了一个 constraint 属性,用户可以输入 JavaScript 表达式来自定义约束行为。JavaScript 表达式返回 true / false ,来表示数据校验是否通过。背后的实现是接了 goja 这个 Go 的 JavaScript Engine 库。我将当前记录传入 JavaScript 运行时的 $this 变量中,将被关联的记录传入 $that 变量中,对于上述需求,我们只需要写 $this.uid !== $that.uid 就可以约束一条评论的父评论不能是它自身。

除了能引用他人的评论,在博客评论中还需要展示评论者的头像,通常的做法是使用评论者的电子邮箱去获取其 Gravatar 头像进行展示。即将评论者的电子邮箱地址全部转换为小写后,再做 MD5 哈希,拼接到 https://gravatar.com/avatar/ 或者其他镜像站地址之后。在 Postgres 里我们可以使用生成列(Generated Columns)来很轻松的做到这一点:

CREATE TABLE comments (
 email TEXT,
 email_md5 TEXT GENERATED ALWAYS AS (md5(lower(email))) STORED
);

但在 Schemaless Table 里呢?一开始我的想法是像上面做字段约束一样接 JavaScript Engine,在添加数据时跑一遍 JavaScript 表达式计算出生成列的值就行。但这存在一个问题:如果 JavaScript 表达式被修改了,那就得全表重新跑重新更新刷一遍数据,这是无法接受的。

最后还是选择让用户编写 Postgres SQL 语句片段,用作创建视图时生成列的定义,就像前面视图的 SQL 定义那张图里的:

md5(lower(sl_records.data ->> 'YXSQhESl'::text)) AS email_md5,

但既然用户能直接编写原生 SQL,SQL 还会被拼接进来创建视图,那我这不直接 SQL 注入被注烂了!就算用黑名单来过滤字符串特殊字符与关键字,保不齐后面出来个我不知道的方法给绕了。这里我使用了 auxten/postgresql-parser 这个库(Bytebase 也在用)来将用户输入的 SQL 语句解析成 AST,然后 Walk 遍历树上的每个节点,发现有 UNION JOIN 以及白名单外的函数调用就直接禁止提交。如果有人 bypass 了这个库的解析规则绕过了我的检验,那也就等同于他找到了 CockroachDB 的洞(这个 AST 解析库是从 CockroachDB 源码中拆出来的),那我直接拿去水个 CVE。😂

在具体代码实现中,由于 postgresql-parser 这个库只能解析完整的 SQL 语句,而用户输入的是 md5(lower(email)) 这样的 SQL 片段,我会在用户输入前拼一个 SELECT 再解析。而像 email 这种字段名,由于提供没有上下文,会被解析成 *tree.UnresolvedName 节点。我需要将这些 *tree.UnresolvedName 节点的替换成 sl_records.data ->> 'YXSQhESl'::text 这样的 JSON 取值语句,直接修改节点的话出来的语句会是:

md5(lower("sl_records.data ->> 'YXSQhESl'::text"))

它将这整一块用双引号包裹,会被 Postgres 一整个当做列名去解析。我也没能找到在 Walk 里修改节点属性的方法,最后只能用一个比较丑陋的 HACK:替换节点内容时前后加上一段分隔符,在最后生成的 SQL 语句中找到这个分隔符,将分隔符和它前面的 " 引号去掉。(不由得想起 PHP 反序列化字符逃逸……)

最终实现大致如下,目前函数白名单仅放开了极少数的哈希函数和字符串处理函数。我还写了不少单元测试来测这个函数的安全性,希望没洞吧……

var whiteFunctions = []string{
 "md5", "sha1", "sha256", "sha512",
 "concat", "substring", "substr", "length", "lower", "upper",
}

func SterilizeExpression(ctx context.Context, input string, allowFields map[string]string) (string, error) {
 w := &walk.AstWalker{
 Fn: func(ctx interface{}, node interface{}) (stop bool) {
 switch v := node.(type) {
 ...
 case *tree.UnresolvedName:
 inputFields = append(inputFields, v.String())

 // HACK: We add separator to get the field name.
 v.Parts[0] = "!<----!" + allowFields[v.Parts[0]] + "!---->!"
 ...

 return false
 },
 }
 ...

 // Remove the separator.
 sql = strings.ReplaceAll(sql, `"!<----!`, "")
 sql = strings.ReplaceAll(sql, `!---->!"`, "")
 return sql, nil
}

API 接口设计

聊完了 Schemaless 特性的实现,我们再来看下自定义 API 接口的实现。这里直接上前端的操作页面,方便我来逐一介绍。

参考之前用过的 Pocketbase,我将接口分为 LIST VIEW CREATE UPDATE DELETE 五种类型。注意这与 HTTP 请求动词或数据库 DDL 操作并无关系,是偏业务上的定义。LIST 返回多条数据、VIEW 查询单条数据、CREATE 添加数据、UPDATE 修改数据、DELETE 删除数据。

就像我们写后端需要定义路由一样,每个 API 接口会有它请求方法和路径。以及会定义每个接口它从 GET Query 和 POST Body 处接收的字段。这些字段除了要有英文的参数名外,还需要有给人看的标签名,用于展示在数据校验的报错信息里。

然后我们会选择一张 Schemaless 数据表作为数据源(记得在 Dreamweaver 里叫“记录集”),把传入参数与数据表中的字段做映射,这样就完成了对数据的操作流程。而就整个请求而言,在请求开始前我们可能会想做一层限流或者验证码,请求结束后需要发送通知邮件或触发 WebHook,因此还需要支持配置路由中间件。

这里有两个值得拿来讨论的部分:数据源的筛选规则与前端拖拽配置路由中间件。

Filter DSL

我们的接口经常会有传入 ?id=1 来筛选指定一条数据的需求,确切的说是在 LIST VIEW UPDATE DELETE 四种类型下都会遇到。Schemaless 表的增删改查在代码上最终都是用 GORM 来构造 SQL 并执行的,“筛选”对应查询中的 WHERE ,对应 GORM 中的 Where 方法。用户在前端编辑好筛选条件后,需要能“翻译”成 GORM 的 Where 查询条件(一个 clause.Expression 类型的变量)。

我在这里设计了一种使用 JSON 格式来表示 Where 查询条件的方法。一个查询条件分为两种类型,一种是单操作符,仅接收一个或零个参数,如字面量 true、「非」操作 NOT xxxx ;另一种是常见的双操作符的,如「与」操作 xxx AND xxxxxx LIKE xxx,它们接收两个参数。

我们定义一个 Operator 结构体,它记录了当前 WHERE 查询的操作类型 Type、单操作符的参数 Value 、双操作符的左值 Left 和右值 Right。注意左值和右值又可以是一个查询条件,构造 WHERE 条件的时候需要递归解析下去。

type Operator struct {
 Type OperatorType `json:"t"`
 Value json.RawMessage `json:"v,omitempty"`
 Left *Operator `json:"l,omitempty"`
 Right *Operator `json:"r,omitempty"`
}

对应的操作符有以下这些,你可以看到上方的双操作符都是对应着 SQL 语句中的操作,下面单操作符中有两个特殊的操作 FIELDLITERAL 。其中 FIELD 会被解析为 Schemaless 表中的字段,而 LITERAL 的内容将被放到 JavaScript Engine 中运行,请求的 Query 和 Body 会被解析后注入到 JavaScript Runtime 中。你可以通过一个值为 $request.query.idLITERAL 操作拿到 id 这个 Query 参数的值。

const (
 // Binary operators
 OperatorTypeAnd OperatorType = "AND"
 OperatorTypeOr OperatorType = "OR"
 OperatorTypeNotEqual OperatorType = "<>"
 OperatorTypeEqual OperatorType = "="
 OperatorTypeGreater OperatorType = ">"
 OperatorTypeLess OperatorType = "<"
 OperatorTypeGreaterEqual OperatorType = ">="
 OperatorTypeLessEqual OperatorType = "<="
 OperatorTypeLike OperatorType = "LIKE"
 OperatorTypeIn OperatorType = "IN"

 // Unary operators
 OperatorTypeNot OperatorType = "NOT"

 OperatorTypeField OperatorType = "FIELD"
 OperatorTypeLiteral OperatorType = "LITERAL"
)

形如上面前端图中的那段 Filter:

{
 "l": {
 "t": "FIELD",
 "v": "raw"
 },
 "r": {
 "t": "LITERAL",
 "v": "$request.query.raw"
 },
 "t": "="
}

我们从最外层开始解析,就是将左值和右值做 = 操作,左值是数据表的 raw 字段,右值是 $request.query.raw 即 Query 参数 raw,所以上述这么一长串到最后的 Go 代码里形如:

query.Where("raw = ?", ctx.Query["raw"])

十分优雅,又十分安全。只是目前前端这个 Filter 还是给你个文本框自己填 Filter JSON,后续会做成纯图形化点点点的组件。(因为评估了下不太好写,所以先咕着🕊)

前端拖拽路由中间件

路由的中间件,我一开始就想把常用的功能封装成模块,然后前端直接拖拽着使用。其中对数据操作的主逻辑为 main 中间件,这个不可删除,其它的可以自由编排。

后端的实现很简单,相信看过任意 Go Web 框架源码的小伙伴都知道,又是些被说烂了的“洋葱模型”之类的东西。说穿了就是对整个中间件的 Slice for 遍历一下,判断发现其中的某个中间件返回响应(ctx.ResponseWriter().Written()true ),就直接整个返回了,这里就不贴代码水字数了。

前端我使用了 vue3-smooth-dnd 这个库,我对比了 Vue 多个拖拽库,貌似只有这一家的动画最为丝滑,并且还带自动吸附。最后实现的效果我也是十分满意:

这个中间件模块的节点是我自己画的,背景设置为灰色, 然后后面放一个细长的 div 作为流程的直线。鼠标放在中间件节点上时会有一个 popup 配置中间件的具体参数。这里是直接用的 TDesign 的 Popup 弹出层组件,里面再放一个 Card 卡片组件把弹出层空间撑开即可。

最后说几句

目前 Sayrud 已经初步开发完并部署到了线上,它已经完美支持了我想要一个静态博客评论后端的需求,后面只需要接上我写得前端就可以用了!(目前我开发的博客评论组件还没上,你现在看到的还是又丑又难用的 Waline)

你可能也注意到了编辑接口前端有一个「响应格式」的 Textarea,这块空着是因为我还没有找到一个能够简洁定义 JSON 数据结构的方式。所以目前接口的返回结构也是固定写死的,这块如果你有好的想法,欢迎告诉我。

这个项目的开发差不多花了一个月的时间,我平时下班后如果有空就会稍微写点。(注意是下班哦,我上班可是兢兢业业干满 8 小时+,恨不得住在鹅厂)由于开发时间不连贯,再加上有时回到家里比较困脑子不清醒,经常会出现后一天否定前一天的设计的情况。最后磕磕绊绊总算是完成了!由于是纯属为满足自己的需求,再加上我对它后端字段的校验还没统一梳理测试过,我目前并不会把这个站向公众开放。而像这种二开一下就能拿去恰烂钱的东西,我当然也更不会开源。

总的来说,Sayrud 也算是圆了自己当年 18 岁时的梦,将自己当时想得东西给做出来了。你可能注意到这个项目的名字也颇耐人寻味,Say - RUDCRUD 的谐音,这其实也代表着我对这个项目未来的规划。嘻嘻😝

记录我在腾讯云上部署一个简单静态网站的艰辛

2024-04-29 17:44:04

文章封面使用 DALL·E 3 生成

从三月底开始一直比较忙,最近一切尘埃落定,自己在家也休息了几天,这才能做点自己的事情。

由于一些原因 (是的,我要入职腾讯了),我准备将之前部署在 Cloudflare Pages 上的博客,也就是你现在看到的这个站点,迁移到国内腾讯云上。本以为是很简单的一个操作,完全没有必要大费周章地专门写一篇文章来记录,但现实是我在腾讯云上来来回回试了好几个产品,最终才勉强将这整套的持续集成方案给搭起来。

我以前一直是阿里云的忠实用户。但我对阿里云是又爱又恨,没少骂过阿里云残缺的产品功能和听不懂人话的弱智客服。甚至以前在 EFC 上班的时候,路过英国中心楼下想到阿里云就气不打一处来。但即使是这样,阿里云还是全中国排名第一的云,这说明什么?说明其他家的云更是草台班子!

说回腾讯云,我大一的时候,曾在腾讯云上开过学生机,后面毕业了优惠没了也就销毁了。腾讯云给我的第一感觉是他的 UI 做得很舒服,操作反馈颇有点 Azure 的感觉。但除开 UI 之外,产品的功能设计还有很大的提升空间。

我感觉国内做云的,都是先拿类 OpenStack 做一套管控机房物理资源的系统,然后开始卖 ECS 这样的云主机,卖了一阵子后觉得我可以在一台 ECS 上装点数据库软件、监控软件、消息队列中间件等东西,然后单独拆成如 RDS 这样的服务来卖。卖了一阵子后,发现又可以把好多台 ECS 合起来卖 Kubernetes 集群托管,Kubernetes 托管卖了之后又发现可以在上面二开跑点容器卖 Serverless 服务……

就这样在之前的产品的能力上糊一层然后演化成新的产品。

我不好评价这样的做法是对还是错。我认为复用已有能力做新产品前,对于新产品的定位以及将具备的核心功能,必须要想清楚。倘若底层的功能过于局限,或者必要配置项比较“狭窄”,则应该考虑另起炉灶而不是在上面糊一层兼容的 Shim。

Web 应用托管 Webify

我一开始是无脑选择腾讯云的 Webify 来部署我的静态页面。从名字就可以看出它是借鉴的 Netlify,产品形态上跟 Netlify、Vercel、Cloudflare Pages 等页面托管产品差不多。

但问题就出在——腾讯云没有将 Webify 作为的一个单独的产品进行研发,它是属于腾讯云 Cloudbase 云开发产品下的一个子功能!这个 Cloudbase 是啥?是一个类似于 LeanCloud 或者 Heroku 一样的东西,用户在上面托管 Serverless 应用,同时使用 Cloudbase 提供的存储、数据库、云函数等功能。

Webify 作为 Cloudbase 产品的一个子功能,复用了 Cloudbase 部署应用时的 CI/CD 工作流。对于 Cloudbase 而言这个 Webify 实例是一个按量计费的 WebifyPackage ”环境“,在控制台上就莫名其妙地将 Cloudbase 的“环境“这个概念集成进了 Webify 产品中,但是这个“环境”是系统创建的,你控制台点进去还会报错说无权限!

在产品计费上,Webify 有自己的一套按月付费的包,包含 CDN 流量、静态存储容量等内容。但这些用量又和底层的 Cloudbase 的用量藕断丝连。以至于我发工单问客服 CDN 流量用完了是怎么计费,他先是说流量用完后直接回原,跟 CDN 服务无关,一会又给我发 CDN 的计费文档,我指出他说得前后矛盾之后,过了一会直接电话打过来跟我解释才讲明白。(我发现现在阿里云和腾讯云的客服水平都变差了,动不动就一个电话过来解释,为啥不能线上消息或者文档说明白?)

tencent-cloud-workorder

但以上种种也都只是控制台操作上有些不合理,让我来试下实际产品怎么样。

首先是 Weblify 不支持 Hugo 站点的自动构建,不像 Cloudflare Pages 或者 Vercel 那样,选择好仓库后能自动推断出技术栈,并补全构建命令。Weblify 只支持常见的 JavaScript 框架编写的项目。

解决的办法也不难,我稍微拐个弯,在 GitHub 上建一个仓库,存放构建好的 Hugo 站点文件即可。只需在原 Hugo 项目的 GitHub Actions 流水线中加条 Hugo 构建并推送到仓库即可。

在 Weblify 上配置 GitHub OAuth 授权后,选择存放构建后静态资源的仓库,直接静态托管该仓库的内容。然后 Webify 构建又报错了……

cloudbase-ci-error

根据构建日志,我发现这垃圾玩意是把 git pull 下来的仓库内容,打成 ZIP 压缩包,再用 Cloudbase CLI 推送上去,然后这 Cloudbase CLI 不支持推送超过 100MB 的文件!发工单问客服,答曰:

Webify目前限制构建产物的体积在100MB内,建议客户减少部署包的体积。 图片、音视频等大体积的资源,可以使用CLI工具手动上传到环境内的某个固定目录。

哈???我站点超过 100MB 还不能自动构建还得手动上传???本来用 Weblify 就是图个方便,最后还要我自己上传?

没办法,我打算把 CLI 手动上传的步骤放到 GitHub Actions 的工作流里,即 Hugo 构建完后直接上传至 Weblify。搞了半天成功了,结果 Webify 访问网页直接显示 NO ROUTE 报错,且在控制台上也完全没有找到默认主页、404 页面的配置项。我想就算我解决了 NO ROUTE 的问题,后面默认主页和 404 页面配置不了也还是残废,索性直接申请退款,放弃!

回归 COS + CDN

那只能回到传统的静态网站部署方案:将静态文件上传至 COS(腾讯云的对象存储),然后前面套个 CDN。

继续改 GitHub Actions 流水线,将构建好的产物上传至 COS Bucket。然后我发现官方提供的 COS Action 就是个 Bug 百出的垃圾!这里我要实名 diss 这个仓库的原作者 mingshun 我不知道你是不是鹅厂的,但我知道你肯定没认真测试过你写的代码!

例如以下代码 TencentCloud/[email protected]#L110

} while (data.IsTruncated === 'true');

这个 IsTruncated 传进来只能是 Boolean 类型的 true 或者 false,你拿他跟一个字符串类型的'true' 强比较,这里恒为 false,导致这个 while 循环永远也跳不出来,一直卡着。我睡一觉醒来后发现我的 Workflow 跑了六个小时,然后被 GitHub 因为超时干掉了。

除了上面的这位原作者,还有 Shirasawa 这位,因为我有朋友也关注了这位老哥,因此我就不喷了。我只能说老哥你多看下 COS SDK 的源码吧,明明就有 accelerate 这个加速域名参数的,你非得自己实现个:

Domain: core.getInput('accelerate') === 'true' ? '{Bucket}.cos.accelerate.myqcloud.com' : undefined,

搞得后面不开accelerate 那就是直接 Domain 为 undefined 然后报错。

没办法,鉴于官方的 Actions 质量如此之差,我索性 Fork 改了个自己用:wuhan005/tencent-cos-action。然后我惊讶的发现,从 GitHub Actions 的美国节点,即使走 accelerate 加速域名上传文件到位于上海的 COS Bucket,也是 1-2 秒上传一个文件,我每次部署都要上传 1000+ 个文件,直接大半个小时过去了,这个部署上传的时间是我无法接受的。

CODING

那我得想办法让 Hugo 在境内的节点进行构建,然后从境内传到 COS Bucket 中。这次,我盯上了腾讯云自己搞的代码托管平台 CODING,本质上就是个啥都有的缝合怪。

好在他可以添加外部的 GitHub 仓库,并通过 GitHub OAuth 授权后,在仓库中安装 CODING 的 GitHub App,配置 WebHook。GitHub 仓库有新的推送后,触发 CODING 的流水线进行构建。经过数次调试后,最终可用的 CODING 流水线文件内容如下:

pipeline {
 agent any
 stages {
 stage('检出') {
 steps {
 checkout([$class: 'GitSCM',
 branches: [[name: GIT_BUILD_REF]],
 userRemoteConfigs: [[
 url: GIT_REPO_URL,
 credentialsId: CREDENTIALS_ID
 ]]])
 }
 }
 stage('安装 Hugo') {
 steps {
 sh 'apt install snapd'
 sh 'snap install hugo dart-sass'
 }
 }
 stage('构建') {
 steps {
 sh 'hugo --minify'
 }
 }
 stage('上传到 COS Bucket') {
 steps {
 sh "coscmd config -a ${COS_SECRET_ID} -s ${COS_SECRET_KEY} -b ${COS_BUCKET_NAME} -r ${COS_BUCKET_REGION}"
 sh "coscmd upload -rfs --delete public/ /"
 }
 }
 stage("刷新 CDN 缓存") {
 steps {
 sh "pip install --upgrade tencentcloud-sdk-python"
 sh "python ./dev/refresh-tencent-cdn.py -i ${COS_SECRET_ID} -k ${COS_SECRET_KEY}"
 }
 }
 }
}

我在 Hugo 仓库中加了个刷新腾讯云 CDN 缓存的 Python 脚本,上传成功后再执行这个脚本刷新 CDN 缓存。现在完整构建并部署一次的时间大约在 3-4 分钟。

coding-ci

勉强能接受吧,要知道在 Cloudflare Pages 上可是 1-2 分钟就能完成,并且还不需要我自己做这个多的配置!!

说回 CDN 防盗刷

费劲周折,我总算是成功的将博客部署到了腾讯云上。

之前迁移至 Cloudflare 的原因是我七牛云和阿里云都因为 CDN 被盗刷,导致一夜之间账单欠了 ¥600+。我也不知道互联网上为什么会有这么多干着这些损人不利己的蠢事的人。

因此在迁移之前,我十分谨慎地调研过腾讯云的 CDN 防盗刷功能,最后的结论是发现他们做得居然还不错,可以说是已经相当尽力了。在 COS 对象存储的「安全管理」菜单下,居然有一个「盗刷风险监测」功能!从各个维度评估了是否有盗刷风险,真的让人眼前一亮!建议阿里云赶紧跟进下。

tencent-cos-security-detection

我总结了下,具体是这几个方面的配置,以及我自己的配置值。

所属产品 配置项 备注
对象存储 COS 存储桶权限 配置为私有读写,授权 CDN 子用户访问,其余公网请求全部 ban 掉
内容分发网络 CDN 防盗链配置 配置白名单 Referer(治标不治本,CC攻击加个头就行)
内容分发网络 CDN IP访问限频配置 10QPS(单个 IP 限制,有一定效果)
内容分发网络 CDN 下行限速配置 全部内容,限速 1024KB/s(这个值我感觉还可以再低,防止被刷流量)
内容分发网络 CDN 用量封顶配置 流量每五分钟瞬时用量超过 2GB、HTTPS 请求数每五分钟超过 100 万次、当天 24 点前累计流量超过 10GB。(触发后会直接停掉 CDN 服务,防止一觉醒来账单爆炸)

以上配置是否能真的防住 CC 攻击,还得看腾讯云的用量封顶配置多久生效。虽然官方说是 10 分钟左右,这个时间我觉得还是有些长,万一对面 10 分钟打出了 1 TB 流量呢?但同时腾讯云官方又给出了一种通过定时 Serverless 函数,请求腾讯云 API 检测 CDN 用量,超过用量后使用 API 关闭 CDN 服务的方法。由于是自建 Serverless 定时函数,时间周期可以设的更短,这个后续我可以尝试下。

最后说几句

后续我可能会把阿里云集群上的业务也迁到腾讯云上来。

最近一个多月以来自己得睡眠质量不是很好,总是忧心忡忡。好在现在都已尘埃落定,我如愿拿到了腾讯的 Offer,自己这波“金三银四”还算顺利。这过程中的怀疑、悔恨、不甘,现在回想起来也都不重要了。

站在人生的又一个起点,我还依旧觉得没什么实感。对于后面匆匆收拾东西,搬去上海,我也不确定自己是否准备好了。但我可以肯定的是,自己已经跳出了原来的舒适圈,面前的是另一个更舒适的舒适圈还是更艰难的挑战,这还尚不可知。

tencent-offer-accepted

NekoPixel —— 一起来画像素画吧!

2024-02-24 21:22:46

文章封面使用 DALL·E 3 生成

NekoBox 自从 2020 年初上线以来,至今磕磕绊绊运行了四年。一开始我只是将其当做一个 CRUD 的练手项目,做完后丢到线上就没管了。谁知在 2022 年开始,这个小站不知什么原因,突然迎来了大量的注册用户,同时还有几个粘性很强的用户,个人主页上有上百条提问。(也是这两三个重度用户,页面改版前每天会跑掉我 3-4 块钱的 CDN 流量)

我感叹自己又一次无心插柳柳成荫,于 2022 年底又写了很多新功能,功能包括数据导出、注销账号、防骚扰、内容安全、内部 BI 面板等等。就在我看着一切都将往好的方向发展时,去年二月被炸弹人搞了一波,这事之后再慢慢聊。从那之后 NekoBox 关站了几个月,后面数据全部迁移到境外,使用新的域名重新恢复了。

我并没有大张旗鼓地去宣传恢复后的域名,原以为之前的用户就这样流失再也不见了。没曾想有铁粉,一遍遍刷着兔小巢上是否有新的动向,找到了我的新域名。这件事令我挺感动的。如今的 NekoBox 每天还有零零散散的几个新注册账号,和几条新增留言评论,我觉得自己不该一直“躺平摆烂式”管理,得想办法给这个小站加点新的元素。

因此,NekoPixel 就诞生了。同 NekoBox 一样,它也是完全开源的:https://github.com/wuhan005/NekoPixel

为什么选择做像素画?

我一直不想宣传 NekoBox,不想让它被太多人知道或被滥用。究其原因,这是我一个人因为兴趣开发运营的站点,我没有那么多精力去即时响应它发生的问题。当我在兔小巢上收到了新的用户反馈时,我只能等到一个不怎么忙且不怎么困的周末,才能静下心来好好写代码开发。我也在刻意降低 NekoBox 的社交属性。访客只能通过给定的链接看到注册用户的提问箱,没有其它任何热门用户推荐的功能。不同的注册用户之间,只能是在现实中或者其它平台上建立联系,在 NekoBox 中,他们互相不会打扰到对方。

既然不方便强调独立个体,那就展现群体的力量!

一群人在网络上一起绘制一幅图画,最早好像是从 Reddit 开始的,后面 B站在 2017 年暑假做了个夏日绘板的活动,用户每间隔一段冷却时间,可以拥有几个像素点,在一张共享的画布上作画。虽然当时B站还没上市,但用户体量是摆在那的,整场活动下来难免有用脚本捣乱的人。但好在最后效果挺好,可以说是 B站二次元属性最后的余晖了。时至今日,当年的活动页还有人在“缅怀”。我认为日后 B站不太有机会再举办这样的活动了,既赚不到钱,还得在内容安全上加大投入。

NekoBox 就很适合做这个,不同兴趣爱好的用户可以画自己喜欢的东西,但前端又不会知道是谁主导绘制的。再加上 NekoBox 的用户本来就不多,大家圈地自萌玩一玩多好。

如何实现的?

像素画的前端开发难度远大于后端。我们先从相对简单的后端讲起。

通过直接生啃 B站夏日绘板的前端(具体文件在 pixel-drawing.d41b770e4052375671dc.js),我们可以知道这是一个 1280 x 720 的图片。通过魔改的 Vue DevTools,可以直接看到其 Vue data 部分的内容:

bilibili-painting-vue-tools

colorMaps 对象存储的就是页面上调色盘的颜色。colorMaps 的 Value 是对应颜色的十六进制,Key 则是从 0 开始一直递增到 A B C… 的索引。那么考虑使用一位的字母或数字作为 Key,我们可以表达 36 种颜色(0-9A-Z),要是加上特殊符号全角半角,则可以表示更多。

在页面的 1.0b2b4b3ccd53641b013c.js 文件中,我们可以看到其返回了很长一串字符串:

webpackJsonp([1],{1697:function(Q,O,E){
 "use strict";function L(){
 return"MGE9EEEE0000090000001100000000"..."111011101" // 就是这一段几百KB的
 }Object.defineProperty(O,"__esModule",{value:!0}),O.getFreeSketchingBitmap=L}});

该字符串中的每个字符是一个像素点,其对应的就是上述 colorMaps 中 Key 所指的颜色。前端通过解析该字符串,在 Canvas 中绘制出原本的图片。这种存储方式颇有点 bitmap 的味道。那么对于后端而言,我们只需要想办法能存储,并快速返回这段字符串即可。

MongoDB?Postgres!

GitHub 上的开源大多是使用 MongoDB 来存储单个像素点,最后汇集起来返回。但我们这个场景下其实不太需要 NoSQL 的灵活功能,我便决定依旧使用 Postgres 来实现。我在 Postgres 中,创建一张名为 canvas_pixels 的表,共 921600 行(1280*720),用于存储整个画面的最新像素。

字段名 类型 说明
user_id INT 最后绘制该像素的用户 ID
x INT 像素在画布上的 X 值
y INT 像素在画布上的 Y 值
index STRING 像素的颜色索引
color STRING 冗余字段,存储像素的十六进制编码

整张表很简单易懂对不对?然后就可以愉快的使用 SQL,现将 xy 排序,保证他们在画布上是依次排列出来的,再将 index 颜色索引字符串合并即可,如此简单粗暴的方法, 就可以将上面的字符串生成出来了啦~ 查询用时在 400ms 左右。

SELECT
 STRING_AGG(t.index, '')
FROM (
 SELECT
 INDEX
 FROM
 "canvas_pixels"
 WHERE
 x >= 0 AND y >= 0 AND x <= 1280 AND y <= 720
 ORDER BY
 y, x
) AS t

但只存这张表会有一个问题,新的像素绘制将老的记录给盖掉了,我们没法追踪整张画布上图像随时间的变化。因此还有张 pixels 表来归档存储所有用户的每次像素操作。必要时可以通过 Scan 这张表,做出像 av13900223 的画板变化动画。

当用户绘制一个像素时,我们先往 pixels 插入一条数据,再更新 canvas_pixels,两个操作包在一个事务中即可。当然这里我有意画蛇添足用了 Trigger 触发器来做,也是想实际体验下触发器的使用。下方这段触发器的代码是直接让 ChatGPT 写的,可以看到它创建了一个函数,先从 colors 表中拿到十六进制颜色所对应的索引,然后更新 canvas_pixels 中对应的像素记录。

CREATE OR REPLACE FUNCTION public.upsert_canvas_pixel()
 RETURNS trigger
 LANGUAGE plpgsql
AS $function$
DECLARE
 colorIndex TEXT;
BEGIN
 SELECT index INTO colorIndex FROM colors WHERE color = NEW.color LIMIT 1;

 IF colorIndex IS NOT NULL THEN
 UPDATE canvas_pixels
 SET color = NEW.color, index = colorIndex
 WHERE x = NEW.x AND y = NEW.y;

 IF NOT FOUND THEN
 INSERT INTO canvas_pixels(x, y, color, index)
 VALUES (NEW.x, NEW.y, NEW.color, colorIndex);
 END IF;
 ELSE
 RAISE EXCEPTION 'Color not found in colors table.';
 END IF;

 RETURN NEW;
END;
$function$

--- 创建触发器
CREATE OR REPLACE TRIGGER trigger_upsert_canvas_pixel AFTER INSERT ON pixels FOR EACH ROW EXECUTE FUNCTION upsert_canvas_pixel ();

上层的 RESTful API 那就随便糊一糊了,创建像素点的时候往 pixels 插一条记录即可,这里就不再赘述。

困难重重的 Canvas

NekoPixel 最难的部分在前端,更确切地说是在 Canvas。一开始我打算直接裸写 HTML + JavaScript,然后被一堆 EventListener搞得很烦,最后还是决定上 Vue3。

先明确一下前端总体的功能:

  • 绘制像素:我们需要将后端返回的字符串转化成十六进制颜色,一个像素一个像素地绘制到 Canvas 上。
  • 滚轮缩放:用户滚动鼠标滚轮,可以实现画布的放大缩小。
  • 点击拖动:用户在放大画布后,点击画布可随意拖动查看。
  • 用户绘制:用户选择颜色后,点击 Canvas,将颜色填充到鼠标所指的像素上。

绘制像素

首先前端请求接口,拿到颜色的字符到十六进制的映射表,然后将后端返回的字符串,一个个字符转换成十六进制颜色数组。然后将颜色绘制上去。

const imageData = baseContext.value.createImageData(width, height)

const arrayBuffer = new ArrayBuffer(imageData.data.length)
const clampedArray = new Uint8ClampedArray(arrayBuffer)
const uint32Array = new Uint32Array(arrayBuffer)
for (let i = 0; i < pixels.canvas.length; i++) {
 const index = pixels.canvas[i]
 const color = colorMap.get(index) ?? [0, 0, 0]
 const pixelValue = (255 << 24) | (color[2] << 16) | (color[1] << 8) | color[0]; // 注意: 这里使用的是big-endian
 uint32Array[i] = pixelValue;
}

imageData.data.set(clampedArray)
baseContext.value.putImageData(imageData, 0, 0)

通过阅读代码,你会发现我们是将像素绘制到了在代码中新建的 baseContext 中,而不是 DOM 上展示的 canvasPixels。这是因为 Canvas 绘制刷新相当于直接将像素盖上去了,我们在后续点击拖动的过程中,看似是在拖动一张大的画布,Canvas 负责展示画布的一部分,其实 Canvas 是在不停地重绘覆盖之前的内容。因此需要有一份完整的备份,页面上的 Canvas 只是从备份中选取指定的部分展示。

还有一个小细节是 Canvas 的 ctx.imageSmoothingEnabled 这个属性,一开始我发现图片绘制到 Canvas 上,放大后整个是糊的,不像 B站一样放大是棱角分明的像素点。问题就出在这个属性上,Canvas 默认将其设置为 True,即开启图像平滑,我们需要设置成 False 才能在 Canvas 放大后显示像素点。

滚轮缩放

用户在 Canvas 上滑动滚轮,我们需要处理 Canvas 的 @wheel 事件。首先使用 preventDefault() 来禁用默认的效果,防止整个浏览器页面被放大了。然后通过事件的 deltaY 属性的正负来判断是放大还是缩小,设置缩放比例后,重绘画布。

画布的缩放,可以直接用 Canvas Context 的 scale()方法:

ctx.scale(ratio.value, ratio.value)

关于画布刷新函数 refreshCanvas(),ChatGPT 告诉我了超好用的 save()restore() 来保存和还原画布状态。

ctx.save()

ctx.clearRect(0, 0, paintingCanvas.value.width, paintingCanvas.value.height)
ctx.scale(ratio.value, ratio.value)
ctx.translate(deltaX.value, deltaY.value)
ctx.drawImage(baseCanvas.value, 0, 0)

ctx.restore()

当调用 save() 时,Canvas 的当前全部状态将被放入栈中,相当于当下成为了 Canvas 的一个默认状态,在 save() 后的任何修改,都是在这个默认状态之上进行。当我们的改动完成后,使用 restore() 将保存的状态从栈中弹出,恢复状态。

点击拖动

点击拖动需要同时处理 @mousedown @mousemove @mouseup 三个事件,分别对应用户操作中的鼠标点击、鼠标移动拖动、鼠标抬起结束拖动。这边使用 isMoving 变量来判断当前鼠标点击,是要拖动还是要画像素点。Canvas Context 中使用 translate() 方法来平移画布,我们根据鼠标拖动事件的增量来计算平移的距离即可:

 // Move canvas with translate.
if (event.buttons === 1) {
 deltaX.value += event.movementX / ratio.value
 deltaY.value += event.movementY / ratio.value

 if (deltaX.value > 0) {
 deltaX.value = 0
 }
 if (deltaY.value > 0) {
 deltaY.value = 0
 }

 if (baseContext.value) {
 refreshCanvas()
 }
}

用户绘制

用户绘制即在 @mousedown 的时候,判断 isMoving === false 时,将对应像素点的颜色,填充进上面提到的备份 Canvas baseContext 中,再用 refreshCanvas() 函数刷到页面上的 Canvas 里。最后用户需要手动点击页面上的结束绘制,这时将用户绘制的像素点信息发送到后端接口入库保存。

如何引入到现有项目中?

以上就是 NekoPixel 的实现原理和关键点,你可以对照开源的代码仔细分析。

NekoPixel 是一个由 Vue3 编写的前后端分离的应用,我该如何将其引入到我的前后端不分离的 NekoBox 中呢?我了解到 Vue 支持 UMD (Universal Module Definition) 组件化构建,最终产物是一个 JavaScript 文件,将其内嵌到 NekoBox 页面中,然后设置其 Mount 到指定的 <div> 元素中即可。

你可以在 vite.config.umd.ts 中看到其 VIte 构建配置。构建出来的前端产物将被发布为 NPM 包:@e99p1ant/neko-pixel-umd,找个 NPM 镜像源引入其 JavaScript 和 CSS 到 NekoBox 中即可使用。日后需要更新,也只用在 NekoBox 的模板中修改下 NPM 的版本号即可,十分方便。

<!-- CSS 样式 -->
<link rel="stylesheet" href="https://unpkg.com/@e99p1ant/[email protected]/style.css"/>

<!-- 挂载 NekoPixel 的 div 标签 -->
<div id="app"></div>

<!-- 自定义配置 -->
<script>
 var NEKO_CONFIG = {pixelBaseURL: '/api/v1/pixel'}
</script>

<!-- NekoPixel UMD 产物 -->
<script src="https://unpkg.com/@e99p1ant/[email protected]/neko-pixel-app.umd.js"></script>

你可能注意到了上面代码中的 NEKO_CONFIG 属性,在 NekoPixel 的 interceptor.ts 中,我通过全局环境下的该变量设置 axios 请求库的 baseURL。这样其实就简单实现了外部与 UMD 组件的沟通。

if(window.NEKO_CONFIG){
 axios.defaults.baseURL = window.NEKO_CONFIG.pixelBaseURL;
}

通过修改原本的 baseURL,将 NekoPixel 画板的所有请求指向 NekoBox 的 /pixel 下,/pixel 路由转发用户请求到服务器上的 NekoPixel。相当于 NekoBox 在中间做了层反代 pixel.go,为的是能将绘制像素点的用户 ID 带上,放到最终请求入库保存。

最后说几句

NekoPixel 是我今年在老家过年的时候开发的,发布上线后,我简单的用像素点写了个 NKBOX。后续真的有大触在上面画了像素画!我的 NekoBox 账号也收到了用户匿名反馈说很喜欢这个新功能。

neko-pixel-0224

但其实还有很多需要完善的地方,比如画板不会向鼠标所在的位置缩放(还是 Y7 提的呜呜呜)、加载画板的时候没有提示、用户绘制时的验证与限流等,都是要努力去实现的。

自从过年那阵子,就有人一直在 DDoS NekoBox 及其子域。刚开始的时候毫无防御被刷了一波阿里云账单,后续由于阿里云产品的各种离谱设定,加上工单客服给的防御方案根本不起作用,我将 NekoBox 以及自己的其他服务由阿里云迁移到了 Cloudflare 上,这才有所缓解。特别是前些日子一晚上打出了 10T 的流量!这要是还放阿里云上,我直接就 5000 块没了。

互联网上还是坏人多呀,一开始是疯狂刷我 NekoBox 上挂的支付宝收款码图片。Y7 也劝我不要再将 NekoBox 的打赏记录公开出来,省得有人眼红搞事,但我想着这是需要对社区公开的信息,且打赏的人可能也是抱着能被展示出来的心情才打赏的。

我也在想 NekoBox 这个站还要不要继续搞下去,更深层次的,我是不是不应该再抱着所谓“开源”和“用爱发电”的心情去面对技术。就像我最近博客收到的一条评论中所提及的,是不是用技术以及信息差去割韭菜是不是才是更重要的?以前看到很多 GitHub 上千 stars 项目的作者,在个人 Profile 里发 want a job,当时还疑惑他们这么出名这么厉害,怎么会没工作的呢?最近这段时间,我开始慢慢理解了。我开始越发觉得“开源”本身是奢侈的。每次发现我的一些开源项目被人拿去商用赚的盆满钵满的时候,我什么也得不到,所谓的“协议”也只是自欺欺人罢了。我现在日常把“开源”当做乐趣,实在是一种“不自量力”的行为。当我下个月没地方住、下顿饭没钱吃的时候,所谓的“社区”又在哪呢?

迟到的 LightCube 八周年总结

2023-11-16 21:07:22

文章头图来自 https://www.pixiv.net/artworks/102940261 因为是八周年所以就选了张 86 的图

按照前面几年的惯例,我一般会在每年的 10 月 4 日左右写一篇博客的总结文章,回顾总结过去一年内博客在内容和技术上发生的变化。 然而今年 10 月是第一次跳票。😂 原因是整个国庆假期虽然是在湖南老家度过,但是全都被工作给排满了。我给自己的项目排期看板上规定了每日八小时的工作量,基本上腾不出什么空闲时间,好在最后这些任务也大差不差地完成了。 国庆假期回来后,又是马不停蹄地要准备办比赛,然后又是去广州出差,出差回来后马上要去参加一个比赛…… 然后又突然得知需要去参加会议分享议题,急急忙忙写稿子写 PPT。基本上这阵子没啥完整的双休日,今天算是难得能停下来喘口气,把八周年的总结给写了。

本来想想还挺遗憾的,但是 y7 说以往几年都是准点的,今年推迟一些,反而能体现跟前面几年有不一样的变化。想来也是。

去年的总结是在深圳的家里写得,主要是讲述博客向云原生的一些转变。今年则是在自己租的杭州郊区房子里,感受着 11 月的寒意,缩在键盘前写下这段总结。过去的一年博客在技术层面没有太多的变化,依旧是部署在阿里云的 Kubernetes 集群中,但不知道是否有人察觉到,今年一整年博客的稳定性都变差了。很多时候访问网站都是直接不通,甚至连个 500 的报错页面也没有。原因则是为了省那一点钱,搞了些骚操作,但是却大大牺牲了博客的稳定性。最后决定还是恢复到之前的网络结构,起码得保证站不炸。

前几天在做这个变更的时候,恰逢阿里云控制台全面宕机,购买的负载均衡实例居然没能成功创建出来,也是够离谱的。(虽说是按量付费,下单的时候没扣钱)等阿里云恢复后,我便尝试把网站的 CDN 也全面切到了阿里云,放弃了原本七牛云的静态资源 CDN。 我一直搞不太懂,明明七牛云背后的云存储设施接的就是阿里云,但它的 CDN 加载个平平无奇的 PNG 图片就是会卡会慢,响应头里也都很明确的说了命中了 Cache。CDN 切到阿里云后,我同时关闭了页面图片懒加载的插件,首页的访问有了一点微小的速度提升。但第一次访问还是会有 1-2 秒的白屏,一直做不到秒出。这要是再跟下去,怕不是得去排查 PHP 那边的性能问题了。眼下我倒是没有很多时间,只能寄希望于 bitnami 的 WordPress 镜像能多做些内置的优化吧。

其实我这几天又开始幻想自己写一套博客系统的可能性了。但所要面对的问题还是跟之前一样 —— 我没有办法完美复刻出一个我现在这样好看的前端出来。目前这个前端的 CSS 和 JavaScript 看得就头大。我如果要重构博客,那还要自己一点点分析,把这些交互和样式的东西给扒出来,真的是有够繁琐的。但是另一方面我又很羡慕那些能直接秒开,媲美静态页面速度的博客。(主要还是这几天帮 y7 弄 hexo 站,那加载速度直接完秒我现在的 WordPress 站。)

嗯,差不多就是这样。因为是抽空写得文章,所以可能会比较乱…… 我已经预想到在新的一年里自己的更新速度不会太高,甚至难得能在网上活跃一下了。年少时还对 “越忙的人,越是不写博客” “博客只有闲人才写” 这类话嗤之以鼻,现在看来也不全无道理。