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 +3 -1
- package/mongo.js +914 -0
- package/package.json +7 -2
- package/server.js +3 -1
package/README.md
CHANGED
|
@@ -16,7 +16,9 @@ tkserver
|
|
|
16
16
|
|
|
17
17
|
| 名称 | 描述 | 默认值 |
|
|
18
18
|
| ---- | ---- | ---- |
|
|
19
|
-
| `
|
|
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.
|
|
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
|
-
"
|
|
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
|
|
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
|
})
|