tkserver 1.6.8 → 1.6.10

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