tkserver 1.6.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +21 -0
  2. package/index.js +917 -0
  3. package/package.json +31 -0
  4. package/server.js +45 -0
package/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # Twikoo 私有部署服务端
2
+
3
+ ## 安装
4
+
5
+ ```
6
+ npm i -g tkserver
7
+ ```
8
+
9
+ ## 启动
10
+
11
+ ```
12
+ tkserver
13
+ ```
14
+
15
+ ## 环境变量
16
+
17
+ | 名称 | 描述 | 默认值 |
18
+ | ---- | ---- | ---- |
19
+ | `TWIKOO_DATA` | 数据库存储路径 | `./data` |
20
+ | `TWIKOO_PORT` | 端口号 | `8080` |
21
+ | `TWIKOO_THROTTLE` | IP 请求限流,当同一 IP 短时间内请求次数超过阈值将对该 IP 返回错误 | `250` |
package/index.js ADDED
@@ -0,0 +1,917 @@
1
+ /*!
2
+ * Twikoo vercel function
3
+ * (c) 2020-present iMaeGoo
4
+ * Released under the MIT License.
5
+ */
6
+
7
+ const { version: VERSION } = require('./package.json')
8
+ const Loki = require('lokijs')
9
+ const Lfsa = require('lokijs/src/loki-fs-structured-adapter')
10
+ const { v4: uuidv4 } = require('uuid') // 用户 id 生成
11
+ const {
12
+ $,
13
+ JSDOM,
14
+ axios,
15
+ createDOMPurify,
16
+ md5,
17
+ xml2js
18
+ } = require('twikoo-func/utils/lib')
19
+ const {
20
+ getFuncVersion,
21
+ getUrlQuery,
22
+ getUrlsQuery,
23
+ parseComment,
24
+ parseCommentForAdmin,
25
+ getAvatar,
26
+ isQQ,
27
+ addQQMailSuffix,
28
+ getQQAvatar,
29
+ getPasswordStatus,
30
+ preCheckSpam,
31
+ getConfig,
32
+ getConfigForAdmin,
33
+ validate
34
+ } = require('twikoo-func/utils')
35
+ const {
36
+ jsonParse,
37
+ commentImportValine,
38
+ commentImportDisqus,
39
+ commentImportArtalk,
40
+ commentImportTwikoo
41
+ } = require('twikoo-func/utils/import')
42
+ const { postCheckSpam } = require('twikoo-func/utils/spam')
43
+ const { sendNotice, emailTest } = require('twikoo-func/utils/notify')
44
+ const { uploadImage } = require('twikoo-func/utils/image')
45
+
46
+ // 初始化反 XSS
47
+ const window = new JSDOM('').window
48
+ const DOMPurify = createDOMPurify(window)
49
+
50
+ // 常量 / constants
51
+ const { RES_CODE, MAX_REQUEST_TIMES } = require('twikoo-func/utils/constants')
52
+
53
+ // 全局变量 / variables
54
+ let db = null
55
+ let config
56
+ const requestTimes = {}
57
+
58
+ connectToDatabase()
59
+
60
+ module.exports = async (request, response) => {
61
+ let accessToken
62
+ const event = request.body || {}
63
+ console.log('请求 IP:', getIp(request))
64
+ console.log('请求函数:', event.event)
65
+ console.log('请求参数:', event)
66
+ let res = {}
67
+ try {
68
+ protect(request)
69
+ accessToken = anonymousSignIn(request)
70
+ await readConfig()
71
+ allowCors(request, response)
72
+ if (request.method === 'OPTIONS') {
73
+ response.status(204).end()
74
+ return
75
+ }
76
+ switch (event.event) {
77
+ case 'GET_FUNC_VERSION':
78
+ res = getFuncVersion({ VERSION })
79
+ break
80
+ case 'COMMENT_GET':
81
+ res = await commentGet(event)
82
+ break
83
+ case 'COMMENT_GET_FOR_ADMIN':
84
+ res = await commentGetForAdmin(event)
85
+ break
86
+ case 'COMMENT_SET_FOR_ADMIN':
87
+ res = await commentSetForAdmin(event)
88
+ break
89
+ case 'COMMENT_DELETE_FOR_ADMIN':
90
+ res = await commentDeleteForAdmin(event)
91
+ break
92
+ case 'COMMENT_IMPORT_FOR_ADMIN':
93
+ res = await commentImportForAdmin(event)
94
+ break
95
+ case 'COMMENT_LIKE':
96
+ res = await commentLike(event)
97
+ break
98
+ case 'COMMENT_SUBMIT':
99
+ res = await commentSubmit(event, request)
100
+ break
101
+ case 'POST_SUBMIT':
102
+ res = await postSubmit(event.comment, request)
103
+ break
104
+ case 'COUNTER_GET':
105
+ res = await counterGet(event)
106
+ break
107
+ case 'GET_PASSWORD_STATUS':
108
+ res = await getPasswordStatus(config, VERSION)
109
+ break
110
+ case 'SET_PASSWORD':
111
+ res = await setPassword(event)
112
+ break
113
+ case 'GET_CONFIG':
114
+ res = await getConfig({ config, VERSION, isAdmin: isAdmin(event.accessToken) })
115
+ break
116
+ case 'GET_CONFIG_FOR_ADMIN':
117
+ res = await getConfigForAdmin({ config, isAdmin: isAdmin(event.accessToken) })
118
+ break
119
+ case 'SET_CONFIG':
120
+ res = await setConfig(event)
121
+ break
122
+ case 'LOGIN':
123
+ res = await login(event.password)
124
+ break
125
+ case 'GET_COMMENTS_COUNT': // >= 0.2.7
126
+ res = await getCommentsCount(event)
127
+ break
128
+ case 'GET_RECENT_COMMENTS': // >= 0.2.7
129
+ res = await getRecentComments(event)
130
+ break
131
+ case 'EMAIL_TEST': // >= 1.4.6
132
+ res = await emailTest(event, config, isAdmin(event.accessToken))
133
+ break
134
+ case 'UPLOAD_IMAGE': // >= 1.5.0
135
+ res = await uploadImage(event, config)
136
+ break
137
+ default:
138
+ if (event.event) {
139
+ res.code = RES_CODE.EVENT_NOT_EXIST
140
+ res.message = '请更新 Twikoo 云函数至最新版本'
141
+ } else {
142
+ res.code = RES_CODE.NO_PARAM
143
+ res.message = 'Twikoo 云函数运行正常,请参考 https://twikoo.js.org/quick-start.html#%E5%89%8D%E7%AB%AF%E9%83%A8%E7%BD%B2 完成前端的配置'
144
+ res.version = VERSION
145
+ }
146
+ }
147
+ } catch (e) {
148
+ console.error('Twikoo 遇到错误,请参考以下错误信息。如有疑问,请反馈至 https://github.com/imaegoo/twikoo/issues')
149
+ console.error('请求参数:', event)
150
+ console.error('错误信息:', e)
151
+ res.code = RES_CODE.FAIL
152
+ res.message = e.message
153
+ }
154
+ if (!res.code && !request.body.accessToken) {
155
+ res.accessToken = accessToken
156
+ }
157
+ console.log('请求返回:', res)
158
+ response.status(200).json(res)
159
+ }
160
+
161
+ function allowCors (request, response) {
162
+ if (request.headers.origin) {
163
+ response.setHeader('Access-Control-Allow-Credentials', true)
164
+ response.setHeader('Access-Control-Allow-Origin', getAllowedOrigin(request))
165
+ response.setHeader('Access-Control-Allow-Methods', 'POST')
166
+ response.setHeader(
167
+ 'Access-Control-Allow-Headers',
168
+ 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version'
169
+ )
170
+ }
171
+ }
172
+
173
+ function getAllowedOrigin (request) {
174
+ const localhostRegex = /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d{1,5})?$/
175
+ if (localhostRegex.test(request.headers.origin)) {
176
+ return request.headers.origin
177
+ } else if (config.CORS_ALLOW_ORIGIN) {
178
+ // 许多用户设置安全域名时,喜欢带结尾的 "/",必须处理掉
179
+ return config.CORS_ALLOW_ORIGIN.replace(/\/$/, '')
180
+ } else {
181
+ return request.headers.origin
182
+ }
183
+ }
184
+
185
+ function anonymousSignIn (request) {
186
+ if (request.body) {
187
+ if (request.body.accessToken) {
188
+ return request.body.accessToken
189
+ } else {
190
+ return uuidv4().replace(/-/g, '')
191
+ }
192
+ }
193
+ }
194
+
195
+ async function connectToDatabase () {
196
+ if (db) return db
197
+ await new Promise((resolve) => {
198
+ console.log('Connecting to database...')
199
+ db = new Loki('data/db.json', {
200
+ adapter: new Lfsa(),
201
+ autoload: true,
202
+ autoloadCallback: resolve,
203
+ autosave: true,
204
+ autosaveInterval: 4000
205
+ })
206
+ })
207
+ await createCollections()
208
+ console.log('Connected to database')
209
+ return db
210
+ }
211
+
212
+ // 写入管理密码
213
+ async function setPassword (event) {
214
+ const isAdminUser = isAdmin(event.accessToken)
215
+ // 如果数据库里没有密码,则写入密码
216
+ // 如果数据库里有密码,则只有管理员可以写入密码
217
+ if (config.ADMIN_PASS && !isAdminUser) {
218
+ return { code: RES_CODE.PASS_EXIST, message: '请先登录再修改密码' }
219
+ }
220
+ const ADMIN_PASS = md5(event.password)
221
+ await writeConfig({ ADMIN_PASS })
222
+ return {
223
+ code: RES_CODE.SUCCESS
224
+ }
225
+ }
226
+
227
+ // 管理员登录
228
+ async function login (password) {
229
+ if (!config) {
230
+ return { code: RES_CODE.CONFIG_NOT_EXIST, message: '数据库无配置' }
231
+ }
232
+ if (!config.ADMIN_PASS) {
233
+ return { code: RES_CODE.PASS_NOT_EXIST, message: '未配置管理密码' }
234
+ }
235
+ if (config.ADMIN_PASS !== md5(password)) {
236
+ return { code: RES_CODE.PASS_NOT_MATCH, message: '密码错误' }
237
+ }
238
+ return {
239
+ code: RES_CODE.SUCCESS
240
+ }
241
+ }
242
+
243
+ // 读取评论
244
+ async function commentGet (event) {
245
+ const res = {}
246
+ try {
247
+ validate(event, ['url'])
248
+ const uid = event.accessToken
249
+ const isAdminUser = isAdmin(event.accessToken)
250
+ const limit = parseInt(config.COMMENT_PAGE_SIZE) || 8
251
+ let more = false
252
+ let condition
253
+ let query
254
+ condition = {
255
+ url: { $in: getUrlQuery(event.url) },
256
+ rid: { $exists: false }
257
+ }
258
+ // 查询非垃圾评论 + 自己的评论
259
+ query = getCommentQuery({ condition, uid, isAdminUser })
260
+ // 读取总条数
261
+ const count = db
262
+ .getCollection('comment')
263
+ .count(query)
264
+ // 读取主楼
265
+ if (event.before) {
266
+ condition.created = { $lt: event.before }
267
+ }
268
+ // 不包含置顶
269
+ condition.top = { $ne: true }
270
+ query = getCommentQuery({ condition, uid, isAdminUser })
271
+ let main = db
272
+ .getCollection('comment')
273
+ .chain()
274
+ .find(query)
275
+ .compoundsort([['created', true]])
276
+ // 流式分页,通过多读 1 条的方式,确认是否还有更多评论
277
+ .limit(limit + 1)
278
+ .data()
279
+ if (main.length > limit) {
280
+ // 还有更多评论
281
+ more = true
282
+ // 删除多读的 1 条
283
+ main.splice(limit, 1)
284
+ }
285
+ let top = []
286
+ if (!config.TOP_DISABLED && !event.before) {
287
+ // 查询置顶评论
288
+ query = {
289
+ ...condition,
290
+ top: true
291
+ }
292
+ top = db
293
+ .getCollection('comment')
294
+ .chain()
295
+ .find(query)
296
+ .compoundsort([['created', true]])
297
+ .data()
298
+ // 合并置顶评论和非置顶评论
299
+ main = [
300
+ ...top,
301
+ ...main
302
+ ]
303
+ }
304
+ condition = {
305
+ rid: { $in: main.map((item) => item._id.toString()) }
306
+ }
307
+ query = getCommentQuery({ condition, uid, isAdminUser })
308
+ // 读取回复楼
309
+ const reply = db
310
+ .getCollection('comment')
311
+ .chain()
312
+ .find(query)
313
+ .data()
314
+ res.data = parseComment([...main, ...reply], uid, config)
315
+ res.more = more
316
+ res.count = count
317
+ } catch (e) {
318
+ res.data = []
319
+ res.message = e.message
320
+ }
321
+ return res
322
+ }
323
+
324
+ function getCommentQuery ({ condition, uid, isAdminUser }) {
325
+ return {
326
+ $or: [
327
+ { ...condition, isSpam: { $ne: isAdminUser ? 'imaegoo' : true } },
328
+ { ...condition, uid }
329
+ ]
330
+ }
331
+ }
332
+
333
+ // 管理员读取评论
334
+ async function commentGetForAdmin (event) {
335
+ const res = {}
336
+ const isAdminUser = isAdmin(event.accessToken)
337
+ if (isAdminUser) {
338
+ validate(event, ['per', 'page'])
339
+ const collection = db
340
+ .getCollection('comment')
341
+ const condition = getCommentSearchCondition(event)
342
+ const count = await collection.count(condition)
343
+ const data = await collection
344
+ .chain()
345
+ .find(condition)
346
+ .compoundsort([['created', true]])
347
+ .offset(event.per * (event.page - 1))
348
+ .limit(event.per)
349
+ .data()
350
+ res.code = RES_CODE.SUCCESS
351
+ res.count = count
352
+ res.data = parseCommentForAdmin(data)
353
+ } else {
354
+ res.code = RES_CODE.NEED_LOGIN
355
+ res.message = '请先登录'
356
+ }
357
+ return res
358
+ }
359
+
360
+ function getCommentSearchCondition (event) {
361
+ let condition
362
+ if (event.type) {
363
+ switch (event.type) {
364
+ case 'VISIBLE':
365
+ condition = { isSpam: { $ne: true } }
366
+ break
367
+ case 'HIDDEN':
368
+ condition = { isSpam: true }
369
+ break
370
+ }
371
+ }
372
+ if (event.keyword) {
373
+ const regExp = {
374
+ $regex: event.keyword,
375
+ $options: 'i'
376
+ }
377
+ condition = {
378
+ $or: [
379
+ { ...condition, nick: regExp },
380
+ { ...condition, mail: regExp },
381
+ { ...condition, link: regExp },
382
+ { ...condition, ip: regExp },
383
+ { ...condition, comment: regExp },
384
+ { ...condition, url: regExp },
385
+ { ...condition, href: regExp }
386
+ ]
387
+ }
388
+ }
389
+ return condition
390
+ }
391
+
392
+ // 管理员修改评论
393
+ async function commentSetForAdmin (event) {
394
+ const res = {}
395
+ const isAdminUser = isAdmin(event.accessToken)
396
+ if (isAdminUser) {
397
+ validate(event, ['id', 'set'])
398
+ const data = db
399
+ .getCollection('comment')
400
+ .findAndUpdate({ _id: event.id }, (obj) => {
401
+ return {
402
+ ...obj,
403
+ ...event.set,
404
+ updated: Date.now()
405
+ }
406
+ })
407
+ res.code = RES_CODE.SUCCESS
408
+ res.updated = data
409
+ } else {
410
+ res.code = RES_CODE.NEED_LOGIN
411
+ res.message = '请先登录'
412
+ }
413
+ return res
414
+ }
415
+
416
+ // 管理员删除评论
417
+ async function commentDeleteForAdmin (event) {
418
+ const res = {}
419
+ const isAdminUser = isAdmin(event.accessToken)
420
+ if (isAdminUser) {
421
+ validate(event, ['id'])
422
+ const data = db
423
+ .getCollection('comment')
424
+ .findAndRemove({ _id: event.id })
425
+ res.code = RES_CODE.SUCCESS
426
+ res.deleted = data.deletedCount
427
+ } else {
428
+ res.code = RES_CODE.NEED_LOGIN
429
+ res.message = '请先登录'
430
+ }
431
+ return res
432
+ }
433
+
434
+ // 管理员导入评论
435
+ async function commentImportForAdmin (event) {
436
+ const res = {}
437
+ let logText = ''
438
+ const log = (message) => {
439
+ logText += `${new Date().toLocaleString()} ${message}\n`
440
+ }
441
+ const isAdminUser = isAdmin(event.accessToken)
442
+ if (isAdminUser) {
443
+ try {
444
+ validate(event, ['source', 'file'])
445
+ log(`开始导入 ${event.source}`)
446
+ let comments
447
+ switch (event.source) {
448
+ case 'valine': {
449
+ const valineDb = await readFile(event.file, 'json', log)
450
+ comments = await commentImportValine(valineDb, log)
451
+ break
452
+ }
453
+ case 'disqus': {
454
+ const disqusDb = await readFile(event.file, 'xml', log)
455
+ comments = await commentImportDisqus(disqusDb, log)
456
+ break
457
+ }
458
+ case 'artalk': {
459
+ const artalkDb = await readFile(event.file, 'json', log)
460
+ comments = await commentImportArtalk(artalkDb, log)
461
+ break
462
+ }
463
+ case 'twikoo': {
464
+ const twikooDb = await readFile(event.file, 'json', log)
465
+ comments = await commentImportTwikoo(twikooDb, log)
466
+ break
467
+ }
468
+ default:
469
+ throw new Error(`不支持 ${event.source} 的导入,请更新 Twikoo 云函数至最新版本`)
470
+ }
471
+ const insertedCount = await bulkSaveComments(comments).length
472
+ log(`导入成功 ${insertedCount} 条评论`)
473
+ } catch (e) {
474
+ log(e.message)
475
+ }
476
+ res.code = RES_CODE.SUCCESS
477
+ res.log = logText
478
+ console.log(logText)
479
+ } else {
480
+ res.code = RES_CODE.NEED_LOGIN
481
+ res.message = '请先登录'
482
+ }
483
+ return res
484
+ }
485
+
486
+ // 读取文件并转为 js object
487
+ async function readFile (file, type, log) {
488
+ try {
489
+ let content = file.toString('utf8')
490
+ log('评论文件读取成功')
491
+ if (type === 'json') {
492
+ content = jsonParse(content)
493
+ log('评论文件 JSON 解析成功')
494
+ } else if (type === 'xml') {
495
+ content = await xml2js.parseStringPromise(content)
496
+ log('评论文件 XML 解析成功')
497
+ }
498
+ return content
499
+ } catch (e) {
500
+ log(`评论文件读取失败:${e.message}`)
501
+ }
502
+ }
503
+
504
+ // 批量导入评论
505
+ async function bulkSaveComments (comments) {
506
+ const batchRes = db
507
+ .getCollection('comment')
508
+ .insert(comments)
509
+ return batchRes
510
+ }
511
+
512
+ // 点赞 / 取消点赞
513
+ async function commentLike (event) {
514
+ const res = {}
515
+ validate(event, ['id'])
516
+ res.updated = await like(event.id, event.accessToken)
517
+ return res
518
+ }
519
+
520
+ // 点赞 / 取消点赞
521
+ async function like (id, uid) {
522
+ const record = db
523
+ .getCollection('comment')
524
+ const comment = await record
525
+ .findOne({ _id: id })
526
+ let likes = comment && comment.like ? comment.like : []
527
+ if (likes.findIndex((item) => item === uid) === -1) {
528
+ // 赞
529
+ likes.push(uid)
530
+ } else {
531
+ // 取消赞
532
+ likes = likes.filter((item) => item !== uid)
533
+ }
534
+ const result = await record.findAndUpdate({ _id: id }, (obj) => {
535
+ obj.like = likes
536
+ return obj
537
+ })
538
+ return result
539
+ }
540
+
541
+ /**
542
+ * 提交评论。分为多个步骤
543
+ * 1. 参数校验
544
+ * 2. 预检测垃圾评论(包括限流、人工审核、违禁词检测等)
545
+ * 3. 保存到数据库
546
+ * 4. 触发异步任务(包括 IM 通知、邮件通知、第三方垃圾评论检测
547
+ * 等,因为这些任务比较耗时,所以要放在另一个线程进行)
548
+ * @param {String} event.nick 昵称
549
+ * @param {String} event.mail 邮箱
550
+ * @param {String} event.link 网址
551
+ * @param {String} event.ua UserAgent
552
+ * @param {String} event.url 评论页地址
553
+ * @param {String} event.comment 评论内容
554
+ * @param {String} event.pid 回复的 ID
555
+ * @param {String} event.rid 评论楼 ID
556
+ */
557
+ async function commentSubmit (event, request) {
558
+ const res = {}
559
+ // 参数校验
560
+ validate(event, ['url', 'ua', 'comment'])
561
+ // 限流
562
+ await limitFilter(request)
563
+ // 预检测、转换
564
+ const data = await parse(event, request)
565
+ // 保存
566
+ const comment = await save(data)
567
+ res.id = comment.id
568
+ // 异步垃圾检测、发送评论通知
569
+ try {
570
+ console.log('开始异步垃圾检测、发送评论通知')
571
+ console.time('POST_SUBMIT')
572
+ await Promise.race([
573
+ axios.post(`https://${request.headers.host}`, {
574
+ event: 'POST_SUBMIT',
575
+ comment
576
+ }, { headers: { 'x-twikoo-recursion': config.ADMIN_PASS || 'true' } }),
577
+ // 如果超过 5 秒还没收到异步返回,直接继续,减少用户等待的时间
578
+ new Promise((resolve) => setTimeout(resolve, 5000))
579
+ ])
580
+ console.timeEnd('POST_SUBMIT')
581
+ } catch (e) {
582
+ console.log('POST_SUBMIT 失败', e)
583
+ }
584
+ return res
585
+ }
586
+
587
+ // 保存评论
588
+ async function save (data) {
589
+ db
590
+ .getCollection('comment')
591
+ .insert(data)
592
+ data.id = data._id
593
+ return data
594
+ }
595
+
596
+ async function getParentComment (currentComment) {
597
+ const parentComment = db
598
+ .getCollection('comment')
599
+ .findOne({ _id: currentComment.pid })
600
+ return parentComment.data[0]
601
+ }
602
+
603
+ // 异步垃圾检测、发送评论通知
604
+ async function postSubmit (comment, request) {
605
+ if (!isRecursion(request)) return { code: RES_CODE.FORBIDDEN }
606
+ // 垃圾检测
607
+ const isSpam = await postCheckSpam(comment)
608
+ await saveSpamCheckResult(comment, isSpam)
609
+ // 发送通知
610
+ await sendNotice(comment, config, getParentComment)
611
+ return { code: RES_CODE.SUCCESS }
612
+ }
613
+
614
+ // 将评论转为数据库存储格式
615
+ async function parse (comment, request) {
616
+ const timestamp = Date.now()
617
+ const isAdminUser = isAdmin(request.body.accessToken)
618
+ const isBloggerMail = comment.mail && comment.mail === config.BLOGGER_EMAIL
619
+ if (isBloggerMail && !isAdminUser) throw new Error('请先登录管理面板,再使用博主身份发送评论')
620
+ const commentDo = {
621
+ _id: uuidv4().replace(/-/g, ''),
622
+ uid: request.body.accessToken,
623
+ nick: comment.nick ? comment.nick : '匿名',
624
+ mail: comment.mail ? comment.mail : '',
625
+ mailMd5: comment.mail ? md5(comment.mail) : '',
626
+ link: comment.link ? comment.link : '',
627
+ ua: comment.ua,
628
+ ip: getIp(request),
629
+ master: isBloggerMail,
630
+ url: comment.url,
631
+ href: comment.href,
632
+ comment: DOMPurify.sanitize(comment.comment, { FORBID_TAGS: ['style'], FORBID_ATTR: ['style'] }),
633
+ pid: comment.pid ? comment.pid : comment.rid,
634
+ rid: comment.rid,
635
+ isSpam: isAdminUser ? false : preCheckSpam(comment, config),
636
+ created: timestamp,
637
+ updated: timestamp
638
+ }
639
+ if (isQQ(comment.mail)) {
640
+ commentDo.mail = addQQMailSuffix(comment.mail)
641
+ commentDo.mailMd5 = md5(commentDo.mail)
642
+ commentDo.avatar = await getQQAvatar(comment.mail)
643
+ }
644
+ return commentDo
645
+ }
646
+
647
+ // 限流
648
+ async function limitFilter (request) {
649
+ // 限制每个 IP 每 10 分钟发表的评论数量
650
+ let limitPerMinute = parseInt(config.LIMIT_PER_MINUTE)
651
+ if (Number.isNaN(limitPerMinute)) limitPerMinute = 10
652
+ if (limitPerMinute) {
653
+ const count = db
654
+ .getCollection('comment')
655
+ .count({
656
+ ip: getIp(request),
657
+ created: { $gt: Date.now() - 600000 }
658
+ })
659
+ if (count > limitPerMinute) {
660
+ throw new Error('发言频率过高')
661
+ }
662
+ }
663
+ // 限制所有 IP 每 10 分钟发表的评论数量
664
+ let limitPerMinuteAll = parseInt(config.LIMIT_PER_MINUTE_ALL)
665
+ if (Number.isNaN(limitPerMinuteAll)) limitPerMinuteAll = 10
666
+ if (limitPerMinuteAll) {
667
+ const count = db
668
+ .getCollection('comment')
669
+ .count({
670
+ created: { $gt: Date.now() - 600000 }
671
+ })
672
+ if (count > limitPerMinuteAll) {
673
+ throw new Error('评论太火爆啦 >_< 请稍后再试')
674
+ }
675
+ }
676
+ }
677
+
678
+ async function saveSpamCheckResult (comment, isSpam) {
679
+ comment.isSpam = isSpam
680
+ if (isSpam) {
681
+ db
682
+ .getCollection('comment')
683
+ .findAndUpdate({ created: comment.created }, (obj) => {
684
+ obj.isSpam = isSpam
685
+ obj.updated = Date.now()
686
+ return obj
687
+ })
688
+ }
689
+ }
690
+
691
+ /**
692
+ * 获取文章点击量
693
+ * @param {String} event.url 文章地址
694
+ */
695
+ async function counterGet (event) {
696
+ const res = {}
697
+ try {
698
+ validate(event, ['url'])
699
+ const record = await readCounter(event.url)
700
+ res.data = record || {}
701
+ res.time = res.data ? res.data.time : 0
702
+ res.updated = await incCounter(event)
703
+ } catch (e) {
704
+ res.message = e.message
705
+ return res
706
+ }
707
+ return res
708
+ }
709
+
710
+ // 读取阅读数
711
+ async function readCounter (url) {
712
+ return db
713
+ .getCollection('counter')
714
+ .findOne({ url })
715
+ }
716
+
717
+ /**
718
+ * 更新阅读数
719
+ * @param {String} event.url 文章地址
720
+ * @param {String} event.title 文章标题
721
+ */
722
+ async function incCounter (event) {
723
+ let result
724
+ result = db
725
+ .getCollection('counter')
726
+ .findAndUpdate({ url: event.url }, (obj) => {
727
+ obj.time = obj.time ? obj.time + 1 : 1
728
+ obj.title = event.title
729
+ obj.updated = Date.now()
730
+ })
731
+ if (result.modifiedCount === 0) {
732
+ result = db
733
+ .getCollection('counter')
734
+ .insert({
735
+ url: event.url,
736
+ title: event.title,
737
+ time: 1,
738
+ created: Date.now(),
739
+ updated: Date.now()
740
+ })
741
+ }
742
+ return result.modifiedCount || result.insertedCount
743
+ }
744
+
745
+ /**
746
+ * 批量获取文章评论数 API
747
+ * @param {Array} event.urls 不包含协议和域名的文章路径列表,必传参数
748
+ * @param {Boolean} event.includeReply 评论数是否包括回复,默认:false
749
+ */
750
+ async function getCommentsCount (event) {
751
+ const res = {}
752
+ try {
753
+ validate(event, ['urls'])
754
+ const query = {}
755
+ query.isSpam = { $ne: true }
756
+ query.url = { $in: getUrlsQuery(event.urls) }
757
+ if (!event.includeReply) {
758
+ query.rid = { $exists: false }
759
+ }
760
+ const result = db
761
+ .getCollection('comment')
762
+ .chain()
763
+ .aggregate([
764
+ { $match: query },
765
+ { $group: { _id: '$url', count: { $sum: 1 } } }
766
+ ])
767
+ .data()
768
+ res.data = []
769
+ for (const url of event.urls) {
770
+ const record = result.find((item) => item._id === url)
771
+ res.data.push({
772
+ url,
773
+ count: record ? record.count : 0
774
+ })
775
+ }
776
+ } catch (e) {
777
+ res.message = e.message
778
+ return res
779
+ }
780
+ return res
781
+ }
782
+
783
+ /**
784
+ * 获取最新评论 API
785
+ * @param {Boolean} event.includeReply 评论数是否包括回复,默认:false
786
+ */
787
+ async function getRecentComments (event) {
788
+ const res = {}
789
+ try {
790
+ const query = {}
791
+ query.isSpam = { $ne: true }
792
+ if (!event.includeReply) query.rid = { $exists: false }
793
+ if (event.pageSize > 100) event.pageSize = 100
794
+ const result = db
795
+ .getCollection('comment')
796
+ .chain()
797
+ .find(query)
798
+ .compoundsort([['created', true]])
799
+ .limit(event.pageSize || 10)
800
+ .data()
801
+ res.data = result.map((comment) => {
802
+ return {
803
+ id: comment._id.toString(),
804
+ url: comment.url,
805
+ nick: comment.nick,
806
+ avatar: getAvatar(comment, config),
807
+ mailMd5: comment.mailMd5 || md5(comment.mail),
808
+ link: comment.link,
809
+ comment: comment.comment,
810
+ commentText: $(comment.comment).text(),
811
+ created: comment.created
812
+ }
813
+ })
814
+ } catch (e) {
815
+ res.message = e.message
816
+ return res
817
+ }
818
+ return res
819
+ }
820
+
821
+ // 修改配置
822
+ async function setConfig (event) {
823
+ const isAdminUser = isAdmin(event.accessToken)
824
+ if (isAdminUser) {
825
+ writeConfig(event.config)
826
+ return {
827
+ code: RES_CODE.SUCCESS
828
+ }
829
+ } else {
830
+ return {
831
+ code: RES_CODE.NEED_LOGIN,
832
+ message: '请先登录'
833
+ }
834
+ }
835
+ }
836
+
837
+ function protect (request) {
838
+ // 防御
839
+ const ip = getIp(request)
840
+ requestTimes[ip] = (requestTimes[ip] || 0) + 1
841
+ if (requestTimes[ip] > MAX_REQUEST_TIMES) {
842
+ console.log(`${ip} 当前请求次数为 ${requestTimes[ip]},已超过最大请求次数`)
843
+ throw new Error('Too Many Requests')
844
+ } else {
845
+ console.log(`${ip} 当前请求次数为 ${requestTimes[ip]}`)
846
+ }
847
+ }
848
+
849
+ // 读取配置
850
+ async function readConfig () {
851
+ try {
852
+ const res = db
853
+ .getCollection('config')
854
+ .findOne({})
855
+ config = res || {}
856
+ return config
857
+ } catch (e) {
858
+ console.error('读取配置失败:', e)
859
+ await createCollections()
860
+ config = {}
861
+ return config
862
+ }
863
+ }
864
+
865
+ // 写入配置
866
+ async function writeConfig (newConfig) {
867
+ if (!Object.keys(newConfig).length) return 0
868
+ console.log('写入配置:', newConfig)
869
+ try {
870
+ const oldConfig = db
871
+ .getCollection('config')
872
+ .chain()
873
+ .find({})
874
+ .limit(1)
875
+ .data()[0]
876
+ if (oldConfig) {
877
+ db.getCollection('config').update({
878
+ ...oldConfig,
879
+ ...newConfig
880
+ })
881
+ } else {
882
+ db.getCollection('config').insert(newConfig)
883
+ }
884
+ // 更新后重置配置缓存
885
+ config = null
886
+ return 1
887
+ } catch (e) {
888
+ console.error('写入配置失败:', e)
889
+ return null
890
+ }
891
+ }
892
+
893
+ // 判断用户是否管理员
894
+ function isAdmin (accessToken) {
895
+ return config.ADMIN_PASS === md5(accessToken)
896
+ }
897
+
898
+ // 判断是否为递归调用(即云函数调用自身)
899
+ function isRecursion (request) {
900
+ return request.headers['x-twikoo-recursion'] === (config.ADMIN_PASS || 'true')
901
+ }
902
+
903
+ // 建立数据库 collections
904
+ async function createCollections () {
905
+ const collections = ['comment', 'config', 'counter']
906
+ const res = {}
907
+ for (const collection of collections) {
908
+ if (db.getCollection(collection) === null) {
909
+ db.addCollection(collection)
910
+ }
911
+ }
912
+ return res
913
+ }
914
+
915
+ function getIp (request) {
916
+ return request.headers['x-forwarded-for'] || request.socket.remoteAddress || ''
917
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "tkserver",
3
+ "version": "1.6.0-beta.2",
4
+ "description": "A simple comment system.",
5
+ "keywords": [
6
+ "twikoo",
7
+ "twikoojs",
8
+ "comment",
9
+ "comment-system"
10
+ ],
11
+ "author": "imaegoo <hello@imaegoo.com> (https://github.com/imaegoo)",
12
+ "license": "MIT",
13
+ "main": "server.js",
14
+ "bin": {
15
+ "tkserver": "server.js"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/imaegoo/twikoo.git"
20
+ },
21
+ "homepage": "https://twikoo.js.org",
22
+ "publishConfig": {
23
+ "access": "public",
24
+ "registry": "https://registry.npmjs.org/"
25
+ },
26
+ "dependencies": {
27
+ "lokijs": "^1.5.12",
28
+ "twikoo-func": "1.6.0-beta.2",
29
+ "uuid": "^8.3.2"
30
+ }
31
+ }
package/server.js ADDED
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const http = require('http')
6
+
7
+ const dataDir = path.resolve(process.cwd(), process.env.TWIKOO_DATA || './data')
8
+ if (!fs.existsSync(dataDir)) {
9
+ fs.mkdirSync(dataDir)
10
+ }
11
+ console.log(`Twikoo database stored at ${dataDir}`)
12
+
13
+ const twikoo = require('./index')
14
+ const server = http.createServer()
15
+
16
+ server.on('request', async function (request, response) {
17
+ try {
18
+ const buffers = []
19
+ for await (const chunk of request) {
20
+ buffers.push(chunk)
21
+ }
22
+ request.body = JSON.parse(Buffer.concat(buffers).toString())
23
+ } catch (e) {
24
+ console.error(e.message)
25
+ request.body = {}
26
+ }
27
+ response.status = function (code) {
28
+ this.statusCode = code
29
+ return this
30
+ }
31
+ response.json = function (json) {
32
+ if (!response.writableEnded) {
33
+ this.writeHead(200, { 'Content-Type': 'application/json' })
34
+ this.end(JSON.stringify(json))
35
+ }
36
+ return this
37
+ }
38
+ return await twikoo(request, response)
39
+ })
40
+
41
+ const port = parseInt(process.env.TWIKOO_PORT) || 8080
42
+
43
+ server.listen(port, function () {
44
+ console.log(`Twikoo function started on port ${port}`)
45
+ })