拖了一个月,也终于是想起来记录一下了

除了标题中提到的博客迁移之外,顺带说一说评论系统的比对选择,安全加固

另外还会提到 Vercel, Cloudflare Pages 等的对比使用感受

# 背景

由于不想继续负担高昂的香港云服务器费用,于是需要将我原来一堆线上的东西部署到别的地方,其中比较重要的就是博客了。宗旨是能白嫖尽量不花钱,另外也需要尽量保证线上服务的境内可用性

# 方案选择

市面上主流的方案:Github Pages, Vercel, Cloudflare Pages

先说 Github Pages,在我 student pack 到期之前,我就用的是私有仓库 + Github Pages + Cloudflare CDN 的方案,后来到期了无法白嫖,才转战的云服务器。另外由于 Cloudflare CDN 免费版的不稳定性,其实并不能很好的保证大陆地区顺畅访问,而我又不想将私有仓库转为公开,故直接放弃 Github Pages 方案

Vercel 的话实测下来访问速度还是很不错的,还有 Serverless Function 可以用,于是博客迁移就选择了 Vercel

Cloudflare Pages 相对于 Vercel 文档资源都会少一些,摸索起来会比较麻烦,不过我的另一个服务也尝试用了它,并且在 Github 上开源了(主要还是存个档便于以后有需求直接当参考,项目本身没啥意义)

# Typecho 迁移到 Hexo

主要是迁移文章和评论,最后评论选择 Valine(主要是这个主题只支持 Valine),网上能找的备份迁移工具效果不如人意,就自己弄了一个迁移一下数据

import md5 from 'md5'
import moment from 'moment'
import AV from 'leancloud-storage'
import { promises } from 'fs'
import { promisify } from 'util'
import { createConnection } from 'mysql'
const dsn = 'mysql://root:pass@localhost/typecho?charset=utf8mb4'
AV.init({
  appId: '',
  appKey: '',
  serverURL: 'https://api.lncldglobal.com'
})
const conn = createConnection(dsn)
const query = promisify(conn.query.bind(conn))
const metas = await query('select * from metas')
const contents = await query('select * from contents')
const comments = await query('select * from comments')
const relationships = await query('select * from relationships')
await promises.mkdir('./backup/raw', { recursive: true })
await promises.writeFile('./backup/raw/metas.json', JSON.stringify(metas, null, 2))
await promises.writeFile('./backup/raw/contents.json', JSON.stringify(contents, null, 2))
await promises.writeFile('./backup/raw/comments.json', JSON.stringify(comments, null, 2))
await promises.writeFile('./backup/raw/relationships.json', JSON.stringify(relationships, null, 2))
const tags = new Map
const cats = new Map
metas.forEach(m => {
  if (m.type === 'tag')
    tags.set(m.mid, m)
  else
    cats.set(m.mid, m)
})
const paths = new Map
const Counter = AV.Object.extend('Counter')
for (const c of contents) {
  await promises.mkdir(`./backup/${c.type}s`, { recursive: true })
  const rs = relationships.filter(r => c.cid === r.cid)
  promises.writeFile(`./backup/${c.type}s/${c.slug}.md`,
`---
title: ${c.title}
date: ${moment(1000 * c.created).format('YYYY-MM-DD HH:mm:ss')}
updated: ${moment(1000 * c.modified).format('YYYY-MM-DD HH:mm:ss')}
categories: [${rs.filter(r => cats.has(r.mid)).map(r => cats.get(r.mid).name).join(', ')}]
tags: [${rs.filter(r => tags.has(r.mid)).map(r => tags.get(r.mid).name).join(', ')}]
---
${c.text.replace('<!--markdown-->', '').replace(/\r\n/g, '\n')}`)
  const path = c.type === 'page' ?
    `${c.slug}.html` :
    `${moment(1000 * c.created).format('YYYY/MM/DD')}/${c.slug}.html`
  paths.set(c.cid, path)
  console.log('insert views', path, c.views)
  const o = new Counter()
  o.set('time', c.views)
  o.set('title', c.title)
  o.set('url', path)
  o.set('xid', path)
  await o.save()
}
const coidObj = new Map
const Comment = AV.Object.extend('Comment')
for (const c of comments) {
  console.log('insert comment', c.author, c.text)
  const o = new Comment()
  o.set('comment', c.text.replace(/\r/, ''))
  o.set('date', new Date(1000 * c.created))
  c.ip && o.set('ip', c.ip)
  o.set('link', c.url ?? '')
  o.set('mail', c.mail)
  o.set('mailMd5', md5(c.mail))
  o.set('nick', c.author)
  if (c.parent) {
    const p = coidObj.get(c.parent)
    o.set('pid', p.getObjectId())
    o.set('rid', p.get('rid') || p.getObjectId())
  }
  c.agent && o.set('ua', c.agent)
  o.set('url', paths.get(c.cid))
  coidObj.set(c.coid, await o.save())
}
process.exit(0)

# Valine 的安全加固

