迁移博客到 Vercel 的记录
Doveccl

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

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

另外还会提到 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),网上能找的备份迁移工具效果不如人意,就自己弄了一个迁移一下数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
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 里预先加一条路由重写规则

1
2
3
4
5
6
{
"rewrites": [{
"source": "/api/1.1/classes/Counter/:id",
"destination": "/api/1.1/classes/Counter"
}]
}

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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))
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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))
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 不就好了么(只要不浏览一些奇奇怪怪的网站

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

 评论
评论插件加载失败
正在加载评论插件
由 Hexo 驱动 & 主题 Keep
总字数 24.2k 访客数 访问量