由于众所周知的 安全问题,直接使用肯定是问题比较大的,加之不太想去改主题的代码来换用评论系统,于是就借助 Vercel 来实现一个 api 的代理层,实际的 secret 这些并不写在前端,并且对一些写逻辑做更细粒度的权限管控

方法也很简单,首先将 Valine 的 serverURLs 配置改成 /api/ , 这样所有评论和阅读统计的请求都会走到同域名 /api/* , 然后代理要用到的接口就好了,这里需要在 vercel.json 里预先加一条路由重写规则

vercel.json
{
  "rewrites": [{
    "source": "/api/1.1/classes/Counter/:id",
    "destination": "/api/1.1/classes/Counter"
  }]
}

然后在 Vercel 的后台配置好 LEAN_API , LEAN_ID , LEAN_KEY 这些的环境变量

最后分别实现评论和阅读接口即可

/api/1.1/_proxy.ts
import axios from 'axios'
import { VercelRequest } from '@vercel/node'
axios.defaults.validateStatus = s => s < 500
axios.defaults.baseURL = process.env.LEAN_API
axios.defaults.headers.common['X-LC-Id'] = process.env.LEAN_ID ?? ''
axios.defaults.headers.common['X-LC-Key'] = process.env.LEAN_KEY ?? ''
export async function proxy(req: VercelRequest) {
  const [path] = req.url?.split('?') ?? ['']
  return (await axios({
    method: req.method,
    url: path.replace(/^\/api/, ''),
    params: req.query,
    data: req.body
  })).data
}
/api/1.1/_util.ts
import type { VercelResponse } from '@vercel/node'
export function desensitize(res: VercelResponse, data: Record<string, unknown>) {
  const { results } = data
  Array.isArray(results) && results.forEach(r => {
    delete r.ip
    delete r.mail
    if (r.date?.iso) {
      r.createdAt = r.date.iso
      delete r.date
    }
  })
  res.json(data)
}
/api/1.1/cloudQuery.ts
import { proxy } from './_proxy'
import { desensitize } from './_util'
import type { VercelRequest, VercelResponse } from '@vercel/node'
export default async function(req: VercelRequest, res: VercelResponse) {
  if (req.method !== 'GET') {
    res.json({ code: -405 })
  } else if (typeof req.query.cql !== 'string') {
    res.json({ code: -400 })
  } else if (!/^select /i.test(req.query.cql)) {
    res.json({ code: -403 })
  } else {
    // use date (if exists) insteadof createdAt
    req.query.cql = req.query.cql.replace(/^select /, 'select date, ')
    desensitize(res, await proxy(req))
  }
}
/api/1.1/classes/Comment.ts
import { proxy } from '../_proxy'
import type { VercelRequest, VercelResponse } from '@vercel/node'
export default async function(req: VercelRequest, res: VercelResponse) {
  // GET ?where={url,...}&count=1&limit=0
  // POST ? {url,nick,mail,ip,ua,...}
  if (req.method === 'GET') {
    req.query.count = '1'
    req.query.limit = '0'
  } else if (req.method === 'POST') {
    req.body.ip = req.headers['x-real-ip']
    req.body.ua = req.headers['user-agent']
  } else {
    res.json({ code: -405 })
    return
  }
  res.json(await proxy(req))
}
/api/1.1/classes/Counter.ts
import { proxy } from '../_proxy'
import type { VercelRequest, VercelResponse } from '@vercel/node'
export default async function(req: VercelRequest, res: VercelResponse) {
  // GET ?where={url}
  // POST ? {url,time,...}
  // PUT :id? {time:{__op:'Increment',amount:1}}
  if (req.method === 'POST') {
    req.body.time = 1
  } else if (req.method === 'PUT') {
    req.body = { time: { __op: 'Increment', amount: 1 } }
  } else if (req.method !== 'GET') {
    res.json({ code: -405 })
    return
  }
  res.json(await proxy(req))
}

换用这个主题之前,还尝试过其他的主题,不过也是中途放弃掉了

确实也算是比较折腾了,如果以后有空了自己搞主题的话,感觉 Twikoo 就挺不错的

# Vercel 与 Cloudflare Pages 的对比

网上都说 Vercel 比较快,不过就我本地的网络测试来看,其实不管是选择哪个,速度都挺快的。不过 Vercel 免费版的运行时限,资源上限这些似乎都相对要多一些,所以做的事情自然也更多一些。Cloudflare Pages 的优势是免费版自带 kv 存储,虽然容量不大,但对我的需求而言是够用的,省去了自己折腾 leancloud 或者 dynamodb 这种东西

两者都提供了本地调试工具,不过用起来各有各的坑。比如 Vercel 我一直想重定向非 /api 开头请求到 /api 未果,遂放弃;再比如 Cloudflare Pages 我想直接在本地读取云端的 kv 数据未果,只能在本地 mock 一个假的

# 最后

其实当初用云服务器最重要的理由是自由上网,线上服务这些都是附属的东西,而且也很容易找到一些免费的替代品

后来仔细想想,其实只要有个正经工作,用公司给的 VPN 不就好了么(只要不浏览一些奇奇怪怪的网站

于是乎腾讯云给的大额优惠券也就让它自己过期好了